From 4a9e78e43249183900fb8f0ffee6249072df95c9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 7 Jul 2025 13:58:25 +0000 Subject: [PATCH 001/732] Add integration tests for web SDK using real crypto module --- js/cli/.eslintrc.js | 43 + js/cli/bun.lock | 769 +++++++++++++++++- js/cli/jest.config.js | 18 + js/cli/package.json | 17 +- js/cli/src/cli/commandAuthLogout.ts | 1 - js/cli/src/cli/commandEventFolder.ts | 2 +- js/cli/src/cli/paths.ts | 9 +- js/cli/tsconfig.json | 31 + .../src/internal/nodes/cryptoService.test.ts | 2 +- js/sdk/src/internal/nodes/cryptoService.ts | 13 +- .../internal/nodes/nodesManagement.test.ts | 12 +- js/sdk/src/internal/nodes/nodesManagement.ts | 4 +- js/sdk/src/protonDriveClient.ts | 2 +- 13 files changed, 889 insertions(+), 34 deletions(-) create mode 100644 js/cli/.eslintrc.js create mode 100644 js/cli/jest.config.js create mode 100644 js/cli/tsconfig.json diff --git a/js/cli/.eslintrc.js b/js/cli/.eslintrc.js new file mode 100644 index 00000000..828c2128 --- /dev/null +++ b/js/cli/.eslintrc.js @@ -0,0 +1,43 @@ +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", + "no-console": "off", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/no-explicit-any": "warn", + }, + overrides: [ + // Crypto module is copy-paste from clients monorepo. + { + files: [ + "src/crypto/**/*", + "src/srp/**/*", + ], + rules: { + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/consistent-type-exports": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-wrapper-object-types": "off", + "prefer-spread": "off", + "tsdoc/syntax": "off", + }, + }, + ], + plugins: [ + "@typescript-eslint/eslint-plugin", + "eslint-plugin-tsdoc" + ] +}; diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 56723ff4..82f5cc25 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -4,6 +4,7 @@ "": { "name": "proton-drive-cli", "dependencies": { + "@openpgp/noble-hashes": "^1.3.3", "@protontech/pmcrypto": "^7.20.0", "bcryptjs": "^2.4.3", "comlink": "^4.4.2", @@ -11,9 +12,17 @@ "ttag": "^1.8.7", }, "devDependencies": { + "@swc/core": "^1.12.3", + "@swc/jest": "^0.2.38", "@types/bcryptjs": "^2.4.6", + "@types/bun": "^1.2.18", + "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", + "@typescript-eslint/eslint-plugin": "^8.19.1", "bun-types": "^1.1.31", + "eslint": "^8.57.1", + "eslint-plugin-tsdoc": "^0.3.0", + "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "typescript": "^5.5.3", "vite": "^5.4.8", @@ -22,15 +31,77 @@ }, }, "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + "@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="], + + "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.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" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="], + + "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + + "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], + + "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], + + "@babel/plugin-syntax-class-properties": ["@babel/plugin-syntax-class-properties@7.12.13", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA=="], + + "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="], + + "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="], + + "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="], + + "@babel/plugin-syntax-json-strings": ["@babel/plugin-syntax-json-strings@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="], + + "@babel/plugin-syntax-logical-assignment-operators": ["@babel/plugin-syntax-logical-assignment-operators@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig=="], + + "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], + + "@babel/plugin-syntax-numeric-separator": ["@babel/plugin-syntax-numeric-separator@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug=="], + + "@babel/plugin-syntax-object-rest-spread": ["@babel/plugin-syntax-object-rest-spread@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA=="], + + "@babel/plugin-syntax-optional-catch-binding": ["@babel/plugin-syntax-optional-catch-binding@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q=="], - "@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], + "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], + + "@babel/plugin-syntax-private-property-in-object": ["@babel/plugin-syntax-private-property-in-object@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg=="], + + "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + + "@babel/types": ["@babel/types@7.28.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], @@ -78,15 +149,75 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "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" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "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" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], + + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], + + "@jest/console": ["@jest/console@29.7.0", "", { "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" } }, "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg=="], + + "@jest/core": ["@jest/core@29.7.0", "", { "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" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg=="], + + "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], + + "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], + + "@jest/expect": ["@jest/expect@29.7.0", "", { "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" } }, "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ=="], + + "@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="], + + "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "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" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], + + "@jest/globals": ["@jest/globals@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", "@jest/types": "^29.6.3", "jest-mock": "^29.7.0" } }, "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ=="], + + "@jest/reporters": ["@jest/reporters@29.7.0", "", { "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" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jest/source-map": ["@jest/source-map@29.6.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", "graceful-fs": "^4.2.9" } }, "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw=="], + + "@jest/test-result": ["@jest/test-result@29.7.0", "", { "dependencies": { "@jest/console": "^29.7.0", "@jest/types": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" } }, "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA=="], + + "@jest/test-sequencer": ["@jest/test-sequencer@29.7.0", "", { "dependencies": { "@jest/test-result": "^29.7.0", "graceful-fs": "^4.2.9", "jest-haste-map": "^29.7.0", "slash": "^3.0.0" } }, "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw=="], + + "@jest/transform": ["@jest/transform@29.7.0", "", { "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" } }, "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw=="], + + "@jest/types": ["@jest/types@29.6.3", "", { "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" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.50.1", "", { "dependencies": { "@microsoft/api-extractor-model": "7.30.3", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0", "@rushstack/rig-package": "0.5.3", "@rushstack/terminal": "0.15.0", "@rushstack/ts-command-line": "4.23.5", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", "typescript": "5.7.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-L18vz0ARLNaBLKwWe0DdEf7eijDsb7ERZspgZK7PxclLoQrc+9hJZo8y4OVfCHxNVyxlwVywY2WdE/3pOFViLQ=="], "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.30.3", "", { "dependencies": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0" } }, "sha512-yEAvq0F78MmStXdqz9TTT4PZ05Xu5R8nqgwI5xmUmQjWBQ9E6R2n8HB/iZMRciG4rf9iwI2mtuQwIzDXBvHn1w=="], - "@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.0", "", {}, "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA=="], + + "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.0", "", { "dependencies": { "@microsoft/tsdoc": "0.15.0", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg=="], - "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@openpgp/asmcrypto.js": ["@openpgp/asmcrypto.js@2.3.3-0", "", {}, "sha512-1jhJ5dld5XgaNEZ3WakXnUs6XhVXSeWPUXP44bxoVXtFN5DcEPhdm5kM/wuuJlPSDeqVwDjh6becPHiJPqIsvg=="], @@ -152,20 +283,104 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@4.23.5", "", { "dependencies": { "@rushstack/terminal": "0.15.0", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-jg70HfoK44KfSP3MTiL5rxsZH7X1ktX3cZs9Sl8eDu1/LxJSbPsh0MOFRC710lIuYYSgxWjI5AjbCBAl7u3RxA=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], + + "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], + + "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + + "@swc/core": ["@swc/core@1.12.9", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.12.9", "@swc/core-darwin-x64": "1.12.9", "@swc/core-linux-arm-gnueabihf": "1.12.9", "@swc/core-linux-arm64-gnu": "1.12.9", "@swc/core-linux-arm64-musl": "1.12.9", "@swc/core-linux-x64-gnu": "1.12.9", "@swc/core-linux-x64-musl": "1.12.9", "@swc/core-win32-arm64-msvc": "1.12.9", "@swc/core-win32-ia32-msvc": "1.12.9", "@swc/core-win32-x64-msvc": "1.12.9" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-O+LfT2JlVMsIMWG9x+rdxg8GzpzeGtCZQfXV7cKc1PjIKUkLFf1QJ7okuseA4f/9vncu37dQ2ZcRrPKy0Ndd5g=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.12.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.12.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-hv2kls7Ilkm2EpeJz+I9MCil7pGS3z55ZAgZfxklEuYsxpICycxeH+RNRv4EraggN44ms+FWCjtZFu0LGg2V3g=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.12.9", "", { "os": "linux", "cpu": "arm" }, "sha512-od9tDPiG+wMU9wKtd6y3nYJdNqgDOyLdgRRcrj1/hrbHoUPOM8wZQZdwQYGarw63iLXGgsw7t5HAF9Yc51ilFA=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.12.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-6qx1ka9LHcLzxIgn2Mros+CZLkHK2TawlXzi/h7DJeNnzi8F1Hw0Yzjp8WimxNCg6s2n+o3jnmin1oXB7gg8rw=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.12.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-yghFZWKPVVGbUdqiD7ft23G0JX6YFGDJPz9YbLLAwGuKZ9th3/jlWoQDAw1Naci31LQhVC+oIji6ozihSuwB2A=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.12.9", "", { "os": "linux", "cpu": "x64" }, "sha512-SFUxyhWLZRNL8QmgGNqdi2Q43PNyFVkRZ2zIif30SOGFSxnxcf2JNeSeBgKIGVgaLSuk6xFVVCtJ3KIeaStgRg=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.12.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9FB0wM+6idCGTI20YsBNBg9xSWtkDBymnpaTCsZM3qDc0l4uOpJMqbfWhQvp17x7r/ulZfb2QY8RDvQmCL6AcQ=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.12.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-zHOusMVbOH9ik5RtRrMiGzLpKwxrPXgXkBm3SbUCa65HAdjV33NZ0/R9Rv1uPESALtEl2tzMYLUxYA5ECFDFhA=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.12.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-aWZf0PqE0ot7tCuhAjRkDFf41AzzSQO0x2xRfTbnhpROp57BRJ/N5eee1VULO/UA2PIJRG7GKQky5bSGBYlFug=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.12.9", "", { "os": "win32", "cpu": "x64" }, "sha512-C25fYftXOras3P3anSUeXXIpxmEkdAcsIL9yrr0j1xepTZ/yKwpnQ6g3coj8UXdeJy4GTVlR6+Ow/QiBgZQNOg=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/jest": ["@swc/jest@0.2.38", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@swc/counter": "^0.1.3", "jsonc-parser": "^3.2.0" }, "peerDependencies": { "@swc/core": "*" } }, "sha512-HMoZgXWMqChJwffdDjvplH53g9G2ALQes3HKXDEdliB/b85OQ0CTSbxG8VSeCwiAn7cOaDVEt4mwmZvbHcS52w=="], + + "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], + "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], "@types/bn.js": ["@types/bn.js@4.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], + "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], + "@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/sharedworker": ["@types/sharedworker@0.0.130", "", {}, "sha512-P8arPQcRUW+vs1xyrnqkFGblb8XABsHs+HnTAsOzEoxe4/jzrpY8pye8WWbvwtCegh4dyAOnUmqspdRByWkHNQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.35.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/type-utils": "8.35.1", "@typescript-eslint/utils": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.35.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.35.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/utils": "8.35.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.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.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@volar/language-core": ["@volar/language-core@2.4.11", "", { "dependencies": { "@volar/source-map": "2.4.11" } }, "sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg=="], "@volar/source-map": ["@volar/source-map@2.4.11", "", {}, "sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ=="], @@ -184,9 +399,11 @@ "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], - "ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -196,22 +413,72 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "babel-jest": ["babel-jest@29.7.0", "", { "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" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], + + "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "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" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], + + "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], + + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "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" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="], + + "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], "bn.js": ["bn.js@4.12.1", "", {}, "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="], - "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + + "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001726", "", {}, "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="], + + "collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], @@ -222,44 +489,232 @@ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "create-jest": ["create-jest@29.7.0", "", { "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" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], + + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.179", "", {}, "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ=="], + + "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.1", "", { "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" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], + + "eslint-plugin-tsdoc": ["eslint-plugin-tsdoc@0.3.0", "", { "dependencies": { "@microsoft/tsdoc": "0.15.0", "@microsoft/tsdoc-config": "0.17.0" } }, "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "execa": ["execa@5.1.1", "", { "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" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], + + "expect": ["expect@29.7.0", "", { "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" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-glob": ["fast-glob@3.3.3", "", { "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" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "index-to-position": ["index-to-position@0.1.2", "", {}, "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-fn": ["is-generator-fn@2.1.0", "", {}, "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "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" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@4.0.1", "", { "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", "source-map": "^0.6.1" } }, "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw=="], + + "istanbul-reports": ["istanbul-reports@3.1.7", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g=="], + + "jest": ["jest@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", "jest-cli": "^29.7.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw=="], + + "jest-changed-files": ["jest-changed-files@29.7.0", "", { "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", "p-limit": "^3.1.0" } }, "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w=="], + + "jest-circus": ["jest-circus@29.7.0", "", { "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" } }, "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw=="], + + "jest-cli": ["jest-cli@29.7.0", "", { "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" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg=="], + + "jest-config": ["jest-config@29.7.0", "", { "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" }, "peerDependencies": { "@types/node": "*", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "ts-node"] }, "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ=="], + + "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "jest-docblock": ["jest-docblock@29.7.0", "", { "dependencies": { "detect-newline": "^3.0.0" } }, "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g=="], + + "jest-each": ["jest-each@29.7.0", "", { "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" } }, "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ=="], + + "jest-environment-node": ["jest-environment-node@29.7.0", "", { "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" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="], + + "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], + + "jest-haste-map": ["jest-haste-map@29.7.0", "", { "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" }, "optionalDependencies": { "fsevents": "^2.3.2" } }, "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA=="], + + "jest-leak-detector": ["jest-leak-detector@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw=="], + + "jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], + + "jest-message-util": ["jest-message-util@29.7.0", "", { "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" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], + + "jest-mock": ["jest-mock@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "jest-util": "^29.7.0" } }, "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw=="], + + "jest-pnp-resolver": ["jest-pnp-resolver@1.2.3", "", { "peerDependencies": { "jest-resolve": "*" }, "optionalPeers": ["jest-resolve"] }, "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w=="], + + "jest-regex-util": ["jest-regex-util@29.6.3", "", {}, "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg=="], + + "jest-resolve": ["jest-resolve@29.7.0", "", { "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" } }, "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA=="], + + "jest-resolve-dependencies": ["jest-resolve-dependencies@29.7.0", "", { "dependencies": { "jest-regex-util": "^29.6.3", "jest-snapshot": "^29.7.0" } }, "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA=="], + + "jest-runner": ["jest-runner@29.7.0", "", { "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" } }, "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ=="], + + "jest-runtime": ["jest-runtime@29.7.0", "", { "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" } }, "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ=="], + + "jest-snapshot": ["jest-snapshot@29.7.0", "", { "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" } }, "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw=="], + + "jest-util": ["jest-util@29.7.0", "", { "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" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], + + "jest-validate": ["jest-validate@29.7.0", "", { "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" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], + + "jest-watcher": ["jest-watcher@29.7.0", "", { "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" } }, "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g=="], + + "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], @@ -268,27 +723,65 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "jsmimeparser": ["@protontech/jsmimeparser@3.0.1", "", {}, "sha512-bi0RBkritKep1cKnQ6U1538++aQ+7XZxG5Uzm4ZivvP7FTE3iaOA5lm0CCFbSoQ5e8KtvjI5KR+Vj6apXhyhXQ=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "ky": ["ky@1.7.5", "", {}, "sha512-HzhziW6sc5m0pwi5M196+7cEBtbt0lCYi67wNsiwMUmz833wloE0gbzJPWKs1gliFKQb34huItDQX97LyOdPdA=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], - "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], @@ -298,14 +791,44 @@ "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.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" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], "openpgp": ["@protontech/openpgp@5.11.2-0", "", { "dependencies": { "asn1.js": "^5.0.0" } }, "sha512-7KdhbuTblnet30IIgom2zOAEqJHIQhVYA78m8dU1PFvRt6UtyiCX2Di72piT/XH4Hv8Br+RyprlBEsBijMTt/g=="], + "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -314,6 +837,10 @@ "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "plural-forms": ["plural-forms@0.5.5", "", {}, "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g=="], @@ -322,35 +849,99 @@ "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rollup": ["rollup@4.34.8", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.34.8", "@rollup/rollup-android-arm64": "4.34.8", "@rollup/rollup-darwin-arm64": "4.34.8", "@rollup/rollup-darwin-x64": "4.34.8", "@rollup/rollup-freebsd-arm64": "4.34.8", "@rollup/rollup-freebsd-x64": "4.34.8", "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", "@rollup/rollup-linux-arm-musleabihf": "4.34.8", "@rollup/rollup-linux-arm64-gnu": "4.34.8", "@rollup/rollup-linux-arm64-musl": "4.34.8", "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", "@rollup/rollup-linux-riscv64-gnu": "4.34.8", "@rollup/rollup-linux-s390x-gnu": "4.34.8", "@rollup/rollup-linux-x64-gnu": "4.34.8", "@rollup/rollup-linux-x64-musl": "4.34.8", "@rollup/rollup-win32-arm64-msvc": "4.34.8", "@rollup/rollup-win32-ia32-msvc": "4.34.8", "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], - "type-fest": ["type-fest@4.35.0", "", {}, "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], @@ -360,34 +951,190 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="], "vite-plugin-dts": ["vite-plugin-dts@4.5.0", "", { "dependencies": { "@microsoft/api-extractor": "^7.49.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^0.5.1", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-M1lrPTdi7gilLYRZoLmGYnl4fbPryVYsehPN9JgaxjJKTs8/f7tuAlvCCvOLB5gRDQTTKnptBcB0ACsaw2wNLw=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], + "yargs": ["yargs@17.7.2", "", { "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" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/template/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "@istanbuljs/load-nyc-config/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "@microsoft/api-extractor/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + + "@microsoft/api-extractor/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@microsoft/api-extractor/minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + + "@microsoft/api-extractor-model/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + + "@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], + "@vue/language-core/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "ajv-draft-04/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], - "@microsoft/api-extractor/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "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" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "jest-config/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "parse-json/type-fest": ["type-fest@4.35.0", "", {}, "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A=="], + + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "resolve-cwd/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], + + "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js new file mode 100644 index 00000000..c50dbb38 --- /dev/null +++ b/js/cli/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + moduleDirectories: ['/node_modules', '/../sdk/node_modules', 'node_modules'], + testPathIgnorePatterns: [], + collectCoverage: false, + transformIgnorePatterns: [ + 'node_modules/(?!(@openpgp|@protontech|openpgp|jsmimeparser)/)' + ], + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + '^.+\\.mjs$': '@swc/jest', + }, + moduleNameMapper: { + '^@openpgp/noble-hashes/esm/(.*)$': '/node_modules/@openpgp/noble-hashes/esm/$1.js', + }, + reporters: ['default'], + testEnvironment: 'node', + setupFilesAfterEnv: [], +}; diff --git a/js/cli/package.json b/js/cli/package.json index 31089b43..7b8f63e1 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,22 +4,35 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { + "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers", + "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", - "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers" + "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", + "test": "jest", + "test:watch": "jest --watch --coverage=false" }, "devDependencies": { + "@swc/core": "^1.12.3", + "@swc/jest": "^0.2.38", "@types/bcryptjs": "^2.4.6", + "@types/bun": "^1.2.18", + "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", + "@typescript-eslint/eslint-plugin": "^8.19.1", "bun-types": "^1.1.31", + "eslint": "^8.57.1", + "eslint-plugin-tsdoc": "^0.3.0", + "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "typescript": "^5.5.3", "vite": "^5.4.8", "vite-plugin-dts": "^4.2.4" }, "dependencies": { + "@openpgp/noble-hashes": "^1.3.3", + "@protontech/pmcrypto": "^7.20.0", "bcryptjs": "^2.4.3", "comlink": "^4.4.2", - "@protontech/pmcrypto": "^7.20.0", "ky": "^1.7.2", "ttag": "^1.8.7" }, diff --git a/js/cli/src/cli/commandAuthLogout.ts b/js/cli/src/cli/commandAuthLogout.ts index 58df4c99..e514d152 100644 --- a/js/cli/src/cli/commandAuthLogout.ts +++ b/js/cli/src/cli/commandAuthLogout.ts @@ -1,4 +1,3 @@ -import { ParseArgsConfig } from "util"; import { Command, ActionArgs } from './interface'; export class CommandAuthLogout implements Command { diff --git a/js/cli/src/cli/commandEventFolder.ts b/js/cli/src/cli/commandEventFolder.ts index 094a4cd1..91305c99 100644 --- a/js/cli/src/cli/commandEventFolder.ts +++ b/js/cli/src/cli/commandEventFolder.ts @@ -11,7 +11,7 @@ export class CommandEventFolder implements Command { const node = await nodePath.getNode(); // Consume the initial data to trigger the subscription. - await Array.fromAsync(sdk.iterateChildren(node)); + await Array.fromAsync(sdk.iterateFolderChildren(node)); await sdk.subscribeToRemoteDataUpdates(); sdk.subscribeToFolder(node, (event) => eventsCallback(json, event)); diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 4c5db423..460dd630 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -67,7 +67,7 @@ export class Path { return path.basename(this.fullPath); } - async getNode() { + async getNode(): Promise { if (this.type === PathType.MyFiles) { const rootNode = await this.sdk.getMyFilesRootFolder(); return this.getNodeByPath(rootNode, this.sectionPath); @@ -128,12 +128,15 @@ export class Path { throw new Error('Trashed node not found'); } - private async getDevicesRootFolder() { + private async getDevicesRootFolder(): Promise { for await (const device of this.sdk.iterateDevices()) { const name = device.name.ok ? device.name.value : device.name.error.name; if (name === this.sectionRootNodeName) { const [maybeNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); - return maybeNode; + if (!maybeNode.ok && 'missingUid' in maybeNode.error) { + throw new Error(`Node not found`); + } + return maybeNode as MaybeNode; } } throw new Error('Device not found'); diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json new file mode 100644 index 00000000..ddd4746b --- /dev/null +++ b/js/cli/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "module": "commonjs", + "moduleResolution": "node", + "noEmit": false, + "noImplicitAny": true, + "strict": true, + "skipLibCheck": true, + "target": "esnext", + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "resolveJsonModule": true, + "types": [ + "bun", + "jest" + ] + }, + "include": [ + "src/**/*.ts", + "integration-tests/**/*.ts" + ], + "exclude": [ + "**/node_modules/*", + "dist/**/*" + ] +} \ 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 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 8487b13ba2fc528a1f5b735fdea1a3b38d595adc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:35:22 +0000 Subject: [PATCH 002/732] 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 6c2a8d699df398ca726cac4d0c7a724f45762ea5 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:38:11 +0000 Subject: [PATCH 003/732] 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 8ff4fe3a0ec27ecd2f9c6ce11b66ca4d03efc71d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 07:38:50 +0000 Subject: [PATCH 004/732] 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 898f5332337e3ec99144192324462a939f147c90 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 08:42:20 +0000 Subject: [PATCH 005/732] 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 56f4eb1b779fd4c1102681f17ec42404b4e968b3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 09:27:57 +0000 Subject: [PATCH 006/732] 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 0424a3274608fe2eada968aa2b00d0c3a89be99e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 9 Jul 2025 09:53:05 +0000 Subject: [PATCH 007/732] i18n: Upgrade translations from crowdin (56f4eb1b). --- 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 8ce93325e646d829f1933070d0a63f4a19ed1261 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 05:55:57 +0000 Subject: [PATCH 008/732] Implement bookmarks management --- js/cli/src/cli/commandBookmarkList.ts | 16 ++ js/cli/src/cli/commandBookmarkRemove.ts | 12 ++ js/cli/src/cli/index.ts | 4 + 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 +++ 16 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 js/cli/src/cli/commandBookmarkList.ts create mode 100644 js/cli/src/cli/commandBookmarkRemove.ts create mode 100644 js/sdk/src/internal/sharing/cryptoService.test.ts diff --git a/js/cli/src/cli/commandBookmarkList.ts b/js/cli/src/cli/commandBookmarkList.ts new file mode 100644 index 00000000..7b80e0f4 --- /dev/null +++ b/js/cli/src/cli/commandBookmarkList.ts @@ -0,0 +1,16 @@ +import { Command, ActionArgs } from './interface'; + +export class CommandBookmarkList implements Command { + group = 'bookmark'; + name = 'list'; + + async action({ sdk, options: { json } }: ActionArgs) { + for await (const bookmark of sdk.iterateBookmarks()) { + if (json) { + console.log(JSON.stringify(bookmark)); + } else { + console.log(bookmark); + } + } + } +} diff --git a/js/cli/src/cli/commandBookmarkRemove.ts b/js/cli/src/cli/commandBookmarkRemove.ts new file mode 100644 index 00000000..1d14caef --- /dev/null +++ b/js/cli/src/cli/commandBookmarkRemove.ts @@ -0,0 +1,12 @@ +import { Command, ActionArgs } from './interface'; + +export class CommandBookmarkRemove implements Command { + group = 'bookmark'; + name = 'remove'; + args = ['bookmarkUid']; + + async action({ sdk, args: [bookmarkUid] }: ActionArgs) { + await sdk.removeBookmark(bookmarkUid); + console.log(`Deleted bookmark: ${bookmarkUid}`); + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 7a4fd0a1..e4861ea1 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -31,6 +31,8 @@ import { CommandSharingRemoveUrl } from "./commandSharingRemoveUrl"; import { CommandSharingSetUrl } from "./commandSharingSetUrl"; import { CommandSharingStatus } from "./commandSharingStatus"; import { Command } from './interface'; +import { CommandBookmarkRemove } from "./commandBookmarkRemove"; +import { CommandBookmarkList } from "./commandBookmarkList"; const COMMANDS = [ new CommandAuthLogin(), @@ -61,6 +63,8 @@ const COMMANDS = [ new CommandSharingSetUrl(), new CommandSharingRemoveUrl(), new CommandInvitationList(), + new CommandBookmarkList(), + new CommandBookmarkRemove(), new CommandDeviceCreate(), new CommandDeviceRename(), new CommandDeviceDelete(), 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 861993ba51f77b50a429820d4f71bc02fbdedec5 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 06:00:19 +0000 Subject: [PATCH 009/732] 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 fa7af69f118fc427643e00c246addffb3f117a9f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 06:01:12 +0000 Subject: [PATCH 010/732] 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 6234c7be5128e3444589be4d784330eba50daa50 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 08:32:43 +0200 Subject: [PATCH 011/732] 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 302b77c0e093df7e2f7b5a390a5975fab49143ae Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 07:05:04 +0000 Subject: [PATCH 012/732] 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 4b1e4533a95830a65909991faa1785e5b344e8b2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Jun 2025 07:25:48 +0200 Subject: [PATCH 013/732] 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 687a01a075bf1d3e521408b3dbdd56064e2dfcb8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 10 Jul 2025 14:11:26 +0300 Subject: [PATCH 014/732] Fix env variable names in readme --- js/cli/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index f097ee8f..aba60606 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -46,6 +46,6 @@ Run the executable without any parameter to get help. | Name | Description | Default Value | |--------------------------|-----------------------------------------------------------------------------|-----------------------------| -| `DRIVE_SDK_BASE_URL` | URL for the API. | `drive-api.proton.me` | -| `DRIVE_SDK_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | -| `DRIVE_SDK_DISABLE_CONSOLE_LOG` | Option to turn off the logging to the stdout. Logs will always be available in the `proton-drive.log`, located in `DRIVE_SDK_CACHE_DIR`. | None | +| `PROTON_DRIVE_BASE_URL` | URL for the API. | `drive-api.proton.me` | +| `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | +| `PROTON_DRIVE_DISABLE_CONSOLE_LOG` | Option to turn off the logging to the stdout. Logs will always be available in the `proton-drive.log`, located in `PROTON_DRIVE_CACHE_DIR`. | None | From 687f1c9655bbe527939b4da93fd8a5eb204db672 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 07:37:10 +0000 Subject: [PATCH 015/732] 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 8d4f1bf086a53c7ac9fa9339a751cb164535490f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 07:37:37 +0000 Subject: [PATCH 016/732] 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 010ae9d273de034372175b27d12c9332dea18f2c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 12:11:50 +0000 Subject: [PATCH 017/732] 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 42da1d36945edb711d4aedfb455db3159653e810 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 11 Jul 2025 13:29:19 +0000 Subject: [PATCH 018/732] 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 c0be76860b799ff810f966797c8314d38396219f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 4 Jul 2025 11:53:16 +0200 Subject: [PATCH 019/732] 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 2e47ca753489cbfb888490c791094b85378ba200 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Jul 2025 11:21:17 +0000 Subject: [PATCH 020/732] Create draft when starting upload --- js/cli/src/cli/commandFileSystemUpload.ts | 5 +- 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 +- 12 files changed, 1334 insertions(+), 1052 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/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/commandFileSystemUpload.ts index 70d928cf..c40fe5fb 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/commandFileSystemUpload.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { ParseArgsConfig } from "util"; +import { Thumbnail } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemUpload implements Command { @@ -43,8 +44,8 @@ export class CommandFileSystemUpload implements Command { console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); - const thumbnails = []; // TODO - const controller = uploader.writeStream(file.stream(), thumbnails, (writtenBytes) => { + const thumbnails: Thumbnail[] = []; // TODO + const controller = await uploader.writeStream(file.stream(), thumbnails, (writtenBytes) => { console.log(`Uploaded ${writtenBytes} bytes`); }); 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 16557d7467bdf390bfb7166d85492d78a974d8c8 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 16 Jul 2025 11:26:28 +0000 Subject: [PATCH 021/732] 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 e9102f456e97a38537e3e92a0a50a3ff35aa5c72 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 18 Jul 2025 10:03:18 +0000 Subject: [PATCH 022/732] 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 ac0db2caeec7eb64f8e7628dd5a3e3ec72e45698 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 18 Jul 2025 10:18:02 +0000 Subject: [PATCH 023/732] 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 d309a81ff16eec14ea73070ebbdff687e35add26 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 22 Jul 2025 07:13:59 +0000 Subject: [PATCH 024/732] Add prettier --- js/cli/bun.lock | 3 +++ js/cli/package.json | 4 +++- js/sdk/package-lock.json | 8 ++++---- js/sdk/package.json | 4 ++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 82f5cc25..db47b437 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -24,6 +24,7 @@ "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", + "prettier": "^3.6.2", "typescript": "^5.5.3", "vite": "^5.4.8", "vite-plugin-dts": "^4.2.4", @@ -851,6 +852,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], diff --git a/js/cli/package.json b/js/cli/package.json index 7b8f63e1..23f16099 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -8,6 +8,7 @@ "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", + "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", "test": "jest", "test:watch": "jest --watch --coverage=false" }, @@ -24,6 +25,7 @@ "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", + "prettier": "^3.6.2", "typescript": "^5.5.3", "vite": "^5.4.8", "vite-plugin-dts": "^4.2.4" @@ -37,4 +39,4 @@ "ttag": "^1.8.7" }, "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" -} \ No newline at end of file +} 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 b3eef2b48c3f8eb3d03434fc92e49a11b79be9bb Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 22 Jul 2025 23:57:30 +0200 Subject: [PATCH 025/732] 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 26eef47c7e61f70a7d36b9fee7faa3b91f9e230a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 13:54:32 +0000 Subject: [PATCH 026/732] 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 344fa4cfe774151fc6bca58c06f2370c3f8a5e3c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 14:00:00 +0000 Subject: [PATCH 027/732] Fix move twice --- js/cli/src/cli/commandFileSystemRename.ts | 2 +- js/sdk/src/internal/nodes/nodesManagement.test.ts | 3 +++ js/sdk/src/internal/nodes/nodesManagement.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/js/cli/src/cli/commandFileSystemRename.ts b/js/cli/src/cli/commandFileSystemRename.ts index 470eaa93..4de29132 100644 --- a/js/cli/src/cli/commandFileSystemRename.ts +++ b/js/cli/src/cli/commandFileSystemRename.ts @@ -15,7 +15,7 @@ export class CommandFileSystemRename implements Command { console.log(JSON.stringify(renamedNode)); } else { // Use inspect to disable the depth limit. - console.log(inspect(node, {showHidden: false, depth: null, colors: true})); + console.log(inspect(renamedNode, {showHidden: false, depth: null, colors: true})); } } } 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 27df8201c260d446d51efebe37a72e852d7ccc89 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 24 Jul 2025 14:03:20 +0000 Subject: [PATCH 028/732] 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 bde10730654f93367ba1828194c1ada596dcf2db Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 28 Jul 2025 11:35:19 +0000 Subject: [PATCH 029/732] Add support of client UID --- js/cli/src/init.ts | 5 +- 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 +- 13 files changed, 156 insertions(+), 38 deletions(-) create mode 100644 js/sdk/src/interface/config.ts diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 044593cb..1e0d56bc 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -73,7 +73,10 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr httpClient, entitiesCache, cryptoCache, - config: { baseUrl: config.baseUrl }, + config: { + baseUrl: config.baseUrl, + clientUid: 'proton-drive-sdk-js-cli-test', + }, telemetry, account, openPGPCryptoModule, 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 f38473cfc3a0650fc3a84b288b5aa770b1991730 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 07:24:32 +0000 Subject: [PATCH 030/732] Add diagnostic tool --- js/cli/src/cli/CommandDiagnosticTree.ts | 44 ++++ js/cli/src/cli/index.ts | 2 + js/cli/src/cli/interface.ts | 2 + js/cli/src/init.ts | 26 +- js/cli/src/proton-drive.ts | 3 +- 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 +- 20 files changed, 1093 insertions(+), 9 deletions(-) create mode 100644 js/cli/src/cli/CommandDiagnosticTree.ts 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/cli/src/cli/CommandDiagnosticTree.ts b/js/cli/src/cli/CommandDiagnosticTree.ts new file mode 100644 index 00000000..cfbcb566 --- /dev/null +++ b/js/cli/src/cli/CommandDiagnosticTree.ts @@ -0,0 +1,44 @@ +import { ParseArgsConfig } from "util"; + +import { Command, ActionArgs } from './interface'; + +export class CommandDiagnosticTree implements Command { + group = 'diagnostic'; + name = 'tree'; + args = ['path']; + options: ParseArgsConfig['options'] = { + content: { + type: 'boolean', + short: 'c', + default: false, + }, + thumbnails: { + type: 'boolean', + short: 't', + default: false, + }, + localStructure: { + type: 'string', + short: 's', + default: '', + } + }; + + async action({ sdkDiagnostic, paths, args: [pathString], options: { json, content, thumbnails } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const options = { + verifyContent: content, + verifyThumbnails: thumbnails, + }; + + for await (const result of sdkDiagnostic.verifyNodeTree(node, options)) { + if (json) { + console.log(JSON.stringify(result)); + } else { + console.log(result); + } + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index e4861ea1..26836dde 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -4,6 +4,7 @@ import { CommandAuthLogout } from './commandAuthLogout'; import { CommandDeviceCreate } from './commandDeviceCreate'; import { CommandDeviceDelete } from './commandDeviceDelete'; import { CommandDeviceRename } from './commandDeviceRename'; +import { CommandDiagnosticTree } from './CommandDiagnosticTree'; import { CommandEventFolder } from './commandEventFolder'; import { CommandEventSharedByMe } from './commandEventSharedByMe'; import { CommandEventSharedWithMe } from './commandEventSharedWithMe'; @@ -68,6 +69,7 @@ const COMMANDS = [ new CommandDeviceCreate(), new CommandDeviceRename(), new CommandDeviceDelete(), + new CommandDiagnosticTree(), ].map((command: Command) => { command.options = command.options || {}; command.options['help'] = { diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index f834a369..2bb5a055 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from "util"; import { ProtonDriveClient } from "../../../sdk/src"; +import { Diagnostic } from "../../../sdk/src/diagnostic"; import { Account } from "../account/account"; import { Paths } from "./paths"; @@ -16,6 +17,7 @@ export interface Command { export interface ActionArgs { account: Account; sdk: ProtonDriveClient; + sdkDiagnostic: Diagnostic; paths: Paths; args: string[]; options: { [name: string]: any }; diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 1e0d56bc..207b9025 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -1,12 +1,13 @@ import { Account } from "./account/account"; +import { Srp } from "./account/srp"; +import { SQLiteEntititesCache } from "./cache"; +import { Paths } from "./cli/paths"; import { Api as CryptoApi } from "./crypto/lib/worker/api"; import { HTTPClient } from "./httpClient"; import { initTelemetry } from "./telemetry"; -import { SQLiteEntititesCache } from "./cache"; -import { Paths } from "./cli/paths"; import { ProtonDriveClient, MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy, VERSION } from "../../sdk/src"; -import { Srp } from "./account/srp"; +import { initDiagnostic } from "../../sdk/src/diagnostic"; interface Config { appVersion: string; @@ -24,10 +25,12 @@ export async function init() { const account = await initAccount(cryptoApi, config); const srp = await initSrp(cryptoApi, config); const sdk = initSDK(cryptoApi, config, account, srp); + const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); const paths = new Paths(sdk); return { account, sdk, + sdkDiagnostic, paths, }; } @@ -84,3 +87,20 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr }); return sdk; } + +function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { + const httpClient = new HTTPClient({ + ...config, + uid: account.session?.uid || "", + accessToken: account.session?.accessToken || "", + }); + const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(cryptoApi); + + return initDiagnostic({ + httpClient, + config: { baseUrl: config.baseUrl }, + account, + openPGPCryptoModule, + srpModule: srp, + }); +} diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 081af2b5..23f33449 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -3,7 +3,7 @@ import { parseArgs } from "util"; import { init } from "./init"; import { getCommand, validateCommandArguments } from "./cli"; -const { account, sdk, paths } = await init(); +const { account, sdk, sdkDiagnostic, paths } = await init(); const groupName = Bun.argv[2]; const commandName = Bun.argv[3]; @@ -34,6 +34,7 @@ if (!options.json) { await command.action({ account, sdk, + sdkDiagnostic, paths, args, options, 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 5340cd1fcd97796ec080597de8b69fc1c36ca4b9 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 12:39:42 +0200 Subject: [PATCH 031/732] 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 83408e7944838b004888a502f8ff47c2d7c88854 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 13:22:28 +0000 Subject: [PATCH 032/732] Refactor event manager: --- js/cli/src/cli/commandEventFolder.ts | 20 - js/cli/src/cli/commandEventSharedByMe.ts | 16 - js/cli/src/cli/commandEventSharedWithMe.ts | 16 - js/cli/src/cli/commandEventSync.ts | 12 - js/cli/src/cli/commandEventTrash.ts | 16 - js/cli/src/cli/commandEventsDrive.ts | 12 + js/cli/src/cli/commandEventsMyVolume.ts | 19 + js/cli/src/cli/commandEventsPath.ts | 32 ++ js/cli/src/cli/events.ts | 31 +- js/cli/src/cli/index.ts | 16 +- 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 +- 51 files changed, 1617 insertions(+), 2325 deletions(-) delete mode 100644 js/cli/src/cli/commandEventFolder.ts delete mode 100644 js/cli/src/cli/commandEventSharedByMe.ts delete mode 100644 js/cli/src/cli/commandEventSharedWithMe.ts delete mode 100644 js/cli/src/cli/commandEventSync.ts delete mode 100644 js/cli/src/cli/commandEventTrash.ts create mode 100644 js/cli/src/cli/commandEventsDrive.ts create mode 100644 js/cli/src/cli/commandEventsMyVolume.ts create mode 100644 js/cli/src/cli/commandEventsPath.ts 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/cli/src/cli/commandEventFolder.ts b/js/cli/src/cli/commandEventFolder.ts deleted file mode 100644 index 91305c99..00000000 --- a/js/cli/src/cli/commandEventFolder.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; - -export class CommandEventFolder implements Command { - group = "event"; - name = "folder"; - args = ["path"]; - - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - // Consume the initial data to trigger the subscription. - await Array.fromAsync(sdk.iterateFolderChildren(node)); - - await sdk.subscribeToRemoteDataUpdates(); - sdk.subscribeToFolder(node, (event) => eventsCallback(json, event)); - await runForever(); - } -} diff --git a/js/cli/src/cli/commandEventSharedByMe.ts b/js/cli/src/cli/commandEventSharedByMe.ts deleted file mode 100644 index a05eb1c3..00000000 --- a/js/cli/src/cli/commandEventSharedByMe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; - -export class CommandEventSharedByMe implements Command { - group = "event"; - name = "shared-by-me"; - - async action({ sdk, options: { json } }: ActionArgs) { - // Consume the initial data to trigger the subscription. - await Array.fromAsync(sdk.iterateSharedNodes()); - - await sdk.subscribeToRemoteDataUpdates(); - sdk.subscribeToSharedNodesByMe((event) => eventsCallback(json, event)); - await runForever(); - } -} diff --git a/js/cli/src/cli/commandEventSharedWithMe.ts b/js/cli/src/cli/commandEventSharedWithMe.ts deleted file mode 100644 index 2151fc90..00000000 --- a/js/cli/src/cli/commandEventSharedWithMe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; - -export class CommandEventSharedWithMe implements Command { - group = "event"; - name = "shared-with-me"; - - async action({ sdk, options: { json } }: ActionArgs) { - // Consume the initial data to trigger the subscription. - await Array.fromAsync(sdk.iterateSharedNodesWithMe()); - - await sdk.subscribeToRemoteDataUpdates(); - sdk.subscribeToSharedNodesWithMe((event) => eventsCallback(json, event)); - await runForever(); - } -} diff --git a/js/cli/src/cli/commandEventSync.ts b/js/cli/src/cli/commandEventSync.ts deleted file mode 100644 index ba866351..00000000 --- a/js/cli/src/cli/commandEventSync.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever } from "./events"; - -export class CommandEventSync implements Command { - group = "event"; - name = "sync"; - - async action({ sdk }: ActionArgs) { - await sdk.subscribeToRemoteDataUpdates(); - await runForever(); - } -} diff --git a/js/cli/src/cli/commandEventTrash.ts b/js/cli/src/cli/commandEventTrash.ts deleted file mode 100644 index d04901bf..00000000 --- a/js/cli/src/cli/commandEventTrash.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; - -export class CommandEventTrash implements Command { - group = "event"; - name = "trash"; - - async action({ sdk, options: { json } }: ActionArgs) { - // Consume the initial data to trigger the subscription. - await Array.fromAsync(sdk.iterateTrashedNodes()); - - await sdk.subscribeToRemoteDataUpdates(); - sdk.subscribeToTrashedNodes((event) => eventsCallback(json, event)); - await runForever(); - } -} diff --git a/js/cli/src/cli/commandEventsDrive.ts b/js/cli/src/cli/commandEventsDrive.ts new file mode 100644 index 00000000..935e46ca --- /dev/null +++ b/js/cli/src/cli/commandEventsDrive.ts @@ -0,0 +1,12 @@ +import { Command, ActionArgs } from "./interface"; +import { runForever, eventsCallback } from "./events"; + +export class CommandEventsDrive implements Command { + group = "events"; + name = "drive"; + + async action({ sdk, options: { json } }: ActionArgs) { + await sdk.subscribeToDriveEvents(async (event) => eventsCallback(json, () => true, event)); + await runForever(); + } +} diff --git a/js/cli/src/cli/commandEventsMyVolume.ts b/js/cli/src/cli/commandEventsMyVolume.ts new file mode 100644 index 00000000..026d9d4b --- /dev/null +++ b/js/cli/src/cli/commandEventsMyVolume.ts @@ -0,0 +1,19 @@ +import { Command, ActionArgs } from "./interface"; +import { runForever, eventsCallback } from "./events"; +import { DriveEvent } from "../../../sdk/src"; + +export class CommandEventsMyVolume implements Command { + group = "events"; + name = "my-volume"; + + async action({ sdk, options: { json } }: ActionArgs) { + const maybeNode = await sdk.getMyFilesRootFolder(); + if (!maybeNode.ok) { + throw Error("My Files not found"); + } + const node = maybeNode.value; + const filter = () => true; + await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event)); + await runForever(); + } +} diff --git a/js/cli/src/cli/commandEventsPath.ts b/js/cli/src/cli/commandEventsPath.ts new file mode 100644 index 00000000..7af2aa11 --- /dev/null +++ b/js/cli/src/cli/commandEventsPath.ts @@ -0,0 +1,32 @@ +import { Command, ActionArgs } from "./interface"; +import { runForever, eventsCallback } from "./events"; +import { DriveEvent, DriveEventType } from "../../../sdk/src"; + +export class CommandEventsPath implements Command { + group = "events"; + name = "path"; + args = ["path"]; + + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const maybeNode = await nodePath.getNode(); + if (!maybeNode.ok) { + throw Error("Folder not found"); + } + const node = maybeNode.value; + + const filter = (event: DriveEvent) => { + if (![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.TreeRefresh].includes(event.type)) { + return false; + } + if ('parentNodeUid' in event) { + // If node is deleted, parent uid may no longer exist, so better to return all deletions + return event.parentNodeUid === node.uid || event.parentNodeUid === null; + } + return false; + } + + await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event)); + await runForever(); + } +} diff --git a/js/cli/src/cli/events.ts b/js/cli/src/cli/events.ts index 12e4daae..a25c825e 100644 --- a/js/cli/src/cli/events.ts +++ b/js/cli/src/cli/events.ts @@ -1,12 +1,37 @@ +import { DriveEvent, DriveEventType } from "../../../sdk/src"; + export async function runForever() { return new Promise(() => {}); -} +} -export function eventsCallback(json: boolean, event: any) { +function mapEventToIcon(eventType: DriveEventType): string { + switch(eventType) { + case DriveEventType.NodeUpdated: + return '♻️'; + case DriveEventType.NodeCreated: + return '🐤'; + case DriveEventType.NodeDeleted: + return '❌'; + case DriveEventType.TreeRefresh: + return '🐤'; + case DriveEventType.SharedWithMeUpdated: + return '👪'; + case DriveEventType.TreeRemove: + return '🪓'; + case DriveEventType.FastForward: + return '⏩'; + } +} + +export function eventsCallback(json: boolean, filter: (event: DriveEvent) => boolean, event: DriveEvent) { + + if (!filter(event)) { + return; + } if (json) { console.log(JSON.stringify(event)); } else { - const icon = event.type === 'update' ? '♻️' : event.type === 'remove' ? '❌' : ''; + const icon = mapEventToIcon(event.type) console.log(icon, event); } } diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 26836dde..9f3fe962 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -5,11 +5,8 @@ import { CommandDeviceCreate } from './commandDeviceCreate'; import { CommandDeviceDelete } from './commandDeviceDelete'; import { CommandDeviceRename } from './commandDeviceRename'; import { CommandDiagnosticTree } from './CommandDiagnosticTree'; -import { CommandEventFolder } from './commandEventFolder'; -import { CommandEventSharedByMe } from './commandEventSharedByMe'; -import { CommandEventSharedWithMe } from './commandEventSharedWithMe'; -import { CommandEventSync } from "./commandEventSync"; -import { CommandEventTrash } from './commandEventTrash'; +import { CommandEventsDrive } from './commandEventsDrive'; +import { CommandEventsMyVolume } from './commandEventsMyVolume'; import { CommandFileSystemCreateFolder } from './commandFileSystemCreateFolder'; import { CommandFileSystemDelete } from "./commandFileSystemDelete"; import { CommandFileSystemDownload } from './commandFileSystemDownload'; @@ -34,15 +31,14 @@ import { CommandSharingStatus } from "./commandSharingStatus"; import { Command } from './interface'; import { CommandBookmarkRemove } from "./commandBookmarkRemove"; import { CommandBookmarkList } from "./commandBookmarkList"; +import { CommandEventsPath } from "./commandEventsPath"; const COMMANDS = [ new CommandAuthLogin(), new CommandAuthLogout(), - new CommandEventSync(), - new CommandEventFolder(), - new CommandEventTrash(), - new CommandEventSharedByMe(), - new CommandEventSharedWithMe(), + new CommandEventsDrive(), + new CommandEventsMyVolume(), + new CommandEventsPath(), new CommandFileSystemList(), new CommandFileSystemInfo(), new CommandFileSystemCreateFolder(), 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 69608e16c2c0f608b5e0b8126d3426ee6f21a2ee Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 13:30:15 +0000 Subject: [PATCH 033/732] Run pretty on all sdk and cli source code --- js/cli/src/cli/CommandDiagnosticTree.ts | 4 +- js/cli/src/cli/commandAuthLogin.ts | 8 +- js/cli/src/cli/commandDeviceCreate.ts | 2 +- js/cli/src/cli/commandDeviceRename.ts | 2 +- js/cli/src/cli/commandEventsDrive.ts | 8 +- js/cli/src/cli/commandEventsMyVolume.ts | 16 +- js/cli/src/cli/commandEventsPath.ts | 26 +- .../src/cli/commandFileSystemCreateFolder.ts | 6 +- js/cli/src/cli/commandFileSystemDelete.ts | 8 +- js/cli/src/cli/commandFileSystemDownload.ts | 48 +- .../commandFileSystemDownloadThumbnails.ts | 16 +- js/cli/src/cli/commandFileSystemInfo.ts | 6 +- js/cli/src/cli/commandFileSystemList.ts | 14 +- js/cli/src/cli/commandFileSystemRename.ts | 6 +- js/cli/src/cli/commandFileSystemRestore.ts | 2 +- js/cli/src/cli/commandFileSystemTrash.ts | 2 +- js/cli/src/cli/commandFileSystemUpload.ts | 6 +- js/cli/src/cli/commandInvitationList.ts | 2 +- js/cli/src/cli/commandRevisionDelete.ts | 2 +- js/cli/src/cli/commandRevisionDownload.ts | 4 +- js/cli/src/cli/commandRevisionList.ts | 6 +- js/cli/src/cli/commandRevisionRestore.ts | 2 +- js/cli/src/cli/commandSharingInvite.ts | 4 +- js/cli/src/cli/commandSharingRemove.ts | 13 +- js/cli/src/cli/commandSharingRemoveUrl.ts | 7 +- js/cli/src/cli/commandSharingSetUrl.ts | 9 +- js/cli/src/cli/commandSharingStatus.ts | 2 +- js/cli/src/cli/events.ts | 7 +- js/cli/src/cli/formatters.ts | 2 +- js/cli/src/cli/index.ts | 44 +- js/cli/src/cli/interface.ts | 10 +- js/cli/src/cli/node.ts | 2 +- js/cli/src/cli/paths.ts | 19 +- js/cli/src/init.ts | 34 +- js/cli/src/proton-drive.ts | 16 +- 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 - 190 files changed, 10645 insertions(+), 9076 deletions(-) diff --git a/js/cli/src/cli/CommandDiagnosticTree.ts b/js/cli/src/cli/CommandDiagnosticTree.ts index cfbcb566..3852709d 100644 --- a/js/cli/src/cli/CommandDiagnosticTree.ts +++ b/js/cli/src/cli/CommandDiagnosticTree.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; @@ -21,7 +21,7 @@ export class CommandDiagnosticTree implements Command { type: 'string', short: 's', default: '', - } + }, }; async action({ sdkDiagnostic, paths, args: [pathString], options: { json, content, thumbnails } }: ActionArgs) { diff --git a/js/cli/src/cli/commandAuthLogin.ts b/js/cli/src/cli/commandAuthLogin.ts index 0e963ccd..6eac852c 100644 --- a/js/cli/src/cli/commandAuthLogin.ts +++ b/js/cli/src/cli/commandAuthLogin.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandAuthLogin implements Command { @@ -13,9 +13,9 @@ export class CommandAuthLogin implements Command { }, }; - async action({ account, args: [ username ], options: { password } }: ActionArgs) { + async action({ account, args: [username], options: { password } }: ActionArgs) { if (!password) { - console.log("Password:"); + console.log('Password:'); // FIXME hide password when typing for await (const line of console) { password = line.trim(); @@ -23,6 +23,6 @@ export class CommandAuthLogin implements Command { } } await account.auth(username, password); - console.log("session:", account.session); + console.log('session:', account.session); } } diff --git a/js/cli/src/cli/commandDeviceCreate.ts b/js/cli/src/cli/commandDeviceCreate.ts index 4d4917b3..c17f7fbb 100644 --- a/js/cli/src/cli/commandDeviceCreate.ts +++ b/js/cli/src/cli/commandDeviceCreate.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandDeviceCreate implements Command { diff --git a/js/cli/src/cli/commandDeviceRename.ts b/js/cli/src/cli/commandDeviceRename.ts index b5762098..a445432a 100644 --- a/js/cli/src/cli/commandDeviceRename.ts +++ b/js/cli/src/cli/commandDeviceRename.ts @@ -13,4 +13,4 @@ export class CommandDeviceRename implements Command { console.log(device); } } -} \ No newline at end of file +} diff --git a/js/cli/src/cli/commandEventsDrive.ts b/js/cli/src/cli/commandEventsDrive.ts index 935e46ca..070c4fa2 100644 --- a/js/cli/src/cli/commandEventsDrive.ts +++ b/js/cli/src/cli/commandEventsDrive.ts @@ -1,9 +1,9 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; +import { Command, ActionArgs } from './interface'; +import { runForever, eventsCallback } from './events'; export class CommandEventsDrive implements Command { - group = "events"; - name = "drive"; + group = 'events'; + name = 'drive'; async action({ sdk, options: { json } }: ActionArgs) { await sdk.subscribeToDriveEvents(async (event) => eventsCallback(json, () => true, event)); diff --git a/js/cli/src/cli/commandEventsMyVolume.ts b/js/cli/src/cli/commandEventsMyVolume.ts index 026d9d4b..300026ff 100644 --- a/js/cli/src/cli/commandEventsMyVolume.ts +++ b/js/cli/src/cli/commandEventsMyVolume.ts @@ -1,19 +1,21 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; -import { DriveEvent } from "../../../sdk/src"; +import { Command, ActionArgs } from './interface'; +import { runForever, eventsCallback } from './events'; +import { DriveEvent } from '../../../sdk/src'; export class CommandEventsMyVolume implements Command { - group = "events"; - name = "my-volume"; + group = 'events'; + name = 'my-volume'; async action({ sdk, options: { json } }: ActionArgs) { const maybeNode = await sdk.getMyFilesRootFolder(); if (!maybeNode.ok) { - throw Error("My Files not found"); + throw Error('My Files not found'); } const node = maybeNode.value; const filter = () => true; - await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event)); + await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => + eventsCallback(json, filter, event), + ); await runForever(); } } diff --git a/js/cli/src/cli/commandEventsPath.ts b/js/cli/src/cli/commandEventsPath.ts index 7af2aa11..47fd41d4 100644 --- a/js/cli/src/cli/commandEventsPath.ts +++ b/js/cli/src/cli/commandEventsPath.ts @@ -1,22 +1,26 @@ -import { Command, ActionArgs } from "./interface"; -import { runForever, eventsCallback } from "./events"; -import { DriveEvent, DriveEventType } from "../../../sdk/src"; +import { Command, ActionArgs } from './interface'; +import { runForever, eventsCallback } from './events'; +import { DriveEvent, DriveEventType } from '../../../sdk/src'; export class CommandEventsPath implements Command { - group = "events"; - name = "path"; - args = ["path"]; + group = 'events'; + name = 'path'; + args = ['path']; async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const maybeNode = await nodePath.getNode(); if (!maybeNode.ok) { - throw Error("Folder not found"); + throw Error('Folder not found'); } const node = maybeNode.value; const filter = (event: DriveEvent) => { - if (![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.TreeRefresh].includes(event.type)) { + if ( + ![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.TreeRefresh].includes( + event.type, + ) + ) { return false; } if ('parentNodeUid' in event) { @@ -24,9 +28,11 @@ export class CommandEventsPath implements Command { return event.parentNodeUid === node.uid || event.parentNodeUid === null; } return false; - } + }; - await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event)); + await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => + eventsCallback(json, filter, event), + ); await runForever(); } } diff --git a/js/cli/src/cli/commandFileSystemCreateFolder.ts b/js/cli/src/cli/commandFileSystemCreateFolder.ts index 6b2d7124..0691b4be 100644 --- a/js/cli/src/cli/commandFileSystemCreateFolder.ts +++ b/js/cli/src/cli/commandFileSystemCreateFolder.ts @@ -1,4 +1,4 @@ -import { inspect } from "util"; +import { inspect } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemCreateFolder implements Command { @@ -6,7 +6,7 @@ export class CommandFileSystemCreateFolder implements Command { name = 'create-folder'; args = ['path', 'name']; - async action({ sdk, paths, args: [ pathString, name ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const parent = await path.getNode(); const folder = await sdk.createFolder(parent, name, new Date()); @@ -15,7 +15,7 @@ export class CommandFileSystemCreateFolder implements Command { console.log(JSON.stringify(folder)); } else { // Use inspect to disable the depth limit. - console.log(inspect(folder, {showHidden: false, depth: null, colors: true})); + console.log(inspect(folder, { showHidden: false, depth: null, colors: true })); } } } diff --git a/js/cli/src/cli/commandFileSystemDelete.ts b/js/cli/src/cli/commandFileSystemDelete.ts index 71dd101f..4eabed33 100644 --- a/js/cli/src/cli/commandFileSystemDelete.ts +++ b/js/cli/src/cli/commandFileSystemDelete.ts @@ -1,10 +1,10 @@ -import { Command, ActionArgs } from "./interface"; +import { Command, ActionArgs } from './interface'; export class CommandFileSystemDelete implements Command { - group = "filesystem"; - name = "delete"; + group = 'filesystem'; + name = 'delete'; // FIXME: support delete of multiple files - args = ["path"]; + args = ['path']; async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); diff --git a/js/cli/src/cli/commandFileSystemDownload.ts b/js/cli/src/cli/commandFileSystemDownload.ts index 20e79c91..bfad65e6 100644 --- a/js/cli/src/cli/commandFileSystemDownload.ts +++ b/js/cli/src/cli/commandFileSystemDownload.ts @@ -1,28 +1,23 @@ -import path from "node:path"; -import { ParseArgsConfig } from "util"; +import path from 'node:path'; +import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from "./interface"; -import { getName } from "./node"; +import { Command, ActionArgs } from './interface'; +import { getName } from './node'; export class CommandFileSystemDownload implements Command { - group = "filesystem"; - name = "download"; + group = 'filesystem'; + name = 'download'; // FIXME: support download of multiple files - args = ["path", "localPath"]; - options: ParseArgsConfig["options"] = { + args = ['path', 'localPath']; + options: ParseArgsConfig['options'] = { name: { - type: "string", - short: "n", - default: "", + type: 'string', + short: 'n', + default: '', }, }; - async action({ - sdk, - paths, - args: [pathString, localParentPath], - options: { name, json }, - }: ActionArgs) { + async action({ sdk, paths, args: [pathString, localParentPath], options: { name, json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); const downloader = await sdk.getFileDownloader(node); @@ -36,14 +31,10 @@ export class CommandFileSystemDownload implements Command { localPath: localPath, name: getName(node), claimedSize, - }) + }), ); } else { - console.log( - `Downloading ${getName(node)} (${ - claimedSize || "N/A" - } bytes) to ${localPath}` - ); + console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); } const file = Bun.file(localPath); @@ -55,14 +46,11 @@ export class CommandFileSystemDownload implements Command { locked: false, }; - const controller = downloader.writeToStream( - writableStream, - (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } + const controller = downloader.writeToStream(writableStream, (writtenBytes) => { + if (!json) { + console.log(`Downloaded ${writtenBytes} bytes`); } - ); + }); await controller.completion(); } diff --git a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts index f78d57dd..8b88b551 100644 --- a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts +++ b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts @@ -7,7 +7,7 @@ export class CommandFileSystemDownloadThumbnails implements Command { // FIXME: support download of multiple thumbnails args = ['path', 'parentLocalPath']; - async action({ sdk, paths, args: [ pathString, parentLocalPath ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString, parentLocalPath], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); @@ -17,16 +17,18 @@ export class CommandFileSystemDownloadThumbnails implements Command { const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); if (json) { - console.log(JSON.stringify({ - ...result, - thumbnail: undefined, // Avoid dumping binary data to JSON. - thumbnailFileName, - })); + console.log( + JSON.stringify({ + ...result, + thumbnail: undefined, // Avoid dumping binary data to JSON. + thumbnailFileName, + }), + ); } else { console.log( result.ok ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, ); } diff --git a/js/cli/src/cli/commandFileSystemInfo.ts b/js/cli/src/cli/commandFileSystemInfo.ts index 70de061b..45642ff7 100644 --- a/js/cli/src/cli/commandFileSystemInfo.ts +++ b/js/cli/src/cli/commandFileSystemInfo.ts @@ -1,4 +1,4 @@ -import { inspect } from "util"; +import { inspect } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemInfo implements Command { @@ -6,14 +6,14 @@ export class CommandFileSystemInfo implements Command { name = 'info'; args = ['path']; - async action({ paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); if (json) { console.log(JSON.stringify(node)); } else { // Use inspect to disable the depth limit. - console.log(inspect(node, {showHidden: false, depth: null, colors: true})); + console.log(inspect(node, { showHidden: false, depth: null, colors: true })); } } } diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/commandFileSystemList.ts index c01465ef..bf968e56 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/commandFileSystemList.ts @@ -1,15 +1,15 @@ -import { ProtonDriveClient, MaybeNode, MemberRole, Device } from "../../../sdk/src"; +import { ProtonDriveClient, MaybeNode, MemberRole, Device } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; import { PathType, Path } from './paths'; import { formatAuthor, formatDate, formatSize } from './formatters'; -import { getName, getClaimedSize, getNode } from "./node"; +import { getName, getClaimedSize, getNode } from './node'; export class CommandFileSystemList implements Command { group = 'filesystem'; name = 'list'; args = ['path']; - async action({ sdk, paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); if (path.type === PathType.Root) { @@ -60,14 +60,12 @@ export class CommandFileSystemList implements Command { } const node = getNode(maybeNode); - const type = node.type === "file" ? "📄" : "🗂️"; - const sharedFlag = node.isShared - ? '🔗' - : ' '; // Two spaces to align with the shared icon. + const type = node.type === 'file' ? '📄' : '🗂️'; + const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. const permissionFlag = getPermissionFlag(node.directMemberRole); const author = formatAuthor(node.keyAuthor); const created = formatDate(node.creationTime, true); - const claimedSize = getClaimedSize(maybeNode) + const claimedSize = getClaimedSize(maybeNode); const size = claimedSize ? formatSize(claimedSize) : '-'; const id = node.uid.split('~')[1]; const name = getName(maybeNode); diff --git a/js/cli/src/cli/commandFileSystemRename.ts b/js/cli/src/cli/commandFileSystemRename.ts index 4de29132..cb99029f 100644 --- a/js/cli/src/cli/commandFileSystemRename.ts +++ b/js/cli/src/cli/commandFileSystemRename.ts @@ -1,4 +1,4 @@ -import { inspect } from "util"; +import { inspect } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemRename implements Command { @@ -6,7 +6,7 @@ export class CommandFileSystemRename implements Command { name = 'rename'; args = ['path', 'newName']; - async action({ sdk, paths, args: [ pathString, newName ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString, newName], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); const renamedNode = await sdk.renameNode(node, newName); @@ -15,7 +15,7 @@ export class CommandFileSystemRename implements Command { console.log(JSON.stringify(renamedNode)); } else { // Use inspect to disable the depth limit. - console.log(inspect(renamedNode, {showHidden: false, depth: null, colors: true})); + console.log(inspect(renamedNode, { showHidden: false, depth: null, colors: true })); } } } diff --git a/js/cli/src/cli/commandFileSystemRestore.ts b/js/cli/src/cli/commandFileSystemRestore.ts index 0e41dce7..ad01f68c 100644 --- a/js/cli/src/cli/commandFileSystemRestore.ts +++ b/js/cli/src/cli/commandFileSystemRestore.ts @@ -6,7 +6,7 @@ export class CommandFileSystemRestore implements Command { // FIXME: support restore of multiple files args = ['path']; - async action({ sdk, paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); for await (const result of sdk.restoreNodes([node])) { diff --git a/js/cli/src/cli/commandFileSystemTrash.ts b/js/cli/src/cli/commandFileSystemTrash.ts index ad25f002..a44ae339 100644 --- a/js/cli/src/cli/commandFileSystemTrash.ts +++ b/js/cli/src/cli/commandFileSystemTrash.ts @@ -6,7 +6,7 @@ export class CommandFileSystemTrash implements Command { // FIXME: support trahs of multiple files args = ['path']; - async action({ sdk, paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); for await (const result of sdk.trashNodes([node])) { diff --git a/js/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/commandFileSystemUpload.ts index c40fe5fb..1743489a 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/commandFileSystemUpload.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Thumbnail } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; @@ -22,14 +22,14 @@ export class CommandFileSystemUpload implements Command { }, }; - async action({ sdk, paths, args: [ localPath, parentPath ], options: { name, newRevision } }: ActionArgs) { + async action({ sdk, paths, args: [localPath, parentPath], options: { name, newRevision } }: ActionArgs) { name = name || path.basename(localPath); const file = Bun.file(localPath); const metadata = { mediaType: file.type, expectedSize: file.size, - } + }; let uploader; if (newRevision) { diff --git a/js/cli/src/cli/commandInvitationList.ts b/js/cli/src/cli/commandInvitationList.ts index 36dbd9dd..d2c283b0 100644 --- a/js/cli/src/cli/commandInvitationList.ts +++ b/js/cli/src/cli/commandInvitationList.ts @@ -1,7 +1,7 @@ import { Command, ActionArgs } from './interface'; export class CommandInvitationList implements Command { - group = 'invitation'; + group = 'invitation'; name = 'list'; async action({ sdk, options: { json } }: ActionArgs) { diff --git a/js/cli/src/cli/commandRevisionDelete.ts b/js/cli/src/cli/commandRevisionDelete.ts index bdf6aba1..b0ec2426 100644 --- a/js/cli/src/cli/commandRevisionDelete.ts +++ b/js/cli/src/cli/commandRevisionDelete.ts @@ -5,7 +5,7 @@ export class CommandRevisionDelete implements Command { name = 'delete'; args = ['revisionUid']; - async action({ sdk, args: [ revisionUid ] }: ActionArgs) { + async action({ sdk, args: [revisionUid] }: ActionArgs) { await sdk.deleteRevision(revisionUid); } } diff --git a/js/cli/src/cli/commandRevisionDownload.ts b/js/cli/src/cli/commandRevisionDownload.ts index 432a9e88..c300c85f 100644 --- a/js/cli/src/cli/commandRevisionDownload.ts +++ b/js/cli/src/cli/commandRevisionDownload.ts @@ -5,7 +5,7 @@ export class CommandRevisionDownload implements Command { name = 'download'; args = ['revisionUid', 'localPath']; - async action({ sdk, args: [ revisionUid, localPath ] }: ActionArgs) { + async action({ sdk, args: [revisionUid, localPath] }: ActionArgs) { const downloader = await sdk.getFileRevisionDownloader(revisionUid); const claimedSize = downloader.getClaimedSizeInBytes(); console.log(`Downloading revision (${claimedSize || 'N/A'} bytes) to ${localPath}`); @@ -17,7 +17,7 @@ export class CommandRevisionDownload implements Command { close: () => writer.end(), abort: () => writer.end(), locked: false, - } + }; const controller = downloader.writeToStream(writableStream, (writtenBytes) => { console.log(`Downloaded ${writtenBytes} bytes`); diff --git a/js/cli/src/cli/commandRevisionList.ts b/js/cli/src/cli/commandRevisionList.ts index 7dd72f7e..4c5d2bf3 100644 --- a/js/cli/src/cli/commandRevisionList.ts +++ b/js/cli/src/cli/commandRevisionList.ts @@ -1,14 +1,14 @@ -import { NodeType, Revision } from "../../../sdk/src"; +import { NodeType, Revision } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; import { formatAuthor, formatDate, formatSize } from './formatters'; -import { getNode } from "./node"; +import { getNode } from './node'; export class CommandRevisionList implements Command { group = 'revision'; name = 'list'; args = ['path']; - async action({ sdk, paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const maybeNode = await path.getNode(); diff --git a/js/cli/src/cli/commandRevisionRestore.ts b/js/cli/src/cli/commandRevisionRestore.ts index 68567439..a10d0093 100644 --- a/js/cli/src/cli/commandRevisionRestore.ts +++ b/js/cli/src/cli/commandRevisionRestore.ts @@ -5,7 +5,7 @@ export class CommandRevisionRestore implements Command { name = 'restore'; args = ['revisionUid']; - async action({ sdk, args: [ revisionUid ] }: ActionArgs) { + async action({ sdk, args: [revisionUid] }: ActionArgs) { await sdk.restoreRevision(revisionUid); } } diff --git a/js/cli/src/cli/commandSharingInvite.ts b/js/cli/src/cli/commandSharingInvite.ts index 25617f51..a4f9acfb 100644 --- a/js/cli/src/cli/commandSharingInvite.ts +++ b/js/cli/src/cli/commandSharingInvite.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandSharingInvite implements Command { @@ -32,7 +32,7 @@ export class CommandSharingInvite implements Command { async action({ sdk, paths, - args: [ pathString ], + args: [pathString], options: { user: userEmails, role, message, includeNodeName, json }, }: ActionArgs) { const path = paths.getPath(pathString); diff --git a/js/cli/src/cli/commandSharingRemove.ts b/js/cli/src/cli/commandSharingRemove.ts index 34579ea7..32a32a88 100644 --- a/js/cli/src/cli/commandSharingRemove.ts +++ b/js/cli/src/cli/commandSharingRemove.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandSharingRemove implements Command { @@ -19,12 +19,7 @@ export class CommandSharingRemove implements Command { }, }; - async action({ - sdk, - paths, - args: [ pathString ], - options: { email: emails, everyone, json }, - }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { email: emails, everyone, json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); @@ -32,8 +27,8 @@ export class CommandSharingRemove implements Command { const sharingInfo = everyone ? await sdk.unshareNode(node) : await sdk.unshareNode(node, { - users: emails, - }); + users: emails, + }); if (json) { console.log(JSON.stringify(sharingInfo)); diff --git a/js/cli/src/cli/commandSharingRemoveUrl.ts b/js/cli/src/cli/commandSharingRemoveUrl.ts index d1702507..57e1da48 100644 --- a/js/cli/src/cli/commandSharingRemoveUrl.ts +++ b/js/cli/src/cli/commandSharingRemoveUrl.ts @@ -5,12 +5,7 @@ export class CommandSharingRemoveUrl implements Command { name = 'remove-url'; args = ['path']; - async action({ - sdk, - paths, - args: [ pathString ], - options: { json }, - }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); diff --git a/js/cli/src/cli/commandSharingSetUrl.ts b/js/cli/src/cli/commandSharingSetUrl.ts index 3d95d160..4e3a0c4a 100644 --- a/js/cli/src/cli/commandSharingSetUrl.ts +++ b/js/cli/src/cli/commandSharingSetUrl.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; export class CommandSharingSetUrl implements Command { @@ -20,12 +20,7 @@ export class CommandSharingSetUrl implements Command { }, }; - async action({ - sdk, - paths, - args: [pathString], - options: { json, role, password, expiration }, - }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json, role, password, expiration } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); diff --git a/js/cli/src/cli/commandSharingStatus.ts b/js/cli/src/cli/commandSharingStatus.ts index ff287326..ffee0eb5 100644 --- a/js/cli/src/cli/commandSharingStatus.ts +++ b/js/cli/src/cli/commandSharingStatus.ts @@ -5,7 +5,7 @@ export class CommandSharingStatus implements Command { name = 'status'; args = ['path']; - async action({ sdk, paths, args: [ pathString ], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); diff --git a/js/cli/src/cli/events.ts b/js/cli/src/cli/events.ts index a25c825e..fe6f7dea 100644 --- a/js/cli/src/cli/events.ts +++ b/js/cli/src/cli/events.ts @@ -1,11 +1,11 @@ -import { DriveEvent, DriveEventType } from "../../../sdk/src"; +import { DriveEvent, DriveEventType } from '../../../sdk/src'; export async function runForever() { return new Promise(() => {}); } function mapEventToIcon(eventType: DriveEventType): string { - switch(eventType) { + switch (eventType) { case DriveEventType.NodeUpdated: return '♻️'; case DriveEventType.NodeCreated: @@ -24,14 +24,13 @@ function mapEventToIcon(eventType: DriveEventType): string { } export function eventsCallback(json: boolean, filter: (event: DriveEvent) => boolean, event: DriveEvent) { - if (!filter(event)) { return; } if (json) { console.log(JSON.stringify(event)); } else { - const icon = mapEventToIcon(event.type) + const icon = mapEventToIcon(event.type); console.log(icon, event); } } diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index bc8f3794..4ac41832 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -1,4 +1,4 @@ -import { Author } from "../../../sdk/src"; +import { Author } from '../../../sdk/src'; export function formatAuthor(author: Author) { return author.ok ? author.value : `(${author.error.claimedAuthor})`; diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 9f3fe962..ba09edc6 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from "util"; +import { ParseArgsConfig } from 'util'; import { CommandAuthLogin } from './commandAuthLogin'; import { CommandAuthLogout } from './commandAuthLogout'; import { CommandDeviceCreate } from './commandDeviceCreate'; @@ -8,7 +8,7 @@ import { CommandDiagnosticTree } from './CommandDiagnosticTree'; import { CommandEventsDrive } from './commandEventsDrive'; import { CommandEventsMyVolume } from './commandEventsMyVolume'; import { CommandFileSystemCreateFolder } from './commandFileSystemCreateFolder'; -import { CommandFileSystemDelete } from "./commandFileSystemDelete"; +import { CommandFileSystemDelete } from './commandFileSystemDelete'; import { CommandFileSystemDownload } from './commandFileSystemDownload'; import { CommandFileSystemDownloadThumbnails } from './commandFileSystemDownloadThumbnails'; import { CommandFileSystemInfo } from './commandFileSystemInfo'; @@ -18,20 +18,20 @@ import { CommandFileSystemRename } from './commandFileSystemRename'; import { CommandFileSystemRestore } from './commandFileSystemRestore'; import { CommandFileSystemUpload } from './commandFileSystemUpload'; import { CommandFileSystemTrash } from './commandFileSystemTrash'; -import { CommandInvitationList } from "./commandInvitationList"; +import { CommandInvitationList } from './commandInvitationList'; import { CommandRevisionDelete } from './commandRevisionDelete'; import { CommandRevisionDownload } from './commandRevisionDownload'; import { CommandRevisionList } from './commandRevisionList'; import { CommandRevisionRestore } from './commandRevisionRestore'; -import { CommandSharingInvite } from "./commandSharingInvite"; -import { CommandSharingRemove } from "./commandSharingRemove"; -import { CommandSharingRemoveUrl } from "./commandSharingRemoveUrl"; -import { CommandSharingSetUrl } from "./commandSharingSetUrl"; -import { CommandSharingStatus } from "./commandSharingStatus"; +import { CommandSharingInvite } from './commandSharingInvite'; +import { CommandSharingRemove } from './commandSharingRemove'; +import { CommandSharingRemoveUrl } from './commandSharingRemoveUrl'; +import { CommandSharingSetUrl } from './commandSharingSetUrl'; +import { CommandSharingStatus } from './commandSharingStatus'; import { Command } from './interface'; -import { CommandBookmarkRemove } from "./commandBookmarkRemove"; -import { CommandBookmarkList } from "./commandBookmarkList"; -import { CommandEventsPath } from "./commandEventsPath"; +import { CommandBookmarkRemove } from './commandBookmarkRemove'; +import { CommandBookmarkList } from './commandBookmarkList'; +import { CommandEventsPath } from './commandEventsPath'; const COMMANDS = [ new CommandAuthLogin(), @@ -85,12 +85,14 @@ export function getCommand(groupName: string, commandName: string): Command { groupName = 'filesystem'; } - let commands = COMMANDS.filter(command => command.group.startsWith(groupName) && command.name === commandName); + let commands = COMMANDS.filter((command) => command.group.startsWith(groupName) && command.name === commandName); if (commands.length === 1) { return commands[0]; } - commands = COMMANDS.filter(command => command.group.startsWith(groupName) && command.name.startsWith(commandName)); + commands = COMMANDS.filter( + (command) => command.group.startsWith(groupName) && command.name.startsWith(commandName), + ); if (commands.length === 1) { return commands[0]; } @@ -107,7 +109,7 @@ export function validateCommandArguments(command: Command, args: string[], value } } - Object.entries(command.options as ParseArgsConfig['options'] || {}).forEach(([key, option]) => { + Object.entries((command.options as ParseArgsConfig['options']) || {}).forEach(([key, option]) => { if (option.default !== undefined) { values[key] = values[key] || option.default; return; @@ -120,27 +122,27 @@ export function validateCommandArguments(command: Command, args: string[], value } function printUsage() { - console.log("Usage:"); + console.log('Usage:'); COMMANDS.map((command) => { printCommandManual(command); }); - console.log("General options:"); - console.log(" --help: Show this help"); - console.log(" -j, --json: Output in JSON format"); + console.log('General options:'); + console.log(' --help: Show this help'); + console.log(' -j, --json: Output in JSON format'); } function printCommandUsage(command: Command) { - console.log("Usage:"); + console.log('Usage:'); printCommandManual(command); } function printCommandManual(command: Command) { - const args = (command.args || []).map(arg => `<${arg}>`).join(' '); + const args = (command.args || []).map((arg) => `<${arg}>`).join(' '); const options = Object.entries(command.options || {}) .filter(([name]) => name !== 'help' && name !== 'json') .map(([name, { type, short }]) => { const flag = short ? `-${short}|--${name}` : `--${name}`; - return type === 'boolean' ? flag : `${flag} <${type}>` + return type === 'boolean' ? flag : `${flag} <${type}>`; }) .join(' '); console.log(` ${command.group} ${command.name} ${args} ${options}`); diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 2bb5a055..c9e4fc85 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,8 +1,8 @@ -import { ParseArgsConfig } from "util"; -import { ProtonDriveClient } from "../../../sdk/src"; -import { Diagnostic } from "../../../sdk/src/diagnostic"; -import { Account } from "../account/account"; -import { Paths } from "./paths"; +import { ParseArgsConfig } from 'util'; +import { ProtonDriveClient } from '../../../sdk/src'; +import { Diagnostic } from '../../../sdk/src/diagnostic'; +import { Account } from '../account/account'; +import { Paths } from './paths'; export interface Command { group: string; diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index 1644641f..8d1a7a66 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -1,4 +1,4 @@ -import { MaybeNode, NodeEntity, DegradedNode } from "../../../sdk/src"; +import { MaybeNode, NodeEntity, DegradedNode } from '../../../sdk/src'; export function getNode(maybeNode: MaybeNode): NodeEntity | DegradedNode { return maybeNode.ok ? maybeNode.value : maybeNode.error; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 460dd630..c1a785f7 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { ProtonDriveClient, MaybeNode } from "../../../sdk/src"; +import { ProtonDriveClient, MaybeNode } from '../../../sdk/src'; import { getName } from './node'; export enum PathType { @@ -17,13 +17,9 @@ export class Paths { } get rootPaths(): string[] { - return [ - PathType.MyFiles, - PathType.Devices, - PathType.SharedByMe, - PathType.SharedWithMe, - PathType.Trash, - ].map((path) => `/${path}`); + return [PathType.MyFiles, PathType.Devices, PathType.SharedByMe, PathType.SharedWithMe, PathType.Trash].map( + (path) => `/${path}`, + ); } getPath(path: string): Path { @@ -32,7 +28,10 @@ export class Paths { } export class Path { - constructor(private sdk: ProtonDriveClient, public fullPath: string) { + constructor( + private sdk: ProtonDriveClient, + public fullPath: string, + ) { this.sdk = sdk; this.fullPath = fullPath; } @@ -146,7 +145,7 @@ export class Path { let node = parentNode; const pathParts = pathString.split(path.sep); for (const part of pathParts) { - if (part === "") { + if (part === '') { continue; } node = await this.getNodeByName(node, part); diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 207b9025..c97d4597 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -1,13 +1,19 @@ -import { Account } from "./account/account"; -import { Srp } from "./account/srp"; -import { SQLiteEntititesCache } from "./cache"; -import { Paths } from "./cli/paths"; -import { Api as CryptoApi } from "./crypto/lib/worker/api"; -import { HTTPClient } from "./httpClient"; -import { initTelemetry } from "./telemetry"; +import { Account } from './account/account'; +import { Srp } from './account/srp'; +import { SQLiteEntititesCache } from './cache'; +import { Paths } from './cli/paths'; +import { Api as CryptoApi } from './crypto/lib/worker/api'; +import { HTTPClient } from './httpClient'; +import { initTelemetry } from './telemetry'; -import { ProtonDriveClient, MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy, VERSION } from "../../sdk/src"; -import { initDiagnostic } from "../../sdk/src/diagnostic"; +import { + ProtonDriveClient, + MemoryCache, + CachedCryptoMaterial, + OpenPGPCryptoWithCryptoProxy, + VERSION, +} from '../../sdk/src'; +import { initDiagnostic } from '../../sdk/src/diagnostic'; interface Config { appVersion: string; @@ -46,7 +52,7 @@ function getConfig(): Config { baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), enableConsoleLog: process.env.PROTON_DRIVE_DISABLE_CONSOLE_LOG === undefined, - } + }; } async function initAccount(cryptoApi: CryptoApi, config: Config) { @@ -62,8 +68,8 @@ async function initSrp(cryptoApi: CryptoApi, config: Config) { function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { const httpClient = new HTTPClient({ ...config, - uid: account.session?.uid || "", - accessToken: account.session?.accessToken || "", + uid: account.session?.uid || '', + accessToken: account.session?.accessToken || '', }); const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(cryptoApi); @@ -91,8 +97,8 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { const httpClient = new HTTPClient({ ...config, - uid: account.session?.uid || "", - accessToken: account.session?.accessToken || "", + uid: account.session?.uid || '', + accessToken: account.session?.accessToken || '', }); const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(cryptoApi); diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 23f33449..e3ae906b 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,7 +1,7 @@ -import { parseArgs } from "util"; +import { parseArgs } from 'util'; -import { init } from "./init"; -import { getCommand, validateCommandArguments } from "./cli"; +import { init } from './init'; +import { getCommand, validateCommandArguments } from './cli'; const { account, sdk, sdkDiagnostic, paths } = await init(); @@ -21,14 +21,14 @@ validateCommandArguments(command, args, options); if (!command.isAuthAction) { if (!account.session) { - throw new Error("You need to login first"); + throw new Error('You need to login first'); } await account.loadPrimaryKeys(); - console.log("----------"); + console.log('----------'); } if (!options.json) { - console.time("Command execution"); + console.time('Command execution'); } await command.action({ @@ -41,7 +41,7 @@ await command.action({ }); if (!options.json) { - console.timeEnd("Command execution"); + console.timeEnd('Command execution'); } -process.exit() +process.exit(); 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 bc59d7663dada2cd2857157defc10f7dde0532a6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 30 Jul 2025 11:33:02 +0200 Subject: [PATCH 034/732] 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 dea45c729d18bfadcf3ae6d58c9979f4c36fe3bb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 30 Jul 2025 12:56:09 +0000 Subject: [PATCH 035/732] 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 b9a3943ee26d69f9926fa0d4a0fcb5d7b36ec071 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 07:29:04 +0000 Subject: [PATCH 036/732] 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 a9d74c5078ce4c724085d857046d5574390f5d35 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 11:13:56 +0000 Subject: [PATCH 037/732] 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 4daea885e60b3e823b7e69b18564d8fd961fbdfd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 31 Jul 2025 14:52:25 +0000 Subject: [PATCH 038/732] 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 5f3039deb4966569097243a6fc2b91311b1c0a95 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 Aug 2025 04:31:54 +0000 Subject: [PATCH 039/732] 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 7c54bba9ccca5e6da407eab8ac07b0a2059c9cdf Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 Aug 2025 06:33:06 +0200 Subject: [PATCH 040/732] 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 7f0c1b683f3c2160b706e81eb5255fb226818629 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 08:20:54 +0000 Subject: [PATCH 041/732] 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 09ed2bf0f0d0dc88fcc4f17acafb8e61885f3fcd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 09:16:28 +0000 Subject: [PATCH 042/732] Fix event subscriptions --- js/cli/src/init.ts | 7 + .../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 + 10 files changed, 287 insertions(+), 191 deletions(-) diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index c97d4597..aba3ab9e 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -90,6 +90,7 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr account, openPGPCryptoModule, srpModule: srp, + latestEventIdProvider: new NoLatestEventIdProvider(), }); return sdk; } @@ -110,3 +111,9 @@ function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Accoun srpModule: srp, }); } + +class NoLatestEventIdProvider { + getLatestEventId(): string | null { + return null; + } +} 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 616f9ad0448ca98a1a6846231af6f503608ea0f5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 Aug 2025 11:22:52 +0200 Subject: [PATCH 043/732] 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 4b3c36bc9fa2b2fe5247e12a53345c46b9cf3b48 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 Aug 2025 10:06:46 +0200 Subject: [PATCH 044/732] 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 6047023d9139e9f2a2dea8bcb5cd7bbe3cec8c21 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 Aug 2025 11:56:08 +0000 Subject: [PATCH 045/732] Add events ready info into CLI --- js/cli/src/cli/commandEventsDrive.ts | 3 ++- js/cli/src/cli/commandEventsMyVolume.ts | 3 ++- js/cli/src/cli/commandEventsPath.ts | 3 ++- js/cli/src/cli/events.ts | 8 ++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/js/cli/src/cli/commandEventsDrive.ts b/js/cli/src/cli/commandEventsDrive.ts index 070c4fa2..c5533fa0 100644 --- a/js/cli/src/cli/commandEventsDrive.ts +++ b/js/cli/src/cli/commandEventsDrive.ts @@ -1,5 +1,5 @@ import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback } from './events'; +import { runForever, eventsCallback, eventsReady } from './events'; export class CommandEventsDrive implements Command { group = 'events'; @@ -7,6 +7,7 @@ export class CommandEventsDrive implements Command { async action({ sdk, options: { json } }: ActionArgs) { await sdk.subscribeToDriveEvents(async (event) => eventsCallback(json, () => true, event)); + eventsReady(json); await runForever(); } } diff --git a/js/cli/src/cli/commandEventsMyVolume.ts b/js/cli/src/cli/commandEventsMyVolume.ts index 300026ff..475fe289 100644 --- a/js/cli/src/cli/commandEventsMyVolume.ts +++ b/js/cli/src/cli/commandEventsMyVolume.ts @@ -1,5 +1,5 @@ import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback } from './events'; +import { runForever, eventsCallback, eventsReady } from './events'; import { DriveEvent } from '../../../sdk/src'; export class CommandEventsMyVolume implements Command { @@ -16,6 +16,7 @@ export class CommandEventsMyVolume implements Command { await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event), ); + eventsReady(json); await runForever(); } } diff --git a/js/cli/src/cli/commandEventsPath.ts b/js/cli/src/cli/commandEventsPath.ts index 47fd41d4..56b000a5 100644 --- a/js/cli/src/cli/commandEventsPath.ts +++ b/js/cli/src/cli/commandEventsPath.ts @@ -1,5 +1,5 @@ import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback } from './events'; +import { runForever, eventsCallback, eventsReady } from './events'; import { DriveEvent, DriveEventType } from '../../../sdk/src'; export class CommandEventsPath implements Command { @@ -33,6 +33,7 @@ export class CommandEventsPath implements Command { await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event), ); + eventsReady(json); await runForever(); } } diff --git a/js/cli/src/cli/events.ts b/js/cli/src/cli/events.ts index fe6f7dea..0e754350 100644 --- a/js/cli/src/cli/events.ts +++ b/js/cli/src/cli/events.ts @@ -23,6 +23,14 @@ function mapEventToIcon(eventType: DriveEventType): string { } } +export function eventsReady(json: boolean) { + if (json) { + console.log('{"ready": true}'); + } else { + console.log('🟢 Events ready'); + } +} + export function eventsCallback(json: boolean, filter: (event: DriveEvent) => boolean, event: DriveEvent) { if (!filter(event)) { return; From 4de5c98de476bab50ef62d4a18b7c1868b545a51 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 8 Aug 2025 05:56:37 +0000 Subject: [PATCH 046/732] Add seeking support for download --- .../cli/commandFileSystemDownloadSeeking.ts | 155 +++++++++++++++ js/cli/src/cli/index.ts | 2 + 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 +- 16 files changed, 1090 insertions(+), 32 deletions(-) create mode 100644 js/cli/src/cli/commandFileSystemDownloadSeeking.ts 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/cli/src/cli/commandFileSystemDownloadSeeking.ts b/js/cli/src/cli/commandFileSystemDownloadSeeking.ts new file mode 100644 index 00000000..167c8f11 --- /dev/null +++ b/js/cli/src/cli/commandFileSystemDownloadSeeking.ts @@ -0,0 +1,155 @@ +import * as readline from 'readline'; + +import { SeekableReadableStream } from '../../../sdk/src'; + +import { Command, ActionArgs } from './interface'; +import { runForever } from './events'; + +export class CommandFileSystemDownloadSeeking implements Command { + group = 'filesystem'; + name = 'download-seeking'; + args = ['path']; + + async action({ sdk, paths, args: [pathString] }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + const downloader = await sdk.getFileDownloader(node); + + const claimedSize = downloader.getClaimedSizeInBytes(); + console.log(`Claimed size: ${claimedSize || 'N/A'} bytes`); + + const stream = downloader.getSeekableStream(); + + this.startInteractiveShell(stream); + + await runForever(); + } + + private startInteractiveShell(stream: SeekableReadableStream) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: '> ', + }); + + console.log('Interactive download shell started. Type "help" for available commands.'); + rl.prompt(); + rl.on('line', async (input: string) => { + await this.processCommand(input, stream, () => rl.close()); + rl.prompt(); + }); + rl.on('close', () => { + process.exit(0); + }); + } + + private async processCommand(input: string, stream: SeekableReadableStream, onExit: () => void) { + const trimmed = input.trim(); + if (!trimmed) { + return; + } + + const parts = trimmed.split(/\s+/); + const command = parts[0].toLowerCase(); + const arg = parts[1]; + + try { + switch (command) { + case 'help': + this.printHelp(); + break; + + case 'read': + const bytesToRead = parseInt(arg); + if (isNaN(bytesToRead)) { + console.log('Error: Please provide a valid number of bytes to read'); + break; + } + + await this.readBytes(stream, bytesToRead); + break; + + case 'seek': + const position = parseInt(arg); + if (isNaN(position)) { + console.log('Error: Please provide a valid byte position to seek to'); + break; + } + + await stream.seek(position); + break; + + case 'exit': + onExit(); + return; + + default: + console.log(`Unknown command: ${command}. Type "help" for available commands.`); + break; + } + } catch (error) { + console.error(`Error executing command: ${error instanceof Error ? error.message : error}`); + } + } + + private printHelp() { + console.log('Available commands:'); + console.log(' help - Show this help'); + console.log(' read NUMBER - Read NUMBER bytes from current position'); + console.log(' seek NUMBER - Seek to byte position NUMBER'); + console.log(' exit - Exit the interactive shell'); + } + + private async readBytes(stream: SeekableReadableStream, bytesToRead: number) { + const { value, done } = await stream.read(bytesToRead); + console.log(this.formatBytes(value)); + if (done) { + console.log('End of stream reached'); + } + } + + private formatBytes(bytes: Uint8Array): string { + const maxDisplayBytes = 200; // Limit display to avoid overwhelming output + const displayBytes = bytes.slice(0, maxDisplayBytes); + + let textDisplay = ''; + let isPrintable = true; + + for (let i = 0; i < displayBytes.length; i++) { + const byte = displayBytes[i]; + if (byte >= 32 && byte <= 126) { + textDisplay += String.fromCharCode(byte); + } else if (byte === 10 || byte === 13 || byte === 9) { + // Allow newlines, carriage returns, and tabs + textDisplay += String.fromCharCode(byte); + } else { + isPrintable = false; + break; + } + } + + let result = ''; + + if (isPrintable && textDisplay.length > 0) { + result += 'Text representation:\n' + textDisplay + '\n\n'; + } + + result += 'Hex representation:\n'; + const hexLines = []; + for (let i = 0; i < displayBytes.length; i += 16) { + const line = displayBytes.slice(i, i + 16); + const hex = Array.from(line, (byte) => byte.toString(16).padStart(2, '0')).join(' '); + const ascii = Array.from(line, (byte) => + byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : '.', + ).join(''); + hexLines.push(`${i.toString(16).padStart(8, '0')}: ${hex.padEnd(47)} |${ascii}|`); + } + result += hexLines.join('\n'); + + if (bytes.length > maxDisplayBytes) { + result += `\n... (${bytes.length - maxDisplayBytes} more bytes)`; + } + + return result; + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index ba09edc6..e8b09cbc 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -10,6 +10,7 @@ import { CommandEventsMyVolume } from './commandEventsMyVolume'; import { CommandFileSystemCreateFolder } from './commandFileSystemCreateFolder'; import { CommandFileSystemDelete } from './commandFileSystemDelete'; import { CommandFileSystemDownload } from './commandFileSystemDownload'; +import { CommandFileSystemDownloadSeeking } from './commandFileSystemDownloadSeeking'; import { CommandFileSystemDownloadThumbnails } from './commandFileSystemDownloadThumbnails'; import { CommandFileSystemInfo } from './commandFileSystemInfo'; import { CommandFileSystemList } from './commandFileSystemList'; @@ -48,6 +49,7 @@ const COMMANDS = [ new CommandFileSystemDelete(), new CommandFileSystemRestore(), new CommandFileSystemDownload(), + new CommandFileSystemDownloadSeeking(), new CommandFileSystemDownloadThumbnails(), new CommandFileSystemUpload(), new CommandRevisionList(), 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 73c5f461b1f9989496c6a8ccce1ad547a1218485 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 29 Jul 2025 09:48:04 +0200 Subject: [PATCH 047/732] 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 c1530e9a551b7de34bd47c8af36173150ab21a14 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 Aug 2025 05:02:53 +0000 Subject: [PATCH 048/732] 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 3b49612f3e1619c8c7bd4972d47a97706a2a7a5e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 04:58:24 +0000 Subject: [PATCH 049/732] 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 127019c1212434ec4eea2df1bac1c7f8c45ac044 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 11:36:06 +0000 Subject: [PATCH 050/732] Add node membership --- js/cli/src/cli/commandFileSystemList.ts | 2 +- 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 +- 26 files changed, 645 insertions(+), 292 deletions(-) diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/commandFileSystemList.ts index bf968e56..c908b588 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/commandFileSystemList.ts @@ -62,7 +62,7 @@ export class CommandFileSystemList implements Command { const type = node.type === 'file' ? '📄' : '🗂️'; const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. - const permissionFlag = getPermissionFlag(node.directMemberRole); + const permissionFlag = getPermissionFlag(node.directRole); const author = formatAuthor(node.keyAuthor); const created = formatDate(node.creationTime, true); const claimedSize = getClaimedSize(maybeNode); 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 c54fc9a041d9e295c9f48cb82f900e6ed80686ff Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 Aug 2025 08:37:13 +0200 Subject: [PATCH 051/732] 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 2f318202a1f08f6bdd25bc7d1bfbb0ba44c5ba38 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 Aug 2025 14:42:11 +0200 Subject: [PATCH 052/732] 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 5425c831d795f946b4182ef1c927c11667dbd5e6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 15 Aug 2025 07:51:01 +0200 Subject: [PATCH 053/732] 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 49769ace87e47c30549f21556ba4844ed8bbea46 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 09:36:27 +0200 Subject: [PATCH 054/732] 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 43aa835f4e75e8959ecfcc81c1c35a9b4a4a1957 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 12:12:09 +0200 Subject: [PATCH 055/732] 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 d2b8e1a1786a7ef0c67f1e81ef3403db25d945a6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 Aug 2025 08:17:13 +0200 Subject: [PATCH 056/732] Add node details to diagnostic results --- js/cli/src/cli/CommandDiagnosticTree.ts | 3 +- js/sdk/src/diagnostic/interface.ts | 54 ++++++++------- js/sdk/src/diagnostic/sdkDiagnostic.ts | 88 ++++++++++++++++--------- 3 files changed, 86 insertions(+), 59 deletions(-) diff --git a/js/cli/src/cli/CommandDiagnosticTree.ts b/js/cli/src/cli/CommandDiagnosticTree.ts index 3852709d..983b49b6 100644 --- a/js/cli/src/cli/CommandDiagnosticTree.ts +++ b/js/cli/src/cli/CommandDiagnosticTree.ts @@ -37,7 +37,8 @@ export class CommandDiagnosticTree implements Command { if (json) { console.log(JSON.stringify(result)); } else { - console.log(result); + // Empty string as first argument is a workaround to print deep structures with Bun. + console.log('', result); } } } 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 2d3c8595ec21ebadc3efb5b65b3ae9e72ec096cb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 Aug 2025 09:43:45 +0200 Subject: [PATCH 057/732] 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 4562b1746d983485ab67df286b6d54244d8bfb1b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 Aug 2025 14:29:30 +0200 Subject: [PATCH 058/732] 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 ef1f0417bf1514f557eda8a86ff6eb8986ada18e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 06:18:44 +0000 Subject: [PATCH 059/732] 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 b3140d69988fd18c986cf538fb88e5627dc2b1a8 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 09:05:51 +0200 Subject: [PATCH 060/732] 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 5709cef8538ca01a06e26ae28bd582ed164b6d6d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 08:21:08 +0200 Subject: [PATCH 061/732] 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 fe3638ffd59883b668eccbae443c0f117586f59f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Sep 2025 09:47:18 +0000 Subject: [PATCH 062/732] 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 c25e6113e4b10d3f26442e0459673c3fe3bf74d8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Sep 2025 07:03:52 +0200 Subject: [PATCH 063/732] 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 c1ae2a884be4f941b07a4f9be1a939b9d10af376 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Sep 2025 07:51:14 +0200 Subject: [PATCH 064/732] 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 9ec8d9879e4a622dbbaa7dae2a5cba1e579e74b8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Sep 2025 08:27:08 +0200 Subject: [PATCH 065/732] 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 c2dd584ea0551cb7659e5b3b00f97da41fa12425 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Sep 2025 12:54:06 +0000 Subject: [PATCH 066/732] Add public access --- js/cli/src/cli/commandPublicList.ts | 38 ++++ js/cli/src/cli/index.ts | 2 + 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 +++++++++++++ 28 files changed, 1277 insertions(+), 40 deletions(-) create mode 100644 js/cli/src/cli/commandPublicList.ts 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/cli/src/cli/commandPublicList.ts b/js/cli/src/cli/commandPublicList.ts new file mode 100644 index 00000000..f0786838 --- /dev/null +++ b/js/cli/src/cli/commandPublicList.ts @@ -0,0 +1,38 @@ + +import { ParseArgsConfig } from 'util'; +import { Command, ActionArgs } from './interface'; + +export class CommandPublicList implements Command { + group = 'public'; + name = 'list'; + args = ['path']; + options: ParseArgsConfig['options'] = { + url: { + type: 'string', + short: 'u', + }, + customPassword: { + type: 'string', + short: 'c', + default: '', + }, + }; + + // TODO: json output + async action({ sdk, options: { url, customPassword } }: ActionArgs) { + const { isCustomPasswordProtected } = await sdk.experimental.getPublicLinkInfo(url); + + if (isCustomPasswordProtected && !customPassword) { + throw new Error('Custom password is required'); + } + + const client = await sdk.experimental.authPublicLink(url, customPassword); + + const root = await client.getRootNode(); + console.log(root); + + for await (const node of client.iterateChildren(root.uid)) { + console.log(node); + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index e8b09cbc..be4f5f7a 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -33,6 +33,7 @@ import { Command } from './interface'; import { CommandBookmarkRemove } from './commandBookmarkRemove'; import { CommandBookmarkList } from './commandBookmarkList'; import { CommandEventsPath } from './commandEventsPath'; +import { CommandPublicList } from './commandPublicList'; const COMMANDS = [ new CommandAuthLogin(), @@ -67,6 +68,7 @@ const COMMANDS = [ new CommandDeviceCreate(), new CommandDeviceRename(), new CommandDeviceDelete(), + new CommandPublicList(), new CommandDiagnosticTree(), ].map((command: Command) => { command.options = command.options || {}; 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 41c926abd2665df39af6c4b244a1474754f159c8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Sep 2025 12:59:25 +0000 Subject: [PATCH 067/732] 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 5aed84130bad0c9219ad7c9e3d6da1dc7db7ee82 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Sep 2025 10:09:12 +0200 Subject: [PATCH 068/732] 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 41a475336fe5e3995533eb4576722bfa766b0d35 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Sep 2025 16:35:56 +0200 Subject: [PATCH 069/732] 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 26f71aa8d48be4627667689d8dc62536f15d4e27 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Sep 2025 07:45:05 +0200 Subject: [PATCH 070/732] 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 513305b14cc10758da12d2c4f289ffa8e88946fd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 06:36:05 +0200 Subject: [PATCH 071/732] 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 88b7ee439c8bf4cc087678cf8974ea036f5e8574 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 11:32:53 +0000 Subject: [PATCH 072/732] 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 75b341a575e386bff9cfa0625c6c27754f7eb778 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 07:35:32 +0200 Subject: [PATCH 073/732] 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 1364de7045be6b0931f5d2dc2c94d8baf907a536 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Sep 2025 05:44:39 +0000 Subject: [PATCH 074/732] Reuse Node entity for public link access --- js/cli/src/cli/commandPublicList.ts | 3 +- 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 +++- 17 files changed, 392 insertions(+), 417 deletions(-) create mode 100644 js/sdk/src/internal/nodes/cryptoReporter.ts diff --git a/js/cli/src/cli/commandPublicList.ts b/js/cli/src/cli/commandPublicList.ts index f0786838..d442a664 100644 --- a/js/cli/src/cli/commandPublicList.ts +++ b/js/cli/src/cli/commandPublicList.ts @@ -28,10 +28,11 @@ export class CommandPublicList implements Command { const client = await sdk.experimental.authPublicLink(url, customPassword); + // TODO: get node by path const root = await client.getRootNode(); console.log(root); - for await (const node of client.iterateChildren(root.uid)) { + for await (const node of client.iterateFolderChildren(root)) { console.log(node); } } 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 7c01a012ed61deb5d837ec1c27506c23028ec95d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Sep 2025 09:21:04 +0000 Subject: [PATCH 075/732] 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 144e38271cb658ad80b61391f85a6dccd3896381 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 09:18:33 +0200 Subject: [PATCH 076/732] 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 85c95233e155cd2402a06f6e3d94aa7976b55dd3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Sep 2025 08:55:13 +0200 Subject: [PATCH 077/732] 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 b0330f9d6888f8db4f5cdfea7b3fc8d5341c3366 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 15:41:10 +0200 Subject: [PATCH 078/732] 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 2cac2652236534f9e02d3c5f6f59085fbf699997 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Sep 2025 08:56:47 +0200 Subject: [PATCH 079/732] Add copyNodes --- js/cli/src/cli/commandFileSystemCopy.ts | 24 +++++ js/cli/src/cli/index.ts | 2 + 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 +++++ 8 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 js/cli/src/cli/commandFileSystemCopy.ts diff --git a/js/cli/src/cli/commandFileSystemCopy.ts b/js/cli/src/cli/commandFileSystemCopy.ts new file mode 100644 index 00000000..637a77a0 --- /dev/null +++ b/js/cli/src/cli/commandFileSystemCopy.ts @@ -0,0 +1,24 @@ +import { Command, ActionArgs } from './interface'; + +export class CommandFileSystemCopy implements Command { + group = 'filesystem'; + name = 'copy'; + // FIXME: support copy of multiple files + args = ['sourcePath', 'targetPath']; + + async action({ sdk, paths, args: [sourcePathString, targetPathString], options: { json } }: ActionArgs) { + const sourcePath = paths.getPath(sourcePathString); + const sourceNode = await sourcePath.getNode(); + + const targetPath = paths.getPath(targetPathString); + const targetNode = await targetPath.getNode(); + + for await (const result of sdk.copyNodes([sourceNode], targetNode)) { + if (json) { + console.log(JSON.stringify(result)); + } else { + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`); + } + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index be4f5f7a..5382108e 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -7,6 +7,7 @@ import { CommandDeviceRename } from './commandDeviceRename'; import { CommandDiagnosticTree } from './CommandDiagnosticTree'; import { CommandEventsDrive } from './commandEventsDrive'; import { CommandEventsMyVolume } from './commandEventsMyVolume'; +import { CommandFileSystemCopy } from './commandFileSystemCopy'; import { CommandFileSystemCreateFolder } from './commandFileSystemCreateFolder'; import { CommandFileSystemDelete } from './commandFileSystemDelete'; import { CommandFileSystemDownload } from './commandFileSystemDownload'; @@ -46,6 +47,7 @@ const COMMANDS = [ new CommandFileSystemCreateFolder(), new CommandFileSystemRename(), new CommandFileSystemMove(), + new CommandFileSystemCopy(), new CommandFileSystemTrash(), new CommandFileSystemDelete(), new CommandFileSystemRestore(), 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 e4bacf9aecaab7ec54b4807f509f7c57a8151ac4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Sep 2025 15:30:52 +0200 Subject: [PATCH 080/732] Add filter options for listing children --- js/cli/src/cli/commandFileSystemList.ts | 25 +++++-- 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 ++- 6 files changed, 170 insertions(+), 30 deletions(-) diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/commandFileSystemList.ts index c908b588..7e8ab180 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/commandFileSystemList.ts @@ -1,4 +1,6 @@ -import { ProtonDriveClient, MaybeNode, MemberRole, Device } from '../../../sdk/src'; +import { ParseArgsConfig } from 'node:util'; + +import { ProtonDriveClient, MaybeNode, MemberRole, Device, NodeType } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; import { PathType, Path } from './paths'; import { formatAuthor, formatDate, formatSize } from './formatters'; @@ -8,17 +10,29 @@ export class CommandFileSystemList implements Command { group = 'filesystem'; name = 'list'; args = ['path']; + options: ParseArgsConfig['options'] = { + type: { + type: 'string', + short: 't', + default: '', + }, + }; - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString], options: { json, type } }: ActionArgs) { const path = paths.getPath(pathString); + const nodeType = type ? Object.entries(NodeType).find(([, value]) => value === type)?.[1] : undefined; + if (type && !nodeType) { + throw new Error(`Invalid node type: ${type}`); + } + if (path.type === PathType.Root) { if (json) { throw new Error('Cannot use --json option with root path'); } paths.rootPaths.forEach((path) => console.log(path)); } else if (path.type === PathType.MyFiles) { - await this.printChildren(sdk, path, { json }); + await this.printChildren(sdk, path, { json, nodeType }); } else if (path.type === PathType.Devices) { if (path.fullPath === '/devices') { for await (const device of sdk.iterateDevices()) { @@ -46,9 +60,10 @@ export class CommandFileSystemList implements Command { } } - private async printChildren(sdk: ProtonDriveClient, path: Path, options: { json: boolean }) { + private async printChildren(sdk: ProtonDriveClient, path: Path, options: { json: boolean; nodeType?: NodeType }) { const parentNode = await path.getNode(); - for await (const node of sdk.iterateFolderChildren(parentNode)) { + const filterOptions = options.nodeType ? { type: options.nodeType } : undefined; + for await (const node of sdk.iterateFolderChildren(parentNode, filterOptions)) { this.printNode(node, options); } } 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 7d8649925ac5814de29aad02f494c5da19316f27 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 05:50:07 +0000 Subject: [PATCH 081/732] Implement ProtonDrivePhotosClient basics --- js/cli/src/cli/commandAlbumList.ts | 17 ++ js/cli/src/cli/commandFileSystemRename.ts | 5 +- js/cli/src/cli/commandPhotoTimeline.ts | 46 ++++ js/cli/src/cli/commandPhotoUpload.ts | 40 +++ js/cli/src/cli/formatters.ts | 7 + js/cli/src/cli/index.ts | 6 + js/cli/src/cli/interface.ts | 2 + js/cli/src/init.ts | 60 +++-- js/cli/src/proton-drive.ts | 3 +- 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 +++++++++++++++--- 34 files changed, 837 insertions(+), 143 deletions(-) create mode 100644 js/cli/src/cli/commandAlbumList.ts create mode 100644 js/cli/src/cli/commandPhotoTimeline.ts create mode 100644 js/cli/src/cli/commandPhotoUpload.ts 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/cli/src/cli/commandAlbumList.ts b/js/cli/src/cli/commandAlbumList.ts new file mode 100644 index 00000000..c1d7b9c3 --- /dev/null +++ b/js/cli/src/cli/commandAlbumList.ts @@ -0,0 +1,17 @@ +import { formatReadableJson } from './formatters'; +import { Command, ActionArgs } from './interface'; + +export class CommandAlbumList implements Command { + group = 'albums'; + name = 'list'; + + async action({ photosSdk, options: { json } }: ActionArgs) { + for await (const album of photosSdk.iterateAlbums()) { + if (json) { + console.log(JSON.stringify(album)); + } else { + console.log(formatReadableJson(album)); + } + } + } +} diff --git a/js/cli/src/cli/commandFileSystemRename.ts b/js/cli/src/cli/commandFileSystemRename.ts index cb99029f..39f7b179 100644 --- a/js/cli/src/cli/commandFileSystemRename.ts +++ b/js/cli/src/cli/commandFileSystemRename.ts @@ -1,4 +1,4 @@ -import { inspect } from 'util'; +import { formatReadableJson } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemRename implements Command { @@ -14,8 +14,7 @@ export class CommandFileSystemRename implements Command { if (json) { console.log(JSON.stringify(renamedNode)); } else { - // Use inspect to disable the depth limit. - console.log(inspect(renamedNode, { showHidden: false, depth: null, colors: true })); + console.log(formatReadableJson(renamedNode)); } } } diff --git a/js/cli/src/cli/commandPhotoTimeline.ts b/js/cli/src/cli/commandPhotoTimeline.ts new file mode 100644 index 00000000..a7cf5087 --- /dev/null +++ b/js/cli/src/cli/commandPhotoTimeline.ts @@ -0,0 +1,46 @@ +import { ParseArgsConfig } from 'util'; + +import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; +import { formatReadableJson } from './formatters'; +import { Command, ActionArgs } from './interface'; + +export class CommandPhotoTimeline implements Command { + group = 'photo'; + name = 'timeline'; + options: ParseArgsConfig['options'] = { + loadDetails: { + type: 'boolean', + short: 'd', + default: false, + }, + }; + + async action({ photosSdk, options: { json, loadDetails } }: ActionArgs) { + if (loadDetails) { + await this.listWithDetails(photosSdk, json); + } else { + await this.list(photosSdk, json); + } + } + + async list(photosSdk: ProtonDrivePhotosClient, json: boolean) { + for await (const photo of photosSdk.iterateTimeline()) { + if (json) { + console.log(JSON.stringify(photo)); + } else { + console.log(formatReadableJson(photo)); + } + } + } + + async listWithDetails(photosSdk: ProtonDrivePhotosClient, json: boolean) { + const nodeUids = await Array.fromAsync(photosSdk.iterateTimeline(), (photo) => photo.nodeUid); + for await (const node of photosSdk.iterateNodes(nodeUids)) { + if (json) { + console.log(JSON.stringify(node)); + } else { + console.log(formatReadableJson(node)); + } + } + } +} diff --git a/js/cli/src/cli/commandPhotoUpload.ts b/js/cli/src/cli/commandPhotoUpload.ts new file mode 100644 index 00000000..336dfa53 --- /dev/null +++ b/js/cli/src/cli/commandPhotoUpload.ts @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'util'; + +import { Thumbnail } from '../../../sdk/src'; +import { Command, ActionArgs } from './interface'; + +export class CommandPhotoUpload implements Command { + group = 'photo'; + name = 'upload'; + // FIXME: support upload of multiple files + args = ['localPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + }; + + async action({ photosSdk, args: [localPath], options: { name } }: ActionArgs) { + name = name || path.basename(localPath); + + const file = Bun.file(localPath); + const metadata = { + mediaType: file.type, + expectedSize: file.size, + }; + + const uploader = await photosSdk.getFileUploader(name, metadata); + + console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + + const thumbnails: Thumbnail[] = []; // TODO + const controller = await uploader.writeStream(file.stream(), thumbnails, (writtenBytes) => { + console.log(`Uploaded ${writtenBytes} bytes`); + }); + + await controller.completion(); + } +} diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index 4ac41832..2d69bfce 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -1,5 +1,12 @@ +import { inspect } from 'util'; + import { Author } from '../../../sdk/src'; +export function formatReadableJson(json: object) { + // Prints the JSON in a readable format without the depth limit. + return inspect(json, { showHidden: false, depth: null, colors: true }); +} + export function formatAuthor(author: Author) { return author.ok ? author.value : `(${author.error.claimedAuthor})`; } diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 5382108e..16bcb6c9 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -1,4 +1,5 @@ import { ParseArgsConfig } from 'util'; +import { CommandAlbumList } from './commandAlbumList'; import { CommandAuthLogin } from './commandAuthLogin'; import { CommandAuthLogout } from './commandAuthLogout'; import { CommandDeviceCreate } from './commandDeviceCreate'; @@ -21,6 +22,8 @@ import { CommandFileSystemRestore } from './commandFileSystemRestore'; import { CommandFileSystemUpload } from './commandFileSystemUpload'; import { CommandFileSystemTrash } from './commandFileSystemTrash'; import { CommandInvitationList } from './commandInvitationList'; +import { CommandPhotoTimeline } from './commandPhotoTimeline'; +import { CommandPhotoUpload } from './commandPhotoUpload'; import { CommandRevisionDelete } from './commandRevisionDelete'; import { CommandRevisionDownload } from './commandRevisionDownload'; import { CommandRevisionList } from './commandRevisionList'; @@ -71,6 +74,9 @@ const COMMANDS = [ new CommandDeviceRename(), new CommandDeviceDelete(), new CommandPublicList(), + new CommandPhotoTimeline(), + new CommandPhotoUpload(), + new CommandAlbumList(), new CommandDiagnosticTree(), ].map((command: Command) => { command.options = command.options || {}; diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index c9e4fc85..79c1a756 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; import { ProtonDriveClient } from '../../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; import { Diagnostic } from '../../../sdk/src/diagnostic'; import { Account } from '../account/account'; import { Paths } from './paths'; @@ -17,6 +18,7 @@ export interface Command { export interface ActionArgs { account: Account; sdk: ProtonDriveClient; + photosSdk: ProtonDrivePhotosClient; sdkDiagnostic: Diagnostic; paths: Paths; args: string[]; diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index aba3ab9e..1d26ba6d 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -13,6 +13,7 @@ import { OpenPGPCryptoWithCryptoProxy, VERSION, } from '../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; import { initDiagnostic } from '../../sdk/src/diagnostic'; interface Config { @@ -31,11 +32,13 @@ export async function init() { const account = await initAccount(cryptoApi, config); const srp = await initSrp(cryptoApi, config); const sdk = initSDK(cryptoApi, config, account, srp); + const photosSdk = initPhotosSDK(cryptoApi, config, account, srp); const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); const paths = new Paths(sdk); return { account, sdk, + photosSdk, sdkDiagnostic, paths, }; @@ -66,17 +69,8 @@ async function initSrp(cryptoApi: CryptoApi, config: Config) { } function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const httpClient = new HTTPClient({ - ...config, - uid: account.session?.uid || '', - accessToken: account.session?.accessToken || '', - }); - const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(cryptoApi); - - const entitiesCache = new SQLiteEntititesCache(config.cacheDir); - const cryptoCache = new MemoryCache(); - - const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); + const { httpClient, entitiesCache, cryptoCache, telemetry, openPGPCryptoModule, latestEventIdProvider } = + createSDKDependencies(config, account, cryptoApi); const sdk = new ProtonDriveClient({ httpClient, @@ -90,18 +84,33 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr account, openPGPCryptoModule, srpModule: srp, - latestEventIdProvider: new NoLatestEventIdProvider(), + latestEventIdProvider, }); return sdk; } -function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const httpClient = new HTTPClient({ - ...config, - uid: account.session?.uid || '', - accessToken: account.session?.accessToken || '', +function initPhotosSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { + const { httpClient, entitiesCache, cryptoCache, telemetry, openPGPCryptoModule, latestEventIdProvider } = + createSDKDependencies(config, account, cryptoApi); + + return new ProtonDrivePhotosClient({ + httpClient, + entitiesCache, + cryptoCache, + config: { + baseUrl: config.baseUrl, + clientUid: 'proton-drive-photos-sdk-js-cli-test', + }, + telemetry, + account, + openPGPCryptoModule, + srpModule: srp, + latestEventIdProvider, }); - const openPGPCryptoModule = new OpenPGPCryptoWithCryptoProxy(cryptoApi); +} + +function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { + const { httpClient, openPGPCryptoModule } = createSDKDependencies(config, account, cryptoApi); return initDiagnostic({ httpClient, @@ -112,6 +121,21 @@ function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Accoun }); } +function createSDKDependencies(config: Config, account: Account, cryptoApi: CryptoApi) { + return { + httpClient: new HTTPClient({ + ...config, + uid: account.session?.uid || '', + accessToken: account.session?.accessToken || '', + }), + entitiesCache: new SQLiteEntititesCache(config.cacheDir), + cryptoCache: new MemoryCache(), + telemetry: initTelemetry(config.cacheDir, config.enableConsoleLog), + openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi), + latestEventIdProvider: new NoLatestEventIdProvider(), + }; +} + class NoLatestEventIdProvider { getLatestEventId(): string | null { return null; diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index e3ae906b..01120756 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -3,7 +3,7 @@ import { parseArgs } from 'util'; import { init } from './init'; import { getCommand, validateCommandArguments } from './cli'; -const { account, sdk, sdkDiagnostic, paths } = await init(); +const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(); const groupName = Bun.argv[2]; const commandName = Bun.argv[3]; @@ -34,6 +34,7 @@ if (!options.json) { await command.action({ account, sdk, + photosSdk, sdkDiagnostic, paths, args, 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 6f7fc3c67b8751396b4b6f25bbe38635bbf928b0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 10:20:31 +0200 Subject: [PATCH 082/732] 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 0c91c675197bd1fbc52d8c3f0596c344ccc0d4a9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Sep 2025 10:21:45 +0200 Subject: [PATCH 083/732] 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 e7f0b4d3205b0a0d90f31bc9f00b285de0fa0d3f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 22 Sep 2025 15:11:18 +0200 Subject: [PATCH 084/732] 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 f38a9994bbd04f5bfc6f1a730fc87732c5b6cee3 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Sep 2025 12:37:50 +0000 Subject: [PATCH 085/732] 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 497c638ffc2e9b650fb4a4c7a4d595c30620bc9c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Sep 2025 10:48:42 +0200 Subject: [PATCH 086/732] 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 27af72e776f1135540b8bba964e59ef59f71ba07 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Sep 2025 14:20:09 +0000 Subject: [PATCH 087/732] Implement CLI photo download --- js/cli/src/cli/commandFileSystemDownload.ts | 2 +- .../commandFileSystemDownloadThumbnails.ts | 14 +++- js/cli/src/cli/commandPhotoDownload.ts | 57 +++++++++++++++++ .../src/cli/commandPhotoDownloadThumbnails.ts | 50 +++++++++++++++ js/cli/src/cli/index.ts | 4 ++ js/cli/src/cli/paths.ts | 64 +++++++++++++++++-- js/cli/src/init.ts | 2 +- js/sdk/src/protonDrivePhotosClient.ts | 34 +++++++++- 8 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 js/cli/src/cli/commandPhotoDownload.ts create mode 100644 js/cli/src/cli/commandPhotoDownloadThumbnails.ts diff --git a/js/cli/src/cli/commandFileSystemDownload.ts b/js/cli/src/cli/commandFileSystemDownload.ts index bfad65e6..ccaaf150 100644 --- a/js/cli/src/cli/commandFileSystemDownload.ts +++ b/js/cli/src/cli/commandFileSystemDownload.ts @@ -8,7 +8,7 @@ export class CommandFileSystemDownload implements Command { group = 'filesystem'; name = 'download'; // FIXME: support download of multiple files - args = ['path', 'localPath']; + args = ['path', 'localParentPath']; options: ParseArgsConfig['options'] = { name: { type: 'string', diff --git a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts index 8b88b551..c525e4a1 100644 --- a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts +++ b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts @@ -1,17 +1,27 @@ import path from 'node:path'; +import { ParseArgsConfig } from 'node:util'; + import { Command, ActionArgs } from './interface'; +import { ThumbnailType } from '../../../sdk/src'; export class CommandFileSystemDownloadThumbnails implements Command { group = 'filesystem'; name = 'download-thumbnails'; // FIXME: support download of multiple thumbnails args = ['path', 'parentLocalPath']; + options: ParseArgsConfig['options'] = { + thumbnailType: { + type: 'string', + short: 't', + default: ThumbnailType.Type1.toString(), + }, + }; - async action({ sdk, paths, args: [pathString, parentLocalPath], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - for await (const result of sdk.iterateThumbnails([node])) { + for await (const result of sdk.iterateThumbnails([node], parseInt(thumbnailType))) { // Thumbnail can be jpeg or webp. All new code should produce webp. const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); diff --git a/js/cli/src/cli/commandPhotoDownload.ts b/js/cli/src/cli/commandPhotoDownload.ts new file mode 100644 index 00000000..17b40912 --- /dev/null +++ b/js/cli/src/cli/commandPhotoDownload.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from './interface'; +import { getName } from './node'; + +export class CommandPhotoDownload implements Command { + group = 'photo'; + name = 'download'; + // FIXME: support download of multiple files + args = ['path', 'localParentPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + }; + + async action({ photosSdk, paths, args: [pathString, localParentPath], options: { json, name } }: ActionArgs) { + const nodePath = paths.getPhotoPath(pathString); + const node = await nodePath.getNode(); + const downloader = await photosSdk.getFileDownloader(node); + + const claimedSize = downloader.getClaimedSizeInBytes(); + const localPath = path.join(localParentPath, name || getName(node)); + + if (json) { + console.log( + JSON.stringify({ + localPath: localPath, + name: getName(node), + claimedSize, + }), + ); + } else { + console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); + } + + const file = Bun.file(localPath); + const writer = file.writer(); + const writableStream: WritableStream = { + getWriter: () => writer, + close: () => writer.end(), + abort: () => writer.end(), + locked: false, + }; + + const controller = downloader.writeToStream(writableStream, (writtenBytes) => { + if (!json) { + console.log(`Downloaded ${writtenBytes} bytes`); + } + }); + + await controller.completion(); + } +} diff --git a/js/cli/src/cli/commandPhotoDownloadThumbnails.ts b/js/cli/src/cli/commandPhotoDownloadThumbnails.ts new file mode 100644 index 00000000..db546b32 --- /dev/null +++ b/js/cli/src/cli/commandPhotoDownloadThumbnails.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'node:util'; + +import { Command, ActionArgs } from './interface'; +import { ThumbnailType } from '../../../sdk/src'; + +export class CommandPhotoDownloadThumbnails implements Command { + group = 'photo'; + name = 'download-thumbnails'; + // FIXME: support download of multiple thumbnails + args = ['path', 'parentLocalPath']; + options: ParseArgsConfig['options'] = { + thumbnailType: { + type: 'string', + short: 't', + default: ThumbnailType.Type1.toString(), + }, + }; + + async action({ photosSdk, paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { + const nodePath = paths.getPhotoPath(pathString); + const node = await nodePath.getNode(); + + for await (const result of photosSdk.iterateThumbnails([node], parseInt(thumbnailType))) { + // Thumbnail can be jpeg or webp. All new code should produce webp. + const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; + const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); + + if (json) { + console.log( + JSON.stringify({ + ...result, + thumbnail: undefined, // Avoid dumping binary data to JSON. + thumbnailFileName, + }), + ); + } else { + console.log( + result.ok + ? `Downloaded thumbnail for ${result.nodeUid}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, + ); + } + + if (result.ok) { + await Bun.write(thumbnailFilePath, result.thumbnail); + } + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 16bcb6c9..32d94d99 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -22,6 +22,8 @@ import { CommandFileSystemRestore } from './commandFileSystemRestore'; import { CommandFileSystemUpload } from './commandFileSystemUpload'; import { CommandFileSystemTrash } from './commandFileSystemTrash'; import { CommandInvitationList } from './commandInvitationList'; +import { CommandPhotoDownload } from './commandPhotoDownload'; +import { CommandPhotoDownloadThumbnails } from './commandPhotoDownloadThumbnails'; import { CommandPhotoTimeline } from './commandPhotoTimeline'; import { CommandPhotoUpload } from './commandPhotoUpload'; import { CommandRevisionDelete } from './commandRevisionDelete'; @@ -75,6 +77,8 @@ const COMMANDS = [ new CommandDeviceDelete(), new CommandPublicList(), new CommandPhotoTimeline(), + new CommandPhotoDownload(), + new CommandPhotoDownloadThumbnails(), new CommandPhotoUpload(), new CommandAlbumList(), new CommandDiagnosticTree(), diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index c1a785f7..e6d73596 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,5 +1,6 @@ import path from 'node:path'; -import { ProtonDriveClient, MaybeNode } from '../../../sdk/src'; +import { ProtonDriveClient, MaybeNode, MaybeMissingNode } from '../../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; import { getName } from './node'; export enum PathType { @@ -12,8 +13,12 @@ export enum PathType { } export class Paths { - constructor(private sdk: ProtonDriveClient) { + constructor( + private sdk: ProtonDriveClient, + private photosSdk: ProtonDrivePhotosClient, + ) { this.sdk = sdk; + this.photosSdk = photosSdk; } get rootPaths(): string[] { @@ -25,6 +30,10 @@ export class Paths { getPath(path: string): Path { return new Path(this.sdk, path); } + + getPhotoPath(path: string): PhotoPath { + return new PhotoPath(this.photosSdk, path); + } } export class Path { @@ -131,11 +140,12 @@ export class Path { for await (const device of this.sdk.iterateDevices()) { const name = device.name.ok ? device.name.value : device.name.error.name; if (name === this.sectionRootNodeName) { - const [maybeNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); - if (!maybeNode.ok && 'missingUid' in maybeNode.error) { + const [maybeMissingNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); + const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); + if (!maybeNode) { throw new Error(`Node not found`); } - return maybeNode as MaybeNode; + return maybeNode; } } throw new Error('Device not found'); @@ -162,3 +172,47 @@ export class Path { throw new Error(`Node not found: ${name}`); } } + +export class PhotoPath { + constructor( + private photosSdk: ProtonDrivePhotosClient, + public fullPath: string, + ) { + this.photosSdk = photosSdk; + this.fullPath = fullPath; + } + + async getNode(): Promise { + if (this.fullPath.includes('~')) { + const nodeUid = this.fullPath; + return this.photosSdk.getNode(nodeUid); + } + + return this.getNodeByName(this.fullPath); + } + + private async getNodeByName(name: string): Promise { + const photoNodeUids = await Array.fromAsync(this.photosSdk.iterateTimeline(), (photo) => photo.nodeUid); + for await (const maybeMissingNode of this.photosSdk.iterateNodes(photoNodeUids)) { + const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); + if (!maybeNode) { + continue; + } + if (getName(maybeNode) === name) { + return maybeNode; + } + } + throw new Error(`Node not found: ${name}`); + } +} + +function getMaybeNodeAndIgnoreMissingNode(maybeMissingNode: MaybeMissingNode): MaybeNode | undefined { + if (maybeMissingNode.ok) { + return maybeMissingNode; + } + const erroredNode = maybeMissingNode.error; + if ('missingUid' in erroredNode) { + return; + } + return { ok: false, error: erroredNode }; +} diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 1d26ba6d..44a67e05 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -34,7 +34,7 @@ export async function init() { const sdk = initSDK(cryptoApi, config, account, srp); const photosSdk = initPhotosSDK(cryptoApi, config, account, srp); const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); - const paths = new Paths(sdk); + const paths = new Paths(sdk, photosSdk); return { account, sdk, 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 f460b839fa62fe9e87d88308a69f74fe2da984fc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 12:46:33 +0000 Subject: [PATCH 088/732] 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 fa6f8bb1c8952215149beaf2416a651d631ea42c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 15:35:53 +0200 Subject: [PATCH 089/732] 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 5323ef76eb8972e7d27fb9c5cf18fb35b6bb65c4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 16:50:58 +0200 Subject: [PATCH 090/732] 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 91d270a1d6e674e5f58fe969c35383803c75d5a8 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 24 Sep 2025 17:20:37 +0200 Subject: [PATCH 091/732] 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/cli/src/cli/commandFileSystemDownload.ts | 2 +- js/cli/src/cli/commandFileSystemUpload.ts | 2 +- js/cli/src/cli/commandPhotoDownload.ts | 2 +- js/cli/src/cli/commandPhotoUpload.ts | 2 +- js/cli/src/cli/commandRevisionDownload.ts | 2 +- 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 +- 20 files changed, 167 insertions(+), 67 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/cli/src/cli/commandFileSystemDownload.ts b/js/cli/src/cli/commandFileSystemDownload.ts index ccaaf150..735141d9 100644 --- a/js/cli/src/cli/commandFileSystemDownload.ts +++ b/js/cli/src/cli/commandFileSystemDownload.ts @@ -46,7 +46,7 @@ export class CommandFileSystemDownload implements Command { locked: false, }; - const controller = downloader.writeToStream(writableStream, (writtenBytes) => { + const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { if (!json) { console.log(`Downloaded ${writtenBytes} bytes`); } diff --git a/js/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/commandFileSystemUpload.ts index 1743489a..2a5833da 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/commandFileSystemUpload.ts @@ -45,7 +45,7 @@ export class CommandFileSystemUpload implements Command { console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); const thumbnails: Thumbnail[] = []; // TODO - const controller = await uploader.writeStream(file.stream(), thumbnails, (writtenBytes) => { + const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { console.log(`Uploaded ${writtenBytes} bytes`); }); diff --git a/js/cli/src/cli/commandPhotoDownload.ts b/js/cli/src/cli/commandPhotoDownload.ts index 17b40912..7cf6c374 100644 --- a/js/cli/src/cli/commandPhotoDownload.ts +++ b/js/cli/src/cli/commandPhotoDownload.ts @@ -46,7 +46,7 @@ export class CommandPhotoDownload implements Command { locked: false, }; - const controller = downloader.writeToStream(writableStream, (writtenBytes) => { + const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { if (!json) { console.log(`Downloaded ${writtenBytes} bytes`); } diff --git a/js/cli/src/cli/commandPhotoUpload.ts b/js/cli/src/cli/commandPhotoUpload.ts index 336dfa53..f6172b00 100644 --- a/js/cli/src/cli/commandPhotoUpload.ts +++ b/js/cli/src/cli/commandPhotoUpload.ts @@ -31,7 +31,7 @@ export class CommandPhotoUpload implements Command { console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); const thumbnails: Thumbnail[] = []; // TODO - const controller = await uploader.writeStream(file.stream(), thumbnails, (writtenBytes) => { + const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { console.log(`Uploaded ${writtenBytes} bytes`); }); diff --git a/js/cli/src/cli/commandRevisionDownload.ts b/js/cli/src/cli/commandRevisionDownload.ts index c300c85f..5bb8102e 100644 --- a/js/cli/src/cli/commandRevisionDownload.ts +++ b/js/cli/src/cli/commandRevisionDownload.ts @@ -19,7 +19,7 @@ export class CommandRevisionDownload implements Command { locked: false, }; - const controller = downloader.writeToStream(writableStream, (writtenBytes) => { + const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { console.log(`Downloaded ${writtenBytes} bytes`); }); 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 0131a06d36b40b5f8953adcb38f7118765b1cfe2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 07:16:44 +0000 Subject: [PATCH 092/732] 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 509442a55c70f72c943665bbbe29ddab55574b2b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 09:26:45 +0200 Subject: [PATCH 093/732] 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 69026f39af3a6782eb14c53f1242011ae966e75a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 10:11:38 +0200 Subject: [PATCH 094/732] 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 ecb665e4be4fbecc8efbaa34e67636688e17bf1c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 16:15:52 +0200 Subject: [PATCH 095/732] 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 6f2d040a6b891187904890e38c1ae3ef5f094728 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 25 Sep 2025 14:40:45 +0000 Subject: [PATCH 096/732] 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 94014c701fc08b55791f2dac9035145006fbbd6c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 07:41:37 +0000 Subject: [PATCH 097/732] Add CLI commands for public access --- js/cli/src/cli/commandFileSystemList.ts | 19 +---- js/cli/src/cli/commandPublicList.ts | 39 ---------- js/cli/src/cli/formatters.ts | 15 +++- js/cli/src/cli/index.ts | 13 +++- js/cli/src/cli/paths.ts | 62 +++++++++++++++ js/cli/src/cli/public/base.ts | 13 ++++ .../src/cli/public/commandPublicDownload.ts | 59 +++++++++++++++ .../public/commandPublicDownloadThumbnails.ts | 52 +++++++++++++ js/cli/src/cli/public/commandPublicInfo.ts | 24 ++++++ js/cli/src/cli/public/commandPublicList.ts | 43 +++++++++++ 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 ++++++++++++++++++- 15 files changed, 409 insertions(+), 63 deletions(-) delete mode 100644 js/cli/src/cli/commandPublicList.ts create mode 100644 js/cli/src/cli/public/base.ts create mode 100644 js/cli/src/cli/public/commandPublicDownload.ts create mode 100644 js/cli/src/cli/public/commandPublicDownloadThumbnails.ts create mode 100644 js/cli/src/cli/public/commandPublicInfo.ts create mode 100644 js/cli/src/cli/public/commandPublicList.ts create mode 100644 js/sdk/src/internal/sharingPublic/nodes.ts diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/commandFileSystemList.ts index 7e8ab180..4f0fac3f 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/commandFileSystemList.ts @@ -1,9 +1,9 @@ import { ParseArgsConfig } from 'node:util'; -import { ProtonDriveClient, MaybeNode, MemberRole, Device, NodeType } from '../../../sdk/src'; +import { ProtonDriveClient, MaybeNode, Device, NodeType } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; import { PathType, Path } from './paths'; -import { formatAuthor, formatDate, formatSize } from './formatters'; +import { formatAuthor, formatDate, formatSize, formatMemberRole } from './formatters'; import { getName, getClaimedSize, getNode } from './node'; export class CommandFileSystemList implements Command { @@ -77,7 +77,7 @@ export class CommandFileSystemList implements Command { const type = node.type === 'file' ? '📄' : '🗂️'; const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. - const permissionFlag = getPermissionFlag(node.directRole); + const permissionFlag = formatMemberRole(node.directRole); const author = formatAuthor(node.keyAuthor); const created = formatDate(node.creationTime, true); const claimedSize = getClaimedSize(maybeNode); @@ -96,16 +96,3 @@ export class CommandFileSystemList implements Command { console.log(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`); } } - -function getPermissionFlag(memberRole: MemberRole): string { - switch (memberRole) { - case MemberRole.Inherited: - return ' '; // Two spaces to align with icon. - case MemberRole.Viewer: - return '👁 '; // Extra space due to how terminal render this. - case MemberRole.Editor: - return '📝'; - case MemberRole.Admin: - return '👑'; - } -} diff --git a/js/cli/src/cli/commandPublicList.ts b/js/cli/src/cli/commandPublicList.ts deleted file mode 100644 index d442a664..00000000 --- a/js/cli/src/cli/commandPublicList.ts +++ /dev/null @@ -1,39 +0,0 @@ - -import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; - -export class CommandPublicList implements Command { - group = 'public'; - name = 'list'; - args = ['path']; - options: ParseArgsConfig['options'] = { - url: { - type: 'string', - short: 'u', - }, - customPassword: { - type: 'string', - short: 'c', - default: '', - }, - }; - - // TODO: json output - async action({ sdk, options: { url, customPassword } }: ActionArgs) { - const { isCustomPasswordProtected } = await sdk.experimental.getPublicLinkInfo(url); - - if (isCustomPasswordProtected && !customPassword) { - throw new Error('Custom password is required'); - } - - const client = await sdk.experimental.authPublicLink(url, customPassword); - - // TODO: get node by path - const root = await client.getRootNode(); - console.log(root); - - for await (const node of client.iterateFolderChildren(root)) { - console.log(node); - } - } -} diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index 2d69bfce..95b4d831 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -1,6 +1,6 @@ import { inspect } from 'util'; -import { Author } from '../../../sdk/src'; +import { Author, MemberRole } from '../../../sdk/src'; export function formatReadableJson(json: object) { // Prints the JSON in a readable format without the depth limit. @@ -36,3 +36,16 @@ export function formatSize(size: number | undefined, humanReadable: boolean = fa } return `${size}`; } + +export function formatMemberRole(memberRole: MemberRole): string { + switch (memberRole) { + case MemberRole.Inherited: + return ' '; // Two spaces to align with icon. + case MemberRole.Viewer: + return '👁 '; // Extra space due to how terminal render this. + case MemberRole.Editor: + return '📝'; + case MemberRole.Admin: + return '👑'; + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 32d94d99..e54dedfe 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -39,11 +39,16 @@ import { Command } from './interface'; import { CommandBookmarkRemove } from './commandBookmarkRemove'; import { CommandBookmarkList } from './commandBookmarkList'; import { CommandEventsPath } from './commandEventsPath'; -import { CommandPublicList } from './commandPublicList'; +import { CommandPublicDownload } from './public/commandPublicDownload'; +import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; +import { CommandPublicInfo } from './public/commandPublicInfo'; +import { CommandPublicList } from './public/commandPublicList'; const COMMANDS = [ + // CLI Account new CommandAuthLogin(), new CommandAuthLogout(), + // ProtonDriveClient new CommandEventsDrive(), new CommandEventsMyVolume(), new CommandEventsPath(), @@ -75,12 +80,18 @@ const COMMANDS = [ new CommandDeviceCreate(), new CommandDeviceRename(), new CommandDeviceDelete(), + // ProtonDrivePublicLinkClient new CommandPublicList(), + new CommandPublicInfo(), + new CommandPublicDownload(), + new CommandPublicDownloadThumbnails(), + // ProtonDrivePhotosClient new CommandPhotoTimeline(), new CommandPhotoDownload(), new CommandPhotoDownloadThumbnails(), new CommandPhotoUpload(), new CommandAlbumList(), + // Diagnostic new CommandDiagnosticTree(), ].map((command: Command) => { command.options = command.options || {}; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index e6d73596..a8265c16 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { ProtonDriveClient, MaybeNode, MaybeMissingNode } from '../../../sdk/src'; import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; import { getName } from './node'; +import { ProtonDrivePublicLinkClient } from '../../../sdk/src/protonDrivePublicLinkClient'; export enum PathType { Root = 'root', @@ -13,6 +14,8 @@ export enum PathType { } export class Paths { + private publicLinkSdk?: ProtonDrivePublicLinkClient; + constructor( private sdk: ProtonDriveClient, private photosSdk: ProtonDrivePhotosClient, @@ -34,6 +37,24 @@ export class Paths { getPhotoPath(path: string): PhotoPath { return new PhotoPath(this.photosSdk, path); } + + getPublicLinkPath(path: string): PublicLinkPath { + if (!this.publicLinkSdk) { + throw new Error('Public link SDK not initialized'); + } + return new PublicLinkPath(this.publicLinkSdk, path); + } + + async authPublicLinkSession(url: string, customPassword: string): Promise { + const { isCustomPasswordProtected } = await this.sdk.experimental.getPublicLinkInfo(url); + + if (isCustomPasswordProtected && !customPassword) { + throw new Error('Custom password is required'); + } + + this.publicLinkSdk = await this.sdk.experimental.authPublicLink(url, customPassword); + return this.publicLinkSdk; + } } export class Path { @@ -173,6 +194,47 @@ export class Path { } } +export class PublicLinkPath { + constructor( + private publicLinkSdk: ProtonDrivePublicLinkClient, + public fullPath: string, + ) { + this.publicLinkSdk = publicLinkSdk; + this.fullPath = fullPath; + } + + async getNode(): Promise { + if (this.fullPath.includes('~')) { + const nodeUid = this.fullPath; + return this.publicLinkSdk.getNode(nodeUid); + } + + const rootNode = await this.publicLinkSdk.getRootNode(); + return this.getNodeByPath(rootNode, this.fullPath); + } + + private async getNodeByPath(parentNode: MaybeNode, pathString: string) { + let node = parentNode; + const pathParts = pathString.split(path.sep); + for (const part of pathParts) { + if (part === '') { + continue; + } + node = await this.getNodeByName(node, part); + } + return node; + } + + private async getNodeByName(parentNode: MaybeNode, name: string) { + for await (const maybeChild of this.publicLinkSdk.iterateFolderChildren(parentNode)) { + if (getName(maybeChild) === name) { + return maybeChild; + } + } + throw new Error(`Node not found: ${name}`); + } +} + export class PhotoPath { constructor( private photosSdk: ProtonDrivePhotosClient, diff --git a/js/cli/src/cli/public/base.ts b/js/cli/src/cli/public/base.ts new file mode 100644 index 00000000..24f9cb88 --- /dev/null +++ b/js/cli/src/cli/public/base.ts @@ -0,0 +1,13 @@ +import { ParseArgsConfig } from 'util'; + +export const PUBLIC_OPTIONS: ParseArgsConfig['options'] = { + url: { + type: 'string', + short: 'u', + }, + customPassword: { + type: 'string', + short: 'c', + default: '', + }, +}; diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts new file mode 100644 index 00000000..d4fb83a2 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicDownload.ts @@ -0,0 +1,59 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; +import { getName } from '../node'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicDownload implements Command { + group = 'public'; + name = 'download'; + args = ['path', 'localParentPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + ...PUBLIC_OPTIONS, + }; + + async action({ paths, args: [pathString, localParentPath], options: { name, json, url, customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const node = await nodePath.getNode(); + const downloader = await client.getFileDownloader(node); + + const claimedSize = downloader.getClaimedSizeInBytes(); + const localPath = path.join(localParentPath, name || getName(node)); + + if (json) { + console.log( + JSON.stringify({ + localPath: localPath, + name: getName(node), + claimedSize, + }), + ); + } else { + console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); + } + + const file = Bun.file(localPath); + const writer = file.writer(); + const writableStream: WritableStream = { + getWriter: () => writer, + close: () => writer.end(), + abort: () => writer.end(), + locked: false, + }; + + const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { + if (!json) { + console.log(`Downloaded ${writtenBytes} bytes`); + } + }); + + await controller.completion(); + } +} diff --git a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts new file mode 100644 index 00000000..9924eb89 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts @@ -0,0 +1,52 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'node:util'; + +import { Command, ActionArgs } from '../interface'; +import { ThumbnailType } from '../../../../sdk/src'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicDownloadThumbnails implements Command { + group = 'public'; + name = 'download-thumbnails'; + args = ['path', 'parentLocalPath']; + options: ParseArgsConfig['options'] = { + thumbnailType: { + type: 'string', + short: 't', + default: ThumbnailType.Type1.toString(), + }, + ...PUBLIC_OPTIONS, + }; + + async action({ paths, args: [pathString, parentLocalPath], options: { json, thumbnailType, url, customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const node = await nodePath.getNode(); + + for await (const result of client.iterateThumbnails([node], parseInt(thumbnailType))) { + // Thumbnail can be jpeg or webp. All new code should produce webp. + const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; + const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); + + if (json) { + console.log( + JSON.stringify({ + ...result, + thumbnail: undefined, // Avoid dumping binary data to JSON. + thumbnailFileName, + }), + ); + } else { + console.log( + result.ok + ? `Downloaded thumbnail for ${result.nodeUid}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, + ); + } + + if (result.ok) { + await Bun.write(thumbnailFilePath, result.thumbnail); + } + } + } +} diff --git a/js/cli/src/cli/public/commandPublicInfo.ts b/js/cli/src/cli/public/commandPublicInfo.ts new file mode 100644 index 00000000..2d78b2fb --- /dev/null +++ b/js/cli/src/cli/public/commandPublicInfo.ts @@ -0,0 +1,24 @@ +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; +import { formatReadableJson } from '../formatters'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicInfo implements Command { + group = 'public'; + name = 'info'; + args = ['path']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args: [pathString], options: { json, url, customPassword } }: ActionArgs) { + await paths.authPublicLinkSession(url, customPassword); + const path = paths.getPublicLinkPath(pathString); + const node = await path.getNode(); + + if (json) { + console.log(JSON.stringify(node)); + } else { + console.log(formatReadableJson(node)); + } + } +} diff --git a/js/cli/src/cli/public/commandPublicList.ts b/js/cli/src/cli/public/commandPublicList.ts new file mode 100644 index 00000000..98bac05f --- /dev/null +++ b/js/cli/src/cli/public/commandPublicList.ts @@ -0,0 +1,43 @@ + +import { ParseArgsConfig } from 'util'; + +import { MaybeNode } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { formatAuthor, formatDate, formatSize, formatMemberRole } from '../formatters'; +import { getName, getClaimedSize, getNode } from '../node'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicList implements Command { + group = 'public'; + name = 'list'; + args = ['path']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args: [pathString], options: { json, url, customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const path = paths.getPublicLinkPath(pathString); + const folder = await path.getNode(); + + for await (const node of client.iterateFolderChildren(folder)) { + this.printNode(node, { json }); + } + } + + private printNode(maybeNode: MaybeNode, options: { json: boolean }) { + if (options.json) { + console.log(JSON.stringify(maybeNode)); + return; + } + const node = getNode(maybeNode); + + const type = node.type === 'file' ? '📄' : '🗂️'; + const permissionFlag = formatMemberRole(node.directRole); + const author = formatAuthor(node.keyAuthor); + const created = formatDate(node.creationTime, true); + const claimedSize = getClaimedSize(maybeNode); + const size = claimedSize ? formatSize(claimedSize) : '-'; + const id = node.uid.split('~')[1]; + const name = getName(maybeNode); + console.log(`${type}${permissionFlag} ${author} ${created} ${size} ${id} ${name}`); + } +} 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 d2633b27ae6562b2f4a6a99801d89c83f0143ac3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 11:25:15 +0200 Subject: [PATCH 098/732] Add CLI commands for invitation accept/reject --- js/cli/src/cli/commandInvitationAccept.ts | 11 +++++++++++ js/cli/src/cli/commandInvitationReject.ts | 11 +++++++++++ js/cli/src/cli/index.ts | 4 ++++ 3 files changed, 26 insertions(+) create mode 100644 js/cli/src/cli/commandInvitationAccept.ts create mode 100644 js/cli/src/cli/commandInvitationReject.ts diff --git a/js/cli/src/cli/commandInvitationAccept.ts b/js/cli/src/cli/commandInvitationAccept.ts new file mode 100644 index 00000000..f0ed28f1 --- /dev/null +++ b/js/cli/src/cli/commandInvitationAccept.ts @@ -0,0 +1,11 @@ +import { Command, ActionArgs } from './interface'; + +export class CommandInvitationAccept implements Command { + group = 'invitation'; + name = 'accept'; + args = ['invitationUid']; + + async action({ sdk, args: [invitationUid] }: ActionArgs) { + await sdk.acceptInvitation(invitationUid); + } +} diff --git a/js/cli/src/cli/commandInvitationReject.ts b/js/cli/src/cli/commandInvitationReject.ts new file mode 100644 index 00000000..0974c42b --- /dev/null +++ b/js/cli/src/cli/commandInvitationReject.ts @@ -0,0 +1,11 @@ +import { Command, ActionArgs } from './interface'; + +export class CommandInvitationReject implements Command { + group = 'invitation'; + name = 'reject'; + args = ['invitationUid']; + + async action({ sdk, args: [invitationUid] }: ActionArgs) { + await sdk.rejectInvitation(invitationUid); + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index e54dedfe..7567d1ff 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -21,7 +21,9 @@ import { CommandFileSystemRename } from './commandFileSystemRename'; import { CommandFileSystemRestore } from './commandFileSystemRestore'; import { CommandFileSystemUpload } from './commandFileSystemUpload'; import { CommandFileSystemTrash } from './commandFileSystemTrash'; +import { CommandInvitationAccept } from './commandInvitationAccept'; import { CommandInvitationList } from './commandInvitationList'; +import { CommandInvitationReject } from './commandInvitationReject'; import { CommandPhotoDownload } from './commandPhotoDownload'; import { CommandPhotoDownloadThumbnails } from './commandPhotoDownloadThumbnails'; import { CommandPhotoTimeline } from './commandPhotoTimeline'; @@ -75,6 +77,8 @@ const COMMANDS = [ new CommandSharingSetUrl(), new CommandSharingRemoveUrl(), new CommandInvitationList(), + new CommandInvitationAccept(), + new CommandInvitationReject(), new CommandBookmarkList(), new CommandBookmarkRemove(), new CommandDeviceCreate(), From e7f0888fd2c3bfc8bd746c6c4e6a6bcc32f9b0af Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 14:52:28 +0200 Subject: [PATCH 099/732] 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 ad6c093e1452a01e62acd82939ffa67998487906 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 29 Sep 2025 11:38:48 +0000 Subject: [PATCH 100/732] [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 744d92afadb37fa79ccaa94f92bee85fa3fe9549 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 30 Sep 2025 08:36:51 +0200 Subject: [PATCH 101/732] 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 930e549518bbc107ca21f6ebacad5eb910fb9260 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 30 Sep 2025 08:48:27 +0200 Subject: [PATCH 102/732] 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 e68ccde9841e525929531d4ef711ebbf449279e6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 12:00:21 +0000 Subject: [PATCH 103/732] 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 b65997e993c748796850b5d363b8be4a8e2876f8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 09:38:52 +0200 Subject: [PATCH 104/732] 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 8d0eb2e93d6432f4683d534bde872484677f1004 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 17:47:51 +0200 Subject: [PATCH 105/732] 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 544c559ef38e886b2847fb0f90fc3632975ae050 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 07:47:06 +0200 Subject: [PATCH 106/732] 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 06980ec5afb2340661b0424282bd9d62a555c7af Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 14:49:21 +0200 Subject: [PATCH 107/732] 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 c54e3692620421c7298fa44a7df0b5d23405f455 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 3 Oct 2025 08:14:10 +0200 Subject: [PATCH 108/732] 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 9be54d230ab415a570e920ca42caf82dabd2c395 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 08:20:50 +0200 Subject: [PATCH 109/732] 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 f73fddd9b79b2765ba0b77d374b13280299c028c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 14 Oct 2025 14:04:22 +0200 Subject: [PATCH 110/732] 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 06514d8911995eb65d684c81da7f772df54f4650 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Oct 2025 11:42:32 +0200 Subject: [PATCH 111/732] 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 5faf82b65cee027b075778ab289a1569db00470f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 06:01:22 +0000 Subject: [PATCH 112/732] =?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 93ba323447de8102a75cf2acfae258b1a6dff651 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 26 Sep 2025 14:45:50 +0200 Subject: [PATCH 113/732] 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 9fbe9d0135dc3b7cd428fce6e248c2adea1838f2 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 14:29:23 +0200 Subject: [PATCH 114/732] 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 94fc605a363cdd62993c3b4bcfc2478233ae59cd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Oct 2025 15:37:12 +0200 Subject: [PATCH 115/732] 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 51b27650032cea3ac658b2a60ce4fe8c3e30cf2e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 30 Sep 2025 15:20:22 +0200 Subject: [PATCH 116/732] Json mode for web CLI --- js/cli/src/cli/commandAlbumList.ts | 10 +--- js/cli/src/cli/commandAuthLogin.ts | 6 +-- js/cli/src/cli/commandBookmarkList.ts | 9 +--- js/cli/src/cli/commandBookmarkRemove.ts | 6 ++- js/cli/src/cli/commandDeviceCreate.ts | 7 +-- js/cli/src/cli/commandDeviceDelete.ts | 6 ++- js/cli/src/cli/commandDeviceRename.ts | 7 +-- js/cli/src/cli/commandFileSystemCopy.ts | 11 ++-- .../src/cli/commandFileSystemCreateFolder.ts | 9 +--- js/cli/src/cli/commandFileSystemDelete.ts | 11 ++-- js/cli/src/cli/commandFileSystemDownload.ts | 10 +--- .../commandFileSystemDownloadThumbnails.ts | 44 +++++++++------- js/cli/src/cli/commandFileSystemInfo.ts | 9 +--- js/cli/src/cli/commandFileSystemList.ts | 52 +++++++++---------- js/cli/src/cli/commandFileSystemMove.ts | 11 ++-- js/cli/src/cli/commandFileSystemRename.ts | 8 +-- js/cli/src/cli/commandFileSystemRestore.ts | 11 ++-- js/cli/src/cli/commandFileSystemTrash.ts | 11 ++-- js/cli/src/cli/commandFileSystemUpload.ts | 10 ++-- js/cli/src/cli/commandInvitationAccept.ts | 5 +- js/cli/src/cli/commandInvitationList.ts | 9 +--- js/cli/src/cli/commandInvitationReject.ts | 5 +- js/cli/src/cli/commandPhotoDownload.ts | 10 +--- .../src/cli/commandPhotoDownloadThumbnails.ts | 52 ++++++++++++------- js/cli/src/cli/commandPhotoTimeline.ts | 18 ++----- js/cli/src/cli/commandPhotoUpload.ts | 10 ++-- js/cli/src/cli/commandRevisionDelete.ts | 5 +- js/cli/src/cli/commandRevisionDownload.ts | 11 ++-- js/cli/src/cli/commandRevisionList.ts | 13 ++--- js/cli/src/cli/commandRevisionRestore.ts | 5 +- js/cli/src/cli/commandSharingInvite.ts | 7 +-- js/cli/src/cli/commandSharingRemove.ts | 7 +-- js/cli/src/cli/commandSharingRemoveUrl.ts | 7 +-- js/cli/src/cli/commandSharingSetUrl.ts | 7 +-- js/cli/src/cli/commandSharingStatus.ts | 7 +-- js/cli/src/cli/formatters.ts | 33 ++++++++++++ .../src/cli/public/commandPublicDownload.ts | 10 +--- .../public/commandPublicDownloadThumbnails.ts | 51 +++++++++++------- js/cli/src/cli/public/commandPublicInfo.ts | 8 +-- js/cli/src/cli/public/commandPublicList.ts | 12 ++--- js/cli/src/init.ts | 9 +--- js/cli/src/proton-drive.ts | 24 +++++++-- 42 files changed, 278 insertions(+), 295 deletions(-) diff --git a/js/cli/src/cli/commandAlbumList.ts b/js/cli/src/cli/commandAlbumList.ts index c1d7b9c3..5fb4788a 100644 --- a/js/cli/src/cli/commandAlbumList.ts +++ b/js/cli/src/cli/commandAlbumList.ts @@ -1,4 +1,4 @@ -import { formatReadableJson } from './formatters'; +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandAlbumList implements Command { @@ -6,12 +6,6 @@ export class CommandAlbumList implements Command { name = 'list'; async action({ photosSdk, options: { json } }: ActionArgs) { - for await (const album of photosSdk.iterateAlbums()) { - if (json) { - console.log(JSON.stringify(album)); - } else { - console.log(formatReadableJson(album)); - } - } + await printIterable(photosSdk.iterateAlbums(), json); } } diff --git a/js/cli/src/cli/commandAuthLogin.ts b/js/cli/src/cli/commandAuthLogin.ts index 6eac852c..e7ff1509 100644 --- a/js/cli/src/cli/commandAuthLogin.ts +++ b/js/cli/src/cli/commandAuthLogin.ts @@ -13,7 +13,7 @@ export class CommandAuthLogin implements Command { }, }; - async action({ account, args: [username], options: { password } }: ActionArgs) { + async action({ account, args: [username], options: { password, json } }: ActionArgs) { if (!password) { console.log('Password:'); // FIXME hide password when typing @@ -22,7 +22,7 @@ export class CommandAuthLogin implements Command { break; } } - await account.auth(username, password); - console.log('session:', account.session); + await account.auth(username, password, !json); + console.log(account.session); } } diff --git a/js/cli/src/cli/commandBookmarkList.ts b/js/cli/src/cli/commandBookmarkList.ts index 7b80e0f4..f2810e85 100644 --- a/js/cli/src/cli/commandBookmarkList.ts +++ b/js/cli/src/cli/commandBookmarkList.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandBookmarkList implements Command { @@ -5,12 +6,6 @@ export class CommandBookmarkList implements Command { name = 'list'; async action({ sdk, options: { json } }: ActionArgs) { - for await (const bookmark of sdk.iterateBookmarks()) { - if (json) { - console.log(JSON.stringify(bookmark)); - } else { - console.log(bookmark); - } - } + await printIterable(sdk.iterateBookmarks(), json); } } diff --git a/js/cli/src/cli/commandBookmarkRemove.ts b/js/cli/src/cli/commandBookmarkRemove.ts index 1d14caef..fcd09d58 100644 --- a/js/cli/src/cli/commandBookmarkRemove.ts +++ b/js/cli/src/cli/commandBookmarkRemove.ts @@ -5,8 +5,10 @@ export class CommandBookmarkRemove implements Command { name = 'remove'; args = ['bookmarkUid']; - async action({ sdk, args: [bookmarkUid] }: ActionArgs) { + async action({ sdk, args: [bookmarkUid], options: { json } }: ActionArgs) { await sdk.removeBookmark(bookmarkUid); - console.log(`Deleted bookmark: ${bookmarkUid}`); + if (!json) { + console.log(`Deleted bookmark: ${bookmarkUid}`); + } } } diff --git a/js/cli/src/cli/commandDeviceCreate.ts b/js/cli/src/cli/commandDeviceCreate.ts index c17f7fbb..af49cdde 100644 --- a/js/cli/src/cli/commandDeviceCreate.ts +++ b/js/cli/src/cli/commandDeviceCreate.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandDeviceCreate implements Command { group = 'device'; @@ -14,10 +15,6 @@ export class CommandDeviceCreate implements Command { async action({ sdk, args: [name], options: { type, json } }: ActionArgs) { const device = await sdk.createDevice(name, type); - if (json) { - console.log(JSON.stringify(device)); - } else { - console.log(device); - } + printObject(device, json); } } diff --git a/js/cli/src/cli/commandDeviceDelete.ts b/js/cli/src/cli/commandDeviceDelete.ts index b47aa676..7ed16bd2 100644 --- a/js/cli/src/cli/commandDeviceDelete.ts +++ b/js/cli/src/cli/commandDeviceDelete.ts @@ -5,8 +5,10 @@ export class CommandDeviceDelete implements Command { name = 'delete'; args = ['deviceUid']; - async action({ sdk, args: [deviceUid] }: ActionArgs) { + async action({ sdk, args: [deviceUid], options: { json } }: ActionArgs) { await sdk.deleteDevice(deviceUid); - console.log(`Deleted device: ${deviceUid}`); + if (!json) { + console.log(`Deleted device: ${deviceUid}`); + } } } diff --git a/js/cli/src/cli/commandDeviceRename.ts b/js/cli/src/cli/commandDeviceRename.ts index a445432a..1a172220 100644 --- a/js/cli/src/cli/commandDeviceRename.ts +++ b/js/cli/src/cli/commandDeviceRename.ts @@ -1,4 +1,5 @@ import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandDeviceRename implements Command { group = 'device'; @@ -7,10 +8,6 @@ export class CommandDeviceRename implements Command { async action({ sdk, args: [deviceUid, name], options: { json } }: ActionArgs) { const device = await sdk.renameDevice(deviceUid, name); - if (json) { - console.log(JSON.stringify(device)); - } else { - console.log(device); - } + printObject(device, json); } } diff --git a/js/cli/src/cli/commandFileSystemCopy.ts b/js/cli/src/cli/commandFileSystemCopy.ts index 637a77a0..96937c5a 100644 --- a/js/cli/src/cli/commandFileSystemCopy.ts +++ b/js/cli/src/cli/commandFileSystemCopy.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemCopy implements Command { @@ -13,12 +14,8 @@ export class CommandFileSystemCopy implements Command { const targetPath = paths.getPath(targetPathString); const targetNode = await targetPath.getNode(); - for await (const result of sdk.copyNodes([sourceNode], targetNode)) { - if (json) { - console.log(JSON.stringify(result)); - } else { - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`); - } - } + await printIterable(sdk.copyNodes([sourceNode], targetNode), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); } } diff --git a/js/cli/src/cli/commandFileSystemCreateFolder.ts b/js/cli/src/cli/commandFileSystemCreateFolder.ts index 0691b4be..0a50b2d4 100644 --- a/js/cli/src/cli/commandFileSystemCreateFolder.ts +++ b/js/cli/src/cli/commandFileSystemCreateFolder.ts @@ -1,4 +1,4 @@ -import { inspect } from 'util'; +import { printObject } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemCreateFolder implements Command { @@ -11,11 +11,6 @@ export class CommandFileSystemCreateFolder implements Command { const parent = await path.getNode(); const folder = await sdk.createFolder(parent, name, new Date()); - if (json) { - console.log(JSON.stringify(folder)); - } else { - // Use inspect to disable the depth limit. - console.log(inspect(folder, { showHidden: false, depth: null, colors: true })); - } + printObject(folder, json); } } diff --git a/js/cli/src/cli/commandFileSystemDelete.ts b/js/cli/src/cli/commandFileSystemDelete.ts index 4eabed33..6364650b 100644 --- a/js/cli/src/cli/commandFileSystemDelete.ts +++ b/js/cli/src/cli/commandFileSystemDelete.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemDelete implements Command { @@ -9,12 +10,8 @@ export class CommandFileSystemDelete implements Command { async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - for await (const result of sdk.deleteNodes([node])) { - if (json) { - console.log(JSON.stringify(result)); - } else { - console.log(result.ok ? `Deleted ${result.uid}` : `Failed to delete ${result.uid}: ${result.error}`); - } - } + await printIterable(sdk.deleteNodes([node]), json, (result) => + console.log(result.ok ? `Deleted ${result.uid}` : `Failed to delete ${result.uid}: ${result.error}`), + ); } } diff --git a/js/cli/src/cli/commandFileSystemDownload.ts b/js/cli/src/cli/commandFileSystemDownload.ts index 735141d9..a6f18ec5 100644 --- a/js/cli/src/cli/commandFileSystemDownload.ts +++ b/js/cli/src/cli/commandFileSystemDownload.ts @@ -25,15 +25,7 @@ export class CommandFileSystemDownload implements Command { const claimedSize = downloader.getClaimedSizeInBytes(); const localPath = path.join(localParentPath, name || getName(node)); - if (json) { - console.log( - JSON.stringify({ - localPath: localPath, - name: getName(node), - claimedSize, - }), - ); - } else { + if (!json) { console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); } diff --git a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts index c525e4a1..d2e5b7a4 100644 --- a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts +++ b/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts @@ -2,7 +2,8 @@ import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; import { Command, ActionArgs } from './interface'; -import { ThumbnailType } from '../../../sdk/src'; +import { MaybeNode, ProtonDriveClient, ThumbnailType } from '../../../sdk/src'; +import { printIterable } from './formatters'; export class CommandFileSystemDownloadThumbnails implements Command { group = 'filesystem'; @@ -21,30 +22,37 @@ export class CommandFileSystemDownloadThumbnails implements Command { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - for await (const result of sdk.iterateThumbnails([node], parseInt(thumbnailType))) { + const thumbnailsIterator = this.processThumbnails(sdk, node, parseInt(thumbnailType), parentLocalPath); + await printIterable(thumbnailsIterator, json, (result) => { + console.log( + result.ok + ? `Downloaded thumbnail for ${result.nodeUid}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, + ); + }); + } + + private async *processThumbnails( + sdk: ProtonDriveClient, + node: MaybeNode, + thumbnailType: ThumbnailType, + parentLocalPath: string, + ) { + for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { // Thumbnail can be jpeg or webp. All new code should produce webp. const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - if (json) { - console.log( - JSON.stringify({ - ...result, - thumbnail: undefined, // Avoid dumping binary data to JSON. - thumbnailFileName, - }), - ); - } else { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - } - if (result.ok) { await Bun.write(thumbnailFilePath, result.thumbnail); } + + yield { + ok: result.ok, + nodeUid: result.nodeUid, + error: !result.ok ? result.error : undefined, + thumbnailFileName, + }; } } } diff --git a/js/cli/src/cli/commandFileSystemInfo.ts b/js/cli/src/cli/commandFileSystemInfo.ts index 45642ff7..42580c81 100644 --- a/js/cli/src/cli/commandFileSystemInfo.ts +++ b/js/cli/src/cli/commandFileSystemInfo.ts @@ -1,4 +1,4 @@ -import { inspect } from 'util'; +import { printObject } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemInfo implements Command { @@ -9,11 +9,6 @@ export class CommandFileSystemInfo implements Command { async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); - if (json) { - console.log(JSON.stringify(node)); - } else { - // Use inspect to disable the depth limit. - console.log(inspect(node, { showHidden: false, depth: null, colors: true })); - } + printObject(node, json); } } diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/commandFileSystemList.ts index 4f0fac3f..061826e8 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/commandFileSystemList.ts @@ -3,7 +3,7 @@ import { ParseArgsConfig } from 'node:util'; import { ProtonDriveClient, MaybeNode, Device, NodeType } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; import { PathType, Path } from './paths'; -import { formatAuthor, formatDate, formatSize, formatMemberRole } from './formatters'; +import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from './formatters'; import { getName, getClaimedSize, getNode } from './node'; export class CommandFileSystemList implements Command { @@ -35,44 +35,47 @@ export class CommandFileSystemList implements Command { await this.printChildren(sdk, path, { json, nodeType }); } else if (path.type === PathType.Devices) { if (path.fullPath === '/devices') { - for await (const device of sdk.iterateDevices()) { - this.printDevice(device, { json }); - } + await this.printDevices(sdk, { json }); } else { await this.printChildren(sdk, path, { json }); } } else if (path.type === PathType.SharedByMe) { - for await (const node of sdk.iterateSharedNodes()) { - this.printNode(node, { json }); - } + await this.printSharedNodes(sdk, { json }); } else if (path.type === PathType.SharedWithMe) { if (path.fullPath === '/shared-with-me') { - for await (const node of sdk.iterateSharedNodesWithMe()) { - this.printNode(node, { json }); - } + await this.printSharedWithMe(sdk, { json }); } else { await this.printChildren(sdk, path, { json }); } } else if (path.type === PathType.Trash) { - for await (const node of sdk.iterateTrashedNodes()) { - this.printNode(node, { json }); - } + await this.printTrashedNodes(sdk, { json }); } } + private async printDevices(sdk: ProtonDriveClient, options: { json: boolean }) { + await printIterable(sdk.iterateDevices(), options.json, (device) => this.printDeviceHuman(device)); + } + private async printChildren(sdk: ProtonDriveClient, path: Path, options: { json: boolean; nodeType?: NodeType }) { const parentNode = await path.getNode(); const filterOptions = options.nodeType ? { type: options.nodeType } : undefined; - for await (const node of sdk.iterateFolderChildren(parentNode, filterOptions)) { - this.printNode(node, options); - } + const childrenIterator = sdk.iterateFolderChildren(parentNode, filterOptions); + await printIterable(childrenIterator, options.json, (node) => this.printNodeHuman(node)); } - private printNode(maybeNode: MaybeNode, options: { json: boolean }) { - if (options.json) { - console.log(JSON.stringify(maybeNode)); - return; - } + private async printSharedNodes(sdk: ProtonDriveClient, options: { json: boolean }) { + await printIterable(sdk.iterateSharedNodes(), options.json, (node) => this.printNodeHuman(node)); + } + + private async printSharedWithMe(sdk: ProtonDriveClient, options: { json: boolean }) { + await printIterable(sdk.iterateSharedNodesWithMe(), options.json, (node) => this.printNodeHuman(node)); + } + + private async printTrashedNodes(sdk: ProtonDriveClient, options: { json: boolean }) { + await printIterable(sdk.iterateTrashedNodes(), options.json, (node) => this.printNodeHuman(node)); + } + + private printNodeHuman(maybeNode: MaybeNode): void { const node = getNode(maybeNode); const type = node.type === 'file' ? '📄' : '🗂️'; @@ -87,12 +90,7 @@ export class CommandFileSystemList implements Command { console.log(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${id} ${name}`); } - private printDevice(device: Device, options: { json: boolean }) { - if (options.json) { - console.log(JSON.stringify(device)); - return; - } - + private printDeviceHuman(device: Device): void { console.log(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`); } } diff --git a/js/cli/src/cli/commandFileSystemMove.ts b/js/cli/src/cli/commandFileSystemMove.ts index 4b445472..b852c7d2 100644 --- a/js/cli/src/cli/commandFileSystemMove.ts +++ b/js/cli/src/cli/commandFileSystemMove.ts @@ -1,4 +1,5 @@ import { ProtonDriveClient } from '../../../sdk/src'; +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; import { Path, PathType } from './paths'; @@ -23,12 +24,8 @@ export class CommandFileSystemMove implements Command { private async moveNode(sdk: ProtonDriveClient, sourcePath: Path, targetPath: Path, json: boolean) { const sourceNode = await sourcePath.getNode(); const targetNode = await targetPath.getNode(); - for await (const result of sdk.moveNodes([sourceNode], targetNode)) { - if (json) { - console.log(JSON.stringify(result)); - } else { - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`); - } - } + await printIterable(sdk.moveNodes([sourceNode], targetNode), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); } } diff --git a/js/cli/src/cli/commandFileSystemRename.ts b/js/cli/src/cli/commandFileSystemRename.ts index 39f7b179..4d82efd9 100644 --- a/js/cli/src/cli/commandFileSystemRename.ts +++ b/js/cli/src/cli/commandFileSystemRename.ts @@ -1,4 +1,4 @@ -import { formatReadableJson } from './formatters'; +import { printObject } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemRename implements Command { @@ -11,10 +11,6 @@ export class CommandFileSystemRename implements Command { const node = await nodePath.getNode(); const renamedNode = await sdk.renameNode(node, newName); - if (json) { - console.log(JSON.stringify(renamedNode)); - } else { - console.log(formatReadableJson(renamedNode)); - } + printObject(renamedNode, json); } } diff --git a/js/cli/src/cli/commandFileSystemRestore.ts b/js/cli/src/cli/commandFileSystemRestore.ts index ad01f68c..5bd4c778 100644 --- a/js/cli/src/cli/commandFileSystemRestore.ts +++ b/js/cli/src/cli/commandFileSystemRestore.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemRestore implements Command { @@ -9,12 +10,8 @@ export class CommandFileSystemRestore implements Command { async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - for await (const result of sdk.restoreNodes([node])) { - if (json) { - console.log(JSON.stringify(result)); - } else { - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`); - } - } + await printIterable(sdk.restoreNodes([node]), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); } } diff --git a/js/cli/src/cli/commandFileSystemTrash.ts b/js/cli/src/cli/commandFileSystemTrash.ts index a44ae339..428e3254 100644 --- a/js/cli/src/cli/commandFileSystemTrash.ts +++ b/js/cli/src/cli/commandFileSystemTrash.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandFileSystemTrash implements Command { @@ -9,12 +10,8 @@ export class CommandFileSystemTrash implements Command { async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - for await (const result of sdk.trashNodes([node])) { - if (json) { - console.log(JSON.stringify(result)); - } else { - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`); - } - } + await printIterable(sdk.trashNodes([node]), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); } } diff --git a/js/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/commandFileSystemUpload.ts index 2a5833da..437fb5b3 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/commandFileSystemUpload.ts @@ -22,7 +22,7 @@ export class CommandFileSystemUpload implements Command { }, }; - async action({ sdk, paths, args: [localPath, parentPath], options: { name, newRevision } }: ActionArgs) { + async action({ sdk, paths, args: [localPath, parentPath], options: { json, name, newRevision } }: ActionArgs) { name = name || path.basename(localPath); const file = Bun.file(localPath); @@ -42,11 +42,15 @@ export class CommandFileSystemUpload implements Command { uploader = await sdk.getFileUploader(parentNode, name, metadata); } - console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + if (!json) { + console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + } const thumbnails: Thumbnail[] = []; // TODO const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { - console.log(`Uploaded ${writtenBytes} bytes`); + if (!json) { + console.log(`Uploaded ${writtenBytes} bytes`); + } }); await controller.completion(); diff --git a/js/cli/src/cli/commandInvitationAccept.ts b/js/cli/src/cli/commandInvitationAccept.ts index f0ed28f1..219a95e9 100644 --- a/js/cli/src/cli/commandInvitationAccept.ts +++ b/js/cli/src/cli/commandInvitationAccept.ts @@ -5,7 +5,10 @@ export class CommandInvitationAccept implements Command { name = 'accept'; args = ['invitationUid']; - async action({ sdk, args: [invitationUid] }: ActionArgs) { + async action({ sdk, args: [invitationUid], options: { json } }: ActionArgs) { await sdk.acceptInvitation(invitationUid); + if (!json) { + console.log(`Invitation accepted: ${invitationUid}`); + } } } diff --git a/js/cli/src/cli/commandInvitationList.ts b/js/cli/src/cli/commandInvitationList.ts index d2c283b0..f2130e93 100644 --- a/js/cli/src/cli/commandInvitationList.ts +++ b/js/cli/src/cli/commandInvitationList.ts @@ -1,3 +1,4 @@ +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandInvitationList implements Command { @@ -5,12 +6,6 @@ export class CommandInvitationList implements Command { name = 'list'; async action({ sdk, options: { json } }: ActionArgs) { - for await (const invitation of sdk.iterateInvitations()) { - if (json) { - console.log(JSON.stringify(invitation)); - } else { - console.log(invitation); - } - } + await printIterable(sdk.iterateInvitations(), json); } } diff --git a/js/cli/src/cli/commandInvitationReject.ts b/js/cli/src/cli/commandInvitationReject.ts index 0974c42b..182d74d1 100644 --- a/js/cli/src/cli/commandInvitationReject.ts +++ b/js/cli/src/cli/commandInvitationReject.ts @@ -5,7 +5,10 @@ export class CommandInvitationReject implements Command { name = 'reject'; args = ['invitationUid']; - async action({ sdk, args: [invitationUid] }: ActionArgs) { + async action({ sdk, args: [invitationUid], options: { json } }: ActionArgs) { await sdk.rejectInvitation(invitationUid); + if (!json) { + console.log(`Invitation rejected: ${invitationUid}`); + } } } diff --git a/js/cli/src/cli/commandPhotoDownload.ts b/js/cli/src/cli/commandPhotoDownload.ts index 7cf6c374..b32f5f04 100644 --- a/js/cli/src/cli/commandPhotoDownload.ts +++ b/js/cli/src/cli/commandPhotoDownload.ts @@ -25,15 +25,7 @@ export class CommandPhotoDownload implements Command { const claimedSize = downloader.getClaimedSizeInBytes(); const localPath = path.join(localParentPath, name || getName(node)); - if (json) { - console.log( - JSON.stringify({ - localPath: localPath, - name: getName(node), - claimedSize, - }), - ); - } else { + if (!json) { console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); } diff --git a/js/cli/src/cli/commandPhotoDownloadThumbnails.ts b/js/cli/src/cli/commandPhotoDownloadThumbnails.ts index db546b32..cb5f71f8 100644 --- a/js/cli/src/cli/commandPhotoDownloadThumbnails.ts +++ b/js/cli/src/cli/commandPhotoDownloadThumbnails.ts @@ -2,7 +2,9 @@ import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; import { Command, ActionArgs } from './interface'; -import { ThumbnailType } from '../../../sdk/src'; +import { MaybeNode, ThumbnailType } from '../../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; +import { printIterable } from './formatters'; export class CommandPhotoDownloadThumbnails implements Command { group = 'photo'; @@ -17,34 +19,46 @@ export class CommandPhotoDownloadThumbnails implements Command { }, }; - async action({ photosSdk, paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { + async action({ + photosSdk, + paths, + args: [pathString, parentLocalPath], + options: { json, thumbnailType }, + }: ActionArgs) { const nodePath = paths.getPhotoPath(pathString); const node = await nodePath.getNode(); - for await (const result of photosSdk.iterateThumbnails([node], parseInt(thumbnailType))) { + const thumbnailsIterator = this.processThumbnails(photosSdk, node, parseInt(thumbnailType), parentLocalPath); + await printIterable(thumbnailsIterator, json, (result) => { + console.log( + result.ok + ? `Downloaded thumbnail for ${result.nodeUid}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, + ); + }); + } + + private async *processThumbnails( + sdk: ProtonDrivePhotosClient, + node: MaybeNode, + thumbnailType: ThumbnailType, + parentLocalPath: string, + ) { + for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { // Thumbnail can be jpeg or webp. All new code should produce webp. const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - if (json) { - console.log( - JSON.stringify({ - ...result, - thumbnail: undefined, // Avoid dumping binary data to JSON. - thumbnailFileName, - }), - ); - } else { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - } - if (result.ok) { await Bun.write(thumbnailFilePath, result.thumbnail); } + + yield { + ok: result.ok, + nodeUid: result.nodeUid, + error: !result.ok ? result.error : undefined, + thumbnailFileName, + }; } } } diff --git a/js/cli/src/cli/commandPhotoTimeline.ts b/js/cli/src/cli/commandPhotoTimeline.ts index a7cf5087..ed1736f3 100644 --- a/js/cli/src/cli/commandPhotoTimeline.ts +++ b/js/cli/src/cli/commandPhotoTimeline.ts @@ -1,7 +1,7 @@ import { ParseArgsConfig } from 'util'; import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; -import { formatReadableJson } from './formatters'; +import { printIterable } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandPhotoTimeline implements Command { @@ -24,23 +24,11 @@ export class CommandPhotoTimeline implements Command { } async list(photosSdk: ProtonDrivePhotosClient, json: boolean) { - for await (const photo of photosSdk.iterateTimeline()) { - if (json) { - console.log(JSON.stringify(photo)); - } else { - console.log(formatReadableJson(photo)); - } - } + await printIterable(photosSdk.iterateTimeline(), json); } async listWithDetails(photosSdk: ProtonDrivePhotosClient, json: boolean) { const nodeUids = await Array.fromAsync(photosSdk.iterateTimeline(), (photo) => photo.nodeUid); - for await (const node of photosSdk.iterateNodes(nodeUids)) { - if (json) { - console.log(JSON.stringify(node)); - } else { - console.log(formatReadableJson(node)); - } - } + await printIterable(photosSdk.iterateNodes(nodeUids), json); } } diff --git a/js/cli/src/cli/commandPhotoUpload.ts b/js/cli/src/cli/commandPhotoUpload.ts index f6172b00..bbd7e9d7 100644 --- a/js/cli/src/cli/commandPhotoUpload.ts +++ b/js/cli/src/cli/commandPhotoUpload.ts @@ -17,7 +17,7 @@ export class CommandPhotoUpload implements Command { }, }; - async action({ photosSdk, args: [localPath], options: { name } }: ActionArgs) { + async action({ photosSdk, args: [localPath], options: { json, name } }: ActionArgs) { name = name || path.basename(localPath); const file = Bun.file(localPath); @@ -28,11 +28,15 @@ export class CommandPhotoUpload implements Command { const uploader = await photosSdk.getFileUploader(name, metadata); - console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + if (!json) { + console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + } const thumbnails: Thumbnail[] = []; // TODO const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { - console.log(`Uploaded ${writtenBytes} bytes`); + if (!json) { + console.log(`Uploaded ${writtenBytes} bytes`); + } }); await controller.completion(); diff --git a/js/cli/src/cli/commandRevisionDelete.ts b/js/cli/src/cli/commandRevisionDelete.ts index b0ec2426..b82d68e7 100644 --- a/js/cli/src/cli/commandRevisionDelete.ts +++ b/js/cli/src/cli/commandRevisionDelete.ts @@ -5,7 +5,10 @@ export class CommandRevisionDelete implements Command { name = 'delete'; args = ['revisionUid']; - async action({ sdk, args: [revisionUid] }: ActionArgs) { + async action({ sdk, args: [revisionUid], options: { json } }: ActionArgs) { await sdk.deleteRevision(revisionUid); + if (!json) { + console.log(`Deleted revision: ${revisionUid}`); + } } } diff --git a/js/cli/src/cli/commandRevisionDownload.ts b/js/cli/src/cli/commandRevisionDownload.ts index 5bb8102e..db17bd47 100644 --- a/js/cli/src/cli/commandRevisionDownload.ts +++ b/js/cli/src/cli/commandRevisionDownload.ts @@ -5,10 +5,13 @@ export class CommandRevisionDownload implements Command { name = 'download'; args = ['revisionUid', 'localPath']; - async action({ sdk, args: [revisionUid, localPath] }: ActionArgs) { + async action({ sdk, args: [revisionUid, localPath], options: { json } }: ActionArgs) { const downloader = await sdk.getFileRevisionDownloader(revisionUid); const claimedSize = downloader.getClaimedSizeInBytes(); - console.log(`Downloading revision (${claimedSize || 'N/A'} bytes) to ${localPath}`); + + if (!json) { + console.log(`Downloading revision (${claimedSize || 'N/A'} bytes) to ${localPath}`); + } const file = Bun.file(localPath); const writer = file.writer(); @@ -20,7 +23,9 @@ export class CommandRevisionDownload implements Command { }; const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - console.log(`Downloaded ${writtenBytes} bytes`); + if (!json) { + console.log(`Downloaded ${writtenBytes} bytes`); + } }); await controller.completion(); diff --git a/js/cli/src/cli/commandRevisionList.ts b/js/cli/src/cli/commandRevisionList.ts index 4c5d2bf3..727fd020 100644 --- a/js/cli/src/cli/commandRevisionList.ts +++ b/js/cli/src/cli/commandRevisionList.ts @@ -1,6 +1,6 @@ import { NodeType, Revision } from '../../../sdk/src'; import { Command, ActionArgs } from './interface'; -import { formatAuthor, formatDate, formatSize } from './formatters'; +import { formatAuthor, formatDate, formatSize, printIterable } from './formatters'; import { getNode } from './node'; export class CommandRevisionList implements Command { @@ -15,17 +15,10 @@ export class CommandRevisionList implements Command { if (getNode(maybeNode).type === NodeType.Folder) { throw new Error('Cannot list revisions of a folder'); } - for await (const revision of sdk.iterateRevisions(maybeNode)) { - this.printRevision(revision, { json }); - } + await printIterable(sdk.iterateRevisions(maybeNode), json, (revision) => this.printRevisionHuman(revision)); } - private printRevision(revision: Revision, options: { json: boolean }) { - if (options.json) { - console.log(JSON.stringify(revision)); - return; - } - + private printRevisionHuman(revision: Revision): void { const author = formatAuthor(revision.contentAuthor); const created = formatDate(revision.creationTime, true); const size = formatSize(revision.claimedSize, true); diff --git a/js/cli/src/cli/commandRevisionRestore.ts b/js/cli/src/cli/commandRevisionRestore.ts index a10d0093..5198ae0e 100644 --- a/js/cli/src/cli/commandRevisionRestore.ts +++ b/js/cli/src/cli/commandRevisionRestore.ts @@ -5,7 +5,10 @@ export class CommandRevisionRestore implements Command { name = 'restore'; args = ['revisionUid']; - async action({ sdk, args: [revisionUid] }: ActionArgs) { + async action({ sdk, args: [revisionUid], options: { json } }: ActionArgs) { await sdk.restoreRevision(revisionUid); + if (!json) { + console.log(`Restored revision: ${revisionUid}`); + } } } diff --git a/js/cli/src/cli/commandSharingInvite.ts b/js/cli/src/cli/commandSharingInvite.ts index a4f9acfb..82ac9483 100644 --- a/js/cli/src/cli/commandSharingInvite.ts +++ b/js/cli/src/cli/commandSharingInvite.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandSharingInvite implements Command { group = 'sharing'; @@ -46,10 +47,6 @@ export class CommandSharingInvite implements Command { }, }); - if (json) { - console.log(JSON.stringify(sharingInfo)); - } else { - console.log(sharingInfo); - } + printObject(sharingInfo, json); } } diff --git a/js/cli/src/cli/commandSharingRemove.ts b/js/cli/src/cli/commandSharingRemove.ts index 32a32a88..e414cebd 100644 --- a/js/cli/src/cli/commandSharingRemove.ts +++ b/js/cli/src/cli/commandSharingRemove.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandSharingRemove implements Command { group = 'sharing'; @@ -30,10 +31,6 @@ export class CommandSharingRemove implements Command { users: emails, }); - if (json) { - console.log(JSON.stringify(sharingInfo)); - } else { - console.log(sharingInfo); - } + printObject(sharingInfo, json); } } diff --git a/js/cli/src/cli/commandSharingRemoveUrl.ts b/js/cli/src/cli/commandSharingRemoveUrl.ts index 57e1da48..819a45c8 100644 --- a/js/cli/src/cli/commandSharingRemoveUrl.ts +++ b/js/cli/src/cli/commandSharingRemoveUrl.ts @@ -1,4 +1,5 @@ import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandSharingRemoveUrl implements Command { group = 'sharing'; @@ -13,10 +14,6 @@ export class CommandSharingRemoveUrl implements Command { publicLink: 'remove', }); - if (json) { - console.log(JSON.stringify(sharingInfo)); - } else { - console.log(sharingInfo); - } + printObject(sharingInfo, json); } } diff --git a/js/cli/src/cli/commandSharingSetUrl.ts b/js/cli/src/cli/commandSharingSetUrl.ts index 4e3a0c4a..4b391cd2 100644 --- a/js/cli/src/cli/commandSharingSetUrl.ts +++ b/js/cli/src/cli/commandSharingSetUrl.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandSharingSetUrl implements Command { group = 'sharing'; @@ -40,10 +41,6 @@ export class CommandSharingSetUrl implements Command { }, }); - if (json) { - console.log(JSON.stringify(sharingInfo)); - } else { - console.log(sharingInfo); - } + printObject(sharingInfo, json); } } diff --git a/js/cli/src/cli/commandSharingStatus.ts b/js/cli/src/cli/commandSharingStatus.ts index ffee0eb5..ed0d2371 100644 --- a/js/cli/src/cli/commandSharingStatus.ts +++ b/js/cli/src/cli/commandSharingStatus.ts @@ -1,3 +1,4 @@ +import { printObject } from './formatters'; import { Command, ActionArgs } from './interface'; export class CommandSharingStatus implements Command { @@ -11,10 +12,6 @@ export class CommandSharingStatus implements Command { const node = await path.getNode(); const sharingInfo = await sdk.getSharingInfo(node); - if (json) { - console.log(JSON.stringify(sharingInfo)); - } else { - console.log(sharingInfo); - } + printObject(sharingInfo, json); } } diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index 95b4d831..88acb7d3 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -2,6 +2,39 @@ import { inspect } from 'util'; import { Author, MemberRole } from '../../../sdk/src'; +export function printObject(object: object | undefined, json: boolean) { + if (json) { + console.log(JSON.stringify(object)); + } else { + console.log(object ? formatReadableJson(object) : 'N/A'); + } +} + +export async function printIterable( + iterable: AsyncIterable, + json: boolean, + humanReadableWriter: (item: T) => void = (item) => console.log(formatReadableJson(item)), + jsonTransform: (item: T) => object = (json) => json, +): Promise { + if (json) { + // Output streaming JSON array format + process.stdout.write('[\n'); + let isFirst = true; + for await (const item of iterable) { + if (!isFirst) { + process.stdout.write(',\n'); + } + process.stdout.write(JSON.stringify(jsonTransform(item))); + isFirst = false; + } + process.stdout.write('\n]\n'); + } else { + for await (const item of iterable) { + humanReadableWriter(item); + } + } +} + export function formatReadableJson(json: object) { // Prints the JSON in a readable format without the depth limit. return inspect(json, { showHidden: false, depth: null, colors: true }); diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts index d4fb83a2..e557d21d 100644 --- a/js/cli/src/cli/public/commandPublicDownload.ts +++ b/js/cli/src/cli/public/commandPublicDownload.ts @@ -27,15 +27,7 @@ export class CommandPublicDownload implements Command { const claimedSize = downloader.getClaimedSizeInBytes(); const localPath = path.join(localParentPath, name || getName(node)); - if (json) { - console.log( - JSON.stringify({ - localPath: localPath, - name: getName(node), - claimedSize, - }), - ); - } else { + if (!json) { console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); } diff --git a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts index 9924eb89..f5256559 100644 --- a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts +++ b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts @@ -1,9 +1,11 @@ import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; +import { printIterable } from '../formatters'; import { Command, ActionArgs } from '../interface'; -import { ThumbnailType } from '../../../../sdk/src'; +import { MaybeNode, ThumbnailType } from '../../../../sdk/src'; import { PUBLIC_OPTIONS } from './base'; +import { ProtonDrivePublicLinkClient } from '../../../../sdk/src/protonDrivePublicLinkClient'; export class CommandPublicDownloadThumbnails implements Command { group = 'public'; @@ -18,35 +20,46 @@ export class CommandPublicDownloadThumbnails implements Command { ...PUBLIC_OPTIONS, }; - async action({ paths, args: [pathString, parentLocalPath], options: { json, thumbnailType, url, customPassword } }: ActionArgs) { + async action({ + paths, + args: [pathString, parentLocalPath], + options: { json, thumbnailType, url, customPassword }, + }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); const nodePath = paths.getPublicLinkPath(pathString); const node = await nodePath.getNode(); - for await (const result of client.iterateThumbnails([node], parseInt(thumbnailType))) { + const thumbnailsIterator = this.processThumbnails(client, node, parseInt(thumbnailType), parentLocalPath); + await printIterable(thumbnailsIterator, json, (result) => { + console.log( + result.ok + ? `Downloaded thumbnail for ${result.nodeUid}` + : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, + ); + }); + } + + private async *processThumbnails( + sdk: ProtonDrivePublicLinkClient, + node: MaybeNode, + thumbnailType: ThumbnailType, + parentLocalPath: string, + ) { + for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { // Thumbnail can be jpeg or webp. All new code should produce webp. const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - if (json) { - console.log( - JSON.stringify({ - ...result, - thumbnail: undefined, // Avoid dumping binary data to JSON. - thumbnailFileName, - }), - ); - } else { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - } - if (result.ok) { await Bun.write(thumbnailFilePath, result.thumbnail); } + + yield { + ok: result.ok, + nodeUid: result.nodeUid, + error: !result.ok ? result.error : undefined, + thumbnailFileName, + }; } } } diff --git a/js/cli/src/cli/public/commandPublicInfo.ts b/js/cli/src/cli/public/commandPublicInfo.ts index 2d78b2fb..e2d3c727 100644 --- a/js/cli/src/cli/public/commandPublicInfo.ts +++ b/js/cli/src/cli/public/commandPublicInfo.ts @@ -1,7 +1,7 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from '../interface'; -import { formatReadableJson } from '../formatters'; +import { printObject } from '../formatters'; import { PUBLIC_OPTIONS } from './base'; export class CommandPublicInfo implements Command { @@ -15,10 +15,6 @@ export class CommandPublicInfo implements Command { const path = paths.getPublicLinkPath(pathString); const node = await path.getNode(); - if (json) { - console.log(JSON.stringify(node)); - } else { - console.log(formatReadableJson(node)); - } + printObject(node, json); } } diff --git a/js/cli/src/cli/public/commandPublicList.ts b/js/cli/src/cli/public/commandPublicList.ts index 98bac05f..a371b8ff 100644 --- a/js/cli/src/cli/public/commandPublicList.ts +++ b/js/cli/src/cli/public/commandPublicList.ts @@ -3,7 +3,7 @@ import { ParseArgsConfig } from 'util'; import { MaybeNode } from '../../../../sdk/src'; import { Command, ActionArgs } from '../interface'; -import { formatAuthor, formatDate, formatSize, formatMemberRole } from '../formatters'; +import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from '../formatters'; import { getName, getClaimedSize, getNode } from '../node'; import { PUBLIC_OPTIONS } from './base'; @@ -18,16 +18,10 @@ export class CommandPublicList implements Command { const path = paths.getPublicLinkPath(pathString); const folder = await path.getNode(); - for await (const node of client.iterateFolderChildren(folder)) { - this.printNode(node, { json }); - } + await printIterable(client.iterateFolderChildren(folder), json, (node) => this.printNodeHuman(node)); } - private printNode(maybeNode: MaybeNode, options: { json: boolean }) { - if (options.json) { - console.log(JSON.stringify(maybeNode)); - return; - } + private printNodeHuman(maybeNode: MaybeNode): void { const node = getNode(maybeNode); const type = node.type === 'file' ? '📄' : '🗂️'; diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 44a67e05..bcbbf3d1 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -6,13 +6,7 @@ import { Api as CryptoApi } from './crypto/lib/worker/api'; import { HTTPClient } from './httpClient'; import { initTelemetry } from './telemetry'; -import { - ProtonDriveClient, - MemoryCache, - CachedCryptoMaterial, - OpenPGPCryptoWithCryptoProxy, - VERSION, -} from '../../sdk/src'; +import { ProtonDriveClient, MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy } from '../../sdk/src'; import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; import { initDiagnostic } from '../../sdk/src/diagnostic'; @@ -26,7 +20,6 @@ interface Config { } export async function init() { - console.log(`Proton Drive SDK for web v${VERSION}`); const cryptoApi = initCrypto(); const config = getConfig(); const account = await initAccount(cryptoApi, config); diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 01120756..9de06998 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,10 +1,10 @@ import { parseArgs } from 'util'; +import { VERSION } from '../../sdk/src'; + import { init } from './init'; import { getCommand, validateCommandArguments } from './cli'; -const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(); - const groupName = Bun.argv[2]; const commandName = Bun.argv[3]; const command = getCommand(groupName, commandName); @@ -19,15 +19,29 @@ const { values: options, positionals } = parseArgs({ const args = positionals.slice(4); validateCommandArguments(command, args, options); +const debug = !options.json; + +const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(); + +if (debug) { + console.log(`Proton Drive SDK for web v${VERSION}`); +} + if (!command.isAuthAction) { if (!account.session) { throw new Error('You need to login first'); } + if (debug) { + console.time('Account initialization'); + console.log('Initializing user keys...'); + } await account.loadPrimaryKeys(); - console.log('----------'); + if (debug) { + console.timeEnd('Account initialization'); + } } -if (!options.json) { +if (debug) { console.time('Command execution'); } @@ -41,7 +55,7 @@ await command.action({ options, }); -if (!options.json) { +if (debug) { console.timeEnd('Command execution'); } From 0e015eceacf0c30a7a015db8d71b5d3686cfdb84 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 16 Oct 2025 15:18:22 +0200 Subject: [PATCH 117/732] Fix app version for CLI app --- js/cli/src/init.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index bcbbf3d1..dc654965 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -44,7 +44,10 @@ function initCrypto() { function getConfig(): Config { return { - appVersion: 'web-drive-sdk-cli@5.0.999.999', + // App version must have two or three parts, no more or less, separated by dash. + // First part is platform name, second is product name, third is optional section. + // That's why we have sdkclijs instead of sdk-cli-js. + appVersion: 'external-drive-sdkclijs@5.0.999.999', baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), enableConsoleLog: process.env.PROTON_DRIVE_DISABLE_CONSOLE_LOG === undefined, From 6618fb1cd2d883aeec1d1ca78cb1bdd53b437183 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 18:47:01 +0200 Subject: [PATCH 118/732] 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 ae79ddfa2edb3e0f566d747dbb175262d6df144e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 08:08:34 +0200 Subject: [PATCH 119/732] 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 b2ac5dace64699fae36b033c0472901973184bd5 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 07:44:06 +0200 Subject: [PATCH 120/732] 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 bd3be590d9c3a3574f00fbe68741f23d8a5e26cd Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 15 Oct 2025 16:07:10 +0200 Subject: [PATCH 121/732] 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 + js/cli/src/cli/commandFileSystemUpload.ts | 2 +- 43 files changed, 497 insertions(+), 283 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 nave 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; } diff --git a/js/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/commandFileSystemUpload.ts index 437fb5b3..7e5ecd5c 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/commandFileSystemUpload.ts @@ -15,7 +15,7 @@ export class CommandFileSystemUpload implements Command { short: 'n', default: '', }, - newRevision: { + 'new-revision': { type: 'boolean', short: 'r', default: false, From 7d0bef15c59b00175e38ab055ad00cb81ae49357 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Oct 2025 06:18:21 +0000 Subject: [PATCH 122/732] 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 bea714c5760bf0b115de2c5f4ec41b56a16d2125 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Oct 2025 07:19:54 +0200 Subject: [PATCH 123/732] Remove console handlers for JSON outputs --- js/cli/README.md | 9 ++++----- js/cli/src/init.ts | 8 ++++---- js/cli/src/proton-drive.ts | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index aba60606..f0513f6a 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -44,8 +44,7 @@ Run the executable without any parameter to get help. ## Environment variables -| Name | Description | Default Value | -|--------------------------|-----------------------------------------------------------------------------|-----------------------------| -| `PROTON_DRIVE_BASE_URL` | URL for the API. | `drive-api.proton.me` | -| `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | -| `PROTON_DRIVE_DISABLE_CONSOLE_LOG` | Option to turn off the logging to the stdout. Logs will always be available in the `proton-drive.log`, located in `PROTON_DRIVE_CACHE_DIR`. | None | +| Name | Description | Default Value | +|--------------------------|-------------------------------------------------------------------|-----------------------------| +| `PROTON_DRIVE_BASE_URL` | URL for the API. | `drive-api.proton.me` | +| `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index dc654965..0d6eb7f0 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -19,9 +19,9 @@ interface Config { accessToken?: string; } -export async function init() { +export async function init(debug: boolean) { const cryptoApi = initCrypto(); - const config = getConfig(); + const config = getConfig(debug); const account = await initAccount(cryptoApi, config); const srp = await initSrp(cryptoApi, config); const sdk = initSDK(cryptoApi, config, account, srp); @@ -42,7 +42,7 @@ function initCrypto() { return new CryptoApi(); } -function getConfig(): Config { +function getConfig(debug: boolean): Config { return { // App version must have two or three parts, no more or less, separated by dash. // First part is platform name, second is product name, third is optional section. @@ -50,7 +50,7 @@ function getConfig(): Config { appVersion: 'external-drive-sdkclijs@5.0.999.999', baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), - enableConsoleLog: process.env.PROTON_DRIVE_DISABLE_CONSOLE_LOG === undefined, + enableConsoleLog: debug, }; } diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 9de06998..ee8e09e5 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -21,7 +21,7 @@ validateCommandArguments(command, args, options); const debug = !options.json; -const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(); +const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(debug); if (debug) { console.log(`Proton Drive SDK for web v${VERSION}`); From 15e2f93d8bc74b465eab8c74072fc8798d7f5556 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 15:31:36 +0200 Subject: [PATCH 124/732] 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 77c15e2a36a835ddc8cbcf4e10a7e16a6c7e8422 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Oct 2025 15:17:20 +0200 Subject: [PATCH 125/732] Add expectedStrcuture options for diagnostics --- js/cli/src/cli/CommandDiagnosticTree.ts | 18 +-- .../{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 ++++++++++++++++-- 5 files changed, 173 insertions(+), 27 deletions(-) rename js/sdk/src/diagnostic/{sdkDiagnosticFull.ts => diagnostic.ts} (66%) diff --git a/js/cli/src/cli/CommandDiagnosticTree.ts b/js/cli/src/cli/CommandDiagnosticTree.ts index 983b49b6..1091e2fa 100644 --- a/js/cli/src/cli/CommandDiagnosticTree.ts +++ b/js/cli/src/cli/CommandDiagnosticTree.ts @@ -1,6 +1,7 @@ import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from './interface'; +import { printObject } from './formatters'; export class CommandDiagnosticTree implements Command { group = 'diagnostic'; @@ -24,22 +25,25 @@ export class CommandDiagnosticTree implements Command { }, }; - async action({ sdkDiagnostic, paths, args: [pathString], options: { json, content, thumbnails } }: ActionArgs) { + async action({ + sdkDiagnostic, + paths, + args: [pathString], + options: { json, content, thumbnails, localStructure }, + }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); + const expectedStructure = localStructure ? JSON.parse(await Bun.file(localStructure).text()) : undefined; + const options = { verifyContent: content, verifyThumbnails: thumbnails, + expectedStructure, }; for await (const result of sdkDiagnostic.verifyNodeTree(node, options)) { - if (json) { - console.log(JSON.stringify(result)); - } else { - // Empty string as first argument is a workaround to print deep structures with Bun. - console.log('', result); - } + printObject(result, json); } } } 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 0cf6fd2fe222e4ffcdcaf0a01602fa643b083694 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Oct 2025 07:35:54 +0200 Subject: [PATCH 126/732] 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 ddb1e964292b9d34640805f2866a5c7f0138ef7c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Oct 2025 05:29:10 +0000 Subject: [PATCH 127/732] Add getAvailableName method --- .../cli/commandFileSystemGetAvailableName.ts | 17 +++++ js/cli/src/cli/index.ts | 2 + 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 + 21 files changed, 285 insertions(+), 202 deletions(-) create mode 100644 js/cli/src/cli/commandFileSystemGetAvailableName.ts 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/cli/src/cli/commandFileSystemGetAvailableName.ts b/js/cli/src/cli/commandFileSystemGetAvailableName.ts new file mode 100644 index 00000000..1f19fbb9 --- /dev/null +++ b/js/cli/src/cli/commandFileSystemGetAvailableName.ts @@ -0,0 +1,17 @@ +import { printObject } from './formatters'; +import { Command, ActionArgs } from './interface'; + +export class CommandFileSystemGetAvailableName implements Command { + group = 'filesystem'; + name = 'getAvailableName'; + args = ['path', 'name']; + + async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { + const path = paths.getPath(pathString); + const node = await path.getNode(); + const availableName = await sdk.getAvailableName(node, name); + printObject({ + availableName, + }, json); + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 7567d1ff..c6abf5e8 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -14,6 +14,7 @@ import { CommandFileSystemDelete } from './commandFileSystemDelete'; import { CommandFileSystemDownload } from './commandFileSystemDownload'; import { CommandFileSystemDownloadSeeking } from './commandFileSystemDownloadSeeking'; import { CommandFileSystemDownloadThumbnails } from './commandFileSystemDownloadThumbnails'; +import { CommandFileSystemGetAvailableName } from './commandFileSystemGetAvailableName'; import { CommandFileSystemInfo } from './commandFileSystemInfo'; import { CommandFileSystemList } from './commandFileSystemList'; import { CommandFileSystemMove } from './commandFileSystemMove'; @@ -67,6 +68,7 @@ const COMMANDS = [ new CommandFileSystemDownloadSeeking(), new CommandFileSystemDownloadThumbnails(), new CommandFileSystemUpload(), + new CommandFileSystemGetAvailableName(), new CommandRevisionList(), new CommandRevisionDownload(), new CommandRevisionRestore(), 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 921af41ac7302ab9ffc0edaa8f4699bb57ca4ef5 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Oct 2025 13:58:32 +0200 Subject: [PATCH 128/732] Expose sharing for Photos SDK --- js/cli/src/cli/{ => auth}/commandAuthLogin.ts | 3 +- .../src/cli/{ => auth}/commandAuthLogout.ts | 2 +- .../cli/{ => bookmark}/commandBookmarkList.ts | 4 +- .../{ => bookmark}/commandBookmarkRemove.ts | 0 .../src/cli/commandFileSystemCreateFolder.ts | 16 -- js/cli/src/cli/commandPhotoDownload.ts | 49 ----- .../src/cli/commandPhotoDownloadThumbnails.ts | 64 ------ js/cli/src/cli/commandPhotoUpload.ts | 44 ---- js/cli/src/cli/commandSharingRemoveUrl.ts | 19 -- js/cli/src/cli/commandSharingStatus.ts | 17 -- .../cli/{ => device}/commandDeviceCreate.ts | 5 +- .../cli/{ => device}/commandDeviceDelete.ts | 2 +- .../cli/{ => device}/commandDeviceRename.ts | 4 +- .../commandDiagnosticTree.ts} | 4 +- .../cli/{ => events}/commandEventsDrive.ts | 4 +- .../cli/{ => events}/commandEventsMyVolume.ts | 6 +- .../src/cli/{ => events}/commandEventsPath.ts | 10 +- .../{ => fileSystem}/commandFileSystemCopy.ts | 9 +- .../commandFileSystemCreateFolder.ts | 22 ++ .../commandFileSystemDelete.ts | 8 +- .../commandFileSystemDownload.ts | 8 +- .../commandFileSystemDownloadSeeking.ts | 11 +- .../commandFileSystemDownloadThumbnails.ts | 13 +- .../commandFileSystemGetAvailableName.ts | 4 +- .../{ => fileSystem}/commandFileSystemInfo.ts | 4 +- .../{ => fileSystem}/commandFileSystemList.ts | 27 ++- .../{ => fileSystem}/commandFileSystemMove.ts | 8 +- .../commandFileSystemRename.ts | 8 +- .../commandFileSystemRestore.ts | 8 +- .../commandFileSystemTrash.ts | 8 +- .../commandFileSystemUpload.ts | 22 +- js/cli/src/cli/index.ts | 87 ++++---- js/cli/src/cli/paths.ts | 118 ++++++----- .../src/cli/{ => photos}/commandAlbumList.ts | 4 +- .../cli/{ => photos}/commandPhotoTimeline.ts | 6 +- .../{ => revision}/commandRevisionDelete.ts | 2 +- .../{ => revision}/commandRevisionDownload.ts | 2 +- .../cli/{ => revision}/commandRevisionList.ts | 8 +- .../{ => revision}/commandRevisionRestore.ts | 2 +- .../{ => sharing}/commandInvitationAccept.ts | 2 +- .../{ => sharing}/commandInvitationList.ts | 4 +- .../{ => sharing}/commandInvitationReject.ts | 2 +- .../cli/{ => sharing}/commandSharingInvite.ts | 12 +- .../cli/{ => sharing}/commandSharingRemove.ts | 15 +- .../cli/sharing/commandSharingRemoveUrl.ts | 19 ++ .../cli/{ => sharing}/commandSharingSetUrl.ts | 13 +- .../src/cli/sharing/commandSharingStatus.ts | 17 ++ js/sdk/src/protonDriveClient.ts | 6 + js/sdk/src/protonDrivePhotosClient.ts | 192 +++++++++++++++++- 49 files changed, 505 insertions(+), 419 deletions(-) rename js/cli/src/cli/{ => auth}/commandAuthLogin.ts (93%) rename js/cli/src/cli/{ => auth}/commandAuthLogout.ts (79%) rename js/cli/src/cli/{ => bookmark}/commandBookmarkList.ts (69%) rename js/cli/src/cli/{ => bookmark}/commandBookmarkRemove.ts (100%) delete mode 100644 js/cli/src/cli/commandFileSystemCreateFolder.ts delete mode 100644 js/cli/src/cli/commandPhotoDownload.ts delete mode 100644 js/cli/src/cli/commandPhotoDownloadThumbnails.ts delete mode 100644 js/cli/src/cli/commandPhotoUpload.ts delete mode 100644 js/cli/src/cli/commandSharingRemoveUrl.ts delete mode 100644 js/cli/src/cli/commandSharingStatus.ts rename js/cli/src/cli/{ => device}/commandDeviceCreate.ts (82%) rename js/cli/src/cli/{ => device}/commandDeviceDelete.ts (86%) rename js/cli/src/cli/{ => device}/commandDeviceRename.ts (76%) rename js/cli/src/cli/{CommandDiagnosticTree.ts => diagnostic/commandDiagnosticTree.ts} (92%) rename js/cli/src/cli/{ => events}/commandEventsDrive.ts (72%) rename js/cli/src/cli/{ => events}/commandEventsMyVolume.ts (77%) rename js/cli/src/cli/{ => events}/commandEventsPath.ts (73%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemCopy.ts (70%) create mode 100644 js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts rename js/cli/src/cli/{ => fileSystem}/commandFileSystemDelete.ts (61%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemDownload.ts (83%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemDownloadSeeking.ts (94%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemDownloadThumbnails.ts (78%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemGetAvailableName.ts (83%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemInfo.ts (77%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemList.ts (75%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemMove.ts (85%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemRename.ts (52%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemRestore.ts (60%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemTrash.ts (59%) rename js/cli/src/cli/{ => fileSystem}/commandFileSystemUpload.ts (69%) rename js/cli/src/cli/{ => photos}/commandAlbumList.ts (69%) rename js/cli/src/cli/{ => photos}/commandPhotoTimeline.ts (83%) rename js/cli/src/cli/{ => revision}/commandRevisionDelete.ts (87%) rename js/cli/src/cli/{ => revision}/commandRevisionDownload.ts (95%) rename js/cli/src/cli/{ => revision}/commandRevisionList.ts (85%) rename js/cli/src/cli/{ => revision}/commandRevisionRestore.ts (87%) rename js/cli/src/cli/{ => sharing}/commandInvitationAccept.ts (87%) rename js/cli/src/cli/{ => sharing}/commandInvitationList.ts (70%) rename js/cli/src/cli/{ => sharing}/commandInvitationReject.ts (87%) rename js/cli/src/cli/{ => sharing}/commandSharingInvite.ts (80%) rename js/cli/src/cli/{ => sharing}/commandSharingRemove.ts (62%) create mode 100644 js/cli/src/cli/sharing/commandSharingRemoveUrl.ts rename js/cli/src/cli/{ => sharing}/commandSharingSetUrl.ts (72%) create mode 100644 js/cli/src/cli/sharing/commandSharingStatus.ts diff --git a/js/cli/src/cli/commandAuthLogin.ts b/js/cli/src/cli/auth/commandAuthLogin.ts similarity index 93% rename from js/cli/src/cli/commandAuthLogin.ts rename to js/cli/src/cli/auth/commandAuthLogin.ts index e7ff1509..df998bf8 100644 --- a/js/cli/src/cli/commandAuthLogin.ts +++ b/js/cli/src/cli/auth/commandAuthLogin.ts @@ -1,5 +1,6 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; + +import { Command, ActionArgs } from '../interface'; export class CommandAuthLogin implements Command { group = 'auth'; diff --git a/js/cli/src/cli/commandAuthLogout.ts b/js/cli/src/cli/auth/commandAuthLogout.ts similarity index 79% rename from js/cli/src/cli/commandAuthLogout.ts rename to js/cli/src/cli/auth/commandAuthLogout.ts index e514d152..088ce5ef 100644 --- a/js/cli/src/cli/commandAuthLogout.ts +++ b/js/cli/src/cli/auth/commandAuthLogout.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandAuthLogout implements Command { group = 'auth'; diff --git a/js/cli/src/cli/commandBookmarkList.ts b/js/cli/src/cli/bookmark/commandBookmarkList.ts similarity index 69% rename from js/cli/src/cli/commandBookmarkList.ts rename to js/cli/src/cli/bookmark/commandBookmarkList.ts index f2810e85..dccd1fc6 100644 --- a/js/cli/src/cli/commandBookmarkList.ts +++ b/js/cli/src/cli/bookmark/commandBookmarkList.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandBookmarkList implements Command { group = 'bookmark'; diff --git a/js/cli/src/cli/commandBookmarkRemove.ts b/js/cli/src/cli/bookmark/commandBookmarkRemove.ts similarity index 100% rename from js/cli/src/cli/commandBookmarkRemove.ts rename to js/cli/src/cli/bookmark/commandBookmarkRemove.ts diff --git a/js/cli/src/cli/commandFileSystemCreateFolder.ts b/js/cli/src/cli/commandFileSystemCreateFolder.ts deleted file mode 100644 index 0a50b2d4..00000000 --- a/js/cli/src/cli/commandFileSystemCreateFolder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { printObject } from './formatters'; -import { Command, ActionArgs } from './interface'; - -export class CommandFileSystemCreateFolder implements Command { - group = 'filesystem'; - name = 'create-folder'; - args = ['path', 'name']; - - async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - const parent = await path.getNode(); - const folder = await sdk.createFolder(parent, name, new Date()); - - printObject(folder, json); - } -} diff --git a/js/cli/src/cli/commandPhotoDownload.ts b/js/cli/src/cli/commandPhotoDownload.ts deleted file mode 100644 index b32f5f04..00000000 --- a/js/cli/src/cli/commandPhotoDownload.ts +++ /dev/null @@ -1,49 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from './interface'; -import { getName } from './node'; - -export class CommandPhotoDownload implements Command { - group = 'photo'; - name = 'download'; - // FIXME: support download of multiple files - args = ['path', 'localParentPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - }; - - async action({ photosSdk, paths, args: [pathString, localParentPath], options: { json, name } }: ActionArgs) { - const nodePath = paths.getPhotoPath(pathString); - const node = await nodePath.getNode(); - const downloader = await photosSdk.getFileDownloader(node); - - const claimedSize = downloader.getClaimedSizeInBytes(); - const localPath = path.join(localParentPath, name || getName(node)); - - if (!json) { - console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); - } - - const file = Bun.file(localPath); - const writer = file.writer(); - const writableStream: WritableStream = { - getWriter: () => writer, - close: () => writer.end(), - abort: () => writer.end(), - locked: false, - }; - - const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } - }); - - await controller.completion(); - } -} diff --git a/js/cli/src/cli/commandPhotoDownloadThumbnails.ts b/js/cli/src/cli/commandPhotoDownloadThumbnails.ts deleted file mode 100644 index cb5f71f8..00000000 --- a/js/cli/src/cli/commandPhotoDownloadThumbnails.ts +++ /dev/null @@ -1,64 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'node:util'; - -import { Command, ActionArgs } from './interface'; -import { MaybeNode, ThumbnailType } from '../../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; -import { printIterable } from './formatters'; - -export class CommandPhotoDownloadThumbnails implements Command { - group = 'photo'; - name = 'download-thumbnails'; - // FIXME: support download of multiple thumbnails - args = ['path', 'parentLocalPath']; - options: ParseArgsConfig['options'] = { - thumbnailType: { - type: 'string', - short: 't', - default: ThumbnailType.Type1.toString(), - }, - }; - - async action({ - photosSdk, - paths, - args: [pathString, parentLocalPath], - options: { json, thumbnailType }, - }: ActionArgs) { - const nodePath = paths.getPhotoPath(pathString); - const node = await nodePath.getNode(); - - const thumbnailsIterator = this.processThumbnails(photosSdk, node, parseInt(thumbnailType), parentLocalPath); - await printIterable(thumbnailsIterator, json, (result) => { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - }); - } - - private async *processThumbnails( - sdk: ProtonDrivePhotosClient, - node: MaybeNode, - thumbnailType: ThumbnailType, - parentLocalPath: string, - ) { - for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { - // Thumbnail can be jpeg or webp. All new code should produce webp. - const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; - const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - - if (result.ok) { - await Bun.write(thumbnailFilePath, result.thumbnail); - } - - yield { - ok: result.ok, - nodeUid: result.nodeUid, - error: !result.ok ? result.error : undefined, - thumbnailFileName, - }; - } - } -} diff --git a/js/cli/src/cli/commandPhotoUpload.ts b/js/cli/src/cli/commandPhotoUpload.ts deleted file mode 100644 index bbd7e9d7..00000000 --- a/js/cli/src/cli/commandPhotoUpload.ts +++ /dev/null @@ -1,44 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Thumbnail } from '../../../sdk/src'; -import { Command, ActionArgs } from './interface'; - -export class CommandPhotoUpload implements Command { - group = 'photo'; - name = 'upload'; - // FIXME: support upload of multiple files - args = ['localPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - }; - - async action({ photosSdk, args: [localPath], options: { json, name } }: ActionArgs) { - name = name || path.basename(localPath); - - const file = Bun.file(localPath); - const metadata = { - mediaType: file.type, - expectedSize: file.size, - }; - - const uploader = await photosSdk.getFileUploader(name, metadata); - - if (!json) { - console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); - } - - const thumbnails: Thumbnail[] = []; // TODO - const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { - if (!json) { - console.log(`Uploaded ${writtenBytes} bytes`); - } - }); - - await controller.completion(); - } -} diff --git a/js/cli/src/cli/commandSharingRemoveUrl.ts b/js/cli/src/cli/commandSharingRemoveUrl.ts deleted file mode 100644 index 819a45c8..00000000 --- a/js/cli/src/cli/commandSharingRemoveUrl.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; - -export class CommandSharingRemoveUrl implements Command { - group = 'sharing'; - name = 'remove-url'; - args = ['path']; - - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); - - const sharingInfo = await sdk.unshareNode(node, { - publicLink: 'remove', - }); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/commandSharingStatus.ts b/js/cli/src/cli/commandSharingStatus.ts deleted file mode 100644 index ed0d2371..00000000 --- a/js/cli/src/cli/commandSharingStatus.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { printObject } from './formatters'; -import { Command, ActionArgs } from './interface'; - -export class CommandSharingStatus implements Command { - group = 'sharing'; - name = 'status'; - args = ['path']; - - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - - const node = await path.getNode(); - const sharingInfo = await sdk.getSharingInfo(node); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/commandDeviceCreate.ts b/js/cli/src/cli/device/commandDeviceCreate.ts similarity index 82% rename from js/cli/src/cli/commandDeviceCreate.ts rename to js/cli/src/cli/device/commandDeviceCreate.ts index af49cdde..599b34f3 100644 --- a/js/cli/src/cli/commandDeviceCreate.ts +++ b/js/cli/src/cli/device/commandDeviceCreate.ts @@ -1,6 +1,7 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandDeviceCreate implements Command { group = 'device'; diff --git a/js/cli/src/cli/commandDeviceDelete.ts b/js/cli/src/cli/device/commandDeviceDelete.ts similarity index 86% rename from js/cli/src/cli/commandDeviceDelete.ts rename to js/cli/src/cli/device/commandDeviceDelete.ts index 7ed16bd2..825b1d81 100644 --- a/js/cli/src/cli/commandDeviceDelete.ts +++ b/js/cli/src/cli/device/commandDeviceDelete.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandDeviceDelete implements Command { group = 'device'; diff --git a/js/cli/src/cli/commandDeviceRename.ts b/js/cli/src/cli/device/commandDeviceRename.ts similarity index 76% rename from js/cli/src/cli/commandDeviceRename.ts rename to js/cli/src/cli/device/commandDeviceRename.ts index 1a172220..9a37faec 100644 --- a/js/cli/src/cli/commandDeviceRename.ts +++ b/js/cli/src/cli/device/commandDeviceRename.ts @@ -1,5 +1,5 @@ -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; export class CommandDeviceRename implements Command { group = 'device'; diff --git a/js/cli/src/cli/CommandDiagnosticTree.ts b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts similarity index 92% rename from js/cli/src/cli/CommandDiagnosticTree.ts rename to js/cli/src/cli/diagnostic/commandDiagnosticTree.ts index 1091e2fa..ba3def38 100644 --- a/js/cli/src/cli/CommandDiagnosticTree.ts +++ b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts @@ -1,7 +1,7 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; export class CommandDiagnosticTree implements Command { group = 'diagnostic'; diff --git a/js/cli/src/cli/commandEventsDrive.ts b/js/cli/src/cli/events/commandEventsDrive.ts similarity index 72% rename from js/cli/src/cli/commandEventsDrive.ts rename to js/cli/src/cli/events/commandEventsDrive.ts index c5533fa0..538c531a 100644 --- a/js/cli/src/cli/commandEventsDrive.ts +++ b/js/cli/src/cli/events/commandEventsDrive.ts @@ -1,5 +1,5 @@ -import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback, eventsReady } from './events'; +import { Command, ActionArgs } from '../interface'; +import { runForever, eventsCallback, eventsReady } from '../events'; export class CommandEventsDrive implements Command { group = 'events'; diff --git a/js/cli/src/cli/commandEventsMyVolume.ts b/js/cli/src/cli/events/commandEventsMyVolume.ts similarity index 77% rename from js/cli/src/cli/commandEventsMyVolume.ts rename to js/cli/src/cli/events/commandEventsMyVolume.ts index 475fe289..98fee3c2 100644 --- a/js/cli/src/cli/commandEventsMyVolume.ts +++ b/js/cli/src/cli/events/commandEventsMyVolume.ts @@ -1,6 +1,6 @@ -import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback, eventsReady } from './events'; -import { DriveEvent } from '../../../sdk/src'; +import { DriveEvent } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { runForever, eventsCallback, eventsReady } from '../events'; export class CommandEventsMyVolume implements Command { group = 'events'; diff --git a/js/cli/src/cli/commandEventsPath.ts b/js/cli/src/cli/events/commandEventsPath.ts similarity index 73% rename from js/cli/src/cli/commandEventsPath.ts rename to js/cli/src/cli/events/commandEventsPath.ts index 56b000a5..ee1f531c 100644 --- a/js/cli/src/cli/commandEventsPath.ts +++ b/js/cli/src/cli/events/commandEventsPath.ts @@ -1,13 +1,13 @@ -import { Command, ActionArgs } from './interface'; -import { runForever, eventsCallback, eventsReady } from './events'; -import { DriveEvent, DriveEventType } from '../../../sdk/src'; +import { DriveEvent, DriveEventType } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { runForever, eventsCallback, eventsReady } from '../events'; export class CommandEventsPath implements Command { group = 'events'; name = 'path'; args = ['path']; - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const maybeNode = await nodePath.getNode(); if (!maybeNode.ok) { @@ -30,7 +30,7 @@ export class CommandEventsPath implements Command { return false; }; - await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => + await nodePath.sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => eventsCallback(json, filter, event), ); eventsReady(json); diff --git a/js/cli/src/cli/commandFileSystemCopy.ts b/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts similarity index 70% rename from js/cli/src/cli/commandFileSystemCopy.ts rename to js/cli/src/cli/fileSystem/commandFileSystemCopy.ts index 96937c5a..becbc81e 100644 --- a/js/cli/src/cli/commandFileSystemCopy.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts @@ -1,5 +1,6 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PathType } from '../paths'; export class CommandFileSystemCopy implements Command { group = 'filesystem'; @@ -14,6 +15,10 @@ export class CommandFileSystemCopy implements Command { const targetPath = paths.getPath(targetPathString); const targetNode = await targetPath.getNode(); + if (sourcePath.type === PathType.Photos || targetPath.type === PathType.Photos) { + throw new Error('Copying photos is not supported'); + } + await printIterable(sdk.copyNodes([sourceNode], targetNode), json, (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), ); diff --git a/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts new file mode 100644 index 00000000..9d79d529 --- /dev/null +++ b/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts @@ -0,0 +1,22 @@ +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PathType } from '../paths'; + +export class CommandFileSystemCreateFolder implements Command { + group = 'filesystem'; + name = 'create-folder'; + args = ['path', 'name']; + + async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const parent = await nodePath.getNode(); + + if (nodePath.type === PathType.Photos) { + throw new Error('Creating folders in photos is not supported'); + } + + const folder = await sdk.createFolder(parent, name, new Date()); + + printObject(folder, json); + } +} diff --git a/js/cli/src/cli/commandFileSystemDelete.ts b/js/cli/src/cli/fileSystem/commandFileSystemDelete.ts similarity index 61% rename from js/cli/src/cli/commandFileSystemDelete.ts rename to js/cli/src/cli/fileSystem/commandFileSystemDelete.ts index 6364650b..11afa8ad 100644 --- a/js/cli/src/cli/commandFileSystemDelete.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDelete.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemDelete implements Command { group = 'filesystem'; @@ -7,10 +7,10 @@ export class CommandFileSystemDelete implements Command { // FIXME: support delete of multiple files args = ['path']; - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - await printIterable(sdk.deleteNodes([node]), json, (result) => + await printIterable(nodePath.sdk.deleteNodes([node]), json, (result) => console.log(result.ok ? `Deleted ${result.uid}` : `Failed to delete ${result.uid}: ${result.error}`), ); } diff --git a/js/cli/src/cli/commandFileSystemDownload.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts similarity index 83% rename from js/cli/src/cli/commandFileSystemDownload.ts rename to js/cli/src/cli/fileSystem/commandFileSystemDownload.ts index a6f18ec5..0abc1761 100644 --- a/js/cli/src/cli/commandFileSystemDownload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { getName } from './node'; +import { Command, ActionArgs } from '../interface'; +import { getName } from '../node'; export class CommandFileSystemDownload implements Command { group = 'filesystem'; @@ -17,10 +17,10 @@ export class CommandFileSystemDownload implements Command { }, }; - async action({ sdk, paths, args: [pathString, localParentPath], options: { name, json } }: ActionArgs) { + async action({ paths, args: [pathString, localParentPath], options: { name, json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - const downloader = await sdk.getFileDownloader(node); + const downloader = await nodePath.sdk.getFileDownloader(node); const claimedSize = downloader.getClaimedSizeInBytes(); const localPath = path.join(localParentPath, name || getName(node)); diff --git a/js/cli/src/cli/commandFileSystemDownloadSeeking.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts similarity index 94% rename from js/cli/src/cli/commandFileSystemDownloadSeeking.ts rename to js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts index 167c8f11..f48bd98e 100644 --- a/js/cli/src/cli/commandFileSystemDownloadSeeking.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts @@ -1,19 +1,18 @@ import * as readline from 'readline'; -import { SeekableReadableStream } from '../../../sdk/src'; - -import { Command, ActionArgs } from './interface'; -import { runForever } from './events'; +import { SeekableReadableStream } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { runForever } from '../events'; export class CommandFileSystemDownloadSeeking implements Command { group = 'filesystem'; name = 'download-seeking'; args = ['path']; - async action({ sdk, paths, args: [pathString] }: ActionArgs) { + async action({ paths, args: [pathString] }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - const downloader = await sdk.getFileDownloader(node); + const downloader = await nodePath.sdk.getFileDownloader(node); const claimedSize = downloader.getClaimedSizeInBytes(); console.log(`Claimed size: ${claimedSize || 'N/A'} bytes`); diff --git a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts similarity index 78% rename from js/cli/src/cli/commandFileSystemDownloadThumbnails.ts rename to js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts index d2e5b7a4..96381050 100644 --- a/js/cli/src/cli/commandFileSystemDownloadThumbnails.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts @@ -1,9 +1,10 @@ import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; -import { Command, ActionArgs } from './interface'; -import { MaybeNode, ProtonDriveClient, ThumbnailType } from '../../../sdk/src'; -import { printIterable } from './formatters'; +import { MaybeNode, ProtonDriveClient, ThumbnailType } from '../../../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; +import { Command, ActionArgs } from '../interface'; +import { printIterable } from '../formatters'; export class CommandFileSystemDownloadThumbnails implements Command { group = 'filesystem'; @@ -18,11 +19,11 @@ export class CommandFileSystemDownloadThumbnails implements Command { }, }; - async action({ sdk, paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { + async action({ paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - const thumbnailsIterator = this.processThumbnails(sdk, node, parseInt(thumbnailType), parentLocalPath); + const thumbnailsIterator = this.processThumbnails(nodePath.sdk, node, parseInt(thumbnailType), parentLocalPath); await printIterable(thumbnailsIterator, json, (result) => { console.log( result.ok @@ -33,7 +34,7 @@ export class CommandFileSystemDownloadThumbnails implements Command { } private async *processThumbnails( - sdk: ProtonDriveClient, + sdk: ProtonDriveClient | ProtonDrivePhotosClient, node: MaybeNode, thumbnailType: ThumbnailType, parentLocalPath: string, diff --git a/js/cli/src/cli/commandFileSystemGetAvailableName.ts b/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts similarity index 83% rename from js/cli/src/cli/commandFileSystemGetAvailableName.ts rename to js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts index 1f19fbb9..476950cd 100644 --- a/js/cli/src/cli/commandFileSystemGetAvailableName.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts @@ -1,5 +1,5 @@ -import { printObject } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemGetAvailableName implements Command { group = 'filesystem'; diff --git a/js/cli/src/cli/commandFileSystemInfo.ts b/js/cli/src/cli/fileSystem/commandFileSystemInfo.ts similarity index 77% rename from js/cli/src/cli/commandFileSystemInfo.ts rename to js/cli/src/cli/fileSystem/commandFileSystemInfo.ts index 42580c81..83febc8b 100644 --- a/js/cli/src/cli/commandFileSystemInfo.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemInfo.ts @@ -1,5 +1,5 @@ -import { printObject } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemInfo implements Command { group = 'filesystem'; diff --git a/js/cli/src/cli/commandFileSystemList.ts b/js/cli/src/cli/fileSystem/commandFileSystemList.ts similarity index 75% rename from js/cli/src/cli/commandFileSystemList.ts rename to js/cli/src/cli/fileSystem/commandFileSystemList.ts index 061826e8..c4940411 100644 --- a/js/cli/src/cli/commandFileSystemList.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemList.ts @@ -1,10 +1,11 @@ import { ParseArgsConfig } from 'node:util'; -import { ProtonDriveClient, MaybeNode, Device, NodeType } from '../../../sdk/src'; -import { Command, ActionArgs } from './interface'; -import { PathType, Path } from './paths'; -import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from './formatters'; -import { getName, getClaimedSize, getNode } from './node'; +import { ProtonDriveClient, MaybeNode, Device, NodeType } from '../../../../sdk/src'; +import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; +import { Command, ActionArgs } from '../interface'; +import { PathType, Path } from '../paths'; +import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from '../formatters'; +import { getName, getClaimedSize, getNode } from '../node'; export class CommandFileSystemList implements Command { group = 'filesystem'; @@ -18,7 +19,7 @@ export class CommandFileSystemList implements Command { }, }; - async action({ sdk, paths, args: [pathString], options: { json, type } }: ActionArgs) { + async action({ sdk, photosSdk, paths, args: [pathString], options: { json, type } }: ActionArgs) { const path = paths.getPath(pathString); const nodeType = type ? Object.entries(NodeType).find(([, value]) => value === type)?.[1] : undefined; @@ -49,6 +50,14 @@ export class CommandFileSystemList implements Command { } } else if (path.type === PathType.Trash) { await this.printTrashedNodes(sdk, { json }); + } else if (path.type === PathType.PhotosSharedByMe) { + await this.printSharedNodes(photosSdk, { json }); + } else if (path.type === PathType.PhotosSharedWithMe) { + await this.printSharedWithMe(photosSdk, { json }); + } else if (path.type === PathType.PhotosTrash) { + await this.printTrashedNodes(photosSdk, { json }); + } else { + throw new Error(`Not supported path type: ${path.type}`); } } @@ -63,15 +72,15 @@ export class CommandFileSystemList implements Command { await printIterable(childrenIterator, options.json, (node) => this.printNodeHuman(node)); } - private async printSharedNodes(sdk: ProtonDriveClient, options: { json: boolean }) { + private async printSharedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { await printIterable(sdk.iterateSharedNodes(), options.json, (node) => this.printNodeHuman(node)); } - private async printSharedWithMe(sdk: ProtonDriveClient, options: { json: boolean }) { + private async printSharedWithMe(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { await printIterable(sdk.iterateSharedNodesWithMe(), options.json, (node) => this.printNodeHuman(node)); } - private async printTrashedNodes(sdk: ProtonDriveClient, options: { json: boolean }) { + private async printTrashedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { await printIterable(sdk.iterateTrashedNodes(), options.json, (node) => this.printNodeHuman(node)); } diff --git a/js/cli/src/cli/commandFileSystemMove.ts b/js/cli/src/cli/fileSystem/commandFileSystemMove.ts similarity index 85% rename from js/cli/src/cli/commandFileSystemMove.ts rename to js/cli/src/cli/fileSystem/commandFileSystemMove.ts index b852c7d2..75eb2fb3 100644 --- a/js/cli/src/cli/commandFileSystemMove.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemMove.ts @@ -1,7 +1,7 @@ -import { ProtonDriveClient } from '../../../sdk/src'; -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; -import { Path, PathType } from './paths'; +import { ProtonDriveClient } from '../../../../sdk/src'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { Path, PathType } from '../paths'; export class CommandFileSystemMove implements Command { group = 'filesystem'; diff --git a/js/cli/src/cli/commandFileSystemRename.ts b/js/cli/src/cli/fileSystem/commandFileSystemRename.ts similarity index 52% rename from js/cli/src/cli/commandFileSystemRename.ts rename to js/cli/src/cli/fileSystem/commandFileSystemRename.ts index 4d82efd9..ed22f5b9 100644 --- a/js/cli/src/cli/commandFileSystemRename.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemRename.ts @@ -1,15 +1,15 @@ -import { printObject } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemRename implements Command { group = 'filesystem'; name = 'rename'; args = ['path', 'newName']; - async action({ sdk, paths, args: [pathString, newName], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString, newName], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - const renamedNode = await sdk.renameNode(node, newName); + const renamedNode = await nodePath.sdk.renameNode(node, newName); printObject(renamedNode, json); } diff --git a/js/cli/src/cli/commandFileSystemRestore.ts b/js/cli/src/cli/fileSystem/commandFileSystemRestore.ts similarity index 60% rename from js/cli/src/cli/commandFileSystemRestore.ts rename to js/cli/src/cli/fileSystem/commandFileSystemRestore.ts index 5bd4c778..d2609f28 100644 --- a/js/cli/src/cli/commandFileSystemRestore.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemRestore.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemRestore implements Command { group = 'filesystem'; @@ -7,10 +7,10 @@ export class CommandFileSystemRestore implements Command { // FIXME: support restore of multiple files args = ['path']; - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - await printIterable(sdk.restoreNodes([node]), json, (result) => + await printIterable(nodePath.sdk.restoreNodes([node]), json, (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), ); } diff --git a/js/cli/src/cli/commandFileSystemTrash.ts b/js/cli/src/cli/fileSystem/commandFileSystemTrash.ts similarity index 59% rename from js/cli/src/cli/commandFileSystemTrash.ts rename to js/cli/src/cli/fileSystem/commandFileSystemTrash.ts index 428e3254..6c7cc8f2 100644 --- a/js/cli/src/cli/commandFileSystemTrash.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemTrash.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandFileSystemTrash implements Command { group = 'filesystem'; @@ -7,10 +7,10 @@ export class CommandFileSystemTrash implements Command { // FIXME: support trahs of multiple files args = ['path']; - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - await printIterable(sdk.trashNodes([node]), json, (result) => + await printIterable(nodePath.sdk.trashNodes([node]), json, (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), ); } diff --git a/js/cli/src/cli/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts similarity index 69% rename from js/cli/src/cli/commandFileSystemUpload.ts rename to js/cli/src/cli/fileSystem/commandFileSystemUpload.ts index 7e5ecd5c..76530701 100644 --- a/js/cli/src/cli/commandFileSystemUpload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts @@ -1,8 +1,9 @@ import path from 'node:path'; import { ParseArgsConfig } from 'util'; -import { Thumbnail } from '../../../sdk/src'; -import { Command, ActionArgs } from './interface'; +import { Thumbnail } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { PathType } from '../paths'; export class CommandFileSystemUpload implements Command { group = 'filesystem'; @@ -22,7 +23,13 @@ export class CommandFileSystemUpload implements Command { }, }; - async action({ sdk, paths, args: [localPath, parentPath], options: { json, name, newRevision } }: ActionArgs) { + async action({ + sdk, + photosSdk, + paths, + args: [localPath, parentPath], + options: { json, name, newRevision }, + }: ActionArgs) { name = name || path.basename(localPath); const file = Bun.file(localPath); @@ -34,12 +41,19 @@ export class CommandFileSystemUpload implements Command { let uploader; if (newRevision) { const parentNodePath = paths.getPath(parentPath); + if (parentNodePath.type === PathType.Photos) { + throw new Error('Uploading revisions to photos is not supported'); + } + const node = await parentNodePath.getChild(name); uploader = await sdk.getFileRevisionUploader(node, metadata); } else { const parentNodePath = paths.getPath(parentPath); const parentNode = await parentNodePath.getNode(); - uploader = await sdk.getFileUploader(parentNode, name, metadata); + uploader = + parentNodePath.type === PathType.Photos + ? await photosSdk.getFileUploader(name, metadata) + : await sdk.getFileUploader(parentNode, name, metadata); } if (!json) { diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index c6abf5e8..18e3f438 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -1,51 +1,49 @@ import { ParseArgsConfig } from 'util'; -import { CommandAlbumList } from './commandAlbumList'; -import { CommandAuthLogin } from './commandAuthLogin'; -import { CommandAuthLogout } from './commandAuthLogout'; -import { CommandDeviceCreate } from './commandDeviceCreate'; -import { CommandDeviceDelete } from './commandDeviceDelete'; -import { CommandDeviceRename } from './commandDeviceRename'; -import { CommandDiagnosticTree } from './CommandDiagnosticTree'; -import { CommandEventsDrive } from './commandEventsDrive'; -import { CommandEventsMyVolume } from './commandEventsMyVolume'; -import { CommandFileSystemCopy } from './commandFileSystemCopy'; -import { CommandFileSystemCreateFolder } from './commandFileSystemCreateFolder'; -import { CommandFileSystemDelete } from './commandFileSystemDelete'; -import { CommandFileSystemDownload } from './commandFileSystemDownload'; -import { CommandFileSystemDownloadSeeking } from './commandFileSystemDownloadSeeking'; -import { CommandFileSystemDownloadThumbnails } from './commandFileSystemDownloadThumbnails'; -import { CommandFileSystemGetAvailableName } from './commandFileSystemGetAvailableName'; -import { CommandFileSystemInfo } from './commandFileSystemInfo'; -import { CommandFileSystemList } from './commandFileSystemList'; -import { CommandFileSystemMove } from './commandFileSystemMove'; -import { CommandFileSystemRename } from './commandFileSystemRename'; -import { CommandFileSystemRestore } from './commandFileSystemRestore'; -import { CommandFileSystemUpload } from './commandFileSystemUpload'; -import { CommandFileSystemTrash } from './commandFileSystemTrash'; -import { CommandInvitationAccept } from './commandInvitationAccept'; -import { CommandInvitationList } from './commandInvitationList'; -import { CommandInvitationReject } from './commandInvitationReject'; -import { CommandPhotoDownload } from './commandPhotoDownload'; -import { CommandPhotoDownloadThumbnails } from './commandPhotoDownloadThumbnails'; -import { CommandPhotoTimeline } from './commandPhotoTimeline'; -import { CommandPhotoUpload } from './commandPhotoUpload'; -import { CommandRevisionDelete } from './commandRevisionDelete'; -import { CommandRevisionDownload } from './commandRevisionDownload'; -import { CommandRevisionList } from './commandRevisionList'; -import { CommandRevisionRestore } from './commandRevisionRestore'; -import { CommandSharingInvite } from './commandSharingInvite'; -import { CommandSharingRemove } from './commandSharingRemove'; -import { CommandSharingRemoveUrl } from './commandSharingRemoveUrl'; -import { CommandSharingSetUrl } from './commandSharingSetUrl'; -import { CommandSharingStatus } from './commandSharingStatus'; -import { Command } from './interface'; -import { CommandBookmarkRemove } from './commandBookmarkRemove'; -import { CommandBookmarkList } from './commandBookmarkList'; -import { CommandEventsPath } from './commandEventsPath'; + +import { CommandAuthLogin } from './auth/commandAuthLogin'; +import { CommandAuthLogout } from './auth/commandAuthLogout'; +import { CommandBookmarkRemove } from './bookmark/commandBookmarkRemove'; +import { CommandBookmarkList } from './bookmark/commandBookmarkList'; +import { CommandDeviceCreate } from './device/commandDeviceCreate'; +import { CommandDeviceDelete } from './device/commandDeviceDelete'; +import { CommandDeviceRename } from './device/commandDeviceRename'; +import { CommandDiagnosticTree } from './diagnostic/commandDiagnosticTree'; +import { CommandEventsDrive } from './events/commandEventsDrive'; +import { CommandEventsMyVolume } from './events/commandEventsMyVolume'; +import { CommandEventsPath } from './events/commandEventsPath'; +import { CommandFileSystemCopy } from './fileSystem/commandFileSystemCopy'; +import { CommandFileSystemCreateFolder } from './fileSystem/commandFileSystemCreateFolder'; +import { CommandFileSystemDelete } from './fileSystem/commandFileSystemDelete'; +import { CommandFileSystemDownload } from './fileSystem/commandFileSystemDownload'; +import { CommandFileSystemDownloadSeeking } from './fileSystem/commandFileSystemDownloadSeeking'; +import { CommandFileSystemDownloadThumbnails } from './fileSystem/commandFileSystemDownloadThumbnails'; +import { CommandFileSystemGetAvailableName } from './fileSystem/commandFileSystemGetAvailableName'; +import { CommandFileSystemInfo } from './fileSystem/commandFileSystemInfo'; +import { CommandFileSystemList } from './fileSystem/commandFileSystemList'; +import { CommandFileSystemMove } from './fileSystem/commandFileSystemMove'; +import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; +import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; +import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; +import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; +import { CommandAlbumList } from './photos/commandAlbumList'; +import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicDownload } from './public/commandPublicDownload'; import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; import { CommandPublicInfo } from './public/commandPublicInfo'; import { CommandPublicList } from './public/commandPublicList'; +import { CommandRevisionDelete } from './revision/commandRevisionDelete'; +import { CommandRevisionDownload } from './revision/commandRevisionDownload'; +import { CommandRevisionList } from './revision/commandRevisionList'; +import { CommandRevisionRestore } from './revision/commandRevisionRestore'; +import { CommandSharingInvite } from './sharing/commandSharingInvite'; +import { CommandSharingRemove } from './sharing/commandSharingRemove'; +import { CommandSharingRemoveUrl } from './sharing/commandSharingRemoveUrl'; +import { CommandSharingSetUrl } from './sharing/commandSharingSetUrl'; +import { CommandSharingStatus } from './sharing/commandSharingStatus'; +import { CommandInvitationAccept } from './sharing/commandInvitationAccept'; +import { CommandInvitationList } from './sharing/commandInvitationList'; +import { CommandInvitationReject } from './sharing/commandInvitationReject'; +import { Command } from './interface'; const COMMANDS = [ // CLI Account @@ -93,9 +91,6 @@ const COMMANDS = [ new CommandPublicDownloadThumbnails(), // ProtonDrivePhotosClient new CommandPhotoTimeline(), - new CommandPhotoDownload(), - new CommandPhotoDownloadThumbnails(), - new CommandPhotoUpload(), new CommandAlbumList(), // Diagnostic new CommandDiagnosticTree(), diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index a8265c16..2cf23ea7 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -11,6 +11,10 @@ export enum PathType { SharedByMe = 'shared-by-me', SharedWithMe = 'shared-with-me', Trash = 'trash', + Photos = 'photos', + PhotosSharedByMe = 'photos-shared-by-me', + PhotosSharedWithMe = 'photos-shared-with-me', + PhotosTrash = 'photos-trash', } export class Paths { @@ -31,11 +35,7 @@ export class Paths { } getPath(path: string): Path { - return new Path(this.sdk, path); - } - - getPhotoPath(path: string): PhotoPath { - return new PhotoPath(this.photosSdk, path); + return new Path(this.sdk, this.photosSdk, path); } getPublicLinkPath(path: string): PublicLinkPath { @@ -59,10 +59,12 @@ export class Paths { export class Path { constructor( - private sdk: ProtonDriveClient, + private driveSdk: ProtonDriveClient, + private photosSdk: ProtonDrivePhotosClient, public fullPath: string, ) { - this.sdk = sdk; + this.driveSdk = driveSdk; + this.photosSdk = photosSdk; this.fullPath = fullPath; } @@ -85,27 +87,52 @@ export class Path { if (this.fullPath.startsWith(`${path.sep}trash`)) { return PathType.Trash; } + if (this.fullPath.startsWith(`${path.sep}photos-shared-by-me`)) { + return PathType.PhotosSharedByMe; + } + if (this.fullPath.startsWith(`${path.sep}photos-shared-with-me`)) { + return PathType.PhotosSharedWithMe; + } + if (this.fullPath.startsWith(`${path.sep}photos-trash`)) { + return PathType.PhotosTrash; + } + if (this.fullPath.startsWith(`${path.sep}photos`)) { + return PathType.Photos; + } throw new Error(`Path "${this.fullPath}" not supported`); } get parentPath() { - return new Path(this.sdk, path.dirname(this.fullPath)); + return new Path(this.driveSdk, this.photosSdk, path.dirname(this.fullPath)); } get name() { return path.basename(this.fullPath); } + get sdk(): ProtonDriveClient | ProtonDrivePhotosClient { + const photoPaths = [ + PathType.Photos, + PathType.PhotosSharedByMe, + PathType.PhotosSharedWithMe, + PathType.PhotosTrash, + ]; + if (photoPaths.includes(this.type)) { + return this.photosSdk; + } + return this.sdk; + } + async getNode(): Promise { if (this.type === PathType.MyFiles) { - const rootNode = await this.sdk.getMyFilesRootFolder(); + const rootNode = await this.driveSdk.getMyFilesRootFolder(); return this.getNodeByPath(rootNode, this.sectionPath); } - if (this.type === PathType.SharedWithMe) { + if (this.type === PathType.SharedWithMe || this.type === PathType.PhotosSharedWithMe) { const rootNodeName = await this.getSharedWithMeRootFolder(); return this.getNodeByPath(rootNodeName, this.sectionPathWithoutRoot); } - if (this.type === PathType.Trash) { + if (this.type === PathType.Trash || this.type === PathType.PhotosTrash) { const parts = this.sectionPath.split(path.sep); if (parts.length > 1) { throw new Error('Browsing trashed folders is not supported'); @@ -116,6 +143,9 @@ export class Path { const rootNodeName = await this.getDevicesRootFolder(); return this.getNodeByPath(rootNodeName, this.sectionPathWithoutRoot); } + if (this.type === PathType.Photos) { + return this.getPhotoNodeByPath(this.sectionPath); + } throw new Error('Not implemented'); } @@ -158,7 +188,7 @@ export class Path { } private async getDevicesRootFolder(): Promise { - for await (const device of this.sdk.iterateDevices()) { + for await (const device of this.driveSdk.iterateDevices()) { const name = device.name.ok ? device.name.value : device.name.error.name; if (name === this.sectionRootNodeName) { const [maybeMissingNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); @@ -179,19 +209,44 @@ export class Path { if (part === '') { continue; } - node = await this.getNodeByName(node, part); + if (isNodeUid(part)) { + node = await this.sdk.getNode(part); + } else { + node = await this.getNodeByName(node, part); + } } return node; } private async getNodeByName(parentNode: MaybeNode, name: string) { - for await (const maybeChild of this.sdk.iterateFolderChildren(parentNode)) { + for await (const maybeChild of this.driveSdk.iterateFolderChildren(parentNode)) { if (getName(maybeChild) === name) { return maybeChild; } } throw new Error(`Node not found: ${name}`); } + + private async getPhotoNodeByPath(pathString: string): Promise { + if (isNodeUid(pathString)) { + return this.photosSdk.getNode(pathString); + } + return this.getPhotoByName(pathString); + } + + private async getPhotoByName(name: string): Promise { + const photoNodeUids = await Array.fromAsync(this.photosSdk.iterateTimeline(), (photo) => photo.nodeUid); + for await (const maybeMissingNode of this.photosSdk.iterateNodes(photoNodeUids)) { + const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); + if (!maybeNode) { + continue; + } + if (getName(maybeNode) === name) { + return maybeNode; + } + } + throw new Error(`Photo not found: ${name}`); + } } export class PublicLinkPath { @@ -204,7 +259,7 @@ export class PublicLinkPath { } async getNode(): Promise { - if (this.fullPath.includes('~')) { + if (isNodeUid(this.fullPath)) { const nodeUid = this.fullPath; return this.publicLinkSdk.getNode(nodeUid); } @@ -235,37 +290,8 @@ export class PublicLinkPath { } } -export class PhotoPath { - constructor( - private photosSdk: ProtonDrivePhotosClient, - public fullPath: string, - ) { - this.photosSdk = photosSdk; - this.fullPath = fullPath; - } - - async getNode(): Promise { - if (this.fullPath.includes('~')) { - const nodeUid = this.fullPath; - return this.photosSdk.getNode(nodeUid); - } - - return this.getNodeByName(this.fullPath); - } - - private async getNodeByName(name: string): Promise { - const photoNodeUids = await Array.fromAsync(this.photosSdk.iterateTimeline(), (photo) => photo.nodeUid); - for await (const maybeMissingNode of this.photosSdk.iterateNodes(photoNodeUids)) { - const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); - if (!maybeNode) { - continue; - } - if (getName(maybeNode) === name) { - return maybeNode; - } - } - throw new Error(`Node not found: ${name}`); - } +function isNodeUid(pathString: string): boolean { + return /^[a-zA-Z0-9=_-]{60,300}~[a-zA-Z0-9=_-]{60,300}$/.test(pathString); } function getMaybeNodeAndIgnoreMissingNode(maybeMissingNode: MaybeMissingNode): MaybeNode | undefined { diff --git a/js/cli/src/cli/commandAlbumList.ts b/js/cli/src/cli/photos/commandAlbumList.ts similarity index 69% rename from js/cli/src/cli/commandAlbumList.ts rename to js/cli/src/cli/photos/commandAlbumList.ts index 5fb4788a..f920f856 100644 --- a/js/cli/src/cli/commandAlbumList.ts +++ b/js/cli/src/cli/photos/commandAlbumList.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandAlbumList implements Command { group = 'albums'; diff --git a/js/cli/src/cli/commandPhotoTimeline.ts b/js/cli/src/cli/photos/commandPhotoTimeline.ts similarity index 83% rename from js/cli/src/cli/commandPhotoTimeline.ts rename to js/cli/src/cli/photos/commandPhotoTimeline.ts index ed1736f3..ed30eae0 100644 --- a/js/cli/src/cli/commandPhotoTimeline.ts +++ b/js/cli/src/cli/photos/commandPhotoTimeline.ts @@ -1,8 +1,8 @@ import { ParseArgsConfig } from 'util'; -import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandPhotoTimeline implements Command { group = 'photo'; diff --git a/js/cli/src/cli/commandRevisionDelete.ts b/js/cli/src/cli/revision/commandRevisionDelete.ts similarity index 87% rename from js/cli/src/cli/commandRevisionDelete.ts rename to js/cli/src/cli/revision/commandRevisionDelete.ts index b82d68e7..6032b3dd 100644 --- a/js/cli/src/cli/commandRevisionDelete.ts +++ b/js/cli/src/cli/revision/commandRevisionDelete.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandRevisionDelete implements Command { group = 'revision'; diff --git a/js/cli/src/cli/commandRevisionDownload.ts b/js/cli/src/cli/revision/commandRevisionDownload.ts similarity index 95% rename from js/cli/src/cli/commandRevisionDownload.ts rename to js/cli/src/cli/revision/commandRevisionDownload.ts index db17bd47..7135207e 100644 --- a/js/cli/src/cli/commandRevisionDownload.ts +++ b/js/cli/src/cli/revision/commandRevisionDownload.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandRevisionDownload implements Command { group = 'revision'; diff --git a/js/cli/src/cli/commandRevisionList.ts b/js/cli/src/cli/revision/commandRevisionList.ts similarity index 85% rename from js/cli/src/cli/commandRevisionList.ts rename to js/cli/src/cli/revision/commandRevisionList.ts index 727fd020..cfc45afe 100644 --- a/js/cli/src/cli/commandRevisionList.ts +++ b/js/cli/src/cli/revision/commandRevisionList.ts @@ -1,7 +1,7 @@ -import { NodeType, Revision } from '../../../sdk/src'; -import { Command, ActionArgs } from './interface'; -import { formatAuthor, formatDate, formatSize, printIterable } from './formatters'; -import { getNode } from './node'; +import { NodeType, Revision } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { formatAuthor, formatDate, formatSize, printIterable } from '../formatters'; +import { getNode } from '../node'; export class CommandRevisionList implements Command { group = 'revision'; diff --git a/js/cli/src/cli/commandRevisionRestore.ts b/js/cli/src/cli/revision/commandRevisionRestore.ts similarity index 87% rename from js/cli/src/cli/commandRevisionRestore.ts rename to js/cli/src/cli/revision/commandRevisionRestore.ts index 5198ae0e..7203f4f8 100644 --- a/js/cli/src/cli/commandRevisionRestore.ts +++ b/js/cli/src/cli/revision/commandRevisionRestore.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandRevisionRestore implements Command { group = 'revision'; diff --git a/js/cli/src/cli/commandInvitationAccept.ts b/js/cli/src/cli/sharing/commandInvitationAccept.ts similarity index 87% rename from js/cli/src/cli/commandInvitationAccept.ts rename to js/cli/src/cli/sharing/commandInvitationAccept.ts index 219a95e9..5fbd18e2 100644 --- a/js/cli/src/cli/commandInvitationAccept.ts +++ b/js/cli/src/cli/sharing/commandInvitationAccept.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandInvitationAccept implements Command { group = 'invitation'; diff --git a/js/cli/src/cli/commandInvitationList.ts b/js/cli/src/cli/sharing/commandInvitationList.ts similarity index 70% rename from js/cli/src/cli/commandInvitationList.ts rename to js/cli/src/cli/sharing/commandInvitationList.ts index f2130e93..23fabb17 100644 --- a/js/cli/src/cli/commandInvitationList.ts +++ b/js/cli/src/cli/sharing/commandInvitationList.ts @@ -1,5 +1,5 @@ -import { printIterable } from './formatters'; -import { Command, ActionArgs } from './interface'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; export class CommandInvitationList implements Command { group = 'invitation'; diff --git a/js/cli/src/cli/commandInvitationReject.ts b/js/cli/src/cli/sharing/commandInvitationReject.ts similarity index 87% rename from js/cli/src/cli/commandInvitationReject.ts rename to js/cli/src/cli/sharing/commandInvitationReject.ts index 182d74d1..dd56a0b8 100644 --- a/js/cli/src/cli/commandInvitationReject.ts +++ b/js/cli/src/cli/sharing/commandInvitationReject.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandInvitationReject implements Command { group = 'invitation'; diff --git a/js/cli/src/cli/commandSharingInvite.ts b/js/cli/src/cli/sharing/commandSharingInvite.ts similarity index 80% rename from js/cli/src/cli/commandSharingInvite.ts rename to js/cli/src/cli/sharing/commandSharingInvite.ts index 82ac9483..8d7fbc63 100644 --- a/js/cli/src/cli/commandSharingInvite.ts +++ b/js/cli/src/cli/sharing/commandSharingInvite.ts @@ -1,6 +1,7 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; + +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; export class CommandSharingInvite implements Command { group = 'sharing'; @@ -31,15 +32,14 @@ export class CommandSharingInvite implements Command { }; async action({ - sdk, paths, args: [pathString], options: { user: userEmails, role, message, includeNodeName, json }, }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); - const sharingInfo = await sdk.shareNode(node, { + const sharingInfo = await nodePath.sdk.shareNode(node, { users: userEmails.map((email: string) => ({ email, role })), emailOptions: { message: message || undefined, diff --git a/js/cli/src/cli/commandSharingRemove.ts b/js/cli/src/cli/sharing/commandSharingRemove.ts similarity index 62% rename from js/cli/src/cli/commandSharingRemove.ts rename to js/cli/src/cli/sharing/commandSharingRemove.ts index e414cebd..c11dd3cd 100644 --- a/js/cli/src/cli/commandSharingRemove.ts +++ b/js/cli/src/cli/sharing/commandSharingRemove.ts @@ -1,6 +1,7 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; + +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; export class CommandSharingRemove implements Command { group = 'sharing'; @@ -20,14 +21,14 @@ export class CommandSharingRemove implements Command { }, }; - async action({ sdk, paths, args: [pathString], options: { email: emails, everyone, json } }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); + async action({ paths, args: [pathString], options: { email: emails, everyone, json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); // FIXME: when supporting public links, everyone should keep it const sharingInfo = everyone - ? await sdk.unshareNode(node) - : await sdk.unshareNode(node, { + ? await nodePath.sdk.unshareNode(node) + : await nodePath.sdk.unshareNode(node, { users: emails, }); diff --git a/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts b/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts new file mode 100644 index 00000000..23cce248 --- /dev/null +++ b/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts @@ -0,0 +1,19 @@ +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; + +export class CommandSharingRemoveUrl implements Command { + group = 'sharing'; + name = 'remove-url'; + args = ['path']; + + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const sharingInfo = await nodePath.sdk.unshareNode(node, { + publicLink: 'remove', + }); + + printObject(sharingInfo, json); + } +} diff --git a/js/cli/src/cli/commandSharingSetUrl.ts b/js/cli/src/cli/sharing/commandSharingSetUrl.ts similarity index 72% rename from js/cli/src/cli/commandSharingSetUrl.ts rename to js/cli/src/cli/sharing/commandSharingSetUrl.ts index 4b391cd2..ebf2be2b 100644 --- a/js/cli/src/cli/commandSharingSetUrl.ts +++ b/js/cli/src/cli/sharing/commandSharingSetUrl.ts @@ -1,6 +1,7 @@ import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from './interface'; -import { printObject } from './formatters'; + +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; export class CommandSharingSetUrl implements Command { group = 'sharing'; @@ -21,9 +22,9 @@ export class CommandSharingSetUrl implements Command { }, }; - async action({ sdk, paths, args: [pathString], options: { json, role, password, expiration } }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); + async action({ paths, args: [pathString], options: { json, role, password, expiration } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); if (role !== 'viewer' && role !== 'editor') { throw new Error(`Invalid role: ${role}`); @@ -33,7 +34,7 @@ export class CommandSharingSetUrl implements Command { throw new Error(`Invalid expiration date: ${expiration}`); } - const sharingInfo = await sdk.shareNode(node, { + const sharingInfo = await nodePath.sdk.shareNode(node, { publicLink: { role, customPassword: password || undefined, diff --git a/js/cli/src/cli/sharing/commandSharingStatus.ts b/js/cli/src/cli/sharing/commandSharingStatus.ts new file mode 100644 index 00000000..c5a59bfc --- /dev/null +++ b/js/cli/src/cli/sharing/commandSharingStatus.ts @@ -0,0 +1,17 @@ +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandSharingStatus implements Command { + group = 'sharing'; + name = 'status'; + args = ['path']; + + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const sharingInfo = await nodePath.sdk.getSharingInfo(node); + + printObject(sharingInfo, json); + } +} 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 9ef818f8cf487f7d67645fee4f80a29873b7b0f1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Oct 2025 10:01:46 +0200 Subject: [PATCH 129/732] Parametrize shared with me and invitations for Photos SDK --- .../cli/sharing/commandInvitationAccept.ts | 18 +++++++++++-- .../src/cli/sharing/commandInvitationList.ts | 17 ++++++++++-- .../cli/sharing/commandInvitationReject.ts | 18 +++++++++++-- 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 +++++- 9 files changed, 90 insertions(+), 20 deletions(-) diff --git a/js/cli/src/cli/sharing/commandInvitationAccept.ts b/js/cli/src/cli/sharing/commandInvitationAccept.ts index 5fbd18e2..14c6d47e 100644 --- a/js/cli/src/cli/sharing/commandInvitationAccept.ts +++ b/js/cli/src/cli/sharing/commandInvitationAccept.ts @@ -1,12 +1,26 @@ +import { ParseArgsConfig } from 'util'; + import { Command, ActionArgs } from '../interface'; export class CommandInvitationAccept implements Command { group = 'invitation'; name = 'accept'; args = ['invitationUid']; + options: ParseArgsConfig['options'] = { + photos: { + type: 'boolean', + short: 'p', + default: false, + }, + }; + + async action({ sdk, photosSdk, args: [invitationUid], options: { photos, json } }: ActionArgs) { + if (photos) { + await photosSdk.acceptInvitation(invitationUid); + } else { + await sdk.acceptInvitation(invitationUid); + } - async action({ sdk, args: [invitationUid], options: { json } }: ActionArgs) { - await sdk.acceptInvitation(invitationUid); if (!json) { console.log(`Invitation accepted: ${invitationUid}`); } diff --git a/js/cli/src/cli/sharing/commandInvitationList.ts b/js/cli/src/cli/sharing/commandInvitationList.ts index 23fabb17..41d56d0f 100644 --- a/js/cli/src/cli/sharing/commandInvitationList.ts +++ b/js/cli/src/cli/sharing/commandInvitationList.ts @@ -1,11 +1,24 @@ +import { ParseArgsConfig } from 'util'; + import { printIterable } from '../formatters'; import { Command, ActionArgs } from '../interface'; export class CommandInvitationList implements Command { group = 'invitation'; name = 'list'; + options: ParseArgsConfig['options'] = { + photos: { + type: 'boolean', + short: 'p', + default: false, + }, + }; - async action({ sdk, options: { json } }: ActionArgs) { - await printIterable(sdk.iterateInvitations(), json); + async action({ sdk, photosSdk, options: { photos, json } }: ActionArgs) { + if (photos) { + await printIterable(photosSdk.iterateInvitations(), json); + } else { + await printIterable(sdk.iterateInvitations(), json); + } } } diff --git a/js/cli/src/cli/sharing/commandInvitationReject.ts b/js/cli/src/cli/sharing/commandInvitationReject.ts index dd56a0b8..b22d7ea2 100644 --- a/js/cli/src/cli/sharing/commandInvitationReject.ts +++ b/js/cli/src/cli/sharing/commandInvitationReject.ts @@ -1,12 +1,26 @@ +import { ParseArgsConfig } from 'util'; + import { Command, ActionArgs } from '../interface'; export class CommandInvitationReject implements Command { group = 'invitation'; name = 'reject'; args = ['invitationUid']; + options: ParseArgsConfig['options'] = { + photos: { + type: 'boolean', + short: 'p', + default: false, + }, + }; + + async action({ sdk, photosSdk, args: [invitationUid], options: { photos, json } }: ActionArgs) { + if (photos) { + await photosSdk.rejectInvitation(invitationUid); + } else { + await sdk.rejectInvitation(invitationUid); + } - async action({ sdk, args: [invitationUid], options: { json } }: ActionArgs) { - await sdk.rejectInvitation(invitationUid); if (!json) { console.log(`Invitation rejected: ${invitationUid}`); } 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 e626d04763760dd98e37a6118d35c17e7f1bb071 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 11:09:39 +0200 Subject: [PATCH 130/732] Unify CLI parameters and docs --- js/cli/src/cli/diagnostic/commandDiagnosticTree.ts | 4 ++-- .../cli/fileSystem/commandFileSystemDownloadThumbnails.ts | 8 ++++++-- js/cli/src/cli/fileSystem/commandFileSystemUpload.ts | 2 +- js/cli/src/cli/paths.ts | 2 +- js/cli/src/cli/photos/commandPhotoTimeline.ts | 4 ++-- js/cli/src/cli/public/base.ts | 2 +- js/cli/src/cli/public/commandPublicDownload.ts | 6 +++++- js/cli/src/cli/public/commandPublicDownloadThumbnails.ts | 4 ++-- js/cli/src/cli/public/commandPublicInfo.ts | 2 +- js/cli/src/cli/public/commandPublicList.ts | 2 +- js/cli/src/cli/sharing/commandSharingInvite.ts | 4 ++-- 11 files changed, 24 insertions(+), 16 deletions(-) diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts index ba3def38..66060a1d 100644 --- a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts +++ b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts @@ -18,7 +18,7 @@ export class CommandDiagnosticTree implements Command { short: 't', default: false, }, - localStructure: { + 'local-structure': { type: 'string', short: 's', default: '', @@ -29,7 +29,7 @@ export class CommandDiagnosticTree implements Command { sdkDiagnostic, paths, args: [pathString], - options: { json, content, thumbnails, localStructure }, + options: { json, content, thumbnails, 'local-structure': localStructure }, }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts index 96381050..2842cee7 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts @@ -12,14 +12,18 @@ export class CommandFileSystemDownloadThumbnails implements Command { // FIXME: support download of multiple thumbnails args = ['path', 'parentLocalPath']; options: ParseArgsConfig['options'] = { - thumbnailType: { + 'thumbnail-type': { type: 'string', short: 't', default: ThumbnailType.Type1.toString(), }, }; - async action({ paths, args: [pathString, parentLocalPath], options: { json, thumbnailType } }: ActionArgs) { + async action({ + paths, + args: [pathString, parentLocalPath], + options: { json, 'thumbnail-type': thumbnailType }, + }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); diff --git a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts index 76530701..05998f19 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts @@ -28,7 +28,7 @@ export class CommandFileSystemUpload implements Command { photosSdk, paths, args: [localPath, parentPath], - options: { json, name, newRevision }, + options: { json, name, 'new-revision': newRevision }, }: ActionArgs) { name = name || path.basename(localPath); diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 2cf23ea7..81355804 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -120,7 +120,7 @@ export class Path { if (photoPaths.includes(this.type)) { return this.photosSdk; } - return this.sdk; + return this.driveSdk; } async getNode(): Promise { diff --git a/js/cli/src/cli/photos/commandPhotoTimeline.ts b/js/cli/src/cli/photos/commandPhotoTimeline.ts index ed30eae0..3897819d 100644 --- a/js/cli/src/cli/photos/commandPhotoTimeline.ts +++ b/js/cli/src/cli/photos/commandPhotoTimeline.ts @@ -8,14 +8,14 @@ export class CommandPhotoTimeline implements Command { group = 'photo'; name = 'timeline'; options: ParseArgsConfig['options'] = { - loadDetails: { + 'load-details': { type: 'boolean', short: 'd', default: false, }, }; - async action({ photosSdk, options: { json, loadDetails } }: ActionArgs) { + async action({ photosSdk, options: { json, 'load-details': loadDetails } }: ActionArgs) { if (loadDetails) { await this.listWithDetails(photosSdk, json); } else { diff --git a/js/cli/src/cli/public/base.ts b/js/cli/src/cli/public/base.ts index 24f9cb88..8e3c2d20 100644 --- a/js/cli/src/cli/public/base.ts +++ b/js/cli/src/cli/public/base.ts @@ -5,7 +5,7 @@ export const PUBLIC_OPTIONS: ParseArgsConfig['options'] = { type: 'string', short: 'u', }, - customPassword: { + 'custom-password': { type: 'string', short: 'c', default: '', diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts index e557d21d..ddb3df8b 100644 --- a/js/cli/src/cli/public/commandPublicDownload.ts +++ b/js/cli/src/cli/public/commandPublicDownload.ts @@ -18,7 +18,11 @@ export class CommandPublicDownload implements Command { ...PUBLIC_OPTIONS, }; - async action({ paths, args: [pathString, localParentPath], options: { name, json, url, customPassword } }: ActionArgs) { + async action({ + paths, + args: [pathString, localParentPath], + options: { name, json, url, 'custom-password': customPassword }, + }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); const nodePath = paths.getPublicLinkPath(pathString); const node = await nodePath.getNode(); diff --git a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts index f5256559..68fb9d7f 100644 --- a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts +++ b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts @@ -12,7 +12,7 @@ export class CommandPublicDownloadThumbnails implements Command { name = 'download-thumbnails'; args = ['path', 'parentLocalPath']; options: ParseArgsConfig['options'] = { - thumbnailType: { + 'thumbnail-type': { type: 'string', short: 't', default: ThumbnailType.Type1.toString(), @@ -23,7 +23,7 @@ export class CommandPublicDownloadThumbnails implements Command { async action({ paths, args: [pathString, parentLocalPath], - options: { json, thumbnailType, url, customPassword }, + options: { json, 'thumbnail-type': thumbnailType, url, 'custom-password': customPassword }, }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); const nodePath = paths.getPublicLinkPath(pathString); diff --git a/js/cli/src/cli/public/commandPublicInfo.ts b/js/cli/src/cli/public/commandPublicInfo.ts index e2d3c727..be62ca43 100644 --- a/js/cli/src/cli/public/commandPublicInfo.ts +++ b/js/cli/src/cli/public/commandPublicInfo.ts @@ -10,7 +10,7 @@ export class CommandPublicInfo implements Command { args = ['path']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - async action({ paths, args: [pathString], options: { json, url, customPassword } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { await paths.authPublicLinkSession(url, customPassword); const path = paths.getPublicLinkPath(pathString); const node = await path.getNode(); diff --git a/js/cli/src/cli/public/commandPublicList.ts b/js/cli/src/cli/public/commandPublicList.ts index a371b8ff..fd2d25c1 100644 --- a/js/cli/src/cli/public/commandPublicList.ts +++ b/js/cli/src/cli/public/commandPublicList.ts @@ -13,7 +13,7 @@ export class CommandPublicList implements Command { args = ['path']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - async action({ paths, args: [pathString], options: { json, url, customPassword } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); const path = paths.getPublicLinkPath(pathString); const folder = await path.getNode(); diff --git a/js/cli/src/cli/sharing/commandSharingInvite.ts b/js/cli/src/cli/sharing/commandSharingInvite.ts index 8d7fbc63..70ef2010 100644 --- a/js/cli/src/cli/sharing/commandSharingInvite.ts +++ b/js/cli/src/cli/sharing/commandSharingInvite.ts @@ -24,7 +24,7 @@ export class CommandSharingInvite implements Command { short: 'm', default: '', }, - includeNodeName: { + 'include-node-name': { type: 'boolean', short: 'n', default: false, @@ -34,7 +34,7 @@ export class CommandSharingInvite implements Command { async action({ paths, args: [pathString], - options: { user: userEmails, role, message, includeNodeName, json }, + options: { user: userEmails, role, message, 'include-node-name': includeNodeName, json }, }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); From 1adff490b42df27619070413a5d954425bb14a54 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 14:50:24 +0200 Subject: [PATCH 131/732] 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 16ec13b73b114b2110ba587ec1780455507a278c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 05:20:24 +0000 Subject: [PATCH 132/732] 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 3dfe698148b89509f613077f91ff88694afdae14 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 10:50:16 +0200 Subject: [PATCH 133/732] Add diagnostic progress --- .../cli/diagnostic/commandDiagnosticTree.ts | 4 +- 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 ++++++++++++------ 5 files changed, 210 insertions(+), 100 deletions(-) diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts index 66060a1d..02920658 100644 --- a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts +++ b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts @@ -42,7 +42,9 @@ export class CommandDiagnosticTree implements Command { expectedStructure, }; - for await (const result of sdkDiagnostic.verifyNodeTree(node, options)) { + for await (const result of sdkDiagnostic.verifyNodeTree(node, options, (progress) => { + printObject(progress, json); + })) { printObject(result, json); } } 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 24e921eb37449ad968b26cec5b4fbfbd32a1f3c2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Oct 2025 14:44:39 +0200 Subject: [PATCH 134/732] Add create folder & upload for public link SDK --- .gitignore | 3 +- js/cli/src/cli/index.ts | 4 ++ js/cli/src/cli/paths.ts | 5 ++ .../cli/public/commandPublicCreateFolder.ts | 22 +++++++ js/cli/src/cli/public/commandPublicUpload.ts | 66 +++++++++++++++++++ js/sdk/src/internal/sharingPublic/index.ts | 3 + js/sdk/src/protonDrivePublicLinkClient.ts | 56 +++++++++++++++- 7 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 js/cli/src/cli/public/commandPublicCreateFolder.ts create mode 100644 js/cli/src/cli/public/commandPublicUpload.ts 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 18e3f438..67d51bc8 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -27,10 +27,12 @@ import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumList } from './photos/commandAlbumList'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; +import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; import { CommandPublicDownload } from './public/commandPublicDownload'; import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; import { CommandPublicInfo } from './public/commandPublicInfo'; import { CommandPublicList } from './public/commandPublicList'; +import { CommandPublicUpload } from './public/commandPublicUpload'; import { CommandRevisionDelete } from './revision/commandRevisionDelete'; import { CommandRevisionDownload } from './revision/commandRevisionDownload'; import { CommandRevisionList } from './revision/commandRevisionList'; @@ -87,8 +89,10 @@ const COMMANDS = [ // ProtonDrivePublicLinkClient new CommandPublicList(), new CommandPublicInfo(), + new CommandPublicCreateFolder(), new CommandPublicDownload(), new CommandPublicDownloadThumbnails(), + new CommandPublicUpload(), // ProtonDrivePhotosClient new CommandPhotoTimeline(), new CommandAlbumList(), diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 81355804..3065c3af 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -268,6 +268,11 @@ export class PublicLinkPath { return this.getNodeByPath(rootNode, this.fullPath); } + async getChild(name: string) { + const node = await this.getNode(); + return this.getNodeByName(node, name); + } + private async getNodeByPath(parentNode: MaybeNode, pathString: string) { let node = parentNode; const pathParts = pathString.split(path.sep); diff --git a/js/cli/src/cli/public/commandPublicCreateFolder.ts b/js/cli/src/cli/public/commandPublicCreateFolder.ts new file mode 100644 index 00000000..ef7ee1c8 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicCreateFolder.ts @@ -0,0 +1,22 @@ +import { ParseArgsConfig } from 'util'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicCreateFolder implements Command { + group = 'public'; + name = 'create-folder'; + args = ['path', 'name']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args: [pathString, name], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const parent = await nodePath.getNode(); + + const folder = await client.createFolder(parent, name, new Date()); + + printObject(folder, json); + } +} diff --git a/js/cli/src/cli/public/commandPublicUpload.ts b/js/cli/src/cli/public/commandPublicUpload.ts new file mode 100644 index 00000000..511c8891 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicUpload.ts @@ -0,0 +1,66 @@ +import path from 'node:path'; +import { ParseArgsConfig } from 'util'; + +import { Thumbnail } from '../../../../sdk/src'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicUpload implements Command { + group = 'public'; + name = 'upload'; + // FIXME: support upload of multiple files + args = ['localPath', 'parentPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + 'new-revision': { + type: 'boolean', + short: 'r', + default: false, + }, + ...PUBLIC_OPTIONS, + }; + + async action({ + paths, + args: [localPath, parentPath], + options: { json, name, 'new-revision': newRevision, url, 'custom-password': customPassword }, + }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + + name = name || path.basename(localPath); + + const file = Bun.file(localPath); + const metadata = { + mediaType: file.type, + expectedSize: file.size, + }; + + let uploader; + if (newRevision) { + const parentNodePath = paths.getPublicLinkPath(parentPath); + const node = await parentNodePath.getChild(name); + uploader = await client.getFileRevisionUploader(node, metadata); + } else { + const parentNodePath = paths.getPublicLinkPath(parentPath); + const parentNode = await parentNodePath.getNode(); + uploader = await client.getFileUploader(parentNode, name, metadata); + } + + if (!json) { + console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); + } + + const thumbnails: Thumbnail[] = []; // TODO + const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { + if (!json) { + console.log(`Uploaded ${writtenBytes} bytes`); + } + }); + + await controller.completion(); + } +} 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 c578f45a0d98df8d7e4926a3dc4e48a729649cff Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 09:35:13 +0000 Subject: [PATCH 135/732] 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 05ef3b4ce8eac525048cbce99fa0082e984350f7 Mon Sep 17 00:00:00 2001 From: drive Date: Sun, 26 Oct 2025 11:37:44 +0100 Subject: [PATCH 136/732] 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 50d247607c0b51139cf0f59272ad4b64db9f91ba Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Oct 2025 09:46:24 +0100 Subject: [PATCH 137/732] 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 b8dfc5c7e7460de5813d6ce624c0b08de54f51a5 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Oct 2025 09:35:51 +0100 Subject: [PATCH 138/732] 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 5d72e1fe2949f5e63aa61c599404736c781921f9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 08:10:43 +0100 Subject: [PATCH 139/732] 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 1ba022542b2a67e22052979c05a849e972b51188 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 10:40:50 +0100 Subject: [PATCH 140/732] 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 cfdeea818022677d6417ae39293e40b6e9d248f0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 11:40:30 +0100 Subject: [PATCH 141/732] 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 284c5dcdb3828aed7f376e160d0f2df87c49f5d6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 14:15:22 +0100 Subject: [PATCH 142/732] Add rename and delete for public link SDK --- js/cli/src/cli/index.ts | 4 ++++ js/cli/src/cli/interface.ts | 1 + .../cli/public/commandPublicCreateFolder.ts | 7 +++++- js/cli/src/cli/public/commandPublicDelete.ts | 23 +++++++++++++++++++ .../src/cli/public/commandPublicDownload.ts | 1 + .../public/commandPublicDownloadThumbnails.ts | 1 + js/cli/src/cli/public/commandPublicInfo.ts | 1 + js/cli/src/cli/public/commandPublicList.ts | 1 + js/cli/src/cli/public/commandPublicRename.ts | 22 ++++++++++++++++++ js/cli/src/cli/public/commandPublicUpload.ts | 1 + js/cli/src/proton-drive.ts | 9 ++++---- js/sdk/src/protonDrivePublicLinkClient.ts | 21 +++++++++++++++++ 12 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 js/cli/src/cli/public/commandPublicDelete.ts create mode 100644 js/cli/src/cli/public/commandPublicRename.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 67d51bc8..786df79c 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -28,10 +28,12 @@ import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumList } from './photos/commandAlbumList'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; +import { CommandPublicDelete } from './public/commandPublicDelete'; import { CommandPublicDownload } from './public/commandPublicDownload'; import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; import { CommandPublicInfo } from './public/commandPublicInfo'; import { CommandPublicList } from './public/commandPublicList'; +import { CommandPublicRename } from './public/commandPublicRename'; import { CommandPublicUpload } from './public/commandPublicUpload'; import { CommandRevisionDelete } from './revision/commandRevisionDelete'; import { CommandRevisionDownload } from './revision/commandRevisionDownload'; @@ -90,6 +92,8 @@ const COMMANDS = [ new CommandPublicList(), new CommandPublicInfo(), new CommandPublicCreateFolder(), + new CommandPublicRename(), + new CommandPublicDelete(), new CommandPublicDownload(), new CommandPublicDownloadThumbnails(), new CommandPublicUpload(), diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 79c1a756..f1473c74 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -9,6 +9,7 @@ export interface Command { group: string; name: string; isAuthAction?: boolean; + isPublicAction?: boolean; args?: string[]; options?: ParseArgsConfig['options']; diff --git a/js/cli/src/cli/public/commandPublicCreateFolder.ts b/js/cli/src/cli/public/commandPublicCreateFolder.ts index ef7ee1c8..ca8dcbb0 100644 --- a/js/cli/src/cli/public/commandPublicCreateFolder.ts +++ b/js/cli/src/cli/public/commandPublicCreateFolder.ts @@ -7,10 +7,15 @@ import { PUBLIC_OPTIONS } from './base'; export class CommandPublicCreateFolder implements Command { group = 'public'; name = 'create-folder'; + isPublicAction = true; args = ['path', 'name']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - async action({ paths, args: [pathString, name], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + async action({ + paths, + args: [pathString, name], + options: { json, url, 'custom-password': customPassword }, + }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); const nodePath = paths.getPublicLinkPath(pathString); const parent = await nodePath.getNode(); diff --git a/js/cli/src/cli/public/commandPublicDelete.ts b/js/cli/src/cli/public/commandPublicDelete.ts new file mode 100644 index 00000000..99fc5dc8 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicDelete.ts @@ -0,0 +1,23 @@ +import { ParseArgsConfig } from 'util'; + +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicDelete implements Command { + group = 'public'; + name = 'delete'; + isPublicAction = true; + args = ['nodeUid']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args: [nodeUid], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(nodeUid); + const node = await nodePath.getNode(); + + await printIterable(client.deleteNodes([node]), json, (result) => + console.log(result.ok ? `✅ Deleted ${result.uid}` : `❌ Failed to delete ${result.uid}: ${result.error}`), + ); + } +} diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts index ddb3df8b..b51f447f 100644 --- a/js/cli/src/cli/public/commandPublicDownload.ts +++ b/js/cli/src/cli/public/commandPublicDownload.ts @@ -8,6 +8,7 @@ import { PUBLIC_OPTIONS } from './base'; export class CommandPublicDownload implements Command { group = 'public'; name = 'download'; + isPublicAction = true; args = ['path', 'localParentPath']; options: ParseArgsConfig['options'] = { name: { diff --git a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts index 68fb9d7f..4e0786b3 100644 --- a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts +++ b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts @@ -10,6 +10,7 @@ import { ProtonDrivePublicLinkClient } from '../../../../sdk/src/protonDrivePubl export class CommandPublicDownloadThumbnails implements Command { group = 'public'; name = 'download-thumbnails'; + isPublicAction = true; args = ['path', 'parentLocalPath']; options: ParseArgsConfig['options'] = { 'thumbnail-type': { diff --git a/js/cli/src/cli/public/commandPublicInfo.ts b/js/cli/src/cli/public/commandPublicInfo.ts index be62ca43..de6e4ec9 100644 --- a/js/cli/src/cli/public/commandPublicInfo.ts +++ b/js/cli/src/cli/public/commandPublicInfo.ts @@ -7,6 +7,7 @@ import { PUBLIC_OPTIONS } from './base'; export class CommandPublicInfo implements Command { group = 'public'; name = 'info'; + isPublicAction = true; args = ['path']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; diff --git a/js/cli/src/cli/public/commandPublicList.ts b/js/cli/src/cli/public/commandPublicList.ts index fd2d25c1..b504b9b6 100644 --- a/js/cli/src/cli/public/commandPublicList.ts +++ b/js/cli/src/cli/public/commandPublicList.ts @@ -10,6 +10,7 @@ import { PUBLIC_OPTIONS } from './base'; export class CommandPublicList implements Command { group = 'public'; name = 'list'; + isPublicAction = true; args = ['path']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; diff --git a/js/cli/src/cli/public/commandPublicRename.ts b/js/cli/src/cli/public/commandPublicRename.ts new file mode 100644 index 00000000..2fe69bd8 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicRename.ts @@ -0,0 +1,22 @@ +import { ParseArgsConfig } from 'util'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicRename implements Command { + group = 'public'; + name = 'rename'; + isPublicAction = true; + args = ['path', 'newName']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args: [pathString, newName], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const node = await nodePath.getNode(); + const renamedNode = await client.renameNode(node, newName); + + printObject(renamedNode, json); + } +} diff --git a/js/cli/src/cli/public/commandPublicUpload.ts b/js/cli/src/cli/public/commandPublicUpload.ts index 511c8891..b8c2a4ef 100644 --- a/js/cli/src/cli/public/commandPublicUpload.ts +++ b/js/cli/src/cli/public/commandPublicUpload.ts @@ -8,6 +8,7 @@ import { PUBLIC_OPTIONS } from './base'; export class CommandPublicUpload implements Command { group = 'public'; name = 'upload'; + isPublicAction = true; // FIXME: support upload of multiple files args = ['localPath', 'parentPath']; options: ParseArgsConfig['options'] = { diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index ee8e09e5..5c654547 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -27,10 +27,11 @@ if (debug) { console.log(`Proton Drive SDK for web v${VERSION}`); } -if (!command.isAuthAction) { - if (!account.session) { - throw new Error('You need to login first'); - } +if (!command.isAuthAction && !command.isPublicAction && !account.session) { + throw new Error('You need to login first'); +} + +if (account.session) { if (debug) { console.time('Account initialization'); console.log('Initializing user keys...'); 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 a41f210fbf496a01e9672b2236e856a450c053f4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 31 Oct 2025 14:57:25 +0000 Subject: [PATCH 143/732] 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 ad9253d7f7d1f61a1f0bce0c418cf5d4d75269e7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 3 Nov 2025 09:44:05 +0000 Subject: [PATCH 144/732] 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 9d138c330f00b37e666e47f972d5827d9c09069e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 3 Nov 2025 15:38:41 +0100 Subject: [PATCH 145/732] 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 576c5db0101580e19c017f6e032381246124d79c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 4 Nov 2025 06:34:27 +0100 Subject: [PATCH 146/732] 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 nave 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 223d8cfbcfbf2aa52c3b3249e3b42227c548023d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 10:04:48 +0000 Subject: [PATCH 147/732] 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 cf4a827b0f808245f3212df4794304157c9abf88 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 14:05:00 +0100 Subject: [PATCH 148/732] 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 ed6052dd4127561aa89b9de7770986a7d2876f3e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 5 Nov 2025 14:40:09 +0100 Subject: [PATCH 149/732] 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 150/732] 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 8e92ce8646cdf46c0be9d00ae148c88d030c9384 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 6 Nov 2025 10:42:57 +0100 Subject: [PATCH 151/732] 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 f062a725fe126d8d4873dca009349364ab6680c2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Nov 2025 11:53:01 +0000 Subject: [PATCH 152/732] 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 4e0f690d0fb5e6eb141c834866f704404a9677c2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 7 Nov 2025 14:19:42 +0100 Subject: [PATCH 153/732] 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 9a537283a5140ada3132a82b0fda0d228c643633 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 09:04:19 +0000 Subject: [PATCH 154/732] 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 aa2b17a7078b9903b3fa0dc74122df52c1d83560 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 11:14:42 +0100 Subject: [PATCH 155/732] 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 6efad133682040ecec3a129fae5066ee1bdbd52f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 10 Nov 2025 11:01:38 +0000 Subject: [PATCH 156/732] Add diagnostics for Photos timeline --- .../commandDiagnosticPhotosTimeline.ts | 50 +++ .../cli/diagnostic/commandDiagnosticTree.ts | 9 +- js/cli/src/cli/index.ts | 2 + js/cli/src/proton-drive.ts | 2 +- 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 ++++ 11 files changed, 480 insertions(+), 206 deletions(-) create mode 100644 js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts 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/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts b/js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts new file mode 100644 index 00000000..8feb34a0 --- /dev/null +++ b/js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts @@ -0,0 +1,50 @@ +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; + +export class CommandDiagnosticPhotosTimeline implements Command { + group = 'diagnostic'; + name = 'photos-timeline'; + options: ParseArgsConfig['options'] = { + content: { + type: 'boolean', + short: 'c', + default: false, + }, + 'content-peak': { + type: 'boolean', + short: 'p', + default: false, + }, + thumbnails: { + type: 'boolean', + short: 't', + default: false, + }, + 'local-structure': { + type: 'string', + short: 's', + default: '', + }, + }; + + async action({ + sdkDiagnostic, + options: { json, content, 'content-peak': contentPeak, thumbnails, 'local-structure': localStructure }, + }: ActionArgs) { + const expectedStructure = localStructure ? JSON.parse(await Bun.file(localStructure).text()) : undefined; + + const options = { + verifyContent: contentPeak ? 'peakOnly' : content, + verifyThumbnails: thumbnails, + expectedStructure, + }; + + for await (const result of sdkDiagnostic.verifyPhotosTimeline(options, (progress) => { + printObject(progress, json); + })) { + printObject(result, json); + } + } +} diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts index 02920658..255232c0 100644 --- a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts +++ b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts @@ -13,6 +13,11 @@ export class CommandDiagnosticTree implements Command { short: 'c', default: false, }, + 'content-peak': { + type: 'boolean', + short: 'p', + default: false, + }, thumbnails: { type: 'boolean', short: 't', @@ -29,7 +34,7 @@ export class CommandDiagnosticTree implements Command { sdkDiagnostic, paths, args: [pathString], - options: { json, content, thumbnails, 'local-structure': localStructure }, + options: { json, content, 'content-peak': contentPeak, thumbnails, 'local-structure': localStructure }, }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); @@ -37,7 +42,7 @@ export class CommandDiagnosticTree implements Command { const expectedStructure = localStructure ? JSON.parse(await Bun.file(localStructure).text()) : undefined; const options = { - verifyContent: content, + verifyContent: contentPeak ? 'peakOnly' : content, verifyThumbnails: thumbnails, expectedStructure, }; diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 786df79c..968b4352 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -7,6 +7,7 @@ import { CommandBookmarkList } from './bookmark/commandBookmarkList'; import { CommandDeviceCreate } from './device/commandDeviceCreate'; import { CommandDeviceDelete } from './device/commandDeviceDelete'; import { CommandDeviceRename } from './device/commandDeviceRename'; +import { CommandDiagnosticPhotosTimeline } from './diagnostic/commandDiagnosticPhotosTimeline'; import { CommandDiagnosticTree } from './diagnostic/commandDiagnosticTree'; import { CommandEventsDrive } from './events/commandEventsDrive'; import { CommandEventsMyVolume } from './events/commandEventsMyVolume'; @@ -102,6 +103,7 @@ const COMMANDS = [ new CommandAlbumList(), // Diagnostic new CommandDiagnosticTree(), + new CommandDiagnosticPhotosTimeline(), ].map((command: Command) => { command.options = command.options || {}; command.options['help'] = { diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 5c654547..18ab9a3c 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -31,7 +31,7 @@ if (!command.isAuthAction && !command.isPublicAction && !account.session) { throw new Error('You need to login first'); } -if (account.session) { +if (!command.isAuthAction && account.session) { if (debug) { console.time('Account initialization'); console.log('Initializing user keys...'); 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 a42a5e72157b85ff94be999213c2e6c4108674f4 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 11 Nov 2025 14:21:55 +0100 Subject: [PATCH 157/732] 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 cf3c7e6490ec45036d349a7b327dcabf84913887 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Nov 2025 11:56:54 +0100 Subject: [PATCH 158/732] 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 ac5fd6f0b0eb614bd1eb342127d7ab41d5cde6c5 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 12 Nov 2025 11:41:45 +0100 Subject: [PATCH 159/732] 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 9d1493fc935b9936ffc096f4da15041b42fca4de Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 13 Nov 2025 16:54:06 +0100 Subject: [PATCH 160/732] 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 3ed170b678fd3e884ef13b50eb27cf2b17eadfcc Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Nov 2025 08:46:20 +0000 Subject: [PATCH 161/732] 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 192d725df7f3a91288e59538aa53bff4ea51c504 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 18 Nov 2025 15:26:35 +0100 Subject: [PATCH 162/732] 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 e613d7443ffb068c6d27e1f0aed4860832036b55 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 17 Nov 2025 09:41:39 +0100 Subject: [PATCH 163/732] 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 ad0a177aee43c16269a488ecc6c3c5d40c4241ec Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 19 Nov 2025 11:31:44 +0100 Subject: [PATCH 164/732] 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 fdcb276986b57ba9f06c17674510777c264fb7f6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 14 Nov 2025 09:14:23 +0100 Subject: [PATCH 165/732] Add isDuplicatePhoto method --- js/cli/bun.lock | 3 + js/cli/package.json | 1 + .../cli/fileSystem/commandFileSystemUpload.ts | 3 +- js/cli/src/cli/index.ts | 2 + js/cli/src/cli/paths.ts | 14 ++- .../src/cli/photos/commandPhotoDuplicate.ts | 32 +++++ 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 +++- 12 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 js/cli/src/cli/photos/commandPhotoDuplicate.ts create mode 100644 js/sdk/src/internal/photos/timeline.test.ts diff --git a/js/cli/bun.lock b/js/cli/bun.lock index db47b437..5d5c9f25 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -4,6 +4,7 @@ "": { "name": "proton-drive-cli", "dependencies": { + "@noble/hashes": "^1.8.0", "@openpgp/noble-hashes": "^1.3.3", "@protontech/pmcrypto": "^7.20.0", "bcryptjs": "^2.4.3", @@ -214,6 +215,8 @@ "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.0", "", { "dependencies": { "@microsoft/tsdoc": "0.15.0", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], diff --git a/js/cli/package.json b/js/cli/package.json index 23f16099..405b8189 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -31,6 +31,7 @@ "vite-plugin-dts": "^4.2.4" }, "dependencies": { + "@noble/hashes": "^1.8.0", "@openpgp/noble-hashes": "^1.3.3", "@protontech/pmcrypto": "^7.20.0", "bcryptjs": "^2.4.3", diff --git a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts index 05998f19..9087df53 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts @@ -49,11 +49,10 @@ export class CommandFileSystemUpload implements Command { uploader = await sdk.getFileRevisionUploader(node, metadata); } else { const parentNodePath = paths.getPath(parentPath); - const parentNode = await parentNodePath.getNode(); uploader = parentNodePath.type === PathType.Photos ? await photosSdk.getFileUploader(name, metadata) - : await sdk.getFileUploader(parentNode, name, metadata); + : await sdk.getFileUploader(await parentNodePath.getNode(), name, metadata); } if (!json) { diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 968b4352..78d3edd5 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -27,6 +27,7 @@ import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore' import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumList } from './photos/commandAlbumList'; +import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; import { CommandPublicDelete } from './public/commandPublicDelete'; @@ -100,6 +101,7 @@ const COMMANDS = [ new CommandPublicUpload(), // ProtonDrivePhotosClient new CommandPhotoTimeline(), + new CommandPhotoDuplicate(), new CommandAlbumList(), // Diagnostic new CommandDiagnosticTree(), diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 3065c3af..622a54fa 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -29,9 +29,17 @@ export class Paths { } get rootPaths(): string[] { - return [PathType.MyFiles, PathType.Devices, PathType.SharedByMe, PathType.SharedWithMe, PathType.Trash].map( - (path) => `/${path}`, - ); + return [ + PathType.MyFiles, + PathType.Devices, + PathType.SharedByMe, + PathType.SharedWithMe, + PathType.Trash, + PathType.Photos, + PathType.PhotosSharedByMe, + PathType.PhotosSharedWithMe, + PathType.PhotosTrash, + ].map((path) => `/${path}`); } getPath(path: string): Path { diff --git a/js/cli/src/cli/photos/commandPhotoDuplicate.ts b/js/cli/src/cli/photos/commandPhotoDuplicate.ts new file mode 100644 index 00000000..4e217993 --- /dev/null +++ b/js/cli/src/cli/photos/commandPhotoDuplicate.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; + +import { sha1 } from '@noble/hashes/sha1'; + +import { arrayToHexString } from '../../crypto/lib/utils'; +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandPhotoDuplicate implements Command { + group = 'photo'; + name = 'duplicate'; + args = ['localPath']; + + + async action({ photosSdk, args: [localPath], options: { json } }: ActionArgs) { + const name = path.basename(localPath); + + const getSha1 = async () => { + const file = Bun.file(localPath); + const fileBytes = await file.bytes(); + + const sha1Instance = sha1.create(); + sha1Instance.update(fileBytes); + const sha1Hash = sha1Instance.digest() as Uint8Array; + + return arrayToHexString(sha1Hash); + } + + const isDuplicate = await photosSdk.isDuplicatePhoto(name, getSha1) + printObject({ isDuplicate }, json); + } +} 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 2d753c5e0d70b776f18a91f5a34f3cebe24891ff Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 08:06:25 +0100 Subject: [PATCH 166/732] 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 e16eec826aa57521065d409a94fdbf6c6ec58939 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 10:12:00 +0100 Subject: [PATCH 167/732] 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 e5590992c485150f997d7d1d55f5c366d0526b1a Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 12:56:45 +0000 Subject: [PATCH 168/732] 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 c675f4d252ecb9e39b540a063ae62e7955c5f3b8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 15:38:49 +0100 Subject: [PATCH 169/732] 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 6aa22e9a82ec84ad1120b6430476bcc22a7d0aa4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 14:18:16 +0000 Subject: [PATCH 170/732] 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 c003b4897f73b1f5ee007202facce621804a610c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 14:36:38 +0000 Subject: [PATCH 171/732] 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 dfe93edf836b04d5c1d2dcc78ecde4f15cc32d19 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:44:02 +0100 Subject: [PATCH 172/732] 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 e37ff3ba76e92308288f624fb398bc972202d7d8 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:42:59 +0100 Subject: [PATCH 173/732] 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 64f2833ab15f1749487fdbd164f15f2f2f886a35 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 16:04:12 +0100 Subject: [PATCH 174/732] 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 54bb133493f0a80b99aa30506bace622097e7854 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 15:38:22 +0100 Subject: [PATCH 175/732] 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 0837a912ccda0b5f5e1aa53659228227fb9a13a1 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 12:08:15 +0100 Subject: [PATCH 176/732] 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 d256e365145798d4fff611dfbd78af1637acf318 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 11:47:32 +0000 Subject: [PATCH 177/732] 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 c441e6a06b0e26427e94bedda93450422882b89b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 13:09:52 +0100 Subject: [PATCH 178/732] 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 582cedb5a0b532bcac96093ea2ac2deaedf68bf5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 13:06:16 +0000 Subject: [PATCH 179/732] 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 57d57a9419bc0b84670a2b9d6712d5b31370e433 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 24 Nov 2025 15:41:16 +0100 Subject: [PATCH 180/732] 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 4f718ba63c00f0b9c92c8f9eef076d2383845b88 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 20 Nov 2025 14:55:44 +0100 Subject: [PATCH 181/732] 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 862425c6370f7eb9de4753a2d8f8c59b494e70c4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 11:44:17 +0000 Subject: [PATCH 182/732] 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 a982489d6902b98e0049f928b21bea38a1cf2b14 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 14:56:58 +0100 Subject: [PATCH 183/732] 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 7719a02a34b80111939fb2672750c235c0547c20 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 16:28:03 +0000 Subject: [PATCH 184/732] 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 4e9365c8fa27d7ee1182fda8b4bf88edb2c60b21 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 10:38:58 +0000 Subject: [PATCH 185/732] 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 87dc8f4973c6c178d431f73613a190f4fbb215a2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 21 Nov 2025 17:11:37 +0100 Subject: [PATCH 186/732] 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 84b8ab355a831fb7173f5ed40f41fbf097a50e07 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 11:07:08 +0100 Subject: [PATCH 187/732] 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 14dae7628bfb64da8ee910942af426057c4d6771 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 12:43:57 +0100 Subject: [PATCH 188/732] 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 66c63a2b0e5a17e3fd36f9abda4ae18f39bdeb3b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 10:34:29 +0100 Subject: [PATCH 189/732] 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 ff35eafb2b878d62b0987803bd15f0260e92b57f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 27 Nov 2025 16:39:31 +0100 Subject: [PATCH 190/732] 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 f9ae85003b53a2dbb50187ac5a45fd12fdb0acf5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 09:33:25 +0100 Subject: [PATCH 191/732] 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 24e7b9b3d24a21266641e34c36ef2c007a1e8e1a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 10:59:04 +0100 Subject: [PATCH 192/732] 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 c12d773f39522067d41ec702ac4bfdd8cfb7e92a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 08:23:01 +0100 Subject: [PATCH 193/732] 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 e48aed5a6343003c01c2b343e0159611ba81255f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 11:44:18 +0100 Subject: [PATCH 194/732] 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 02bf4c3f50ba5d2c3bcbb18f3aaf5539aeb713d1 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 26 Nov 2025 15:05:43 +0100 Subject: [PATCH 195/732] 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 1e528d917afb96b3e3f5673c652dd8db22054de1 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:00:22 +0000 Subject: [PATCH 196/732] 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 7ebce0f85b52fca5d1d5cb6dc63c46e4baea5d58 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:03:26 +0000 Subject: [PATCH 197/732] 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 65771d2dfb716d48156f6a5eb78f4448111d4436 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 15:14:32 +0100 Subject: [PATCH 198/732] 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 871266063f36c9e4a4f4c0e675d5edf7074fe9fd Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:30:12 +0000 Subject: [PATCH 199/732] Add unauth prefix for all API calls from public link context --- js/cli/src/cli/paths.ts | 6 +- js/cli/src/cli/public/commandPublicDelete.ts | 8 +- js/cli/src/init.ts | 2 +- js/cli/src/proton-drive.ts | 29 +- 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 +- 33 files changed, 744 insertions(+), 177 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/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 622a54fa..1def9570 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -23,6 +23,9 @@ export class Paths { constructor( private sdk: ProtonDriveClient, private photosSdk: ProtonDrivePhotosClient, + private account: { + isLoggedIn(): Promise; + }, ) { this.sdk = sdk; this.photosSdk = photosSdk; @@ -60,7 +63,8 @@ export class Paths { throw new Error('Custom password is required'); } - this.publicLinkSdk = await this.sdk.experimental.authPublicLink(url, customPassword); + const isAnonymousContext = !(await this.account.isLoggedIn()); + this.publicLinkSdk = await this.sdk.experimental.authPublicLink(url, customPassword, isAnonymousContext); return this.publicLinkSdk; } } diff --git a/js/cli/src/cli/public/commandPublicDelete.ts b/js/cli/src/cli/public/commandPublicDelete.ts index 99fc5dc8..803e0cb3 100644 --- a/js/cli/src/cli/public/commandPublicDelete.ts +++ b/js/cli/src/cli/public/commandPublicDelete.ts @@ -8,16 +8,16 @@ export class CommandPublicDelete implements Command { group = 'public'; name = 'delete'; isPublicAction = true; - args = ['nodeUid']; + args = ['path']; options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - async action({ paths, args: [nodeUid], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(nodeUid); + const nodePath = paths.getPublicLinkPath(pathString); const node = await nodePath.getNode(); await printIterable(client.deleteNodes([node]), json, (result) => console.log(result.ok ? `✅ Deleted ${result.uid}` : `❌ Failed to delete ${result.uid}: ${result.error}`), - ); + ); } } diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 0d6eb7f0..6c034e5d 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -27,7 +27,7 @@ export async function init(debug: boolean) { const sdk = initSDK(cryptoApi, config, account, srp); const photosSdk = initPhotosSDK(cryptoApi, config, account, srp); const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); - const paths = new Paths(sdk, photosSdk); + const paths = new Paths(sdk, photosSdk, account); return { account, sdk, diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 18ab9a3c..64adea3f 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -46,15 +46,26 @@ if (debug) { console.time('Command execution'); } -await command.action({ - account, - sdk, - photosSdk, - sdkDiagnostic, - paths, - args, - options, -}); +try { + await command.action({ + account, + sdk, + photosSdk, + sdkDiagnostic, + paths, + args, + options, + }); +} catch (error: unknown) { + console.error('==============================================='); + console.trace(error); + + // Get all the object properties and convert through JSON to avoid custom object interpretation. + console.debug('Error details:'); + console.debug(JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(error as object))))); + + process.exit(1); +} if (debug) { console.timeEnd('Command execution'); 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 228cc953b5a3e3dcb357a77e4f95d801287b7795 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 14:34:10 +0000 Subject: [PATCH 200/732] 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 0c5572765fe667c9ebcacde70a1a0b3156d08182 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 16:49:18 +0100 Subject: [PATCH 201/732] 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 be1ad571adfb4ea12e97e85643f9b01ee707b164 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 17:12:32 +0000 Subject: [PATCH 202/732] 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 bee7c04d30b91e6d6dad2ed6129dfc3b604b873a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 17:36:30 +0100 Subject: [PATCH 203/732] 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 a98a63c036d91201c3d37235bd081ae725b3635b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Dec 2025 20:44:00 +0000 Subject: [PATCH 204/732] 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 d9f689a0d4bc01e8885dd20959a1e9541f96607a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Dec 2025 21:50:47 +0100 Subject: [PATCH 205/732] 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 969239c51e0d5d6014f1a4c6bfe5c4209cf6acab Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 28 Nov 2025 15:45:30 +0100 Subject: [PATCH 206/732] 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 ec9d0a906ee33d7fe6f5e7df977575363f0f9550 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 07:15:43 +0000 Subject: [PATCH 207/732] 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 f76f6977f8cfcc6311d3d3682ab804a0265fcaee Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 08:32:15 +0000 Subject: [PATCH 208/732] 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 c01899597836e311366405768cdc7f683c467091 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 11:30:00 +0100 Subject: [PATCH 209/732] 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 1af2d103adca1b5834dc107e362206ed0517b846 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 15:54:13 +0100 Subject: [PATCH 210/732] 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 ff4d706064932a6498ea61e51c218425e978209b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Dec 2025 15:42:30 +0000 Subject: [PATCH 211/732] 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 1d0f5269b3fa9b8ccf243cbb4cac8d5efb08b8bd Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 08:50:43 +0000 Subject: [PATCH 212/732] 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 667e216622d9aaad7c9ec1ac32ece634094fabd8 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 11:31:14 +0100 Subject: [PATCH 213/732] 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 39abd69c8415cc58d2c5fd7697566e570688ea6b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 10:45:58 +0000 Subject: [PATCH 214/732] 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 ce4ea0d5c9545bded9b659bebb2ba0f97a225c68 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 07:40:33 +0000 Subject: [PATCH 215/732] 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 d4dc348d5d03d2b1417d589b22524a205db3101c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 09:21:43 +0000 Subject: [PATCH 216/732] 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 b0cf7d206d5208dac96a8cf926a784d2d2f7a64d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Dec 2025 12:00:42 +0100 Subject: [PATCH 217/732] 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 4b82a2f7c18f3570ce08c718281aff4cafc4f542 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Dec 2025 13:49:45 +0100 Subject: [PATCH 218/732] 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 f9c56a1d765553610046c6a72e51a1cd7ef6c5a5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 06:38:35 +0000 Subject: [PATCH 219/732] Add new name param to copy --- .../cli/fileSystem/commandFileSystemCopy.ts | 16 ++- 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 +++- 9 files changed, 187 insertions(+), 78 deletions(-) diff --git a/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts b/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts index becbc81e..50b55b66 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts @@ -1,3 +1,5 @@ +import { ParseArgsConfig } from 'node:util'; + import { printIterable } from '../formatters'; import { Command, ActionArgs } from '../interface'; import { PathType } from '../paths'; @@ -7,8 +9,15 @@ export class CommandFileSystemCopy implements Command { name = 'copy'; // FIXME: support copy of multiple files args = ['sourcePath', 'targetPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + }; - async action({ sdk, paths, args: [sourcePathString, targetPathString], options: { json } }: ActionArgs) { + async action({ sdk, paths, args: [sourcePathString, targetPathString], options: { name, json } }: ActionArgs) { const sourcePath = paths.getPath(sourcePathString); const sourceNode = await sourcePath.getNode(); @@ -19,7 +28,10 @@ export class CommandFileSystemCopy implements Command { throw new Error('Copying photos is not supported'); } - await printIterable(sdk.copyNodes([sourceNode], targetNode), json, (result) => + const nodeUid = sourceNode.ok ? sourceNode.value.uid : sourceNode.error.uid; + const param = name ? { uid: nodeUid, name } : sourceNode; + + await printIterable(sdk.copyNodes([param], targetNode), json, (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), ); } 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 7cb01afa9b36f0b9d6cfb4cc3e46478f81eda83f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 08:23:29 +0100 Subject: [PATCH 220/732] 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 2a5066e1854181b3e9d1f55fb1fb5dfcef80e0ad Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 07:33:22 +0100 Subject: [PATCH 221/732] 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 03dbfd4a4f59729077448c445c10cf6726ca93f4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 15:21:02 +0100 Subject: [PATCH 222/732] 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 d3c88f759a1a4f07ad814f4cd448cb50fd9c7bc2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 16:59:14 +0000 Subject: [PATCH 223/732] 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 +++++ js/cli/src/cli/node.ts | 20 ++++++++++++----- js/cli/src/cli/paths.ts | 2 +- 7 files changed, 63 insertions(+), 25 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); diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index 8d1a7a66..f62a2db4 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -5,13 +5,16 @@ export function getNode(maybeNode: MaybeNode): NodeEntity | DegradedNode { } export function getName(maybeNode: MaybeNode): string { + let name; + let uid; if (maybeNode.ok) { - return maybeNode.value.name; + name = maybeNode.value.name; + uid = maybeNode.value.uid; + } else { + name = maybeNode.error.name.ok ? maybeNode.error.name.value : maybeNode.error.name.error.name; + uid = maybeNode.error.uid; } - if (maybeNode.error.name.ok) { - return maybeNode.error.name.value; - } - return maybeNode.error.name.error.name || maybeNode.error.uid; + return validateName(name) ? name : uid; } export function getClaimedSize(maybeNode: MaybeNode): number | undefined { @@ -22,3 +25,10 @@ export function getClaimedSize(maybeNode: MaybeNode): number | undefined { return maybeNode.error.activeRevision.value.claimedSize; } } + +function validateName(name: string): boolean { + if (name.length == 0 || name.includes('/')) { + return false; + } + return true; +} diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 1def9570..b4f2565c 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -308,7 +308,7 @@ export class PublicLinkPath { } function isNodeUid(pathString: string): boolean { - return /^[a-zA-Z0-9=_-]{60,300}~[a-zA-Z0-9=_-]{60,300}$/.test(pathString); + return /^([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})~([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})$/.test(pathString); } function getMaybeNodeAndIgnoreMissingNode(maybeMissingNode: MaybeMissingNode): MaybeNode | undefined { From a585be6586fba88419c6dd00f29e71c1fdb84372 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 14:09:49 +0000 Subject: [PATCH 224/732] 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 3b6edf6c71904f6c5431a872e9609ac30e543507 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Dec 2025 08:36:11 +0100 Subject: [PATCH 225/732] 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 79df26c9f973c2abba0e2c1d214da5030b5c613c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 17:30:19 +0000 Subject: [PATCH 226/732] 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 fcbce22b93f9724233ddc43079d834428fb1d856 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Dec 2025 16:12:30 +0100 Subject: [PATCH 227/732] 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 1ebe2fd99ba71a25ff51bf88d96383be6220c568 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 08:58:05 +0100 Subject: [PATCH 228/732] 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 acf25e5da66c415b55d8a3d22e0a45d0e5fb4231 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 12:27:47 +0100 Subject: [PATCH 229/732] 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 23edb487f898340f11bab09037f20a6914d67c17 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 07:36:32 +0100 Subject: [PATCH 230/732] Add getMyPhotosRootFolder --- js/cli/src/cli/index.ts | 2 ++ js/cli/src/cli/photos/commandPhotoRoot.ts | 12 ++++++++++++ js/sdk/src/protonDrivePhotosClient.ts | 8 ++++++++ 3 files changed, 22 insertions(+) create mode 100644 js/cli/src/cli/photos/commandPhotoRoot.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 78d3edd5..7b739dda 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -28,6 +28,7 @@ import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumList } from './photos/commandAlbumList'; import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; +import { CommandPhotoRoot } from './photos/commandPhotoRoot'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; import { CommandPublicDelete } from './public/commandPublicDelete'; @@ -100,6 +101,7 @@ const COMMANDS = [ new CommandPublicDownloadThumbnails(), new CommandPublicUpload(), // ProtonDrivePhotosClient + new CommandPhotoRoot(), new CommandPhotoTimeline(), new CommandPhotoDuplicate(), new CommandAlbumList(), diff --git a/js/cli/src/cli/photos/commandPhotoRoot.ts b/js/cli/src/cli/photos/commandPhotoRoot.ts new file mode 100644 index 00000000..3a896531 --- /dev/null +++ b/js/cli/src/cli/photos/commandPhotoRoot.ts @@ -0,0 +1,12 @@ +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandPhotoRoot implements Command { + group = 'photo'; + name = 'root'; + + async action({ photosSdk, options: { json } }: ActionArgs) { + const root = await photosSdk.getMyPhotosRootFolder(); + printObject(root, json); + } +} 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 b3a4c2dca08e5073aad6e34a6e4a743a22509b17 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 12:05:51 +0100 Subject: [PATCH 231/732] 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 461c24ae3a557038105e6929790b9682f947955c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 13:23:01 +0000 Subject: [PATCH 232/732] 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 b0fc563773c8425aec9b173ec00f43d3109d92ab Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 16:39:22 +0100 Subject: [PATCH 233/732] 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 5fb1bb6c0e858b0917cbc879056666867ef4927a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 17:32:55 +0000 Subject: [PATCH 234/732] 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 0e018a5e5467f2ff9ee756ecc1f18f1edede19c7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 07:59:55 +0100 Subject: [PATCH 235/732] 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 236/732] 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 4f0bdeacc0d473088d4cc3649e710bf6d8150305 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Dec 2025 07:59:41 +0100 Subject: [PATCH 237/732] 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 7072996dcc69c0ccf416188be10b3e2dc83dddeb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 22:17:12 +0100 Subject: [PATCH 238/732] 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 55d93ec36cd8369bb395e7af248e54a7e42e7d6e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 08:02:04 +0000 Subject: [PATCH 239/732] Create findPhotoDuplicates to get uids of duplicates --- .../src/cli/photos/commandPhotoDuplicate.ts | 4 +- js/sdk/src/internal/photos/timeline.test.ts | 46 ++++++++++++++++--- js/sdk/src/internal/photos/timeline.ts | 8 ++-- js/sdk/src/protonDrivePhotosClient.ts | 26 ++++++++++- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/js/cli/src/cli/photos/commandPhotoDuplicate.ts b/js/cli/src/cli/photos/commandPhotoDuplicate.ts index 4e217993..6212f31e 100644 --- a/js/cli/src/cli/photos/commandPhotoDuplicate.ts +++ b/js/cli/src/cli/photos/commandPhotoDuplicate.ts @@ -26,7 +26,7 @@ export class CommandPhotoDuplicate implements Command { return arrayToHexString(sha1Hash); } - const isDuplicate = await photosSdk.isDuplicatePhoto(name, getSha1) - printObject({ isDuplicate }, json); + const duplicates = await photosSdk.findPhotoDuplicates(name, getSha1) + printObject({ duplicates }, json); } } 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 313528435ead154d4a85071d67f36f3eac951cbc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 19:26:37 +0100 Subject: [PATCH 240/732] 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 292e17be7b94e93d3a02568454d88d3297cebf3e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 14:08:14 +0100 Subject: [PATCH 241/732] 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 a8d09962d59cb84fbbffbc695250cfe15cae932a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 16:10:27 +0100 Subject: [PATCH 242/732] 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 0b83b06d99ad0d3c923ce5f805467196cf91b270 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Dec 2025 10:20:38 +0100 Subject: [PATCH 243/732] 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 aa34b3a070c1e65cfe3f66f2fb6fdf098c93f7b6 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 07:51:15 +0000 Subject: [PATCH 244/732] 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 cf87ed45089efc9c2a9a5a9504aa8f04f23dce14 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 12:01:36 +0100 Subject: [PATCH 245/732] 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 ff4f436baffc09247663656d3c31ba9d0911bf05 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 15:00:19 +0100 Subject: [PATCH 246/732] 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 87ad1652ec2210050d9925c264345a533a181f3f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 14:20:13 +0100 Subject: [PATCH 247/732] 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 90351be04338e898d33d7bd98b4d4b8620ced96b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Dec 2025 14:42:11 +0100 Subject: [PATCH 248/732] 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 19a15bdd927c1ed431f7d704544b8d786162d94d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 15:07:04 +0000 Subject: [PATCH 249/732] 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 0b1dea4086ccaa636082d53cd2027005fa171da6 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Dec 2025 18:11:08 +0100 Subject: [PATCH 250/732] 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 87824426fdf3f2f50852de951b5d66b3a506c281 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 15 Dec 2025 16:34:43 +0000 Subject: [PATCH 251/732] 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 999d62f9f0843ea515c023748811aa2ce808c45e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:36:13 +0100 Subject: [PATCH 252/732] 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 c40db9051516fc7b05ca5ece21558d860ba1a458 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:50:46 +0100 Subject: [PATCH 253/732] 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 b509c74f6f676de2b4f5e77385f39a3210cf1710 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 09:26:12 +0100 Subject: [PATCH 254/732] 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 f7f772d409d6740848a975b2aacf64b24ec80c2e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 13:10:20 +0100 Subject: [PATCH 255/732] 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 da66de3a850294ed2b18ec0c063211c8d913377a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:17:26 +0100 Subject: [PATCH 256/732] Add empty-trash Implementation --- .../src/cli/fileSystem/commandFileSystemEmptyTrash.ts | 11 +++++++++++ js/cli/src/cli/index.ts | 2 ++ js/sdk/src/internal/nodes/apiService.ts | 7 +++++++ js/sdk/src/internal/nodes/nodesManagement.ts | 6 ++++++ js/sdk/src/protonDriveClient.ts | 2 +- 5 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts diff --git a/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts b/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts new file mode 100644 index 00000000..0addb31b --- /dev/null +++ b/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts @@ -0,0 +1,11 @@ +import { Command, ActionArgs } from '../interface'; + +export class CommandFileSystemEmptyTrash implements Command { + group = 'filesystem'; + name = 'empty-trash'; + + async action({ sdk }: ActionArgs) { + await sdk.emptyTrash(); + console.log('Trash emptied'); + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 7b739dda..355f23d3 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -18,6 +18,7 @@ import { CommandFileSystemDelete } from './fileSystem/commandFileSystemDelete'; import { CommandFileSystemDownload } from './fileSystem/commandFileSystemDownload'; import { CommandFileSystemDownloadSeeking } from './fileSystem/commandFileSystemDownloadSeeking'; import { CommandFileSystemDownloadThumbnails } from './fileSystem/commandFileSystemDownloadThumbnails'; +import { CommandFileSystemEmptyTrash } from './fileSystem/commandFileSystemEmptyTrash'; import { CommandFileSystemGetAvailableName } from './fileSystem/commandFileSystemGetAvailableName'; import { CommandFileSystemInfo } from './fileSystem/commandFileSystemInfo'; import { CommandFileSystemList } from './fileSystem/commandFileSystemList'; @@ -69,6 +70,7 @@ const COMMANDS = [ new CommandFileSystemTrash(), new CommandFileSystemDelete(), new CommandFileSystemRestore(), + new CommandFileSystemEmptyTrash(), new CommandFileSystemDownload(), new CommandFileSystemDownloadSeeking(), new CommandFileSystemDownloadThumbnails(), 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 636172923e7d1d925d218863f11b24cdb276c9de Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:45:07 +0100 Subject: [PATCH 257/732] 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 2f8334ca1cbf3c534f48c8e39728104431e90388 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 10:07:30 +0000 Subject: [PATCH 258/732] 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 44f62b02ff3636006c7dc953107cabed5c45a926 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 09:56:55 +0100 Subject: [PATCH 259/732] Allow download with signature issues --- js/cli/src/cli/downloader.ts | 49 +++++++++++++++++++ .../fileSystem/commandFileSystemDownload.ts | 28 +++-------- .../src/cli/public/commandPublicDownload.ts | 28 +++-------- .../cli/revision/commandRevisionDownload.ts | 26 +++------- 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 ++++++ 11 files changed, 148 insertions(+), 85 deletions(-) create mode 100644 js/cli/src/cli/downloader.ts diff --git a/js/cli/src/cli/downloader.ts b/js/cli/src/cli/downloader.ts new file mode 100644 index 00000000..aa87d854 --- /dev/null +++ b/js/cli/src/cli/downloader.ts @@ -0,0 +1,49 @@ +import { FileDownloader } from '../../../sdk/src/interface'; + +export async function download({ + downloader, + downloadingName, + localPath, + json, +}: { + downloader: FileDownloader; + downloadingName: string; + localPath: string; + json: boolean; +}) { + + const claimedSize = downloader.getClaimedSizeInBytes(); + + if (!json) { + console.log(`Downloading ${downloadingName} (${claimedSize || 'N/A'} bytes) to ${localPath}`); + } + + const file = Bun.file(localPath); + const writer = file.writer(); + const writableStream: WritableStream = { + // @ts-expect-error: Bun's FileSink writer is not fully compatible with the WritableStream interface, but this is good enough for testing purposes. + getWriter: () => writer, + close: async () => { + await writer.end(); + }, + abort: async () => { + await writer.end(); + }, + locked: false, + }; + + const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { + if (!json) { + console.log(`Downloaded ${writtenBytes} bytes`); + } + }); + + try { + await controller.completion(); + await writer.end(); + } catch (error: unknown) { + // When error is passed, Bun still flushes the buffer and closes the file, but marks operation as failed. + await writer.end(error instanceof Error ? error : new Error('Unknown error')); + throw error; + } +} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts index 0abc1761..87d4dc74 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from '../interface'; +import { download } from '../downloader'; import { getName } from '../node'; export class CommandFileSystemDownload implements Command { @@ -22,28 +23,11 @@ export class CommandFileSystemDownload implements Command { const node = await nodePath.getNode(); const downloader = await nodePath.sdk.getFileDownloader(node); - const claimedSize = downloader.getClaimedSizeInBytes(); - const localPath = path.join(localParentPath, name || getName(node)); - - if (!json) { - console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); - } - - const file = Bun.file(localPath); - const writer = file.writer(); - const writableStream: WritableStream = { - getWriter: () => writer, - close: () => writer.end(), - abort: () => writer.end(), - locked: false, - }; - - const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } + await download({ + downloader, + downloadingName: getName(node), + localPath: path.join(localParentPath, name || getName(node)), + json, }); - - await controller.completion(); } } diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts index b51f447f..dd849258 100644 --- a/js/cli/src/cli/public/commandPublicDownload.ts +++ b/js/cli/src/cli/public/commandPublicDownload.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { ParseArgsConfig } from 'util'; import { Command, ActionArgs } from '../interface'; +import { download } from '../downloader'; import { getName } from '../node'; import { PUBLIC_OPTIONS } from './base'; @@ -29,28 +30,11 @@ export class CommandPublicDownload implements Command { const node = await nodePath.getNode(); const downloader = await client.getFileDownloader(node); - const claimedSize = downloader.getClaimedSizeInBytes(); - const localPath = path.join(localParentPath, name || getName(node)); - - if (!json) { - console.log(`Downloading ${getName(node)} (${claimedSize || 'N/A'} bytes) to ${localPath}`); - } - - const file = Bun.file(localPath); - const writer = file.writer(); - const writableStream: WritableStream = { - getWriter: () => writer, - close: () => writer.end(), - abort: () => writer.end(), - locked: false, - }; - - const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } + await download({ + downloader, + downloadingName: getName(node), + localPath: path.join(localParentPath, name || getName(node)), + json, }); - - await controller.completion(); } } diff --git a/js/cli/src/cli/revision/commandRevisionDownload.ts b/js/cli/src/cli/revision/commandRevisionDownload.ts index 7135207e..de3f939c 100644 --- a/js/cli/src/cli/revision/commandRevisionDownload.ts +++ b/js/cli/src/cli/revision/commandRevisionDownload.ts @@ -1,4 +1,5 @@ import { Command, ActionArgs } from '../interface'; +import { download } from '../downloader'; export class CommandRevisionDownload implements Command { group = 'revision'; @@ -7,27 +8,12 @@ export class CommandRevisionDownload implements Command { async action({ sdk, args: [revisionUid, localPath], options: { json } }: ActionArgs) { const downloader = await sdk.getFileRevisionDownloader(revisionUid); - const claimedSize = downloader.getClaimedSizeInBytes(); - if (!json) { - console.log(`Downloading revision (${claimedSize || 'N/A'} bytes) to ${localPath}`); - } - - const file = Bun.file(localPath); - const writer = file.writer(); - const writableStream: WritableStream = { - getWriter: () => writer, - close: () => writer.end(), - abort: () => writer.end(), - locked: false, - }; - - const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } + await download({ + downloader, + downloadingName: `revision`, + localPath, + json, }); - - await controller.completion(); } } 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 081f2d0650e701c1a710509f26a4cb0c48ba9860 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 11:25:02 +0100 Subject: [PATCH 260/732] 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 c5ddbea279508e9e8d8c20fc9ca0072308bbfb0c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 10:17:05 +0100 Subject: [PATCH 261/732] 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 a9037d2f31afdca271d4fe558f0070a16ef6a6ce Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 17 Dec 2025 10:51:05 +0100 Subject: [PATCH 262/732] 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 cff9b08170720522c672c1b0c9f9bcd476f7fe75 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 18 Dec 2025 17:02:22 +0000 Subject: [PATCH 263/732] 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 f838a45b8b2f13aa2f69f07cdff0b85952cb7305 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 18 Dec 2025 16:45:21 +0100 Subject: [PATCH 264/732] 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 509a27eaf3eafaa300daa43619fbe744a9dc6881 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 06:52:32 +0000 Subject: [PATCH 265/732] 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 485cf8c39fda49a9e613578349f9e5babbd8a4e1 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 09:49:50 +0100 Subject: [PATCH 266/732] 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 b712e4676634c28becda74b50900d745f3cfa47a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 13:24:46 +0100 Subject: [PATCH 267/732] 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 c32dd0692afb8afb8a4066b1653871d50ff3ae8d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 13:14:12 +0000 Subject: [PATCH 268/732] 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 99f98c254f816f0eb31ec08f95f51d17c2744087 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 15:37:16 +0100 Subject: [PATCH 269/732] 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 366e7e50a036d07d2538d3bbd84c11fbb65d7574 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 19 Dec 2025 16:56:19 +0100 Subject: [PATCH 270/732] 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 744fd4b572185b1c374068aa46215119c4de7cbd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 22 Dec 2025 11:18:46 +0100 Subject: [PATCH 271/732] 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 fd0a55de6430d7be493a605eafcaa30d9ed1f6e8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 09:09:43 +0000 Subject: [PATCH 272/732] 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 03daaa5f4cb6f82092688d3e21c3cedc1248b0a5 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 09:55:59 +0000 Subject: [PATCH 273/732] 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 001ea5ac49b1467e2863fb678d9884e5835316fd Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 23 Dec 2025 16:16:27 +0000 Subject: [PATCH 274/732] 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 d6e89ace3899c81ad2f414f3cea367a9243599af Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 31 Dec 2025 02:04:07 +0000 Subject: [PATCH 275/732] 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 baba8f9a28a30b102580a79222ba2d684d8b7274 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 29 Dec 2025 06:03:20 +0000 Subject: [PATCH 276/732] 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 b41abdbc423e36c378075279c7067ddc39e469de Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Oct 2025 14:41:39 +0200 Subject: [PATCH 277/732] 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 b976f6d00a7cb1d3427e98cdebd84b03e4deb4dc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 5 Jan 2026 14:22:30 +0000 Subject: [PATCH 278/732] 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 4056304bda463cb8a7bac3f5a091d676c922e651 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 10:24:28 +0100 Subject: [PATCH 279/732] 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 9f645a08c2daf3413bf2a22b562553eaf752f86d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 11:00:59 +0100 Subject: [PATCH 280/732] 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 38da6333ff91507bc8bd31df5a179d32cfdb8e60 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 6 Jan 2026 12:32:31 +0000 Subject: [PATCH 281/732] 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 7996f6dbd493605273cdd78dd71f2a4c58acdcd3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 06:18:12 +0000 Subject: [PATCH 282/732] 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 f1fdeae724d953b2d804d2e0ea5bbfc586b98163 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 07:22:01 +0100 Subject: [PATCH 283/732] 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 ad4f22fdf6e4b334a67ed168f3f6b7ab2cbd7ed2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 12:57:12 +0100 Subject: [PATCH 284/732] 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 04f03d0039988d63d7d2ba61b3ac22284b67c4e9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 14:48:07 +0000 Subject: [PATCH 285/732] 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 cf6e33a4d2c7314a01bc22fff2cfa8e9d686af84 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 15:33:26 +0100 Subject: [PATCH 286/732] 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 c11f208b09a5b866662867e7c80cf730a4b51c18 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 7 Jan 2026 17:17:56 +0100 Subject: [PATCH 287/732] 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 6e60b0d48da530be084294bf2419484467211a5d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 8 Jan 2026 06:33:11 +0000 Subject: [PATCH 288/732] 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 1d664ecd7086ec543f000947e5fed5b0147b9b14 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 10:57:29 +0000 Subject: [PATCH 289/732] 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 7530eac036c761c513eab92df58a539da7a8c1fb Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 16 Dec 2025 15:27:42 +0100 Subject: [PATCH 290/732] 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 14f653b87f4b8c0e7b3912e21ee90a4f8fb83f0a Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 12:48:09 +0000 Subject: [PATCH 291/732] 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 17b4eae38b5e3517676fff8112cf579f9bc6787f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 12 Jan 2026 14:36:56 +0100 Subject: [PATCH 292/732] 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 36979ad0e3d5e5fd9627766ce44e635969cab862 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 06:01:04 +0000 Subject: [PATCH 293/732] Add tree structure to diagnostics --- .../commandDiagnosticGetTreeStructure.ts | 24 +++++++++ js/cli/src/cli/index.ts | 2 + 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 ++++++++++++++++++- 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts b/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts new file mode 100644 index 00000000..2d96d3e7 --- /dev/null +++ b/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts @@ -0,0 +1,24 @@ +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; +import { PathType } from '../paths'; + +export class CommandDiagnosticGetTreeStructure implements Command { + group = 'diagnostic'; + name = 'get-tree-structure'; + args = ['path']; + + async action({ sdkDiagnostic, paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + + if ([PathType.MyFiles, PathType.Devices, PathType.SharedWithMe].includes(nodePath.type)) { + const node = await nodePath.getNode(); + const structure = await sdkDiagnostic.getNodeTreeStructure(node); + printObject(structure, json); + } else if ([PathType.Photos].includes(nodePath.type)) { + const structure = await sdkDiagnostic.getPhotosTimelineStructure(); + printObject(structure, json); + } else { + throw new Error(`Not supported path type: ${nodePath.type}`); + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 355f23d3..f1794e17 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -9,6 +9,7 @@ import { CommandDeviceDelete } from './device/commandDeviceDelete'; import { CommandDeviceRename } from './device/commandDeviceRename'; import { CommandDiagnosticPhotosTimeline } from './diagnostic/commandDiagnosticPhotosTimeline'; import { CommandDiagnosticTree } from './diagnostic/commandDiagnosticTree'; +import { CommandDiagnosticGetTreeStructure } from './diagnostic/commandDiagnosticGetTreeStructure'; import { CommandEventsDrive } from './events/commandEventsDrive'; import { CommandEventsMyVolume } from './events/commandEventsMyVolume'; import { CommandEventsPath } from './events/commandEventsPath'; @@ -110,6 +111,7 @@ const COMMANDS = [ // Diagnostic new CommandDiagnosticTree(), new CommandDiagnosticPhotosTimeline(), + new CommandDiagnosticGetTreeStructure(), ].map((command: Command) => { command.options = command.options || {}; command.options['help'] = { 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 e1b48147990e3ab5c6a583fc133ba93a01db7b7d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 06:06:54 +0000 Subject: [PATCH 294/732] Fix typing of CryptoProxy and CLI --- js/cli/src/cli/index.ts | 2 +- js/cli/src/cli/interface.ts | 2 +- js/sdk/src/crypto/interface.ts | 45 ++--------- js/sdk/src/crypto/openPGPCrypto.ts | 79 ++++++++++--------- .../src/internal/nodes/cryptoService.test.ts | 2 +- 5 files changed, 52 insertions(+), 78 deletions(-) diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index f1794e17..bf7d8787 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -147,7 +147,7 @@ export function getCommand(groupName: string, commandName: string): Command { throw new Error(`Command not found`); } -export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: any }) { +export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { if (command.args) { if (args.length !== command.args.length) { printCommandUsage(command); diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index f1473c74..2e529188 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -23,5 +23,5 @@ export interface ActionArgs { sdkDiagnostic: Diagnostic; paths: Paths; args: string[]; - options: { [name: string]: any }; + options: { [name: string]: unknown }; } 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 c752be8fd71e9357f70ad462502ce39b597b9f9b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 07:09:32 +0100 Subject: [PATCH 295/732] 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 e5ce38b79c629b1a9d418570f45fc4852ad31877 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 12 Jan 2026 12:00:00 +0100 Subject: [PATCH 296/732] 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 a384aeb5b37ec78bc5f5e36727641774453c359c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 13 Jan 2026 16:13:29 +0000 Subject: [PATCH 297/732] 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 1956478bc3c51037584de5c519dfae16924e61aa Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:45:05 +0100 Subject: [PATCH 298/732] 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 0e012aa1bbc17d6b2050a6bf3e755dc9d033d6f6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:44:02 +0000 Subject: [PATCH 299/732] 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 4baa1f0f4b390a3f90d9574fbeb97e9ec9048417 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 9 Jan 2026 16:58:09 +0100 Subject: [PATCH 300/732] 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 55094f3f728f609580a54da094a71ecd8bc87384 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 13:52:25 +0000 Subject: [PATCH 301/732] 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 43b793cc9e72f35a4315b15cd9f69e1f5aa19bb2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 14:53:02 +0100 Subject: [PATCH 302/732] 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 5b66ac9461741bb9923d5d36219239b5233f0fb4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 10:03:01 +0100 Subject: [PATCH 303/732] 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 ee1d29485decaf211e8802063a790cfa52beddae Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 10:31:23 +0100 Subject: [PATCH 304/732] 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 f49d282af1df7d20793ec9a269d137a13f2ee602 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 09:44:28 +0000 Subject: [PATCH 305/732] 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 a5e7b103494f49928c04cb5633ae59fe0645500e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 09:55:24 +0000 Subject: [PATCH 306/732] 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 ee2c53222035c2ea0905ff73a53bea6b65880484 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 11:27:16 +0100 Subject: [PATCH 307/732] 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 13bcea2170e82e27618fa10bc4e991fd39cad259 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 12:37:54 +0100 Subject: [PATCH 308/732] 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 3598c23a94c8e1e2cc3f720d4c20130b2444223f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 14:01:04 +0000 Subject: [PATCH 309/732] Upgrade CryptoProxy and SRP --- js/cli/bun.lock | 20 ++------ js/cli/jest.config.js | 4 +- js/cli/package.json | 4 +- .../src/cli/bookmark/commandBookmarkRemove.ts | 2 +- js/cli/src/cli/interface.ts | 2 +- .../src/cli/photos/commandPhotoDuplicate.ts | 2 +- js/cli/tsconfig.json | 5 +- js/sdk/src/crypto/openPGPCrypto.ts | 50 +++++++------------ 8 files changed, 33 insertions(+), 56 deletions(-) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 5d5c9f25..7db7c3f8 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/noble-hashes": "^1.3.3", - "@protontech/pmcrypto": "^7.20.0", + "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "comlink": "^4.4.2", "ky": "^1.7.2", @@ -223,15 +223,11 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@openpgp/asmcrypto.js": ["@openpgp/asmcrypto.js@2.3.3-0", "", {}, "sha512-1jhJ5dld5XgaNEZ3WakXnUs6XhVXSeWPUXP44bxoVXtFN5DcEPhdm5kM/wuuJlPSDeqVwDjh6becPHiJPqIsvg=="], - "@openpgp/noble-hashes": ["@openpgp/noble-hashes@1.3.3", "", { "dependencies": { "@types/bn.js": "^4.11.6", "bn.js": "^4.11.8" } }, "sha512-CPDNePrQTQrL5Fxd/K3B53Gty4xbNVxnMSUdDmYjW5tTpRqOlme0INYTnnQqoE/XUdIlq5fByOMDuDWR5JpXZw=="], - "@openpgp/tweetnacl": ["@openpgp/tweetnacl@1.0.3", "", {}, "sha512-KGXNhU/mRg+uTsLGva55V340jwbX2pC8LndjOVI2oQ8vewPVTS2KnDOIXQ8O6KyT/c9Qy16KUQ5mwewe72m1Yw=="], - - "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.0.13", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-VQ0O0lUcD9ilLcMLQMJMgPhp8fDgMd4copd+UhSBGjud0vbI1ONQ3ffAhixEMml/AApLJtqCpd7PJcccPliFSA=="], + "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.1.3", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ=="], - "@protontech/pmcrypto": ["@protontech/pmcrypto@7.20.0", "", { "dependencies": { "@openpgp/asmcrypto.js": "^2.3.3-0", "@openpgp/noble-hashes": "^1.3.2-2", "@openpgp/tweetnacl": "^1.0.3", "@openpgp/web-stream-tools": "^0.0.13", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.1", "openpgp": "npm:@protontech/openpgp@~5.11.2-0" } }, "sha512-QOijmmT0tWC5QKkvEYLqWke+f/2jnE+EdxvIURaA8Atmx4zE1dxXOoblon7/3w+8BMCT9RrK3iMeuGaHkHewEQ=="], + "@protontech/pmcrypto": ["@protontech/pmcrypto@8.8.1", "", { "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.2.2" } }, "sha512-iYrCgHDL3m+xt9oZD1oSFG73mrQFIfqL3xfRA++O1Zv2PEfsQ1d7SXefHCzG5QZjWKVlHXL8uI7ucl2+yesTpg=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "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" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], @@ -427,8 +423,6 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], - "babel-jest": ["babel-jest@29.7.0", "", { "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" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "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" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], @@ -729,7 +723,7 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "jsmimeparser": ["@protontech/jsmimeparser@3.0.1", "", {}, "sha512-bi0RBkritKep1cKnQ6U1538++aQ+7XZxG5Uzm4ZivvP7FTE3iaOA5lm0CCFbSoQ5e8KtvjI5KR+Vj6apXhyhXQ=="], + "jsmimeparser": ["@protontech/jsmimeparser@3.0.2", "", {}, "sha512-PConkdRdY8xc1A8+oEmQ2pGC7nnJPqWwkUb/+odjFPbof2w3zpIws77D9J9assvZg8OdB8dRe/cZdBU+BCijgA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -783,8 +777,6 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], @@ -811,7 +803,7 @@ "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.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" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], - "openpgp": ["@protontech/openpgp@5.11.2-0", "", { "dependencies": { "asn1.js": "^5.0.0" } }, "sha512-7KdhbuTblnet30IIgom2zOAEqJHIQhVYA78m8dU1PFvRt6UtyiCX2Di72piT/XH4Hv8Br+RyprlBEsBijMTt/g=="], + "openpgp": ["@protontech/openpgp@6.2.2", "", {}, "sha512-gfpd+TF+Qdeu9A1P55Tl50dvZlrhQrYDgsLJ1E8mokszkMFks6I20nn7jWu3tmD5BqAtJ2yynA3077tNRmcnYg=="], "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -889,8 +881,6 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js index c50dbb38..9742f719 100644 --- a/js/cli/jest.config.js +++ b/js/cli/jest.config.js @@ -11,8 +11,10 @@ module.exports = { }, moduleNameMapper: { '^@openpgp/noble-hashes/esm/(.*)$': '/node_modules/@openpgp/noble-hashes/esm/$1.js', + '^@openpgp/web-stream-tools$': '/node_modules/@openpgp/web-stream-tools/lib/index.js', + '^openpgp/lightweight$': '/node_modules/openpgp/dist/lightweight/openpgp.min.mjs', }, reporters: ['default'], testEnvironment: 'node', - setupFilesAfterEnv: [], + setupFilesAfterEnv: ['/jest.setup.js'], }; diff --git a/js/cli/package.json b/js/cli/package.json index 405b8189..627281a3 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers", + "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers --conditions=browser", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", @@ -33,7 +33,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/noble-hashes": "^1.3.3", - "@protontech/pmcrypto": "^7.20.0", + "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "comlink": "^4.4.2", "ky": "^1.7.2", diff --git a/js/cli/src/cli/bookmark/commandBookmarkRemove.ts b/js/cli/src/cli/bookmark/commandBookmarkRemove.ts index fcd09d58..5a2d4c5b 100644 --- a/js/cli/src/cli/bookmark/commandBookmarkRemove.ts +++ b/js/cli/src/cli/bookmark/commandBookmarkRemove.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from './interface'; +import { Command, ActionArgs } from '../interface'; export class CommandBookmarkRemove implements Command { group = 'bookmark'; diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 2e529188..5bce05ca 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -23,5 +23,5 @@ export interface ActionArgs { sdkDiagnostic: Diagnostic; paths: Paths; args: string[]; - options: { [name: string]: unknown }; + options: { [name: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/js/cli/src/cli/photos/commandPhotoDuplicate.ts b/js/cli/src/cli/photos/commandPhotoDuplicate.ts index 6212f31e..579f7363 100644 --- a/js/cli/src/cli/photos/commandPhotoDuplicate.ts +++ b/js/cli/src/cli/photos/commandPhotoDuplicate.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { sha1 } from '@noble/hashes/sha1'; -import { arrayToHexString } from '../../crypto/lib/utils'; +import { arrayToHexString } from '../../crypto/cryptoUtils'; import { printObject } from '../formatters'; import { Command, ActionArgs } from '../interface'; diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json index ddd4746b..5f202837 100644 --- a/js/cli/tsconfig.json +++ b/js/cli/tsconfig.json @@ -4,7 +4,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "incremental": true, - "module": "commonjs", + "module": "esnext", "moduleResolution": "node", "noEmit": false, "noImplicitAny": true, @@ -26,6 +26,7 @@ ], "exclude": [ "**/node_modules/*", + "**/api-core-types.ts", "dist/**/*" ] -} \ No newline at end of file +} 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 54704e6b20effb8ae2c0632d0d6123e06ed7563d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 14 Jan 2026 10:29:49 +0100 Subject: [PATCH 310/732] 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 98aa5c3b5e14a1bd7efc11f33f0247f857c6e471 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 09:08:30 +0100 Subject: [PATCH 311/732] Debounce Account key requests for CLI --- js/cli/src/init.ts | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 6c034e5d..ece3ab7b 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -6,7 +6,15 @@ import { Api as CryptoApi } from './crypto/lib/worker/api'; import { HTTPClient } from './httpClient'; import { initTelemetry } from './telemetry'; -import { ProtonDriveClient, MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy } from '../../sdk/src'; +import { + ProtonDriveClient, + MemoryCache, + CachedCryptoMaterial, + OpenPGPCryptoWithCryptoProxy, + MetricEvent, + Logger, +} from '../../sdk/src'; +import { Telemetry } from '../../sdk/src/telemetry'; import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; import { initDiagnostic } from '../../sdk/src/diagnostic'; @@ -22,10 +30,11 @@ interface Config { export async function init(debug: boolean) { const cryptoApi = initCrypto(); const config = getConfig(debug); - const account = await initAccount(cryptoApi, config); + const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); + const account = await initAccount(cryptoApi, config, telemetry.getLogger('account')); const srp = await initSrp(cryptoApi, config); - const sdk = initSDK(cryptoApi, config, account, srp); - const photosSdk = initPhotosSDK(cryptoApi, config, account, srp); + const sdk = initSDK(cryptoApi, config, account, srp, telemetry); + const photosSdk = initPhotosSDK(cryptoApi, config, account, srp, telemetry); const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); const paths = new Paths(sdk, photosSdk, account); return { @@ -54,8 +63,8 @@ function getConfig(debug: boolean): Config { }; } -async function initAccount(cryptoApi: CryptoApi, config: Config) { - const account = new Account(cryptoApi, config); +async function initAccount(cryptoApi: CryptoApi, config: Config, logger: Logger) { + const account = new Account(cryptoApi, config, logger); await account.loadSession(); return account; } @@ -64,8 +73,8 @@ async function initSrp(cryptoApi: CryptoApi, config: Config) { return new Srp(cryptoApi, config); } -function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const { httpClient, entitiesCache, cryptoCache, telemetry, openPGPCryptoModule, latestEventIdProvider } = +function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp, telemetry: Telemetry) { + const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider } = createSDKDependencies(config, account, cryptoApi); const sdk = new ProtonDriveClient({ @@ -85,8 +94,14 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr return sdk; } -function initPhotosSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const { httpClient, entitiesCache, cryptoCache, telemetry, openPGPCryptoModule, latestEventIdProvider } = +function initPhotosSDK( + cryptoApi: CryptoApi, + config: Config, + account: Account, + srp: Srp, + telemetry: Telemetry, +) { + const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider } = createSDKDependencies(config, account, cryptoApi); return new ProtonDrivePhotosClient({ @@ -126,7 +141,6 @@ function createSDKDependencies(config: Config, account: Account, cryptoApi: Cryp }), entitiesCache: new SQLiteEntititesCache(config.cacheDir), cryptoCache: new MemoryCache(), - telemetry: initTelemetry(config.cacheDir, config.enableConsoleLog), openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi), latestEventIdProvider: new NoLatestEventIdProvider(), }; From d9b342d8fb1b4ae4dc849d7fbdcfe12dd163c787 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 17:01:14 +0100 Subject: [PATCH 312/732] 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 1f9cae817a856a09bf97029342135da5e9257aaf Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 17:30:55 +0100 Subject: [PATCH 313/732] 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 846562139c2e3734906ca6d70033dcb832f99752 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 18:17:49 +0100 Subject: [PATCH 314/732] 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 0da44cf77fedb00498d84a73e12e96304c294dcd Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 10:00:57 +0100 Subject: [PATCH 315/732] 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 3850d42e6e2373cd6a0a4a3ac948e986769fbe18 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 11:24:58 +0100 Subject: [PATCH 316/732] 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 75bba4d9cfa9e7fbbaa195422f3591a044647bd5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 12:38:19 +0000 Subject: [PATCH 317/732] 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 5da9a1086ca865f99265deb4b5591f3cf2866eca Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 14:50:10 +0100 Subject: [PATCH 318/732] 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 4244d56d1175770f0d17ad4a4c4d2bab64ab5ef0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 16 Jan 2026 15:07:17 +0100 Subject: [PATCH 319/732] 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 f1dece513f6056f531aee17f2a85051cb21ff12c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 06:03:16 +0000 Subject: [PATCH 320/732] i18n: Upgrade translations from crowdin (4244d56d). --- 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 886207583d5c4f0b0a5ea7731063cafd41d2cfa7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 07:08:26 +0100 Subject: [PATCH 321/732] 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 4e5e35ccd6a7c4be572a9cc80d52efef4e57252f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 11:08:52 +0100 Subject: [PATCH 322/732] 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 5c5a67c4ece87818beae37d53ee47820c3f2d683 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 19 Jan 2026 11:22:35 +0000 Subject: [PATCH 323/732] 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 b1168bc00e9dcde4aebd7f09f8e4f3a202f2b6b0 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 07:25:25 +0100 Subject: [PATCH 324/732] 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 bedb3c285cb1340080daf7fb7ba03d3958ecc541 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 12:43:22 +0000 Subject: [PATCH 325/732] 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 297c47a0a6f7914b8ebf1171abb377cda35ee347 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 13:26:34 +0100 Subject: [PATCH 326/732] 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 9e13b1bc4f1edae55608152e30e056a685e7f080 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 21 Jan 2026 16:09:36 +0100 Subject: [PATCH 327/732] Add function to scan for malware --- js/cli/src/cli/index.ts | 2 ++ .../src/cli/public/commandPublicScanHashes.ts | 23 ++++++++++++ .../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 +++++ 6 files changed, 86 insertions(+) create mode 100644 js/cli/src/cli/public/commandPublicScanHashes.ts create mode 100644 js/sdk/src/internal/sharingPublic/nodesSecurity.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index bf7d8787..12aa019f 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -40,6 +40,7 @@ import { CommandPublicInfo } from './public/commandPublicInfo'; import { CommandPublicList } from './public/commandPublicList'; import { CommandPublicRename } from './public/commandPublicRename'; import { CommandPublicUpload } from './public/commandPublicUpload'; +import { CommandPublicScanHashes } from './public/commandPublicScanHashes'; import { CommandRevisionDelete } from './revision/commandRevisionDelete'; import { CommandRevisionDownload } from './revision/commandRevisionDownload'; import { CommandRevisionList } from './revision/commandRevisionList'; @@ -103,6 +104,7 @@ const COMMANDS = [ new CommandPublicDownload(), new CommandPublicDownloadThumbnails(), new CommandPublicUpload(), + new CommandPublicScanHashes(), // ProtonDrivePhotosClient new CommandPhotoRoot(), new CommandPhotoTimeline(), diff --git a/js/cli/src/cli/public/commandPublicScanHashes.ts b/js/cli/src/cli/public/commandPublicScanHashes.ts new file mode 100644 index 00000000..bda578cb --- /dev/null +++ b/js/cli/src/cli/public/commandPublicScanHashes.ts @@ -0,0 +1,23 @@ +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; +import { printObject } from '../formatters'; + +export class CommandPublicScanHashes implements Command { + group = 'public'; + name = 'scan-hashes'; + isPublicAction = true; + args = ['hash']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ paths, args, options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + if (args.length === 0) { + throw new Error('At least one hash must be provided'); + } + + const client = await paths.authPublicLinkSession(url, customPassword); + const result = await client.experimental.scanHashes(args); + printObject(result, json); + } +} 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 a64f490dcf0072bffe7ffdbc000802813e204642 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 07:30:14 +0100 Subject: [PATCH 328/732] 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 53eaa571518a7c42de6afd9fd3ffbd8b6756298e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 06:28:59 +0100 Subject: [PATCH 329/732] 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 efbddd83bd017f23ae0fe69e00dd448ffe8b8be2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 16:08:12 +0100 Subject: [PATCH 330/732] 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 83823acee66cf04b927787262d2eb542709f6f58 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 12:42:23 +0100 Subject: [PATCH 331/732] 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 6da15a2f0d694e72f3b5c129ad46f143a2477109 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 21 Jan 2026 19:32:26 +0100 Subject: [PATCH 332/732] 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 0e3ce58b836dc2db203df9f209f49a8eaf9743d8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 22 Jan 2026 20:03:22 +0100 Subject: [PATCH 333/732] 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 206db2cfbf6c6b0474d32eab2be23755b1fda65e Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:01:19 +0000 Subject: [PATCH 334/732] 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 f50cde4165de04df69b50c276b9690c5e369c52f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:16:33 +0000 Subject: [PATCH 335/732] 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 c0a449ce963c6fc922c6bddfbcfe3cabf4cb81fa Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 14:24:16 +0100 Subject: [PATCH 336/732] 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 6a5e4674c899f135d56382960d99c891e7e34376 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 13:48:06 +0100 Subject: [PATCH 337/732] 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 74a0e4d4658566fa283a5e850f3197ce5d8efe8d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 16:00:40 +0000 Subject: [PATCH 338/732] 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 9b0d20b1757ea5a5561cc07b9d45374367fe4196 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 17:44:02 +0100 Subject: [PATCH 339/732] 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 4bc299d4bc4ebe858703a4a4fb0c42b5d64dd8bc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 11:34:07 +0100 Subject: [PATCH 340/732] 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 b26049b440b2332bc893b5acd05c193d65cd0c8d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 12:57:39 +0100 Subject: [PATCH 341/732] 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 e66db772f0d098b5c35b3babb96711c0478baf07 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 16:11:54 +0100 Subject: [PATCH 342/732] 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 aaf103de99c9cf10426261e6becbcfc9cb45c92d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 13:27:14 +0000 Subject: [PATCH 343/732] 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 a4a7088a9adb2fa892d39aa20d5d84d21504cd5b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 26 Jan 2026 18:50:05 +0000 Subject: [PATCH 344/732] 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 fc7fbb3b51e730c458d6a8d857bacbe2e6ab0e4a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 Jan 2026 14:51:02 +0100 Subject: [PATCH 345/732] 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 f6a068b7fbd6b4f47514be9340d20675da42f1e8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 27 Jan 2026 15:50:53 +0100 Subject: [PATCH 346/732] 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 9f4343a13e968c03fcefb9c097b6da7a96f9315a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 16:55:57 +0100 Subject: [PATCH 347/732] 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 5b8eab555627b341cbf88b4926b9191c15f37eb7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 07:27:17 +0000 Subject: [PATCH 348/732] 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 3b9b4aaef73ae7c8f8338e13b2bbe6a0a2b8b228 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 08:28:44 +0100 Subject: [PATCH 349/732] 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 4858b67722f97ed7728e7cd9e920967e1c5182ab Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 12:12:31 +0000 Subject: [PATCH 350/732] 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 54c94c0ef1529568c9ca20cdeb99696e9c740962 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 14:05:01 +0000 Subject: [PATCH 351/732] Add function to create bookmark --- .../src/cli/bookmark/commandBookmarkCreate.ts | 16 +++++ js/cli/src/cli/index.ts | 2 + 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 ++++++-- 13 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 js/cli/src/cli/bookmark/commandBookmarkCreate.ts diff --git a/js/cli/src/cli/bookmark/commandBookmarkCreate.ts b/js/cli/src/cli/bookmark/commandBookmarkCreate.ts new file mode 100644 index 00000000..fe700cfb --- /dev/null +++ b/js/cli/src/cli/bookmark/commandBookmarkCreate.ts @@ -0,0 +1,16 @@ +import { ParseArgsConfig } from 'util'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from '../public/base'; + +export class CommandBookmarkCreate implements Command { + group = 'bookmark'; + name = 'create'; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ sdk, options: { json, url, 'custom-password': customPassword } }: ActionArgs) { + await sdk.createBookmark(url, customPassword); + if (!json) { + console.log(`Created bookmark: ${url}`); + } + } +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 12aa019f..07342617 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -54,6 +54,7 @@ import { CommandInvitationAccept } from './sharing/commandInvitationAccept'; import { CommandInvitationList } from './sharing/commandInvitationList'; import { CommandInvitationReject } from './sharing/commandInvitationReject'; import { Command } from './interface'; +import { CommandBookmarkCreate } from './bookmark/commandBookmarkCreate'; const COMMANDS = [ // CLI Account @@ -90,6 +91,7 @@ const COMMANDS = [ new CommandInvitationList(), new CommandInvitationAccept(), new CommandInvitationReject(), + new CommandBookmarkCreate(), new CommandBookmarkList(), new CommandBookmarkRemove(), new CommandDeviceCreate(), 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 b1cd56cb77da3ea6c28190ea5115621b711d66c8 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 Jan 2026 08:56:18 +0100 Subject: [PATCH 352/732] 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 3d3e7dca8d8a1e0b6961f59169f12d57dc1c6495 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 29 Jan 2026 17:54:04 +0100 Subject: [PATCH 353/732] 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 1510180661d16b25473b41b13c5e6ffd1447c0c9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 30 Jan 2026 14:48:49 +0000 Subject: [PATCH 354/732] Add experimental createDocument to create Docs/Sheets --- js/cli/src/cli/index.ts | 2 + .../cli/public/commandPublicCreateDocument.ts | 35 +++++ js/sdk/src/internal/sharingPublic/index.ts | 6 +- js/sdk/src/internal/sharingPublic/nodes.ts | 120 +++++++++++++++++- js/sdk/src/protonDrivePublicLinkClient.ts | 14 ++ 5 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 js/cli/src/cli/public/commandPublicCreateDocument.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 07342617..cb7162e5 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -33,6 +33,7 @@ import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoRoot } from './photos/commandPhotoRoot'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; +import { CommandPublicCreateDocument } from './public/commandPublicCreateDocument'; import { CommandPublicDelete } from './public/commandPublicDelete'; import { CommandPublicDownload } from './public/commandPublicDownload'; import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; @@ -101,6 +102,7 @@ const COMMANDS = [ new CommandPublicList(), new CommandPublicInfo(), new CommandPublicCreateFolder(), + new CommandPublicCreateDocument(), new CommandPublicRename(), new CommandPublicDelete(), new CommandPublicDownload(), diff --git a/js/cli/src/cli/public/commandPublicCreateDocument.ts b/js/cli/src/cli/public/commandPublicCreateDocument.ts new file mode 100644 index 00000000..7aedc6be --- /dev/null +++ b/js/cli/src/cli/public/commandPublicCreateDocument.ts @@ -0,0 +1,35 @@ +import { ParseArgsConfig } from 'util'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicCreateDocument implements Command { + group = 'public'; + name = 'create-document'; + isPublicAction = true; + args = ['path', 'name']; + options: ParseArgsConfig['options'] = { + ...PUBLIC_OPTIONS, + type: { + type: 'string', + short: 't', + default: 'docs', + }, + }; + + async action({ + paths, + args: [pathString, name], + options: { json, url, 'custom-password': customPassword, type }, + }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const parent = await nodePath.getNode(); + + const documentType = type === 'docs' ? 1 : 2; + const document = await client.experimental.createDocument(parent, name, documentType); + + printObject(document, json); + } +} 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 f028681ad293a6cfb792092d867e5bd0790ed1e3 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Feb 2026 06:03:26 +0000 Subject: [PATCH 355/732] 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 321f92ca692a8186cbe24c5c2df2ad1f1d7a3f8d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 15:15:03 +0100 Subject: [PATCH 356/732] 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 a55807b4f4420551adf992b2ab18169ca08e71bc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 28 Jan 2026 11:25:39 +0100 Subject: [PATCH 357/732] 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 f2e19249891bff26a2a4a03f628238de6e73118b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Feb 2026 08:45:00 +0100 Subject: [PATCH 358/732] 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 ff6a035875510032257e61f13008a1b25a7d6d47 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Feb 2026 13:14:59 +0100 Subject: [PATCH 359/732] 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 d4bb294e8b8f620f47a1b463a5cf44e1fea0d81c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 04:45:17 +0000 Subject: [PATCH 360/732] 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 d419421c83eca5b1843a66d7719bfc69d3eb7cde Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 07:19:03 +0000 Subject: [PATCH 361/732] 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 ecc9ebf436cb88e1b4bdf0f802e19234779af3f0 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 15:50:11 +0000 Subject: [PATCH 362/732] 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 60a9b86156f96193c6fb7bfcef886aca02ce4528 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 01:54:39 +0000 Subject: [PATCH 363/732] 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 b341d6cd5eb5a1f0ddc63b903a22cbe9a7059cb2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Feb 2026 07:24:56 +0000 Subject: [PATCH 364/732] 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 46455cc803441f5f9e61d2b2a3b097e70b8966d6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 09:22:06 +0100 Subject: [PATCH 365/732] 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 552d4858745be7914fd5a6537e21bb1502eed385 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 08:53:44 +0000 Subject: [PATCH 366/732] 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 dd989719c4597bdfabe0b0003bfd9d0c723abbf1 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 14:08:18 +0100 Subject: [PATCH 367/732] [DRVWEB-5135] Add empty trash for photo volume --- .gitignore | 1 + README.md | 16 ++++++++-------- js/cli/src/cli/index.ts | 2 ++ js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts | 11 +++++++++++ js/sdk/src/protonDrivePhotosClient.ts | 8 +++----- 5 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index cb7162e5..371dccac 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -56,6 +56,7 @@ import { CommandInvitationList } from './sharing/commandInvitationList'; import { CommandInvitationReject } from './sharing/commandInvitationReject'; import { Command } from './interface'; import { CommandBookmarkCreate } from './bookmark/commandBookmarkCreate'; +import { CommandPhotoEmptyTrash } from './photos/CommandPhotoEmptyTrash'; const COMMANDS = [ // CLI Account @@ -114,6 +115,7 @@ const COMMANDS = [ new CommandPhotoTimeline(), new CommandPhotoDuplicate(), new CommandAlbumList(), + new CommandPhotoEmptyTrash(), // Diagnostic new CommandDiagnosticTree(), new CommandDiagnosticPhotosTimeline(), diff --git a/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts b/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts new file mode 100644 index 00000000..8df728bc --- /dev/null +++ b/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts @@ -0,0 +1,11 @@ +import { Command, ActionArgs } from '../interface'; + +export class CommandPhotoEmptyTrash implements Command { + group = 'photo'; + name = 'trash'; + + async action({ photosSdk }: ActionArgs) { + await photosSdk.emptyTrash(); + console.log('Photo volume trash emptied'); + } +} 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 dee945cba1e8777c7ff7f2da3ffa18df73fc6817 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 09:41:38 +0000 Subject: [PATCH 368/732] 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 88dc2ef7e6df89bd7d8392d00601935aaefaf2f7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Feb 2026 13:10:24 +0000 Subject: [PATCH 369/732] 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 3293185842ea353f937a6892f8448882f494b46f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Feb 2026 06:18:26 +0000 Subject: [PATCH 370/732] Add album management --- js/cli/src/cli/index.ts | 8 +- js/cli/src/cli/paths.ts | 25 ++ js/cli/src/cli/photos/commandAlbumCreate.ts | 13 + js/cli/src/cli/photos/commandAlbumDelete.ts | 23 ++ js/cli/src/cli/photos/commandAlbumUpdate.ts | 51 ++++ 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 ++++- 14 files changed, 929 insertions(+), 9 deletions(-) create mode 100644 js/cli/src/cli/photos/commandAlbumCreate.ts create mode 100644 js/cli/src/cli/photos/commandAlbumDelete.ts create mode 100644 js/cli/src/cli/photos/commandAlbumUpdate.ts 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 371dccac..d9d822f0 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -28,7 +28,10 @@ import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; +import { CommandAlbumCreate } from './photos/commandAlbumCreate'; +import { CommandAlbumDelete } from './photos/commandAlbumDelete'; import { CommandAlbumList } from './photos/commandAlbumList'; +import { CommandAlbumUpdate } from './photos/commandAlbumUpdate'; import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoRoot } from './photos/commandPhotoRoot'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; @@ -114,8 +117,11 @@ const COMMANDS = [ new CommandPhotoRoot(), new CommandPhotoTimeline(), new CommandPhotoDuplicate(), - new CommandAlbumList(), new CommandPhotoEmptyTrash(), + new CommandAlbumList(), + new CommandAlbumCreate(), + new CommandAlbumUpdate(), + new CommandAlbumDelete(), // Diagnostic new CommandDiagnosticTree(), new CommandDiagnosticPhotosTimeline(), diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index b4f2565c..e53db678 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -11,6 +11,7 @@ export enum PathType { SharedByMe = 'shared-by-me', SharedWithMe = 'shared-with-me', Trash = 'trash', + Albums = 'albums', Photos = 'photos', PhotosSharedByMe = 'photos-shared-by-me', PhotosSharedWithMe = 'photos-shared-with-me', @@ -38,6 +39,7 @@ export class Paths { PathType.SharedByMe, PathType.SharedWithMe, PathType.Trash, + PathType.Albums, PathType.Photos, PathType.PhotosSharedByMe, PathType.PhotosSharedWithMe, @@ -111,6 +113,9 @@ export class Path { if (this.fullPath.startsWith(`${path.sep}photos`)) { return PathType.Photos; } + if (this.fullPath.startsWith(`${path.sep}albums`)) { + return PathType.Albums; + } throw new Error(`Path "${this.fullPath}" not supported`); } @@ -124,6 +129,7 @@ export class Path { get sdk(): ProtonDriveClient | ProtonDrivePhotosClient { const photoPaths = [ + PathType.Albums, PathType.Photos, PathType.PhotosSharedByMe, PathType.PhotosSharedWithMe, @@ -158,6 +164,9 @@ export class Path { if (this.type === PathType.Photos) { return this.getPhotoNodeByPath(this.sectionPath); } + if (this.type === PathType.Albums) { + return this.getAlbumNodeByPath(this.sectionPath); + } throw new Error('Not implemented'); } @@ -259,6 +268,22 @@ export class Path { } throw new Error(`Photo not found: ${name}`); } + + private async getAlbumNodeByPath(pathString: string): Promise { + if (isNodeUid(pathString)) { + return this.photosSdk.getNode(pathString); + } + return this.getAlbumByName(pathString); + } + + private async getAlbumByName(name: string): Promise { + for await (const maybeAlbum of this.photosSdk.iterateAlbums()) { + if (getName(maybeAlbum) === name) { + return maybeAlbum; + } + } + throw new Error(`Album not found: ${name}`); + } } export class PublicLinkPath { diff --git a/js/cli/src/cli/photos/commandAlbumCreate.ts b/js/cli/src/cli/photos/commandAlbumCreate.ts new file mode 100644 index 00000000..6c4b59e9 --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumCreate.ts @@ -0,0 +1,13 @@ +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumCreate implements Command { + group = 'albums'; + name = 'create'; + args = ['name']; + + async action({ photosSdk, args: [name], options: { json } }: ActionArgs) { + const album = await photosSdk.createAlbum(name); + printObject(album, json); + } +} diff --git a/js/cli/src/cli/photos/commandAlbumDelete.ts b/js/cli/src/cli/photos/commandAlbumDelete.ts new file mode 100644 index 00000000..5c395eeb --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumDelete.ts @@ -0,0 +1,23 @@ +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumDelete implements Command { + group = 'albums'; + name = 'delete'; + args = ['pathString']; + options: ParseArgsConfig['options'] = { + force: { + type: 'boolean', + short: 'f', + default: false, + }, + }; + + async action({ paths, photosSdk, args: [pathString], options: { force } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + await photosSdk.deleteAlbum(node, { force }); + console.log(`Album deleted successfully`); + } +} diff --git a/js/cli/src/cli/photos/commandAlbumUpdate.ts b/js/cli/src/cli/photos/commandAlbumUpdate.ts new file mode 100644 index 00000000..ff46246b --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumUpdate.ts @@ -0,0 +1,51 @@ +import { ParseArgsConfig } from 'util'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumUpdate implements Command { + group = 'albums'; + name = 'update'; + args = ['pathString']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + 'cover-photo-uid': { + type: 'string', + short: 'c', + default: '', + }, + }; + + async action({ + paths, + photosSdk, + args: [pathString], + options: { json, name, 'cover-photo-uid': coverPhotoUid }, + }: ActionArgs) { + if (!name && !coverPhotoUid) { + throw new Error('At least one update option must be provided: --name, --cover-photo-uid'); + } + + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const updates: { + name?: string; + coverPhotoNodeUid?: string | null; + } = {}; + + if (name) { + updates.name = name; + } + if (coverPhotoUid) { + updates.coverPhotoNodeUid = coverPhotoUid; + } + + const album = await photosSdk.updateAlbum(node, updates); + printObject(album, json); + } +} 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 e7914f005c6ea9acd1b8857f8ac7ca0a5055b985 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:32:26 +0000 Subject: [PATCH 371/732] 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 7fbc92dccc79d5f141765ca02b203f81789f9d07 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:43:06 +0000 Subject: [PATCH 372/732] 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 77ee3628ca0e278a0d86f20503d50ee79cd07dd2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 07:58:41 +0000 Subject: [PATCH 373/732] 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 79a35ac063271137ccfe54cbbe6be7c313578f1a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 08:11:21 +0000 Subject: [PATCH 374/732] 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 af17a54708cbc495e893f334e408798060a87318 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Feb 2026 20:26:25 +0100 Subject: [PATCH 375/732] 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 0386b3c54305a84ae0bc255c02129aa39a3bdb85 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Feb 2026 16:09:30 +0100 Subject: [PATCH 376/732] 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 b3e50686a93220439c85131665d95b7352cee989 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 14:29:17 +0100 Subject: [PATCH 377/732] 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 bf457cdd3f5cbcc5ece0c6548c3ed6c45336e70a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 14:25:05 +0000 Subject: [PATCH 378/732] Add method to remove photos from an album --- js/cli/src/cli/index.ts | 2 + .../src/cli/photos/commandAlbumRemovePhoto.ts | 28 ++++++++++++ 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 +++++++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 js/cli/src/cli/photos/commandAlbumRemovePhoto.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index d9d822f0..4781d633 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -31,6 +31,7 @@ import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumCreate } from './photos/commandAlbumCreate'; import { CommandAlbumDelete } from './photos/commandAlbumDelete'; import { CommandAlbumList } from './photos/commandAlbumList'; +import { CommandAlbumRemovePhoto } from './photos/commandAlbumRemovePhoto'; import { CommandAlbumUpdate } from './photos/commandAlbumUpdate'; import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoRoot } from './photos/commandPhotoRoot'; @@ -122,6 +123,7 @@ const COMMANDS = [ new CommandAlbumCreate(), new CommandAlbumUpdate(), new CommandAlbumDelete(), + new CommandAlbumRemovePhoto(), // Diagnostic new CommandDiagnosticTree(), new CommandDiagnosticPhotosTimeline(), diff --git a/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts b/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts new file mode 100644 index 00000000..d4d4be7a --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts @@ -0,0 +1,28 @@ +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumRemovePhoto implements Command { + group = 'albums'; + name = 'remove-photo'; + args = ['albumPath', 'photoPath']; + + async action({ paths, photosSdk, args: [albumPathString, ...photoPathStrings], options: { json } }: ActionArgs) { + if (photoPathStrings.length === 0) { + throw new Error('At least one photo identifier must be provided'); + } + + const albumNodePath = paths.getPath(albumPathString); + const albumNode = await albumNodePath.getNode(); + + const photoNodes = await Promise.all( + photoPathStrings.map(async (photoPathString) => { + const photoPath = paths.getPath(photoPathString); + return photoPath.getNode(); + }), + ); + + await printIterable(photosSdk.removePhotosFromAlbum(albumNode, photoNodes), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); + } +} 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 b4c6da16e4abebdfd252bd1f5959ce775d3f5eba Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 09:12:39 +0000 Subject: [PATCH 379/732] 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 901cffa19e0262d84dc40ca9b7c6d9d519a185cd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Feb 2026 07:54:38 +0100 Subject: [PATCH 380/732] Add iterator of album photos --- js/cli/src/cli/index.ts | 2 ++ js/cli/src/cli/photos/commandAlbumPhotos.ts | 39 +++++++++++++++++++++ js/cli/src/cli/photos/commandAlbumUpdate.ts | 2 +- 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 ++++++++++----- 9 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 js/cli/src/cli/photos/commandAlbumPhotos.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 4781d633..11603a2f 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -31,6 +31,7 @@ import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandAlbumCreate } from './photos/commandAlbumCreate'; import { CommandAlbumDelete } from './photos/commandAlbumDelete'; import { CommandAlbumList } from './photos/commandAlbumList'; +import { CommandAlbumPhotos } from './photos/commandAlbumPhotos'; import { CommandAlbumRemovePhoto } from './photos/commandAlbumRemovePhoto'; import { CommandAlbumUpdate } from './photos/commandAlbumUpdate'; import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; @@ -120,6 +121,7 @@ const COMMANDS = [ new CommandPhotoDuplicate(), new CommandPhotoEmptyTrash(), new CommandAlbumList(), + new CommandAlbumPhotos(), new CommandAlbumCreate(), new CommandAlbumUpdate(), new CommandAlbumDelete(), diff --git a/js/cli/src/cli/photos/commandAlbumPhotos.ts b/js/cli/src/cli/photos/commandAlbumPhotos.ts new file mode 100644 index 00000000..e6e4b524 --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumPhotos.ts @@ -0,0 +1,39 @@ +import { ParseArgsConfig } from 'util'; + +import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumPhotos implements Command { + group = 'albums'; + name = 'photos'; + args = ['path']; + options: ParseArgsConfig['options'] = { + 'load-details': { + type: 'boolean', + short: 'd', + default: false, + }, + }; + + async action({ paths, photosSdk, args: [pathString], options: { json, 'load-details': loadDetails } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + const albumUid = node.ok ? node.value.uid : node.error.uid; + + if (loadDetails) { + await this.listWithDetails(photosSdk, albumUid, json); + } else { + await this.list(photosSdk, albumUid, json); + } + } + + async list(photosSdk: ProtonDrivePhotosClient, albumNodeUid: string, json: boolean) { + await printIterable(photosSdk.iterateAlbum(albumNodeUid), json); + } + + async listWithDetails(photosSdk: ProtonDrivePhotosClient, albumNodeUid: string, json: boolean) { + const nodeUids = await Array.fromAsync(photosSdk.iterateAlbum(albumNodeUid), (photo) => photo.nodeUid); + await printIterable(photosSdk.iterateNodes(nodeUids), json); + } +} diff --git a/js/cli/src/cli/photos/commandAlbumUpdate.ts b/js/cli/src/cli/photos/commandAlbumUpdate.ts index ff46246b..94ecc97d 100644 --- a/js/cli/src/cli/photos/commandAlbumUpdate.ts +++ b/js/cli/src/cli/photos/commandAlbumUpdate.ts @@ -35,7 +35,7 @@ export class CommandAlbumUpdate implements Command { const updates: { name?: string; - coverPhotoNodeUid?: string | null; + coverPhotoNodeUid?: string; } = {}; if (name) { 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 9d037c778d26ab8478a593272d1f7216c602099e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 15:49:23 +0000 Subject: [PATCH 381/732] 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 86768127cca581c4faa2e523c7dfbad0e13bb48b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Feb 2026 16:44:47 +0000 Subject: [PATCH 382/732] 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 7433049d5be4d011bfc1651e591aba2f6d438814 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Feb 2026 11:15:44 +0000 Subject: [PATCH 383/732] 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 cd931687ef882779956456e50dc38f313d3af7f9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Feb 2026 12:04:11 +0000 Subject: [PATCH 384/732] 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 66faca15337786404ad2814c20e89222c936c449 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 06:03:14 +0000 Subject: [PATCH 385/732] i18n(weekly-mr): Upgrade translations from crowdin (cd931687). --- 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 8a4ac626826fe259b90e3f45cf2cd36ebb7b3978 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 16:33:10 +0100 Subject: [PATCH 386/732] Patch pmcrypto to point to non-browser-specific openpgp dist Since the CLI runs in Node. TS module resolution was also not working properly, causing unexpected type issues. --- js/cli/bun.lock | 4 +++ js/cli/jest.config.js | 1 - js/cli/package.json | 4 ++- .../@protontech%2Fpmcrypto@8.8.1.patch | 35 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 7db7c3f8..ccc86649 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "proton-drive-cli", @@ -32,6 +33,9 @@ }, }, }, + "patchedDependencies": { + "@protontech/pmcrypto@8.8.1": "patches/@protontech%2Fpmcrypto@8.8.1.patch", + }, "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js index 9742f719..cebee46b 100644 --- a/js/cli/jest.config.js +++ b/js/cli/jest.config.js @@ -12,7 +12,6 @@ module.exports = { moduleNameMapper: { '^@openpgp/noble-hashes/esm/(.*)$': '/node_modules/@openpgp/noble-hashes/esm/$1.js', '^@openpgp/web-stream-tools$': '/node_modules/@openpgp/web-stream-tools/lib/index.js', - '^openpgp/lightweight$': '/node_modules/openpgp/dist/lightweight/openpgp.min.mjs', }, reporters: ['default'], testEnvironment: 'node', diff --git a/js/cli/package.json b/js/cli/package.json index 627281a3..02745456 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -39,5 +39,7 @@ "ky": "^1.7.2", "ttag": "^1.8.7" }, - "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" + "patchedDependencies": { + "@protontech/pmcrypto@8.8.1": "patches/@protontech%2Fpmcrypto@8.8.1.patch" + } } diff --git a/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch b/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch new file mode 100644 index 00000000..be491afd --- /dev/null +++ b/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/@protontech/pmcrypto/.bun-tag-510b298809627b34 b/.bun-tag-510b298809627b34 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@protontech/pmcrypto/.bun-tag-bbd8917749136bea b/.bun-tag-bbd8917749136bea +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/lib/openpgp.ts b/lib/openpgp.ts +index 134ae5bae14dc0c9241f7122f6f5d033a9e9ca60..3b2b952b0da0168112b4cc16fad9dacd72544e16 100644 +--- a/lib/openpgp.ts ++++ b/lib/openpgp.ts +@@ -1,4 +1,4 @@ +-import { config } from 'openpgp/lightweight'; ++import { config } from 'openpgp'; + + export const setConfig = () => { + config.s2kIterationCountByte = 255; +@@ -31,4 +31,4 @@ export const setConfig = () => { + config.enableParsingV5Entities = true; + }; + +-export * from 'openpgp/lightweight'; ++export * from 'openpgp'; +diff --git a/lib/pmcrypto.d.ts b/lib/pmcrypto.d.ts +index 49868bbe9f0b8e4e254c18bfb0992bab47deb54c..dd8e98695375e4d9b90e98a0bc0519e28c456089 100644 +--- a/lib/pmcrypto.d.ts ++++ b/lib/pmcrypto.d.ts +@@ -19,7 +19,7 @@ import { + readKey, readKeys, readPrivateKey, readPrivateKeys, + type PartialConfig, + type AlgorithmInfo +-} from 'openpgp/lightweight'; ++} from 'openpgp'; + + import { VERIFICATION_STATUS, SIGNATURE_TYPES } from './constants'; + import type { ContextSigningOptions, ContextVerificationOptions } from './message/context'; From 836c97ef3953e020881c79765254f7e9b72ae1a4 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 16:50:30 +0100 Subject: [PATCH 387/732] Cleanup crypto utils and fix type errors Update helpers based on monorepo changes. And use native Uint8Array hex and base64 helpers in cli module --- .../src/cli/photos/commandPhotoDuplicate.ts | 3 +- js/sdk/src/crypto/interface.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/js/cli/src/cli/photos/commandPhotoDuplicate.ts b/js/cli/src/cli/photos/commandPhotoDuplicate.ts index 579f7363..ba80e0f7 100644 --- a/js/cli/src/cli/photos/commandPhotoDuplicate.ts +++ b/js/cli/src/cli/photos/commandPhotoDuplicate.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { sha1 } from '@noble/hashes/sha1'; -import { arrayToHexString } from '../../crypto/cryptoUtils'; import { printObject } from '../formatters'; import { Command, ActionArgs } from '../interface'; @@ -23,7 +22,7 @@ export class CommandPhotoDuplicate implements Command { sha1Instance.update(fileBytes); const sha1Hash = sha1Instance.digest() as Uint8Array; - return arrayToHexString(sha1Hash); + return sha1Hash.toHex(); } const duplicates = await photosSdk.findPhotoDuplicates(name, getSha1) 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 c76dfca5850a4ad1ae7ed212e36b1cbff822c164 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 15 Jan 2026 16:51:58 +0100 Subject: [PATCH 388/732] Drop unused `comlink` and `@openpgp/noble-hashes` deps --- js/cli/bun.lock | 10 ---------- js/cli/jest.config.js | 1 - js/cli/package.json | 2 -- 3 files changed, 13 deletions(-) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index ccc86649..98f76a41 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -6,10 +6,8 @@ "name": "proton-drive-cli", "dependencies": { "@noble/hashes": "^1.8.0", - "@openpgp/noble-hashes": "^1.3.3", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", - "comlink": "^4.4.2", "ky": "^1.7.2", "ttag": "^1.8.7", }, @@ -227,8 +225,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@openpgp/noble-hashes": ["@openpgp/noble-hashes@1.3.3", "", { "dependencies": { "@types/bn.js": "^4.11.6", "bn.js": "^4.11.8" } }, "sha512-CPDNePrQTQrL5Fxd/K3B53Gty4xbNVxnMSUdDmYjW5tTpRqOlme0INYTnnQqoE/XUdIlq5fByOMDuDWR5JpXZw=="], - "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.1.3", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ=="], "@protontech/pmcrypto": ["@protontech/pmcrypto@8.8.1", "", { "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.2.2" } }, "sha512-iYrCgHDL3m+xt9oZD1oSFG73mrQFIfqL3xfRA++O1Zv2PEfsQ1d7SXefHCzG5QZjWKVlHXL8uI7ucl2+yesTpg=="], @@ -333,8 +329,6 @@ "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], - "@types/bn.js": ["@types/bn.js@4.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg=="], - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], @@ -441,8 +435,6 @@ "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], - "bn.js": ["bn.js@4.12.1", "", {}, "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="], - "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -483,8 +475,6 @@ "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], - "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js index cebee46b..c99a8cbc 100644 --- a/js/cli/jest.config.js +++ b/js/cli/jest.config.js @@ -10,7 +10,6 @@ module.exports = { '^.+\\.mjs$': '@swc/jest', }, moduleNameMapper: { - '^@openpgp/noble-hashes/esm/(.*)$': '/node_modules/@openpgp/noble-hashes/esm/$1.js', '^@openpgp/web-stream-tools$': '/node_modules/@openpgp/web-stream-tools/lib/index.js', }, reporters: ['default'], diff --git a/js/cli/package.json b/js/cli/package.json index 02745456..b1f604ae 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -32,10 +32,8 @@ }, "dependencies": { "@noble/hashes": "^1.8.0", - "@openpgp/noble-hashes": "^1.3.3", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", - "comlink": "^4.4.2", "ky": "^1.7.2", "ttag": "^1.8.7" }, From 3d2d5c04cc30af4252ea3388541098891067c764 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 20 Jan 2026 13:21:55 +0100 Subject: [PATCH 389/732] 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/cli/.eslintrc.js | 8 +- js/cli/bun.lock | 6 +- js/cli/package.json | 2 +- js/cli/src/init.ts | 3 +- 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 +- 29 files changed, 407 insertions(+), 870 deletions(-) diff --git a/js/cli/.eslintrc.js b/js/cli/.eslintrc.js index 828c2128..ba7df1c7 100644 --- a/js/cli/.eslintrc.js +++ b/js/cli/.eslintrc.js @@ -1,8 +1,8 @@ module.exports = { extends: [ - 'plugin:@typescript-eslint/recommended' + "plugin:@typescript-eslint/recommended" ], - parser: '@typescript-eslint/parser', + parser: "@typescript-eslint/parser", parserOptions: { tsconfigRootDir: __dirname, project: "./tsconfig.json", @@ -32,12 +32,12 @@ module.exports = { "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-wrapper-object-types": "off", "prefer-spread": "off", - "tsdoc/syntax": "off", + "tsdoc/syntax": "off" }, }, ], plugins: [ "@typescript-eslint/eslint-plugin", - "eslint-plugin-tsdoc" + "eslint-plugin-tsdoc", ] }; diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 98f76a41..80df089a 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -25,7 +25,7 @@ "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", - "typescript": "^5.5.3", + "typescript": "^5.9.3", "vite": "^5.4.8", "vite-plugin-dts": "^4.2.4", }, @@ -933,7 +933,7 @@ "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="], @@ -1011,6 +1011,8 @@ "@microsoft/api-extractor/minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="], + "@microsoft/api-extractor/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], "@microsoft/api-extractor-model/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], diff --git a/js/cli/package.json b/js/cli/package.json index b1f604ae..909c8992 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -26,7 +26,7 @@ "jest": "^29.7.0", "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", - "typescript": "^5.5.3", + "typescript": "^5.9.3", "vite": "^5.4.8", "vite-plugin-dts": "^4.2.4" }, diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index ece3ab7b..d21aa072 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -11,6 +11,7 @@ import { MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy, + OpenPGPCryptoProxy, MetricEvent, Logger, } from '../../sdk/src'; @@ -141,7 +142,7 @@ function createSDKDependencies(config: Config, account: Account, cryptoApi: Cryp }), entitiesCache: new SQLiteEntititesCache(config.cacheDir), cryptoCache: new MemoryCache(), - openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi), + openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi as OpenPGPCryptoProxy), latestEventIdProvider: new NoLatestEventIdProvider(), }; } 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 8a7d5ebbebb12a2167c40c32a709cd34b370082d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 08:25:18 +0100 Subject: [PATCH 390/732] Do not build for browser --- js/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/package.json b/js/cli/package.json index 909c8992..cdfb5923 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers --conditions=browser", + "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", From 5ee6c34667833fa6c4624dc808dafbf49955970f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 23 Jan 2026 08:35:33 +0100 Subject: [PATCH 391/732] Patch pmcrypto for npm usage for integration tests --- js/cli/bun.lock | 102 ++++++++++++++++-- js/cli/package.json | 2 + .../patches/@protontech+pmcrypto+8.8.1.patch | 29 +++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 js/cli/patches/@protontech+pmcrypto+8.8.1.patch diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 80df089a..88995eb0 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "proton-drive-cli", @@ -24,6 +23,7 @@ "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", + "patch-package": "^8.0.0", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^5.4.8", @@ -395,6 +395,8 @@ "@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="], + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -447,6 +449,12 @@ "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -499,12 +507,16 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.179", "", {}, "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ=="], "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], @@ -515,6 +527,12 @@ "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -567,11 +585,13 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], @@ -583,8 +603,12 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -593,12 +617,18 @@ "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], @@ -629,6 +659,8 @@ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -643,6 +675,10 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -725,6 +761,8 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -733,8 +771,12 @@ "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], @@ -763,6 +805,8 @@ "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -773,6 +817,8 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -791,10 +837,14 @@ "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.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" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], "openpgp": ["@protontech/openpgp@6.2.2", "", {}, "sha512-gfpd+TF+Qdeu9A1P55Tl50dvZlrhQrYDgsLJ1E8mokszkMFks6I20nn7jWu3tmD5BqAtJ2yynA3077tNRmcnYg=="], @@ -811,6 +861,8 @@ "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], + "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -875,7 +927,9 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -885,7 +939,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -919,6 +973,8 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -971,6 +1027,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], "yargs": ["yargs@17.7.2", "", { "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" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1005,12 +1063,24 @@ "@istanbuljs/load-nyc-config/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "@jest/console/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "@jest/core/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "@jest/reporters/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "@jest/test-sequencer/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@microsoft/api-extractor/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], "@microsoft/api-extractor/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], "@microsoft/api-extractor/minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="], + "@microsoft/api-extractor/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@microsoft/api-extractor/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], "@microsoft/api-extractor-model/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], @@ -1025,6 +1095,10 @@ "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], + "@rushstack/node-core-library/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], + + "@rushstack/node-core-library/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -1033,8 +1107,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1049,6 +1121,8 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "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" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1059,14 +1133,30 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "istanbul-lib-instrument/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-circus/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-config/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "jest-config/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "jest-resolve/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "jest-runtime/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "jest-snapshot/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "make-dir/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "parse-json/type-fest": ["type-fest@4.35.0", "", {}, "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A=="], diff --git a/js/cli/package.json b/js/cli/package.json index cdfb5923..f9773669 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -9,6 +9,7 @@ "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", + "postinstall": "patch-package", "test": "jest", "test:watch": "jest --watch --coverage=false" }, @@ -25,6 +26,7 @@ "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", + "patch-package": "^8.0.0", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^5.4.8", diff --git a/js/cli/patches/@protontech+pmcrypto+8.8.1.patch b/js/cli/patches/@protontech+pmcrypto+8.8.1.patch new file mode 100644 index 00000000..91c902da --- /dev/null +++ b/js/cli/patches/@protontech+pmcrypto+8.8.1.patch @@ -0,0 +1,29 @@ +diff --git a/node_modules/@protontech/pmcrypto/lib/openpgp.ts b/node_modules/@protontech/pmcrypto/lib/openpgp.ts +index 134ae5b..3b2b952 100644 +--- a/node_modules/@protontech/pmcrypto/lib/openpgp.ts ++++ b/node_modules/@protontech/pmcrypto/lib/openpgp.ts +@@ -1,4 +1,4 @@ +-import { config } from 'openpgp/lightweight'; ++import { config } from 'openpgp'; + + export const setConfig = () => { + config.s2kIterationCountByte = 255; +@@ -31,4 +31,4 @@ export const setConfig = () => { + config.enableParsingV5Entities = true; + }; + +-export * from 'openpgp/lightweight'; ++export * from 'openpgp'; +diff --git a/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts b/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts +index 49868bb..dd8e986 100644 +--- a/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts ++++ b/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts +@@ -19,7 +19,7 @@ import { + readKey, readKeys, readPrivateKey, readPrivateKeys, + type PartialConfig, + type AlgorithmInfo +-} from 'openpgp/lightweight'; ++} from 'openpgp'; + + import { VERIFICATION_STATUS, SIGNATURE_TYPES } from './constants'; + import type { ContextSigningOptions, ContextVerificationOptions } from './message/context'; From 619b6d4cfb02a31fa07948cdea531bd58bfa41a3 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:26:53 +0100 Subject: [PATCH 392/732] Build CLI as production --- js/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/package.json b/js/cli/package.json index f9773669..56d1cb0f 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "bun build --compile ./src/proton-drive.ts --minify-identifiers", + "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --minify-identifiers", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", From d62daca0b57711261f39616434da5da072effe7a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:33:26 +0100 Subject: [PATCH 393/732] 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 5ad2cb987e99aab1d6b0773cdad2518fe7bb515f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:47:55 +0100 Subject: [PATCH 394/732] Upgrade eslint-plugin --- js/cli/bun.lock | 74 ++++++++++++++++++++++++++++++++++++++++----- js/cli/package.json | 2 +- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 88995eb0..6aafa494 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -17,7 +17,7 @@ "@types/bun": "^1.2.18", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", - "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/eslint-plugin": "^8.55.0", "bun-types": "^1.1.31", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", @@ -155,7 +155,7 @@ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "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" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], @@ -357,25 +357,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.35.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/type-utils": "8.35.1", "@typescript-eslint/utils": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.35.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0" } }, "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q=="], "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.35.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/utils": "8.35.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.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.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.55.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -579,6 +579,8 @@ "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -973,13 +975,15 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], @@ -1105,8 +1109,32 @@ "@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], + + "@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + + "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + + "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], + + "@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], + + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1129,6 +1157,8 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1167,6 +1197,8 @@ "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], @@ -1191,8 +1223,30 @@ "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], @@ -1211,6 +1265,10 @@ "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/js/cli/package.json b/js/cli/package.json index 56d1cb0f..c6bcdf83 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -20,7 +20,7 @@ "@types/bun": "^1.2.18", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", - "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/eslint-plugin": "^8.55.0", "bun-types": "^1.1.31", "eslint": "^8.57.1", "eslint-plugin-tsdoc": "^0.3.0", From 6e94a821b19a3a7ef3b193d17230a3a0f64af8ec Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 13:33:50 +0100 Subject: [PATCH 395/732] 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 c0a83ebd817105bcb58215db08aa246707605c14 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 15:24:09 +0100 Subject: [PATCH 396/732] 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 36651e79d0de79075ddeef249c70a184211d838a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 13:39:23 +0100 Subject: [PATCH 397/732] 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 a48ce224ac81e0dc4a1ef5a6bad8e329fabab76b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 18:28:17 +0100 Subject: [PATCH 398/732] 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 617671a6bb2db34fb750c0c9c45884001e1877c8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 07:43:21 +0000 Subject: [PATCH 399/732] Add capability to add photos to albums --- js/cli/src/cli/index.ts | 4 +- js/cli/src/cli/photos/commandAlbumAddPhoto.ts | 28 + 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 +- 18 files changed, 1494 insertions(+), 74 deletions(-) create mode 100644 js/cli/src/cli/photos/commandAlbumAddPhoto.ts 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 11603a2f..3bd46284 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -28,6 +28,7 @@ import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; +import { CommandAlbumAddPhoto } from './photos/commandAlbumAddPhoto'; import { CommandAlbumCreate } from './photos/commandAlbumCreate'; import { CommandAlbumDelete } from './photos/commandAlbumDelete'; import { CommandAlbumList } from './photos/commandAlbumList'; @@ -121,10 +122,11 @@ const COMMANDS = [ new CommandPhotoDuplicate(), new CommandPhotoEmptyTrash(), new CommandAlbumList(), - new CommandAlbumPhotos(), new CommandAlbumCreate(), new CommandAlbumUpdate(), new CommandAlbumDelete(), + new CommandAlbumPhotos(), + new CommandAlbumAddPhoto(), new CommandAlbumRemovePhoto(), // Diagnostic new CommandDiagnosticTree(), diff --git a/js/cli/src/cli/photos/commandAlbumAddPhoto.ts b/js/cli/src/cli/photos/commandAlbumAddPhoto.ts new file mode 100644 index 00000000..2b33dc1a --- /dev/null +++ b/js/cli/src/cli/photos/commandAlbumAddPhoto.ts @@ -0,0 +1,28 @@ +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +export class CommandAlbumAddPhoto implements Command { + group = 'albums'; + name = 'add-photo'; + args = ['albumPath', 'photoPath']; + + async action({ paths, photosSdk, args: [albumPathString, ...photoPathStrings], options: { json } }: ActionArgs) { + if (photoPathStrings.length === 0) { + throw new Error('At least one photo identifier must be provided'); + } + + const albumNodePath = paths.getPath(albumPathString); + const albumNode = await albumNodePath.getNode(); + + const photoNodes = await Promise.all( + photoPathStrings.map(async (photoPathString) => { + const photoPath = paths.getPath(photoPathString); + return photoPath.getNode(); + }), + ); + + await printIterable(photosSdk.addPhotosToAlbum(albumNode, photoNodes), json, (result) => + console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); + } +} 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 9706b2b53335153652f091c3a2b2381c72a28e7a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Feb 2026 09:22:14 +0100 Subject: [PATCH 400/732] 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 aaf4dd243c606a2dcc79e4b8c06ab8476dead30d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 07:38:48 +0000 Subject: [PATCH 401/732] 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 6c1232351451ff2e78c1f092e1ba209c5d23ff5c Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 11:00:00 +0100 Subject: [PATCH 402/732] 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 cd59e56e5bf86e6136b9128c5ea1fc88315a1155 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 07:24:51 +0100 Subject: [PATCH 403/732] 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 a89eb9510f7568af75ee7a2d0c58b7b3662af4c9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:26:52 +0000 Subject: [PATCH 404/732] 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 c2f418d4afe5bc930bf039b116f30ede5db0ba7d Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:37:42 +0000 Subject: [PATCH 405/732] 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 5096b96ff4718283db6bf3f8958073210528bc78 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 15:20:37 +0100 Subject: [PATCH 406/732] 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 24eafea43e2d60ac9e707cb11865fb1a9c4aa509 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 12:10:20 +0100 Subject: [PATCH 407/732] 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 5407055bfc0ba6e26ef73f9ec7c750a390877dbd Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 12:21:42 +0000 Subject: [PATCH 408/732] 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 876c9718152d94120f3af15843ef1c41cf588f8b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 10:35:30 +0100 Subject: [PATCH 409/732] 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 f4569840a6a51751c18757ee1b4c2da35f6a1f36 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 13:38:00 +0100 Subject: [PATCH 410/732] 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 bf1ee4294cef487e2a6be25cfb09a8f074f32d87 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 17 Feb 2026 09:12:18 +0100 Subject: [PATCH 411/732] 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 412/732] 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 4cd631be32f31c19cb773a4a329303b082d10794 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 09:54:03 +0100 Subject: [PATCH 413/732] 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 e02d400464740e65b45bafee0f45e1fba8cbbeae Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 15:08:04 +0100 Subject: [PATCH 414/732] 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 e035d61bf69bf9c5fd1b2c74fc871bc259461056 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Feb 2026 15:39:47 +0100 Subject: [PATCH 415/732] Add doc and CLI command for public version of getting available name --- .../commandFileSystemGetAvailableName.ts | 11 ++++--- js/cli/src/cli/index.ts | 2 ++ .../public/commandPublicGetAvailableName.ts | 31 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 js/cli/src/cli/public/commandPublicGetAvailableName.ts diff --git a/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts b/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts index 476950cd..022e4ec7 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts @@ -3,15 +3,18 @@ import { Command, ActionArgs } from '../interface'; export class CommandFileSystemGetAvailableName implements Command { group = 'filesystem'; - name = 'getAvailableName'; + name = 'get-available-name'; args = ['path', 'name']; async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { const path = paths.getPath(pathString); const node = await path.getNode(); const availableName = await sdk.getAvailableName(node, name); - printObject({ - availableName, - }, json); + printObject( + { + availableName, + }, + json, + ); } } diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 3bd46284..6edd1205 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -39,6 +39,7 @@ import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoRoot } from './photos/commandPhotoRoot'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; +import { CommandPublicGetAvailableName } from './public/commandPublicGetAvailableName'; import { CommandPublicCreateDocument } from './public/commandPublicCreateDocument'; import { CommandPublicDelete } from './public/commandPublicDelete'; import { CommandPublicDownload } from './public/commandPublicDownload'; @@ -110,6 +111,7 @@ const COMMANDS = [ new CommandPublicInfo(), new CommandPublicCreateFolder(), new CommandPublicCreateDocument(), + new CommandPublicGetAvailableName(), new CommandPublicRename(), new CommandPublicDelete(), new CommandPublicDownload(), diff --git a/js/cli/src/cli/public/commandPublicGetAvailableName.ts b/js/cli/src/cli/public/commandPublicGetAvailableName.ts new file mode 100644 index 00000000..0fdc3016 --- /dev/null +++ b/js/cli/src/cli/public/commandPublicGetAvailableName.ts @@ -0,0 +1,31 @@ +import { ParseArgsConfig } from 'util'; + +import { printObject } from '../formatters'; +import { Command, ActionArgs } from '../interface'; +import { PUBLIC_OPTIONS } from './base'; + +export class CommandPublicGetAvailableName implements Command { + group = 'public'; + name = 'get-available-name'; + isPublicAction = true; + args = ['path', 'name']; + options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; + + async action({ + paths, + args: [pathString, name], + options: { json, url, 'custom-password': customPassword }, + }: ActionArgs) { + const client = await paths.authPublicLinkSession(url, customPassword); + const nodePath = paths.getPublicLinkPath(pathString); + const parent = await nodePath.getNode(); + const availableName = await client.getAvailableName(parent, name); + + printObject( + { + availableName, + }, + json, + ); + } +} From 74df754fff019fd8948df6fda5b2e1c0819fd68e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 14:11:29 +0000 Subject: [PATCH 416/732] 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 96ea3addb29dd5cfdf59a8973001cb2b2e24e9ca Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 06:03:13 +0000 Subject: [PATCH 417/732] i18n(weekly-mr): Upgrade translations from crowdin (74df754f). --- 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 742211eac1f30eff68c82f6e1aab3dc903c10680 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Feb 2026 14:18:28 +0100 Subject: [PATCH 418/732] 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 4c5494d513bd12198a5e0ad94ddfc5a39847cf83 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Feb 2026 15:34:28 +0100 Subject: [PATCH 419/732] 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 ce720e58a01fd65e28eea5a041caa35fa2566a29 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 16:11:37 +0100 Subject: [PATCH 420/732] 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 54cda0ab3824c0ea02d3529410d8207933a434aa Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 16:59:11 +0100 Subject: [PATCH 421/732] 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 2f9a59d5ce86a16483c3ade9acb2ccf02e62bf32 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Feb 2026 07:27:26 +0100 Subject: [PATCH 422/732] 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 423/732] 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 268159efb5743ffdb05964f2d36c67d537b6d014 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Feb 2026 11:13:28 +0000 Subject: [PATCH 424/732] 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 e4ee641335003bb96c21265534fe086936be48e2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Feb 2026 13:08:38 +0000 Subject: [PATCH 425/732] 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 3ac89092eb76ffa659a4200fb43e30370a5e87bc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 07:22:32 +0000 Subject: [PATCH 426/732] 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 35a6dcae48bb5a389ef7730f0e63b64919b42f42 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 10:30:48 +0100 Subject: [PATCH 427/732] 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 8dc9afccb1da2779455d1c635c6bad9ba7999de3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 10:33:11 +0100 Subject: [PATCH 428/732] 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 d205ae6864aa4857a727ed8531328e306e8ab098 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 13:45:22 +0100 Subject: [PATCH 429/732] Use brwoser target for bun --- js/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/package.json b/js/cli/package.json index c6bcdf83..69a3ca72 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --minify-identifiers", + "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --minify-identifiers --conditions=browser", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", From 3e45d7423cc5c45fdb08e6949379f9de601a30f9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 09:57:42 +0100 Subject: [PATCH 430/732] 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 899244e49009fad7c6e6def37f8d9e752171b519 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:39:19 +0000 Subject: [PATCH 431/732] 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 6ea2e9ac474206df1b711a074d6daaafeaf8a141 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:43:22 +0000 Subject: [PATCH 432/732] 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 3146cf5d8a510e072be6224757ba047a91d0a55a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Feb 2026 16:58:07 +0100 Subject: [PATCH 433/732] 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 a1477d7bfb650b8712782c2401dd62573f2ddfea Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 11:03:21 +0000 Subject: [PATCH 434/732] 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 011dd835936dbc3422ff3c45476a844a4317ecb7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 12:55:44 +0000 Subject: [PATCH 435/732] Add method to update photo tags --- js/cli/src/cli/index.ts | 2 + js/cli/src/cli/photos/commandPhotoUpdate.ts | 93 +++++ 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 +- 17 files changed, 1259 insertions(+), 193 deletions(-) create mode 100644 js/cli/src/cli/photos/commandPhotoUpdate.ts 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 6edd1205..7331b7c5 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -38,6 +38,7 @@ import { CommandAlbumUpdate } from './photos/commandAlbumUpdate'; import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; import { CommandPhotoRoot } from './photos/commandPhotoRoot'; import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; +import { CommandPhotoUpdate } from './photos/commandPhotoUpdate'; import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; import { CommandPublicGetAvailableName } from './public/commandPublicGetAvailableName'; import { CommandPublicCreateDocument } from './public/commandPublicCreateDocument'; @@ -122,6 +123,7 @@ const COMMANDS = [ new CommandPhotoRoot(), new CommandPhotoTimeline(), new CommandPhotoDuplicate(), + new CommandPhotoUpdate(), new CommandPhotoEmptyTrash(), new CommandAlbumList(), new CommandAlbumCreate(), diff --git a/js/cli/src/cli/photos/commandPhotoUpdate.ts b/js/cli/src/cli/photos/commandPhotoUpdate.ts new file mode 100644 index 00000000..d44e725f --- /dev/null +++ b/js/cli/src/cli/photos/commandPhotoUpdate.ts @@ -0,0 +1,93 @@ +import { ParseArgsConfig } from 'util'; + +import { PhotoTag } from '../../../../sdk/src'; +import { printIterable } from '../formatters'; +import { Command, ActionArgs } from '../interface'; + +const TAG_NAME_TO_ENUM: Record = { + favorites: PhotoTag.Favorites, + screenshots: PhotoTag.Screenshots, + videos: PhotoTag.Videos, + 'live-photos': PhotoTag.LivePhotos, + 'motion-photos': PhotoTag.MotionPhotos, + selfies: PhotoTag.Selfies, + portraits: PhotoTag.Portraits, + bursts: PhotoTag.Bursts, + panoramas: PhotoTag.Panoramas, + raw: PhotoTag.Raw, +}; + +export class CommandPhotoUpdate implements Command { + group = 'photo'; + name = 'update'; + args = ['path'] + options: ParseArgsConfig['options'] = { + 'add-tags': { + type: 'string', + short: 'a', + default: '', + }, + 'remove-tags': { + type: 'string', + short: 'r', + default: '', + }, + }; + + async action({ + paths, + photosSdk, + args: pathStrings, + options: { json, 'add-tags': addTagsOpt, 'remove-tags': removeTagsOpt }, + }: ActionArgs) { + if (pathStrings.length === 0) { + throw new Error('At least one photo path must be provided'); + } + + const addTags = parseTagOption(addTagsOpt || ''); + const removeTags = parseTagOption(removeTagsOpt || ''); + + if (addTags.length === 0 && removeTags.length === 0) { + throw new Error('At least one of --add-tags or --remove-tags must be provided'); + } + + const photoNodes = await Promise.all( + pathStrings.map(async (pathString) => { + const photoPath = paths.getPath(pathString); + return photoPath.getNode(); + }), + ); + + await printIterable( + photosSdk.updatePhotos( + photoNodes.map((node) => ({ nodeUid: node, tagsToAdd: addTags, tagsToRemove: removeTags })), + ), + json, + (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), + ); + } +} + +function parseTagOption(value: string): PhotoTag[] { + if (!value.trim()) { + return []; + } + const tags: PhotoTag[] = []; + const parts = value.split(',').map((s) => s.trim().toLowerCase()); + for (const part of parts) { + const asEnum = TAG_NAME_TO_ENUM[part]; + if (asEnum !== undefined) { + tags.push(asEnum); + } else { + const asNum = parseInt(part, 10); + if (Number.isInteger(asNum) && asNum >= 0 && asNum <= 9) { + tags.push(asNum as PhotoTag); + } else { + throw new Error( + `Invalid tag: "${part}". Use comma-separated names (e.g. favorites,screenshots) or numbers 0-9.`, + ); + } + } + } + return tags; +} 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 c60daed49fde38806e2dfe8276b210e9f7b8797c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Feb 2026 12:58:21 +0000 Subject: [PATCH 436/732] 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 e4efe623bdc3b1f9b4332184882e486f3033d26c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 06:55:29 +0000 Subject: [PATCH 437/732] 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 296949223fb0e4a0d06531fe57924796e69891e8 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 10:02:05 +0000 Subject: [PATCH 438/732] Support AEAD block encryption --- .gitignore | 8 +-- js/cli/src/cli/index.ts | 2 + js/cli/src/init.ts | 55 ++++++++++++---- js/cli/src/proton-drive.ts | 4 +- 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 +++ 17 files changed, 244 insertions(+), 53 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/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 7331b7c5..b85846c1 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -66,6 +66,8 @@ import { Command } from './interface'; import { CommandBookmarkCreate } from './bookmark/commandBookmarkCreate'; import { CommandPhotoEmptyTrash } from './photos/CommandPhotoEmptyTrash'; +export { formatReadableJson } from './formatters'; + const COMMANDS = [ // CLI Account new CommandAuthLogin(), diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index d21aa072..72c18763 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -14,6 +14,7 @@ import { OpenPGPCryptoProxy, MetricEvent, Logger, + FeatureFlags, } from '../../sdk/src'; import { Telemetry } from '../../sdk/src/telemetry'; import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; @@ -34,8 +35,8 @@ export async function init(debug: boolean) { const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); const account = await initAccount(cryptoApi, config, telemetry.getLogger('account')); const srp = await initSrp(cryptoApi, config); - const sdk = initSDK(cryptoApi, config, account, srp, telemetry); - const photosSdk = initPhotosSDK(cryptoApi, config, account, srp, telemetry); + const sdk = await initSDK(cryptoApi, config, account, srp, telemetry); + const photosSdk = await initPhotosSDK(cryptoApi, config, account, srp, telemetry); const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); const paths = new Paths(sdk, photosSdk, account); return { @@ -74,9 +75,15 @@ async function initSrp(cryptoApi: CryptoApi, config: Config) { return new Srp(cryptoApi, config); } -function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp, telemetry: Telemetry) { - const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider } = - createSDKDependencies(config, account, cryptoApi); +async function initSDK( + cryptoApi: CryptoApi, + config: Config, + account: Account, + srp: Srp, + telemetry: Telemetry, +) { + const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider, featureFlagProvider } = + await createSDKDependencies(config, account, cryptoApi); const sdk = new ProtonDriveClient({ httpClient, @@ -91,19 +98,20 @@ function initSDK(cryptoApi: CryptoApi, config: Config, account: Account, srp: Sr openPGPCryptoModule, srpModule: srp, latestEventIdProvider, + featureFlagProvider, }); return sdk; } -function initPhotosSDK( +async function initPhotosSDK( cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp, telemetry: Telemetry, ) { - const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider } = - createSDKDependencies(config, account, cryptoApi); + const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider, featureFlagProvider } = + await createSDKDependencies(config, account, cryptoApi); return new ProtonDrivePhotosClient({ httpClient, @@ -118,11 +126,12 @@ function initPhotosSDK( openPGPCryptoModule, srpModule: srp, latestEventIdProvider, + featureFlagProvider, }); } -function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const { httpClient, openPGPCryptoModule } = createSDKDependencies(config, account, cryptoApi); +async function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { + const { httpClient, openPGPCryptoModule } = await createSDKDependencies(config, account, cryptoApi); return initDiagnostic({ httpClient, @@ -133,7 +142,7 @@ function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Accoun }); } -function createSDKDependencies(config: Config, account: Account, cryptoApi: CryptoApi) { +async function createSDKDependencies(config: Config, account: Account, cryptoApi: CryptoApi) { return { httpClient: new HTTPClient({ ...config, @@ -144,6 +153,7 @@ function createSDKDependencies(config: Config, account: Account, cryptoApi: Cryp cryptoCache: new MemoryCache(), openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi as OpenPGPCryptoProxy), latestEventIdProvider: new NoLatestEventIdProvider(), + featureFlagProvider: await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), }; } @@ -152,3 +162,26 @@ class NoLatestEventIdProvider { return null; } } + +class FeatureFlagProvider { + constructor(private flags: Record) { + this.flags = flags; + } + + static async fromJsonFile(jsonFile: string) { + const file = Bun.file(jsonFile); + + if (!(await file.exists())) { + return new FeatureFlagProvider({} as Record); + } + + const bytes = await file.bytes(); + const content = new TextDecoder().decode(bytes); + const flags = JSON.parse(content) as Record; + return new FeatureFlagProvider(flags); + } + + isEnabled(flagName: FeatureFlags): Promise { + return Promise.resolve(this.flags[flagName] ?? false); + } +} diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 64adea3f..9ee02699 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -3,7 +3,7 @@ import { parseArgs } from 'util'; import { VERSION } from '../../sdk/src'; import { init } from './init'; -import { getCommand, validateCommandArguments } from './cli'; +import { getCommand, validateCommandArguments, formatReadableJson } from './cli'; const groupName = Bun.argv[2]; const commandName = Bun.argv[3]; @@ -62,7 +62,7 @@ try { // Get all the object properties and convert through JSON to avoid custom object interpretation. console.debug('Error details:'); - console.debug(JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(error as object))))); + console.debug(formatReadableJson(Object.fromEntries(Object.entries(error as object)))); process.exit(1); } 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 2a1eb0f970bd1b300de01d31ff52663bb0c3d498 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 12:57:37 +0100 Subject: [PATCH 439/732] 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 9a0ff6d590acfc861077779383ced877421e1a41 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Feb 2026 14:58:50 +0100 Subject: [PATCH 440/732] 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 c8502500ca6fb579490b97e678faf8bb75162168 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 06:03:14 +0000 Subject: [PATCH 441/732] 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 b6b7c4b99e3b59217e0ced6ce6bd2fd7340fd570 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 06:19:43 +0000 Subject: [PATCH 442/732] 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 0e23b05abd2748fe7e750a2d4f379804439a7a6b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 09:52:42 +0000 Subject: [PATCH 443/732] 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 941c9a4e4d7dce1c52688a90af0b10b3c507d564 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:25:16 +0000 Subject: [PATCH 444/732] 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 664abb5c1611420a0a5d8243c62b86dcc10fbfc5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:27:44 +0000 Subject: [PATCH 445/732] 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 b394fddd1519495801037ad29caaf930eca311ca Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 10:43:32 +0000 Subject: [PATCH 446/732] 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 bf9126e8426d8c3117676bd144c8b1b7860ec0ae Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 17:07:19 +0100 Subject: [PATCH 447/732] 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 605c054726a19286d9976fedee802efc6ecebb85 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 2 Mar 2026 16:22:48 +0000 Subject: [PATCH 448/732] 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 152c31f2e5ce5d76f1b0d9793be6b659e121e16a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Mar 2026 10:20:03 +0100 Subject: [PATCH 449/732] 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 bcefe82603c92ec2615077686b2fd4be1a7035be Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Mar 2026 10:22:40 +0000 Subject: [PATCH 450/732] 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 d2809fff678e3938c5514437e0cc4ed38ebcfdd6 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 3 Mar 2026 13:10:30 +0100 Subject: [PATCH 451/732] Await init of diagnostics in CLI --- js/cli/src/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 72c18763..3decac09 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -37,7 +37,7 @@ export async function init(debug: boolean) { const srp = await initSrp(cryptoApi, config); const sdk = await initSDK(cryptoApi, config, account, srp, telemetry); const photosSdk = await initPhotosSDK(cryptoApi, config, account, srp, telemetry); - const sdkDiagnostic = initSDKDiagnostic(cryptoApi, config, account, srp); + const sdkDiagnostic = await initSDKDiagnostic(cryptoApi, config, account, srp); const paths = new Paths(sdk, photosSdk, account); return { account, From 0e2ca3c5251148b53ea06b10b08f853b58a64958 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Mar 2026 13:03:21 +0000 Subject: [PATCH 452/732] 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 e7255152dedfa41f4b57ad1ab84b5fc414a47214 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 4 Mar 2026 13:35:19 +0000 Subject: [PATCH 453/732] 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 2270ba061833f96378ecd95ada576f787ab24aa3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Mar 2026 06:21:18 +0000 Subject: [PATCH 454/732] 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 e5c70d07c062b40188a43de750c07284620a8d71 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 5 Mar 2026 14:08:59 +0000 Subject: [PATCH 455/732] 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 b4ea340affa29cd2c2b97f77fccb266113b1a1ed Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Mar 2026 15:21:56 +0000 Subject: [PATCH 456/732] 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 b04531911b70b8f00fe92ccbf22421b0f9e1d98d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 6 Mar 2026 15:47:19 +0000 Subject: [PATCH 457/732] 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 f8d404506fd3431336eb1fce2faad13df95d515d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 06:03:16 +0000 Subject: [PATCH 458/732] i18n(weekly-mr): Upgrade translations from crowdin (b0453191). --- 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 283087f375546ef8ffa7f884f025e412bc27ae1b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 09:44:19 +0000 Subject: [PATCH 459/732] 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 c2fa907f4a8c18248f99058e4ecf195757e17310 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 15:00:03 +0000 Subject: [PATCH 460/732] Add fuzzy tests --- js/cli/src/cli/fileSystem/commandFileSystemUpload.ts | 5 ++++- js/cli/src/cli/public/commandPublicUpload.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts index 9087df53..a86822c5 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts @@ -4,6 +4,7 @@ import { ParseArgsConfig } from 'util'; import { Thumbnail } from '../../../../sdk/src'; import { Command, ActionArgs } from '../interface'; import { PathType } from '../paths'; +import { printObject } from '../formatters'; export class CommandFileSystemUpload implements Command { group = 'filesystem'; @@ -66,6 +67,8 @@ export class CommandFileSystemUpload implements Command { } }); - await controller.completion(); + const result = await controller.completion(); + + printObject(result, json); } } diff --git a/js/cli/src/cli/public/commandPublicUpload.ts b/js/cli/src/cli/public/commandPublicUpload.ts index b8c2a4ef..8dbaf05f 100644 --- a/js/cli/src/cli/public/commandPublicUpload.ts +++ b/js/cli/src/cli/public/commandPublicUpload.ts @@ -4,6 +4,7 @@ import { ParseArgsConfig } from 'util'; import { Thumbnail } from '../../../../sdk/src'; import { Command, ActionArgs } from '../interface'; import { PUBLIC_OPTIONS } from './base'; +import { printObject } from '../formatters'; export class CommandPublicUpload implements Command { group = 'public'; @@ -62,6 +63,8 @@ export class CommandPublicUpload implements Command { } }); - await controller.completion(); + const result = await controller.completion(); + + printObject(result, json); } } From 3a73864212a73258098ec0045869905469587c05 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 15:28:41 +0000 Subject: [PATCH 461/732] 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 b96c91dc88ad8d0e80d8b7f050b3b47bac1a475c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 9 Mar 2026 20:07:08 +0100 Subject: [PATCH 462/732] 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 30d3df82f18e26b9a173428970417097bb31ca12 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Mar 2026 14:06:54 +0000 Subject: [PATCH 463/732] 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 14104e797f851edc36cb4993a9c376a8e64c1800 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 10 Mar 2026 14:09:26 +0000 Subject: [PATCH 464/732] 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 51e7e6684e11d9cd4d73f29b5b2c3d4f02e01f41 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 08:28:48 +0000 Subject: [PATCH 465/732] 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 0fd9d8dd5b3374c02bb17a914fa791cc3e24f684 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 09:19:32 +0000 Subject: [PATCH 466/732] 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 6a8bd7e1110b15da487c395a16a828e7b9544b65 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 11:16:27 +0000 Subject: [PATCH 467/732] 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 5a5bf30dc3c6b990acdc79d631e8bd223f61aebf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 11:29:06 +0000 Subject: [PATCH 468/732] 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 4024b644e70f37967198247b1eb843c9d281f5f1 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 12:24:35 +0000 Subject: [PATCH 469/732] 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 c63b2d7a0ff056bae966f008d05820e69cb3ff70 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 11 Mar 2026 14:23:39 +0000 Subject: [PATCH 470/732] 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 471/732] 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 cdf2118f4b7c9232205c4d8f99e350852494e577 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 12:41:38 +0100 Subject: [PATCH 472/732] 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 8d6772c17d13ce2fb0c9ffcc837127daf498e303 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 11:52:57 +0000 Subject: [PATCH 473/732] 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 b66e2e936b1306994465e0cdd24fde1065a78ea6 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 13 Mar 2026 15:34:49 +0000 Subject: [PATCH 474/732] 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 f801cefaca4cfa524d95444290201129aeda46bf Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 07:00:21 +0000 Subject: [PATCH 475/732] 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 07b510fe08f3bf54748edbb6700f86956442034b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 10:32:36 +0100 Subject: [PATCH 476/732] 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 6209acf3e4edebc5bfa1c572af31ee2ae41fa169 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 06:03:17 +0000 Subject: [PATCH 477/732] i18n(weekly-mr): Upgrade translations from crowdin (b66e2e93). --- 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 130188ea2d6c4360e19a62a63073513019b0835e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 08:51:10 +0000 Subject: [PATCH 478/732] 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 5c4fa748c0d5d71d60aaa358776415a125deb8f1 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 08:20:42 +0100 Subject: [PATCH 479/732] 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 bdae64374808bdc66161c689000de79ba8aae94f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 11:24:19 +0100 Subject: [PATCH 480/732] 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 925f97333b4ef2e89fe68d57516fae6fdf79750f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 16 Mar 2026 11:47:06 +0100 Subject: [PATCH 481/732] 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 f59e6b24d7d6f80f084ebc892ac0374b9e9ddb5e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 09:22:02 +0100 Subject: [PATCH 482/732] 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 bae8f0b84f60c3796bcce939c8acc9c786764c5e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 18 Mar 2026 19:28:20 +0000 Subject: [PATCH 483/732] 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 0fe6c48f5c08696c53c70542e4e0b7fa1ea7ec92 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 08:03:25 +0000 Subject: [PATCH 484/732] 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 b53133c64cc574e045cf4121251e4c98f7120f8c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 09:33:30 +0100 Subject: [PATCH 485/732] 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 d1e58c558fababb58b1ddc440551e4d76ae69ace Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 08:49:06 +0100 Subject: [PATCH 486/732] 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 9f0ba247bc366b98932a10e1c1e2ac73faa0c4c2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 11:59:35 +0100 Subject: [PATCH 487/732] 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 8e7d74ec9a2c1835314f3d9d4fbd166a64db97fc Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 13:10:26 +0100 Subject: [PATCH 488/732] 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 890ba4e70d53964b16b3e4d1c72981a8164aaf0b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 13:27:24 +0000 Subject: [PATCH 489/732] 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 468a9edd652f044176db5c8253188b091a82106f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 06:21:30 +0000 Subject: [PATCH 490/732] 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 1719cd03ace094f1290d571cbfa12a4b5852821b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 08:02:42 +0100 Subject: [PATCH 491/732] 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 4bad5b71588b053aad8854b4445a492831fe6a06 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 08:54:17 +0100 Subject: [PATCH 492/732] 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 425d6ab9df9b18982134aa3d4873f5b635dbfcb0 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 07:51:21 +0000 Subject: [PATCH 493/732] 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 b83ae2b0b0ffb510455bba368026c697f63e0b94 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 09:38:01 +0100 Subject: [PATCH 494/732] 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 b47646d8d7760cdf07c71374d5cafeedf84dabc3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:29:13 +0100 Subject: [PATCH 495/732] 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 753dbea500c1e230b21562f9c8e5cc91194aab21 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:36:04 +0000 Subject: [PATCH 496/732] 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 ba12ce042410d69502491b055e3551de681cae35 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:55:58 +0000 Subject: [PATCH 497/732] 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 cd883d2d6fe1b59ce653694bbda359ada3a9b4db Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 09:08:50 +0100 Subject: [PATCH 498/732] 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 c3e9f53dcd208ba6c014e46ae2ff40d68e1fb902 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 15:12:19 +0000 Subject: [PATCH 499/732] 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 c64e0ab384f7a1a3bfacd43105ae1ec78a4821c5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 08:56:04 +0100 Subject: [PATCH 500/732] 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 02f0936a962bd934eb2dc81afe8781b0af84b6e5 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:31:56 +0100 Subject: [PATCH 501/732] 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 294c70feaa3099b38df6ac2c9b034915ce41b027 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:05:08 +0000 Subject: [PATCH 502/732] 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 b90b7eff4cebfc06825c9ea3bda2f8a0ecc816f2 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:35:02 +0000 Subject: [PATCH 503/732] Allow saving photos when deleting albums --- js/cli/src/cli/photos/commandAlbumDelete.ts | 9 +- 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 +- 12 files changed, 462 insertions(+), 20 deletions(-) diff --git a/js/cli/src/cli/photos/commandAlbumDelete.ts b/js/cli/src/cli/photos/commandAlbumDelete.ts index 5c395eeb..1848c614 100644 --- a/js/cli/src/cli/photos/commandAlbumDelete.ts +++ b/js/cli/src/cli/photos/commandAlbumDelete.ts @@ -12,12 +12,17 @@ export class CommandAlbumDelete implements Command { short: 'f', default: false, }, + save: { + type: 'boolean', + short: 's', + default: false, + }, }; - async action({ paths, photosSdk, args: [pathString], options: { force } }: ActionArgs) { + async action({ paths, photosSdk, args: [pathString], options: { force, save } }: ActionArgs) { const nodePath = paths.getPath(pathString); const node = await nodePath.getNode(); - await photosSdk.deleteAlbum(node, { force }); + await photosSdk.deleteAlbum(node, { force, saveToTimeline: save }); console.log(`Album deleted successfully`); } } 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 0057c540bfa1d4796173bc30f3e62e8c37146574 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 12:44:18 +0000 Subject: [PATCH 504/732] 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 91c8f0432754718ef133c551038f1e070e4fc165 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 19 Mar 2026 18:27:30 +0800 Subject: [PATCH 505/732] 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 2ccd3997d97fd884f815c6829973261adb79cc0e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:43:13 +0000 Subject: [PATCH 506/732] 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 0c977528a46ae841e79db506f6e1420ee928071d Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 16:25:28 +0000 Subject: [PATCH 507/732] 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 6709a2c129084d8f8c5468b89679407902fa2e02 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 18:26:50 +0800 Subject: [PATCH 508/732] 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 a5547b97f6a65938a9dfd73c7508b6e8565bc822 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 11:04:53 +0000 Subject: [PATCH 509/732] 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 11ac98f902db8772a64f4866d65071e5ab5b2744 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 15:10:45 +0100 Subject: [PATCH 510/732] 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 a0d0dbbe6a35e787b2cc5fcefc601f8fb8b10490 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 24 Mar 2026 14:34:44 +0000 Subject: [PATCH 511/732] 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 58d0800f382b1dbc3bd14256cf6f57f49aeb7801 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 10:38:33 +0000 Subject: [PATCH 512/732] 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 0316135b5ffd65e95186132ae1870625490e30ed Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 12:26:07 +0000 Subject: [PATCH 513/732] 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 04dca9014a227c60ee2fd9592dc7eeea68bfcc55 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 15:49:08 +0000 Subject: [PATCH 514/732] 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 83f3a192ae26cafa240d561fcbadd15f74a7c1d2 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 25 Mar 2026 15:58:04 +0000 Subject: [PATCH 515/732] 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 3f5c267f19b54d18618c287c7a465e25cbd15e98 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:47:20 +0800 Subject: [PATCH 516/732] 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 56536db96a95420d7660b7d48e9034db30a2c192 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Mar 2026 13:04:25 +0000 Subject: [PATCH 517/732] 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 19c57b023236027584ef6aa5dee461b3b5a6debc Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 12 Mar 2026 08:02:25 +0100 Subject: [PATCH 518/732] 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 a27032aa966c89233d4fc411ccae81a88fc40b33 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 11:13:55 +0800 Subject: [PATCH 519/732] 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 680411dafcfc48336c5f342363a5b6d1cb0be77d Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 08:52:34 +0000 Subject: [PATCH 520/732] 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 3b463ad8c9c247add51f2ff143169e0c8d520518 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 12:53:33 +0000 Subject: [PATCH 521/732] Handle thumbnails in small file upload --- js/cli/bun.lock | 140 +++++++++---- js/cli/package.json | 7 + .../cli/fileSystem/commandFileSystemUpload.ts | 4 +- .../src/cli/fileSystem/generateThumbnails.ts | 59 ++++++ 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 ++++---- 12 files changed, 453 insertions(+), 210 deletions(-) create mode 100644 js/cli/src/cli/fileSystem/generateThumbnails.ts diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 6aafa494..d8a402a8 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -1,9 +1,17 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "proton-drive-cli", "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", @@ -201,6 +209,26 @@ "@jest/types": ["@jest/types@29.6.3", "", { "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" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -317,6 +345,8 @@ "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -361,17 +391,17 @@ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0" } }, "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.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.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.55.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow=="], @@ -397,6 +427,8 @@ "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -423,6 +455,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "babel-jest": ["babel-jest@29.7.0", "", { "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" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "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" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], @@ -435,8 +469,12 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -445,6 +483,8 @@ "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], @@ -561,8 +601,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "execa": ["execa@5.1.1", "", { "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" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], "expect": ["expect@29.7.0", "", { "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" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], @@ -583,6 +629,8 @@ "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -613,6 +661,8 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -641,8 +691,12 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], @@ -747,6 +801,8 @@ "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -815,6 +871,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -841,6 +899,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -859,6 +919,8 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], @@ -877,6 +939,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -891,6 +955,8 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -899,6 +965,8 @@ "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -909,6 +977,10 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -929,6 +1001,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -959,6 +1033,8 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], @@ -967,6 +1043,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -975,6 +1053,8 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -983,6 +1063,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], @@ -1007,6 +1089,8 @@ "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="], @@ -1041,6 +1125,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1111,30 +1197,24 @@ "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], - "@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], - "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.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.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], - "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + "@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], + "@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + "@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], - - "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1163,6 +1243,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "istanbul-lib-instrument/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1223,30 +1305,18 @@ "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="], - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], - "@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], @@ -1265,9 +1335,7 @@ "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], diff --git a/js/cli/package.json b/js/cli/package.json index 69a3ca72..5f1de15a 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -33,6 +33,13 @@ "vite-plugin-dts": "^4.2.4" }, "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", diff --git a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts index a86822c5..b29ebb46 100644 --- a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts @@ -1,10 +1,10 @@ import path from 'node:path'; import { ParseArgsConfig } from 'util'; -import { Thumbnail } from '../../../../sdk/src'; import { Command, ActionArgs } from '../interface'; import { PathType } from '../paths'; import { printObject } from '../formatters'; +import { generateThumbnails } from './generateThumbnails'; export class CommandFileSystemUpload implements Command { group = 'filesystem'; @@ -60,7 +60,7 @@ export class CommandFileSystemUpload implements Command { console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); } - const thumbnails: Thumbnail[] = []; // TODO + const thumbnails = await generateThumbnails(metadata.mediaType || '', localPath); const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { if (!json) { console.log(`Uploaded ${writtenBytes} bytes`); diff --git a/js/cli/src/cli/fileSystem/generateThumbnails.ts b/js/cli/src/cli/fileSystem/generateThumbnails.ts new file mode 100644 index 00000000..0c4d3231 --- /dev/null +++ b/js/cli/src/cli/fileSystem/generateThumbnails.ts @@ -0,0 +1,59 @@ +import { createJimp } from '@jimp/core'; +import bmp, { msBmp } from '@jimp/js-bmp'; +import gif from '@jimp/js-gif'; +import jpeg from '@jimp/js-jpeg'; +import png from '@jimp/js-png'; +import tiff from '@jimp/js-tiff'; +import * as resize from '@jimp/plugin-resize'; + +import { Thumbnail, ThumbnailType } from '../../../../sdk/src'; + +const Jimp = createJimp({ + formats: [bmp, msBmp, gif, jpeg, png, tiff], + plugins: [resize.methods], +}); + +const MAX_UPLOAD_THUMBNAIL_SIDE = 512; +const MAX_THUMBNAIL_BYTES = 64 * 1024 - 512; + +const IMAGE_MEDIA_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/bmp', + 'image/tiff', +]); + +export async function generateThumbnails(mediaType: string, localPath: string): Promise { + if (!IMAGE_MEDIA_TYPES.has(mediaType.trim().toLowerCase())) { + return []; + } + + try { + const file = await Bun.file(localPath).arrayBuffer(); + const image = await Jimp.read(Buffer.from(file)); + image.scaleToFit({ w: MAX_UPLOAD_THUMBNAIL_SIDE, h: MAX_UPLOAD_THUMBNAIL_SIDE }); + + let quality = 90; + let thumbnailBuffer: Buffer | undefined; + while (quality > 0) { + thumbnailBuffer = await image.getBuffer('image/jpeg', { quality }); + if (thumbnailBuffer.length <= MAX_THUMBNAIL_BYTES || quality <= 10) { + break; + } + quality -= 20; + } + if (!thumbnailBuffer) { + return []; + } + return [ + { + type: ThumbnailType.Type1, + thumbnail: new Uint8Array(thumbnailBuffer), + }, + ]; + } catch { + return []; + } +} 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 7e022f0c28ced1417afd3cef7abc05d4747e51c9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 13:43:03 +0100 Subject: [PATCH 522/732] 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 ceff6aca660c9cc8597ac59272ceddcbbeda52f7 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 17:01:53 +0100 Subject: [PATCH 523/732] 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 6e6450d6428d56fbf546897a3e96565c5537e4c6 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:01:39 +0000 Subject: [PATCH 524/732] 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 2e438e72e3c6cbfb5d1b4a336dc7ed0a62d4ceee Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:03:42 +0000 Subject: [PATCH 525/732] i18n(weekly-mr): Upgrade translations from crowdin (6e6450d6). --- 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 3adeba61fb69c48a7e09649b1b2f00fd3e631479 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 09:23:00 +0100 Subject: [PATCH 526/732] 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 59e57feec10a811b4b87e592253467e9893d1372 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 05:29:59 +0000 Subject: [PATCH 527/732] 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 e1227d51b6d768015d29992459be7e8dff32dae9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 26 Mar 2026 17:27:28 +0100 Subject: [PATCH 528/732] 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 4ce99988c1c369ee7b1b69fe345802d4bc28a4f7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 12:54:17 +0000 Subject: [PATCH 529/732] 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 b2ce7d9c91b641ff00f8c19cb60d89da08e63ba2 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 15:48:40 +0200 Subject: [PATCH 530/732] 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 ccc807479561c46c1426f69e71080ee9e0c6e1d3 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 14:56:17 +0000 Subject: [PATCH 531/732] 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 901b942314860fd193d6afea92fef7a6eee39165 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 27 Mar 2026 17:40:35 +0100 Subject: [PATCH 532/732] 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 693cc8e58a14e7d53aebb285f2da719288b662f6 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 15:04:09 +0000 Subject: [PATCH 533/732] 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 7ae884432301d634b0cfca0c87b7c54977c184de Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 30 Mar 2026 17:24:08 +0000 Subject: [PATCH 534/732] 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 92bfbd905daccfa9c0a47b99174944ee55ba0aea Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 05:44:21 +0000 Subject: [PATCH 535/732] 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 b910b5cd8a74025adbda471d5a948cdf99b4bca7 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 11:33:42 +0200 Subject: [PATCH 536/732] 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 65cce67d68270671869318a65929a2e7548141e4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 23 Mar 2026 15:15:29 +0100 Subject: [PATCH 537/732] 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 66061334220601821b690ce1de799112a73076ec Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 31 Mar 2026 11:42:58 +0000 Subject: [PATCH 538/732] 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 ba92da2e2c5c8ee32e46b8b1b92d1c42a44a3d46 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 06:58:08 +0000 Subject: [PATCH 539/732] 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 568ce17998495611cd8781f6751c2e294b4880ae Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 12:36:40 +0000 Subject: [PATCH 540/732] 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 b1eba15269761c3660c8b6efa1e06ca12d044231 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 13:21:37 +0000 Subject: [PATCH 541/732] 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 8b37b89a8ba0a837162782eac90fd6bea99a7022 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 1 Apr 2026 13:55:30 +0000 Subject: [PATCH 542/732] 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 4ba440028a3eac955fb49891691180fbce234f4e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 10:20:20 +0000 Subject: [PATCH 543/732] 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 37f18667fe4ad1e464eb3a05ff458cbfc1597f7c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 14:38:16 +0200 Subject: [PATCH 544/732] 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 bf32f527c8a30bfe13bd9675f4d0ee8e359a357c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 10:43:07 +0000 Subject: [PATCH 545/732] 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 991d59eb0e1a8b563be60159bed3ee6abf1d19c8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 12:40:43 +0000 Subject: [PATCH 546/732] 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 7f65bdcadba3ab05cf9769046cbaea4421f617cc Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 2 Apr 2026 18:03:19 +0200 Subject: [PATCH 547/732] 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 444fe55f1ee6ec8e3254a3ca717ef87e3961163c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 3 Apr 2026 10:50:54 +0200 Subject: [PATCH 548/732] 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 ccd2b983a3f1d761962537b65dde7255d1a991c2 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 06:57:38 +0200 Subject: [PATCH 549/732] 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 7ec83f5c909e5f616e28ea7e8140ece9ca47ab33 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 6 Apr 2026 05:03:17 +0000 Subject: [PATCH 550/732] i18n(weekly-mr): Upgrade translations from crowdin (444fe55f). --- 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 b93231e69fda7ef18fe5ba26eee5f63f3f735f16 Mon Sep 17 00:00:00 2001 From: drive Date: Sat, 4 Apr 2026 09:01:58 +0200 Subject: [PATCH 551/732] 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 3826968b23409f472ecdf60b6b6ec6106d81b915 Mon Sep 17 00:00:00 2001 From: drive Date: Sat, 4 Apr 2026 10:01:38 +0200 Subject: [PATCH 552/732] 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 56305af3fdd56284b27b36824d5d4d169bf8a4f4 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 06:42:44 +0000 Subject: [PATCH 553/732] 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 6b67409356fcdbb23dc2feeb02001b7198d0357c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 07:24:45 +0200 Subject: [PATCH 554/732] 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 a333cebb113da557add74441d107c152eae7f07a Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 13:59:27 +0000 Subject: [PATCH 555/732] Support NonProtonInvitation conversion --- js/cli/src/cli/index.ts | 2 + ...ommandSharingConvertNonProtonInvitation.ts | 25 +++++++ 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 +++++ 9 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index b85846c1..555c30c6 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -54,6 +54,7 @@ import { CommandRevisionDelete } from './revision/commandRevisionDelete'; import { CommandRevisionDownload } from './revision/commandRevisionDownload'; import { CommandRevisionList } from './revision/commandRevisionList'; import { CommandRevisionRestore } from './revision/commandRevisionRestore'; +import { CommandSharingConvertNonProtonInvitation } from './sharing/commandSharingConvertNonProtonInvitation'; import { CommandSharingInvite } from './sharing/commandSharingInvite'; import { CommandSharingRemove } from './sharing/commandSharingRemove'; import { CommandSharingRemoveUrl } from './sharing/commandSharingRemoveUrl'; @@ -97,6 +98,7 @@ const COMMANDS = [ new CommandRevisionDelete(), new CommandSharingStatus(), new CommandSharingInvite(), + new CommandSharingConvertNonProtonInvitation(), new CommandSharingRemove(), new CommandSharingSetUrl(), new CommandSharingRemoveUrl(), diff --git a/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts b/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts new file mode 100644 index 00000000..accde5b9 --- /dev/null +++ b/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts @@ -0,0 +1,25 @@ +import { ParseArgsConfig } from 'util'; + +import { Command, ActionArgs } from '../interface'; +import { printObject } from '../formatters'; + +export class CommandSharingConvertNonProtonInvitation implements Command { + group = 'sharing'; + name = 'convert-non-proton-invitation'; + args = ['path']; + options: ParseArgsConfig['options'] = { + 'invitation-uid': { + type: 'string', + short: 'i', + }, + }; + + async action({ paths, args: [pathString], options: { 'invitation-uid': invitationUid, json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const invitation = await nodePath.sdk.convertNonProtonInvitation(node, invitationUid as string); + + printObject(invitation, json); + } +} 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 ba8580fd9ef06623cda4a05b635f44d0bc856402 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 9 Apr 2026 08:26:31 +0000 Subject: [PATCH 556/732] 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 557/732] 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 40bb9922ca0409cefd1f9227968f176ae5048a3b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 9 Apr 2026 11:28:22 +0000 Subject: [PATCH 558/732] 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 5d1b45eb325503c01ad4fb5ebbe808890f81533f Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 10 Apr 2026 05:14:58 +0000 Subject: [PATCH 559/732] 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 6f854d673d53b89aff827741081c7d25d918e124 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Apr 2026 05:03:24 +0000 Subject: [PATCH 560/732] i18n(weekly-mr): Upgrade translations from crowdin (5d1b45eb). --- 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 3c2c7c446018c8f81dbcc05df881685d6f8a59bd Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 13 Apr 2026 13:00:15 +0000 Subject: [PATCH 561/732] 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 1601ddba26ca5eb7446bf147e641c40af7ff7290 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 16 Apr 2026 12:00:30 +0200 Subject: [PATCH 562/732] 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 d5f775681fbe3cc949ba773661f79800d29fea09 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 16 Apr 2026 11:08:33 +0000 Subject: [PATCH 563/732] 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 399f8e76b1f3fa76e811b4c980e26bc068514b75 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 05:52:46 +0000 Subject: [PATCH 564/732] 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 06bdde86f1cdfd5c723f1d94abc40e7136e23553 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 05:54:36 +0000 Subject: [PATCH 565/732] 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 b3a11b0972b6435fc6287995ff5577dab8a2be16 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 13:20:39 +0000 Subject: [PATCH 566/732] 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 100f9f9ef32e98fae30abfe1b5eea604b93e39bf Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 05:03:23 +0000 Subject: [PATCH 567/732] i18n(weekly-mr): Upgrade translations from crowdin (b3a11b09). --- 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 4df886dd7b34df3537b7bbbb733527ceeb2c9522 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 17 Apr 2026 18:26:17 +0200 Subject: [PATCH 568/732] 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 00bf8257c31050672688333cee2137634767f270 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 09:47:55 +0000 Subject: [PATCH 569/732] 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 5ad96b15df4b25211fcc539ec9084469f4c5707f Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 10:32:54 +0000 Subject: [PATCH 570/732] 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 a5b1e9118dc337527736db356715000f06875e7e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 20 Apr 2026 16:24:55 +0200 Subject: [PATCH 571/732] 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 63a464da2b17254785d1682c4d1f2852dd2697cb Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Apr 2026 09:56:18 +0000 Subject: [PATCH 572/732] Add encrypted credentials and cache --- .gitignore | 2 + js/cli/package.json | 2 +- js/cli/src/api/accountApi.ts | 265 + js/cli/src/api/addresses.ts | 324 + js/cli/src/api/api-auth-types.ts | 2831 ++ js/cli/src/api/api-core-types.ts | 23919 ++++++++++++++++ js/cli/src/api/apiClient.ts | 160 + js/cli/src/api/auth.ts | 220 + js/cli/src/api/authWeb.ts | 60 + js/cli/src/api/httpClient.ts | 29 + js/cli/src/api/index.ts | 33 + js/cli/src/api/sleep.ts | 20 + js/cli/src/api/srp.ts | 59 + js/cli/src/cache/crypto.ts | 41 + js/cli/src/cache/encryptedCache.ts | 51 + js/cli/src/cache/index.ts | 22 + js/cli/src/cache/sqliteCache.ts | 73 + js/cli/src/cli/auth/commandAuthLogin.ts | 30 +- js/cli/src/cli/auth/commandAuthLogout.ts | 4 +- js/cli/src/cli/interface.ts | 4 +- js/cli/src/cli/openBrowserUrl.ts | 38 + js/cli/src/cli/paths.ts | 6 +- js/cli/src/cli/readPasswordLine.ts | 88 + js/cli/src/config.ts | 26 + js/cli/src/credentials/credentials.ts | 96 + .../src/credentials/fileCredentialsStore.ts | 43 + js/cli/src/credentials/index.ts | 20 + js/cli/src/credentials/interface.ts | 17 + .../src/credentials/parseCredentials.test.ts | 88 + js/cli/src/credentials/parseCredentials.ts | 33 + .../src/credentials/secretCredentialsStore.ts | 34 + js/cli/src/init.ts | 164 +- js/cli/src/proton-drive.ts | 23 +- js/sdk/package-lock.json | 8 + js/sdk/package.json | 1 + js/sdk/typings/index.d.ts | 2 - 36 files changed, 28668 insertions(+), 168 deletions(-) create mode 100644 js/cli/src/api/accountApi.ts create mode 100644 js/cli/src/api/addresses.ts create mode 100644 js/cli/src/api/api-auth-types.ts create mode 100644 js/cli/src/api/api-core-types.ts create mode 100644 js/cli/src/api/apiClient.ts create mode 100644 js/cli/src/api/auth.ts create mode 100644 js/cli/src/api/authWeb.ts create mode 100644 js/cli/src/api/httpClient.ts create mode 100644 js/cli/src/api/index.ts create mode 100644 js/cli/src/api/sleep.ts create mode 100644 js/cli/src/api/srp.ts create mode 100644 js/cli/src/cache/crypto.ts create mode 100644 js/cli/src/cache/encryptedCache.ts create mode 100644 js/cli/src/cache/index.ts create mode 100644 js/cli/src/cache/sqliteCache.ts create mode 100644 js/cli/src/cli/openBrowserUrl.ts create mode 100644 js/cli/src/cli/readPasswordLine.ts create mode 100644 js/cli/src/config.ts create mode 100644 js/cli/src/credentials/credentials.ts create mode 100644 js/cli/src/credentials/fileCredentialsStore.ts create mode 100644 js/cli/src/credentials/index.ts create mode 100644 js/cli/src/credentials/interface.ts create mode 100644 js/cli/src/credentials/parseCredentials.test.ts create mode 100644 js/cli/src/credentials/parseCredentials.ts create mode 100644 js/cli/src/credentials/secretCredentialsStore.ts 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/cli/package.json b/js/cli/package.json index 5f1de15a..d7d44432 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -7,7 +7,7 @@ "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --minify-identifiers --conditions=browser", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", - "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/account/*-types.ts'", + "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", "postinstall": "patch-package", "test": "jest", diff --git a/js/cli/src/api/accountApi.ts b/js/cli/src/api/accountApi.ts new file mode 100644 index 00000000..70e57489 --- /dev/null +++ b/js/cli/src/api/accountApi.ts @@ -0,0 +1,265 @@ +import { HTTPError } from 'ky'; + +import type { paths as AuthPaths } from './api-auth-types'; +import type { paths as CorePaths } from './api-core-types'; +import { ApiClient } from './apiClient'; + +type AuthResponse = Extract< + CorePaths['/core/{_version}/auth/info']['post']['responses']['200']['content']['application/json'], + { Modulus?: string } +>; + +type SessionForkInitResponse = { + Code: number; + Selector: string; + UserCode: string; +}; + +type SessionForkStatusResponse = { + Code: number; + Payload: string; + UID: string; + AccessToken: string; + RefreshToken?: string; +}; + +type ApiErrorDetails = { + Code?: unknown; +} & object; + +async function makeAccountApiError(error: unknown): Promise { + if (error instanceof AccountApiError) { + return error; + } + if (error instanceof HTTPError) { + const details = await parseErrorDetails(error.response); + const code = typeof details?.Code === 'number' ? details.Code : 0; + if (code === 33103) { + return new AddressNotFoundError(error.message, { + httpCode: error.response.status, + code, + debug: details, + cause: error, + }); + } + return new AccountApiError(error.message, { + httpCode: error.response.status, + code, + debug: details, + cause: error, + }); + } + const message = error instanceof Error ? error.message : String(error); + return new AccountApiError(message, { cause: error }); +} + +async function parseErrorDetails(response: Response): Promise { + try { + const parsed = await response.json(); + if (parsed !== null && typeof parsed === 'object') { + return parsed as ApiErrorDetails; + } + } catch { + // Ignore parsing errors and fall back to generic error details. + } + return undefined; +} + +export class AccountApiError extends Error { + public readonly httpCode?: number; + public readonly code?: number; + public readonly debug?: object; + + constructor( + message: string, + options: { + httpCode?: number; + code?: number; + debug?: object; + cause?: unknown; + }, + ) { + super(message, { cause: options.cause }); + this.name = 'AccountApiError'; + this.httpCode = options.httpCode; + this.code = options.code; + this.debug = options.debug; + } +} + +export class AddressNotFoundError extends AccountApiError {} + +export class AccountApi { + constructor(private readonly apiClient: ApiClient) {} + + async sessionForksInit(): Promise { + try { + return await this.apiClient.unauthenticatedRequest + .get(`${this.apiClient.baseUrlWithProtocol}/auth/v4/sessions/forks`) + .json(); + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async sessionForksStatus(selector: string): Promise { + try { + return await this.apiClient.unauthenticatedRequest + .get(`${this.apiClient.baseUrlWithProtocol}/auth/v4/sessions/forks/${encodeURIComponent(selector)}`) + .json(); + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async sessions(): Promise< + AuthPaths['/auth/{_version}/sessions']['post']['responses']['200']['content']['application/json'] + > { + try { + const response = await this.apiClient.authenticatedRequest.post< + AuthPaths['/auth/{_version}/sessions']['post']['responses']['200']['content']['application/json'] + >(`${this.apiClient.baseUrlWithProtocol}/auth/v4/sessions`, { + headers: { + 'x-enforce-unauthsession': 'true', + }, + }); + return response.json(); + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async auth(data: { + ClientEphemeral: string; + ClientProof: string; + Payload: { [key: string]: string }; + PersistentCookies: number; + SRPSession: string; + Username: string; + }): Promise { + try { + const response = await this.apiClient.unauthenticatedRequest + .post( + `${this.apiClient.baseUrlWithProtocol}/core/v4/auth`, + { + json: { + ...data, + }, + }, + ) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async info(Username: string): Promise { + try { + const response = await this.apiClient.unauthenticatedRequest + .post< + CorePaths['/core/{_version}/auth/info']['post']['responses']['200']['content']['application/json'] + >(`${this.apiClient.baseUrlWithProtocol}/core/v4/auth/info`, { + json: { + Intent: 'Proton', + Username, + }, + }) + .json(); + + if ('Modulus' in response) { + return response; + } + throw new AccountApiError('Invalid auth response', { debug: response }); + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async users(): Promise< + CorePaths['/core/{_version}/users']['get']['responses']['200']['content']['application/json'] + > { + try { + const response = await this.apiClient.authenticatedRequest + .get< + CorePaths['/core/{_version}/users']['get']['responses']['200']['content']['application/json'] + >(`${this.apiClient.baseUrlWithProtocol}/core/v4/users`) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async addresses(): Promise< + CorePaths['/core/{_version}/addresses']['get']['responses']['200']['content']['application/json'] + > { + try { + const response = await this.apiClient.authenticatedRequest + .get( + `${this.apiClient.baseUrlWithProtocol}/core/v4/addresses`, + { + searchParams: { + Page: 0, + PageSize: 50, + }, + }, + ) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async salts(): Promise< + CorePaths['/core/{_version}/keys/salts']['get']['responses']['200']['content']['application/json'] + > { + try { + const response = await this.apiClient.authenticatedRequest + .get< + CorePaths['/core/{_version}/keys/salts']['get']['responses']['200']['content']['application/json'] + >(`${this.apiClient.baseUrlWithProtocol}/core/v4/keys/salts`) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async keys( + email: string, + ): Promise { + try { + const response = await this.apiClient.authenticatedRequest + .get( + `${this.apiClient.baseUrlWithProtocol}/core/v4/keys/all`, + { + searchParams: { + Email: email, + InternalOnly: 1, + }, + }, + ) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } + + async modulus(): Promise< + CorePaths['/core/{_version}/auth/modulus']['get']['responses']['200']['content']['application/json'] + > { + try { + const response = await this.apiClient.authenticatedRequest + .get< + CorePaths['/core/{_version}/auth/modulus']['get']['responses']['200']['content']['application/json'] + >(`${this.apiClient.baseUrlWithProtocol}/core/v4/auth/modulus`) + .json(); + return response; + } catch (error: unknown) { + throw await makeAccountApiError(error); + } + } +} diff --git a/js/cli/src/api/addresses.ts b/js/cli/src/api/addresses.ts new file mode 100644 index 00000000..3193d968 --- /dev/null +++ b/js/cli/src/api/addresses.ts @@ -0,0 +1,324 @@ +import { ProtonDriveAccountAddress, Logger } from '../../../sdk/src'; +import { Credentials } from '../credentials'; +import { PrivateKeyReference, PublicKeyReference, VERIFICATION_STATUS } from '../crypto/lib'; +import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; +import type { components as coreComponents } from './api-core-types'; +import { AccountApi, AddressNotFoundError } from './accountApi'; + +interface UserData { + userPrimaryPrivateKeys: PrivateKeyReference[]; + userPrimaryPublicKeys: PublicKeyReference[]; + primaryAddress: { + email: string; + addressId: string; + addressKeyId: string; + }; + addresses: coreComponents['schemas']['AddressUser'][]; +} + +export class Addresses { + private userDataPromise?: Promise; + + private otherUsersPublicKeysByEmailPromises: Map> = new Map(); + private addressKeysByKeyIdPromises: Map< + string, + Promise<{ id: string; privateKey: PrivateKeyReference; publicKey: PublicKeyReference }> + > = new Map(); + + constructor( + private readonly cryptoApi: CryptoApiInterface, + private readonly accountApi: AccountApi, + private readonly credentials: Credentials, + private readonly logger: Logger, + ) { + credentials.on('sessionInfoChanged', () => { + this.logger.debug(`Session info changed, clearing addresses cache`); + this.userDataPromise = undefined; + this.otherUsersPublicKeysByEmailPromises.clear(); + this.addressKeysByKeyIdPromises.clear(); + }); + } + + async getOwnPrimaryAddress(): Promise { + const { primaryAddress } = await this.getUserData(); + return this.getOwnAddress(primaryAddress.addressId); + } + + async getOwnAddresses(): Promise { + const userData = await this.getUserData(); + + const addresses: ProtonDriveAccountAddress[] = []; + for (const address of userData.addresses) { + if (!address.ID) { + continue; + } + const ownAddress = await this.getOwnAddress(address.ID); + addresses.push(ownAddress); + } + + return addresses; + } + + async getOwnAddress(emailOrAddressId: string): Promise { + const userData = await this.getUserData(); + + const address = userData.addresses.find((a) => a.ID === emailOrAddressId || a.Email === emailOrAddressId); + if (!address?.ID || !address?.Email) { + throw new Error(`Address ${emailOrAddressId} not found`); + } + + const keys: { id: string; key: PrivateKeyReference }[] = []; + const errors: unknown[] = []; + for (const key of address.Keys || []) { + try { + const { id, privateKey } = await this.getAddressKey( + userData.userPrimaryPrivateKeys, + userData.userPrimaryPublicKeys, + key, + address.Email, + ); + keys.push({ id, key: privateKey }); + } catch (error: unknown) { + errors.push(error); + } + } + + if (keys.length === 0) { + throw new Error(`No private key found`, { cause: errors }); + } + + return { + email: address.Email, + addressId: address.ID, + primaryKeyIndex: 0, + keys: keys, + }; + } + + async hasProtonAccount(email: string): Promise { + const keys = await this.getPublicKeys(email); + return keys.length > 0; + } + + async getPublicKeys(email: string): Promise { + if (!this.credentials.isLoggedIn()) { + return []; + } + + const userData = await this.getUserData(); + const address = userData.addresses.find((a) => a.Email === email); + if (address) { + return this.getOwnPublicKeys(address); + } + return this.getOtherPublicKeys(email); + } + + private async getOwnPublicKeys(address: coreComponents['schemas']['AddressUser']): Promise { + const { userPrimaryPrivateKeys, userPrimaryPublicKeys } = await this.getUserData(); + + const keys: PublicKeyReference[] = []; + const errors: unknown[] = []; + for (const key of address.Keys || []) { + try { + const { publicKey } = await this.getAddressKey( + userPrimaryPrivateKeys, + userPrimaryPublicKeys, + key, + address.Email, + ); + keys.push(publicKey); + } catch (error: unknown) { + errors.push(error); + } + } + + if (errors.length > 0) { + this.logger.error('Errors loading public keys', errors); + } + if (keys.length === 0 && errors.length > 0) { + throw new Error(`Failed to load public keys`, { cause: errors }); + } + + return keys; + } + + private async getOtherPublicKeys(email: string): Promise { + if (this.otherUsersPublicKeysByEmailPromises.has(email)) { + return this.otherUsersPublicKeysByEmailPromises.get(email)!; + } + const { promise, resolve, reject } = Promise.withResolvers(); + this.otherUsersPublicKeysByEmailPromises.set(email, promise); + + try { + const response = await this.accountApi.keys(email); + + const publicKeys = await Promise.all( + (response.Address?.Keys || []).map((key) => { + return this.cryptoApi.importPublicKey({ armoredKey: key.PublicKey }); + }), + ); + resolve(publicKeys); + } catch (error) { + if (error instanceof AddressNotFoundError) { + resolve([]); + } else { + this.logger.error(`Error loading public keys for email ${email}`, error); + this.otherUsersPublicKeysByEmailPromises.delete(email); + reject(error); + } + } + + return promise; + } + + private async getUserData(): Promise { + if (this.userDataPromise) { + return this.userDataPromise; + } + const { promise, resolve, reject } = Promise.withResolvers(); + this.userDataPromise = promise; + + this.logger.debug(`Loading user data`); + + try { + const userKeyPassword = this.credentials.getUserKeyPassword(); + if (!userKeyPassword) { + throw new Error('Password is not set'); + } + + const users = await this.accountApi.users(); + + const userKeys = users.User?.Keys; + + const userPrimaryPrivateKeys: PrivateKeyReference[] = []; + const userPrimaryPublicKeys: PublicKeyReference[] = []; + for (const userKey of userKeys || []) { + try { + const userPrimaryPrivateKey = await this.cryptoApi.importPrivateKey({ + armoredKey: userKey?.PrivateKey, + passphrase: userKeyPassword, + }); + + const userPrimaryPublicKey = await this.cryptoApi.importPublicKey({ + binaryKey: await this.cryptoApi.exportPublicKey({ + key: userPrimaryPrivateKey, + format: 'binary', + }), + }); + + userPrimaryPrivateKeys.push(userPrimaryPrivateKey); + userPrimaryPublicKeys.push(userPrimaryPublicKey); + } catch (error) { + this.logger.error(`Error importing user key: ${JSON.stringify(userKey)}`, error); + } + } + + const addresses = await this.accountApi.addresses(); + const primaryAddress = addresses.Addresses?.at(0); + const primaryAddressPrimaryKey = primaryAddress?.Keys?.at(0); + + if (!primaryAddress?.Email || !primaryAddress?.ID || !primaryAddressPrimaryKey?.ID) { + throw new Error('Missing primary address'); + } + + if (!userPrimaryPrivateKeys.length || !userPrimaryPublicKeys.length) { + throw new Error('Missing user primary keys'); + } + + resolve({ + userPrimaryPrivateKeys, + userPrimaryPublicKeys, + primaryAddress: { + email: primaryAddress?.Email, + addressId: primaryAddress?.ID, + addressKeyId: primaryAddressPrimaryKey?.ID, + }, + addresses: addresses.Addresses || [], + }); + } catch (error: unknown) { + this.logger.error(`Error loading user data`, error); + this.userDataPromise = undefined; + reject(error); + } + + return promise; + } + + private async getAddressKey( + userPrimaryPrivateKeys: PrivateKeyReference[], + userPrimaryPublicKeys: PublicKeyReference[], + key: coreComponents['schemas']['AddressKey'], + email?: string, + ) { + const keyId = key.ID; + if (!keyId) { + throw new Error('Missing key ID'); + } + + if (this.addressKeysByKeyIdPromises.has(keyId)) { + return this.addressKeysByKeyIdPromises.get(keyId)!; + } + const { promise, resolve, reject } = Promise.withResolvers<{ + id: string; + privateKey: PrivateKeyReference; + publicKey: PublicKeyReference; + }>(); + this.addressKeysByKeyIdPromises.set(keyId, promise); + + this.logger.debug(`Loading address key ${keyId} for email ${email}`); + + try { + if (!key?.Token && key?.PrivateKey) { + const userKeyPassword = this.credentials.getUserKeyPassword(); + if (!userKeyPassword) { + throw new Error('User key password is not set'); + } + + const privateKey = await this.cryptoApi.importPrivateKey({ + armoredKey: key?.PrivateKey, + passphrase: userKeyPassword, + }); + + resolve({ + id: keyId, + privateKey, + publicKey: await this.cryptoApi.importPublicKey({ + binaryKey: await this.cryptoApi.exportPublicKey({ key: privateKey, format: 'binary' }), + }), + }); + return promise; + } + + const { data: decryptedToken, verificationStatus } = await this.cryptoApi.decryptMessage({ + armoredMessage: key?.Token || '', + armoredSignature: key?.Signature || '', + decryptionKeys: userPrimaryPrivateKeys, + verificationKeys: userPrimaryPublicKeys, + }); + + if (verificationStatus !== VERIFICATION_STATUS.SIGNED_AND_VALID) { + throw new Error('Failed to verify address key'); + } + + const privateKey = await this.cryptoApi.importPrivateKey({ + armoredKey: key?.PrivateKey || '', + passphrase: decryptedToken.toString(), + }); + const publicKey = await this.cryptoApi.importPublicKey({ + binaryKey: await this.cryptoApi.exportPublicKey({ key: privateKey, format: 'binary' }), + }); + + resolve({ + id: keyId, + privateKey, + publicKey, + }); + } catch (error) { + this.logger.error(`Error loading address key ${keyId} for email ${email}`, error); + this.addressKeysByKeyIdPromises.delete(keyId); + reject(error); + } + + return promise; + } +} diff --git a/js/cli/src/api/api-auth-types.ts b/js/cli/src/api/api-auth-types.ts new file mode 100644 index 00000000..5405ec0f --- /dev/null +++ b/js/cli/src/api/api-auth-types.ts @@ -0,0 +1,2831 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/auth/{_version}/devices/{deviceId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['post_auth-{_version}-devices-{deviceId}']; + delete: operations['delete_auth-{_version}-devices-{deviceId}']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/devices/{deviceId}/associate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['post_auth-{_version}-devices-{deviceId}-associate']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/devices': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['get_auth-{_version}-devices']; + put?: never; + post: operations['post_auth-{_version}-devices']; + delete: operations['delete_auth-{_version}-devices']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/devices/{deviceId}/admin': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations['put_auth-{_version}-devices-{deviceId}-admin']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/devices/{deviceId}/reject': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations['put_auth-{_version}-devices-{deviceId}-reject']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/uuid': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['get_auth-{_version}-sessions-uuid']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/forks': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['get_auth-{_version}-sessions-forks']; + put?: never; + post: operations['post_auth-{_version}-sessions-forks']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/forks/{selector}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a forked session. + * @description Obtains a child session, and can only be used once per parent fork call + */ + get: operations['get_auth-{_version}-sessions-forks-{selector}']; + put?: never; + post?: never; + /** + * Invalidate the selector. + * @description Invalidates the forking selector, without forking the session. + */ + delete: operations['delete_auth-{_version}-sessions-forks-{selector}']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get active sessions. */ + get: operations['get_auth-{_version}-sessions']; + put?: never; + /** Issues a new session (and related UID) */ + post: operations['post_auth-{_version}-sessions']; + /** Revoke all other access tokens, locked. */ + delete: operations['delete_auth-{_version}-sessions']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/payload': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Logs frontend fingerprints of current session. For anti-abuse purposes. + * @description Most clients should submit fingerprints synchronously during session creation. + * Used by web client, b/c we don't want to slow down loading of the app by fingerprinting. + */ + post: operations['post_auth-{_version}-sessions-payload']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/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_auth-{_version}-sessions-{uid}']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/local/key': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the local storage key. + * @description Use to encrypt the local storage data + */ + get: operations['get_auth-{_version}-sessions-local-key']; + /** Set or Update the local storage key. */ + put: operations['put_auth-{_version}-sessions-local-key']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sessions/local': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all sessions on the same device. + * @description Returns a list of sessions that have been created on the same client + * according to the following conditions: + * - The session does not require cookies; + * - The client provides a valid access cookie for it (expired is also OK). + */ + get: operations['get_auth-{_version}-sessions-local']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/mnemonic/validate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Validate mnemonic */ + get: operations['get_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + put: operations['put_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + post: operations['post_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + delete: operations['delete_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + options: operations['options_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + head: operations['head_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + patch: operations['patch_auth-{_version}-mnemonic-validate']; + /** Validate mnemonic */ + trace: operations['trace_auth-{_version}-mnemonic-validate']; + }; + '/auth/{_version}/info': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set up SRP authentication request. */ + post: operations['post_auth-{_version}-info']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/sso/{token}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Initiate SSO flow using token from POST /auth/info */ + get: operations['get_auth-{_version}-sso-{token}']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/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_auth-{_version}-saml']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate. */ + post: operations['post_auth-{_version}']; + /** Revoke a token. */ + delete: operations['delete_auth-{_version}']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/jwt': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate using pre-issued JWT. */ + post: operations['post_auth-{_version}-jwt']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/2fa': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit second factor. */ + post: operations['post_auth-{_version}-2fa']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/modulus': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get random SRP modulus. */ + get: operations['get_auth-{_version}-modulus']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/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_auth-{_version}-scopes']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/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_auth-{_version}-refresh']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/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_auth-{_version}-cookies']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/credentialless': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create and authenticate a credential-less user. */ + post: operations['post_auth-{_version}-credentialless']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/mnemonic/info': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set up Mnemonic SRP authentication request. */ + post: operations['post_auth-{_version}-mnemonic-info']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/mnemonic': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate with mnemonic. */ + post: operations['post_auth-{_version}-mnemonic']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/auth/{_version}/mnemonic/reauth': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Re-authenticate with mnemonic and add Password scope to the session if the authentication is successful. */ + post: operations['post_auth-{_version}-mnemonic-reauth']; + 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; + }; + ActivateAuthDeviceInput: { + EncryptedSecret: components['schemas']['BinaryString']; + }; + /** @description An encrypted ID */ + Id: string; + AssociateAuthDeviceInput: { + /** @description DeviceToken of the created device */ + DeviceToken: string; + }; + CreateAuthDeviceInput: { + /** @description Optional. If the user is already set-up, a 32-byte random token encoded as base64 and encrypted to the primary address key */ + ActivationToken?: components['schemas']['PGPMessage'] | null; + /** + * @description User-facing device name + * @example Fairphone 4 + */ + Name: string; + }; + PushForkSessionInput: { + /** @description Base64 encoded and encrypted payload to communicate to the child session */ + Payload?: string | null; + /** @description Expected ClientID of the child */ + ChildClientID: string; + Independent: components['schemas']['BoolInt']; + /** @description 8-char-base32-crockford */ + UserCode?: string | null; + }; + 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; + }; + }; + AssociateAuthDeviceOutput: { + ID: components['schemas']['Id']; + EncryptedSecret: components['schemas']['BinaryString2']; + }; + 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']['PGPMessage2'] | null; + ActivationAddressID?: components['schemas']['Id'] | null; + MemberID?: components['schemas']['Id'] | null; + /** + * @description DeviceToken of the created device + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + DeviceToken?: string | null; + }; + Session: { + /** @example cc0a3ec21c3af3461c9c310bf3f568795fdf6dc5 */ + UID?: string; + /** @example Web */ + ClientID?: string; + /** @example 1527262849 */ + CreateTime?: number; + /** @example IhcUWoRxdY3S-6pfk2L1oSTeZx5kvpeqcxuii8h1ic1nYnSJa11LP8DABcgsRJCwXXDjxwPFSxEGJrlrvMWFpQ== */ + MemberID?: string; + /** @example 0 */ + Revocable?: number; + }; + Fido2RegisteredKey: { + /** @example fido2-u2f */ + AttestationFormat?: string; + CredentialID?: Record[]; + /** @example My security key */ + Name?: string; + }; + 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[]; + }; + /** + * @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; + }; + /** + * @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; + }; + /** @description Base64 encoded binary data */ + BinaryString: string; + /** @description An armored PGP Message */ + PGPMessage: string; + /** @enum {integer} */ + BoolInt: 1 | 0; + /** @description Base64 encoded binary data */ + BinaryString2: string; + /** @enum {integer} */ + AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; + TranslatedStringInterface: Record; + /** @description An armored PGP Message */ + PGPMessage2: string; + }; + 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_auth-{_version}-devices-{deviceId}': { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components['schemas']['Id']; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['ActivateAuthDeviceInput']; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-devices-{deviceId}': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + deviceId: components['schemas']['Id']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'post_auth-{_version}-devices-{deviceId}-associate': { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components['schemas']['Id']; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['AssociateAuthDeviceInput']; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Device?: components['schemas']['AssociateAuthDeviceOutput']; + }; + }; + }; + }; + }; + 'get_auth-{_version}-devices': { + 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'][]; + }; + }; + }; + }; + }; + 'post_auth-{_version}-devices': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['CreateAuthDeviceInput']; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + Device?: components['schemas']['AuthDeviceOutput']; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-devices': { + 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_auth-{_version}-devices-{deviceId}-admin': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + deviceId: components['schemas']['Id']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'put_auth-{_version}-devices-{deviceId}-reject': { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components['schemas']['Id']; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions-uuid': { + 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 Apple transaction UUID for the current user + * @example f81d4fae-7dec-11d0-a765-00a0c91e6bf6 + */ + UUID?: string; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions-forks': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + 'post_auth-{_version}-sessions-forks': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['PushForkSessionInput']; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example random-string */ + Selector?: string; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions-forks-{selector}': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + selector: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example base64-encrypted-payload */ + Payload?: string; + /** @example 1 */ + LocalID?: number; + /** @example dreiohgaf250627aghcdce2d47fb74b9 */ + UID?: string; + /** @example 4309h5mfe21f3iad8b3cb13bae050443 */ + AccessToken?: string; + /** @example weiojhtio43jtdc35bv3cb13bae05044 */ + RefreshToken?: string; + /** + * @deprecated + * @example 86400 + */ + ExpiresIn?: number; + /** @example Bearer */ + TokenType?: string; + /** + * @deprecated + * @example full self organization payments keys paid nondelinquent mail calendar + */ + Scope?: string; + Scopes?: string[]; + /** @example 10o-V0b0IUyUSzrZOypPB3VkwmYV0U5ZxwrZaQJAapnkwz-wVQwWj09d_w08ScP-r7X37SMuIrNLxn55ACHSbg== */ + UserID?: string; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-sessions-forks-{selector}': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + selector: string; + }; + 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 2001 */ + Code?: number; + /** @example Invalid selector */ + Error?: string; + /** @example [] */ + Details?: Record; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions': { + 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']; + 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; + })[]; + }; + }; + }; + }; + }; + 'post_auth-{_version}-sessions': { + 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?: string | null; + /** @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; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 */ + AccessToken?: string; + /** @example kafe30367aa7dc09bf5c48ghaza93e65 */ + RefreshToken?: string; + /** @example Bearer */ + TokenType?: string; + /** @example [] */ + Scopes?: string[]; + /** @example rf3c4fida9c2066e6c5669a293177cik */ + UID?: string; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-sessions': { + 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_auth-{_version}-sessions-payload': { + 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?: string | null; + /** @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; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-sessions-{uid}': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions-local-key': { + 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 32-byte-random-base64-encoded or null */ + ClientKey?: string; + }; + }; + }; + }; + }; + 'put_auth-{_version}-sessions-local-key': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** @example 32-byte-base64-encrypted-key */ + Key?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + }; + }; + }; + }; + }; + 'get_auth-{_version}-sessions-local': { + parameters: { + query?: { + /** + * @description Return only sessions where the user has the specified email address + * @example user-address@proton.me + */ + Email?: 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']; + Sessions?: { + /** @example null */ + Username?: string; + /** @example Display name */ + DisplayName?: string; + /** @example 1 */ + LocalID?: number; + /** @example 10o-l...wmYV0U */ + UserID?: string; + /** @example test@protonmail.com */ + PrimaryEmail?: string; + }[]; + }; + }; + }; + }; + }; + 'get_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'put_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'post_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'delete_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'options_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'head_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'patch_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'trace_auth-{_version}-mnemonic-validate': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; + 'post_auth-{_version}-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; + }; + }; + }; + 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_auth-{_version}-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_auth-{_version}-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_auth-{_version}': { + 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_auth-{_version}': { + 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_auth-{_version}-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_auth-{_version}-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_auth-{_version}-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_auth-{_version}-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_auth-{_version}-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; + /** @example http://protonmail.ch */ + RedirectURI?: 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_auth-{_version}-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; + /** @example http://protonmail.ch */ + RedirectURI?: 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_auth-{_version}-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']; + }; + }; + }; + }; + 'post_auth-{_version}-mnemonic-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; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + 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; + }; + }; + }; + }; + }; + 'post_auth-{_version}-mnemonic': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** + * @description only if the session is not in cookie mode + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + AccessToken?: string; + /** + * @deprecated + * @description only if no session already present + * @example 360000 + */ + ExpiresIn?: number; + /** + * @description only if the session is not in cookie mode + * @example Bearer + */ + TokenType?: string; + Scopes?: string[]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ + UserID?: string; + /** + * @description only if the session is not in cookie mode + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + RefreshToken?: string; + /** @example ACXDmTaBub14w== */ + EventID?: string; + /** @example */ + ServerProof?: string; + /** @example 0 */ + LocalID?: number; + /** @example 2 */ + PasswordMode?: number; + /** @description To notify the user if 2FA is being disabled */ + '2FA'?: { + /** + * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both + * @example 3 + */ + Enabled?: number; + }; + }; + }; + }; + }; + }; + 'post_auth-{_version}-mnemonic-reauth': { + 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 defaults to 0 if not present, transforms cookies into persistent cookies + * @example 1 + */ + PersistentCookies?: number; + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + Code?: components['schemas']['ResponseCodeSuccess']; + /** @example */ + ServerProof?: string; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example 8002 */ + Code?: number; + /** @example Incorrect login credentials. Please try again. */ + Error?: string; + }; + }; + }; + }; + }; +} diff --git a/js/cli/src/api/api-core-types.ts b/js/cli/src/api/api-core-types.ts new file mode 100644 index 00000000..3fc49db4 --- /dev/null +++ b/js/cli/src/api/api-core-types.ts @@ -0,0 +1,23919 @@ +// @ts-nocheck — generated OpenAPI types; duplicates in upstream spec break tsc; consumers use `import type` only. +/** + * 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}/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/{memberId}/saml': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations['delete_core-{_version}-members-{memberId}-saml']; + 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}/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}/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/{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 + * @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}-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/user': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add new user key. + * @description Add new user key by sending the encrypted PrivateKey and a value indicating whether the key should become + * primary, locked route. + */ + post: operations['post_core-{_version}-keys-user']; + 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 + */ + 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/{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/{_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': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get organization. + * @description Get information about 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/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/{enc_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-{enc_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}/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}/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}/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/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}/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}/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}/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; + }; + LogoRequest: { + /** + * The percent encoded address. Either Domain or Address are required. + * @default null + * @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; + /** + * @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; + SignedKeyList?: components['schemas']['SignedKeyListInput']; + }; + 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']['Id2']; + 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; + }; + 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; + }; + AddGroupMemberRequest: { + Type: components['schemas']['GroupMemberType']; + GroupID: components['schemas']['Id2']; + 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; + /** + * @example 1000 + * @enum {integer} + */ + readonly Code: 1000; + }; + /** @description An encrypted ID */ + EncryptedId: string; + GroupMembersResponse: { + Members: components['schemas']['GroupMember'][]; + Total: number; + /** + * @example 1000 + * @enum {integer} + */ + readonly Code: 1000; + }; + InternalGroupMembershipsResponse: { + Memberships: components['schemas']['InternalGroupMembership'][]; + Total: number; + /** + * @example 1000 + * @enum {integer} + */ + readonly 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; + SignedKeyListInputWrapper: { + SignedKeyList?: components['schemas']['SignedKeyListInput']; + }; + 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'][]; + }; + 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'][]; + }; + AddNewUserKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example 1 */ + Primary?: Record; + }; + 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; + }; + 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?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + 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'][]; + /** + * @description Activate new key for all other non-private admins + * @default null + */ + AdminActivations: components['schemas']['ReplaceOrganizationKeyActivationDto'][]; + }; + 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; + /** + * 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; + /** + * @description For passwordless key, signature of the token key packet + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature: string; + }; + 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'][]; + /** + * @description Activate new key for all other non-private admins + * @default null + */ + AdminActivations: components['schemas']['MigrateOrganizationKeyActivationDto'][]; + }; + UpdateOrgKeyFingerprintSignatureInput: { + Signature: components['schemas']['PGPSignature']; + AddressID: components['schemas']['Id2']; + }; + OrganizationLogo: { + /** + * @description The base64 encrypted logo + * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= + */ + Image: string; + }; + SsoXml: { + /** + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID?: string; + /** + * @description Base64 encoded XML blob + * @example + */ + XML?: 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; + }; + 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']; + }; + /** 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; + }; + 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']; + /** @default null */ + Addresses: components['schemas']['EventCollectionOutput']; + /** @default null */ + 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']; + /** + * @example 1000 + * @enum {integer} + */ + readonly Code: 1000; + }; + /** @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'][]; + }; + 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; + }; + }; + /** 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; + }; + /** 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; + }; + 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 + * @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; + /** @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; + }; + /** 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']['PGPMessage2'] | 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']['GroupMemberType2']; + State: components['schemas']['GroupMemberState']; + CreateTime: number; + GroupID: components['schemas']['Id']; + AddressID?: components['schemas']['Id'] | null; + Email?: string | null; + Permissions: components['schemas']['GroupMemberPermissions2']; + }; + /** GroupResponse */ + GroupResponse: { + ID: components['schemas']['Id']; + Name: string; + Address: unknown[]; + Permissions: components['schemas']['GroupPermissions2']; + CreateTime: number; + Flags: components['schemas']['GroupFlags2']; + Description?: string | null; + }; + MemberInfo: Record; + UpdateMemberRoleInput: { + Role: number; + /** @default null */ + OrganizationKeyInvitation: components['schemas']['OrganizationKeyInvitationDto'] | null; + /** @default null */ + OrganizationKeyActivation: components['schemas']['OrganizationKeyActivationDto'] | null; + }; + VPNAuthenticationCertificateAdminTransformer: { + /** + * @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; + }[]; + }; + 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']['PGPSignature2'] | 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']['PGPSignature2'] | null; + /** @description Organization public key */ + OrgPublicKey?: components['schemas']['PGPPublicKey2'] | 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; + }; + 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; + }; + 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 */ + InAppPromosHidden?: 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; + /** + * @description Bitmap informing which client the user has logged in to. See ClientFlagBit + * @example 1 + */ + UsedClientFlags?: number; + }; + 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; + }; + 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; + }; + 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; + }; + 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']['Filter']; + }[]; + 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']['CalendarAugmentedTransformer']; + }[]; + CalendarMembers?: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example 1 */ + Action?: number; + Member?: components['schemas']['MemberTransformer']; + }[]; + 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; + }; + 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[]; + }; + /** + * @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; + }; + /** + * @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; + }; + 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; + }; + /** @description An encrypted ID */ + Id2: string; + /** @description Base64 encoded binary data */ + BinaryString: string; + ResetAuthDevicesUserKeyDto: { + ID: components['schemas']['EncryptedId2']; + PrivateKey: components['schemas']['PGPPrivateKey']; + }; + /** @enum {integer} */ + GroupMemberType: 0 | 1; + /** @description An armored PGP Signature */ + PGPSignature: string; + /** @description An armored PGP Private Key */ + PGPPrivateKey: string; + /** @description An armored PGP Message */ + PGPMessage: string; + GroupProxyInstance: { + PgpVersion: number; + GroupAddressKeyFingerprint: string; + GroupMemberAddressKeyFingerprint: string; + ProxyParam: string; + }; + /** @enum {integer} */ + GroupPermissions: 0 | 1 | 2 | 3; + /** @enum {integer} */ + GroupFlags: 0; + /** @enum {integer} */ + GroupMemberPermissions: 0 | 1 | 2 | 3; + ExternalGroupMembership: { + ID: components['schemas']['Id']; + /** Format: date-time */ + CreateTime: string; + State: components['schemas']['GroupMemberState']; + Type: components['schemas']['GroupMemberType2']; + Email?: string | null; + Permissions: components['schemas']['GroupMemberPermissions2']; + /** 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']['GroupMemberType2']; + AddressID?: components['schemas']['Id'] | null; + Email?: string | null; + Permissions: components['schemas']['GroupMemberPermissions2']; + }; + InternalGroupMembership: { + ID: components['schemas']['Id']; + /** Format: date-time */ + CreateTime: string; + State: components['schemas']['GroupMemberState']; + Type: components['schemas']['GroupMemberType2']; + AddressId?: components['schemas']['Id'] | null; + Email?: string | null; + Permissions: components['schemas']['GroupMemberPermissions2']; + /** Format: date-time */ + JoinTime?: string | null; + TokenKeyPacket?: components['schemas']['BinaryString2'] | null; + TokenSignaturePacket?: components['schemas']['BinaryString2'] | null; + AddressSignaturePacket?: components['schemas']['BinaryString2'] | 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; + }; + /** @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; + }; + /** @enum {integer} */ + MemberPermission: 1; + /** @enum {integer} */ + MemberPermissionAction: 0 | 1; + UserKeyInput: { + PrivateKey: components['schemas']['PGPPrivateKey2']; + OrgPrivateKey: components['schemas']['PGPPrivateKey2']; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + OrgToken?: string; + }; + UnprivatizeMemberUserKeyDto: { + OrgPrivateKey: components['schemas']['PGPPrivateKey']; + OrgToken: components['schemas']['PGPMessage']; + }; + UnprivatizeMemberAddressKeyDto: { + AddressKeyID: components['schemas']['Id2']; + OrgTokenKeyPacket: components['schemas']['BinaryString']; + OrgSignature: components['schemas']['PGPSignature']; + }; + ReplaceOrganizationKeyInvitationDto: { + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString2']; + Signature: components['schemas']['PGPSignature2']; + SignatureAddressID: components['schemas']['Id']; + EncryptionAddressID: components['schemas']['Id']; + }; + ReplaceOrganizationKeyActivationDto: { + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString2']; + Signature: components['schemas']['PGPSignature2']; + }; + MigrateOrganizationKeyInvitationDto: { + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString2']; + Signature: components['schemas']['PGPSignature2']; + }; + MigrateOrganizationKeyActivationDto: { + MemberID: components['schemas']['Id']; + TokenKeyPacket: components['schemas']['BinaryString2']; + Signature: components['schemas']['PGPSignature2']; + }; + /** @enum {integer} */ + AIAssistantFlags: 0 | 1 | 2 | 3; + /** @enum {integer} */ + Environment: 6 | 7 | 16 | 4; + /** @description An armored PGP Public Key */ + PGPPublicKey: string; + /** @enum {integer} */ + PingNotificationStatus: 0 | 1; + /** @enum {integer} */ + PushNotificationStatus: 0 | 1; + /** @enum {integer} */ + Product: 1 | 2 | 3 | 4 | 5 | 6 | 7; + 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; + }; + /** @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; + }; + /** @enum {integer} */ + AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; + TranslatedStringInterface: Record; + /** @description An armored PGP Message */ + PGPMessage2: string; + /** @enum {integer} */ + GroupMemberType2: 0 | 1; + /** @enum {integer} */ + GroupMemberState: 0 | 1 | 2 | 3 | 4; + /** @enum {integer} */ + GroupMemberPermissions2: 0 | 1 | 2 | 3; + /** @enum {integer} */ + GroupPermissions2: 0 | 1 | 2 | 3; + /** @enum {integer} */ + GroupFlags2: 0; + OrganizationKeyInvitationDto: { + TokenKeyPacket: components['schemas']['BinaryString2']; + /** @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']['BinaryString2']; + /** @description Signature of the token key packet by the user key of the member */ + Signature: string; + }; + /** @description An armored PGP Signature */ + PGPSignature2: string; + /** @description An armored PGP Public Key */ + PGPPublicKey2: string; + /** @enum {string} */ + AuthLogStatus: 'success' | 'attempt' | 'failure'; + /** @enum {integer} */ + AuthLogProtection: 1 | 2 | 3 | 4 | 5; + /** @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; + }; + Filter: { + /** @example Ik65N-aChBuWFdo1JpmHJB4iWetfzjVLNILERQqbYFBZc5crnxOabXKuIMKhiwBNwiuogItetAUvkFTwJFJPQg== */ + ID?: string; + /** @example Updated */ + Name?: string; + /** @example 1 */ + Status?: number; + /** @example 1 */ + Priority?: number; + /** @example require ["fileinto"]; if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { fileinto "mylabel"; } else { keep; } */ + Sieve?: string; + Tree?: { + If?: { + Headers?: string[]; + Keys?: string[]; + Match?: { + /** @example Is */ + Type?: string; + }; + Format?: { + /** @example UnicodeCaseMap */ + Type?: string; + }; + /** @example Address */ + Type?: string; + AddressPart?: { + /** @example Domain */ + Type?: string; + }; + }; + Then?: { + /** @example Fileinto */ + Type?: string; + /** @example mylabel */ + Name?: string; + }[]; + /** @example If */ + Type?: string; + }[]; + /** @example 2 */ + 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; + }; + CalendarAugmentedTransformer: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** @example Organizational Calendar */ + Name?: string; + /** @example This text describes the calendar */ + Description?: string; + /** + * @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; + /** + * @description normal calendar: `0`, subscribed calendar: `1` + * @example 0 + * @enum {integer} + */ + Type?: 0 | 1; + Members?: components['schemas']['MemberTransformer'][]; + }; + MemberTransformer: { + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + ID?: string; + /** + * @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; + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + AddressID?: string; + /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ + CalendarID?: string; + /** @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; + /** + * @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; + }; + 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']['BinaryString2'] | 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']['BinaryString2']; + /** + * @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']['BinaryString2'] | 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']['BinaryString2']; + /** @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']['PGPSignature2'] | 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']['PGPMessage2']; + /** + * @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; + Type: components['schemas']['TransactionType']; + /** @default null */ + HashedTransactionID: components['schemas']['BinaryString2'] | null; + /** @default null */ + Label: components['schemas']['BinaryString2'] | null; + /** @default null */ + ExchangeRate: components['schemas']['ExchangeRateOutput'] | null; + /** @default null */ + Sender: components['schemas']['PGPMessage2'] | null; + /** @default null */ + ToList: components['schemas']['PGPMessage2'] | null; + /** @default null */ + Subject: components['schemas']['PGPMessage2'] | null; + /** @default null */ + Body: components['schemas']['PGPMessage2'] | 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; + }; + }; + /** @description An encrypted ID */ + EncryptedId2: string; + GroupMembershipGroup: { + ID: components['schemas']['Id']; + Name: string; + Address: string; + }; + /** @description Base64 encoded binary data */ + BinaryString2: string; + ForwardingKeys: { + PrivateKey?: components['schemas']['PGPPrivateKey2'] | null; + ActivationToken?: components['schemas']['PGPMessage2'] | null; + }; + /** @description An armored PGP Private Key */ + PGPPrivateKey2: string; + 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']['Id3']; + 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']['Id4']; + 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; + }; + /** @enum {integer} */ + AddressForwardingType: 1 | 2 | 3; + /** @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; + }; + /** @enum {integer} */ + WalletStatus: 0 | 1; + /** @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; + /** @enum {integer} */ + ScriptType: 1 | 2 | 3 | 4; + /** + * @description BTC address + * @example 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa + */ + BitcoinAddress: string; + /** @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; + }; + /** @enum {integer} */ + EventAction: 0 | 1 | 2 | 3; + /** @description An encrypted ID */ + Id3: string; + /** @enum {string} */ + Disposition: 'attachment' | 'inline'; + /** @description An encrypted ID */ + Id4: string; + /** @enum {string} */ + Disposition2: 'attachment' | 'inline'; + Tree: { + List?: string[]; + /** @example Require */ + Type?: string; + }[]; + /** @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': { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example 1 */ + Primary?: number; + 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; + }; + }; + }; + }; + }; + }; + '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': { + /** @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?: { + /** @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; + /** @example -----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + SignedKeyList?: { + /** @example JSON.stringify([{"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }[]; + /** @description include to enable 1 password mode, otherwise 2 password mode */ + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + AddressList?: components['schemas']['KTAddressListTransformer']; + /** + * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x + */ + EncryptedSecret?: string; + }; + }; + }; + 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': { + 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']; + }; + }; + }; + }; + }; + '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': { + 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']; + }; + }; + }; + }; + }; + 'put_core-{_version}-keys-private': { + 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; + }[]; + /** + * @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; + }; + /** + * @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 Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example + */ + EncryptedSecret?: 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}-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'][]; + }; + }; + }; + }; + }; + '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']; + }; + }; + }; + }; + }; + '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']; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + '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']; + }; + }; + }; + }; + }; + 'put_core-{_version}-groups-external-{jwt}': { + parameters: { + query?: { + GroupID?: components['schemas']['Id'] | null; + }; + 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']; + }; + }; + }; + }; + }; + 'delete_core-{_version}-groups-external-{jwt}': { + parameters: { + query?: { + GroupID?: components['schemas']['Id'] | null; + }; + 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']; + }; + }; + }; + }; + }; + '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']; + }; + }; + }; + }; + }; + '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: { + [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: { + [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: { + [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: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ProtonSuccess']; + }; + }; + }; + }; + '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. First key is primary. */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @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. First key is primary. */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @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 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?: Record; + /** @description It will return all SKLs where a revision change happened after the specified revision */ + AfterRevision?: 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']; + 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?: 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']; + 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': { + /** @example 5Pz8W...basdk== */ + ID?: 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?: { + /** @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']; + }; + }; + }; + }; + }; + '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-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; + }; + }; + }; + }; + }; + 'post_core-{_version}-keys-reset': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** + * @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----- */ + PrivateKey?: 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; + /** @description In the signed key list there is only the new keys */ + SignedKeyList?: { + /** @example JSON.stringify([{"Fingerprint": "de90483475164ec6353c93f767df53b0ca8395cf","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }[]; + /** @description Include to enable 1 password mode, otherwise 2 password mode */ + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + AddressList?: components['schemas']['KTAddressListTransformer']; + }; + }; + }; + 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']; + }; + }; + }; + }; + }; + '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']['VPNAuthenticationCertificateAdminTransformer'][]; + AuthenticationCertificates?: components['schemas']['VPNAuthenticationCertificateAdminTransformer'][]; + }; + }; + }; + }; + }; + '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']; + }; + }; + }; + }; + }; + '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': { + 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; + }; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + }; + }; + }; + '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-{enc_id}': { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components['schemas']['Id']; + }; + 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']['OrganizationSettings']; + }; + }; + }; + }; + 'put_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']; + }; + }; + }; + }; + '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?: Record; + /** @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?: Record; + /** @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?: never; + 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?: 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': { + 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?: 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}-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}-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?: Record; + }; + 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; + }; + }; + }; + 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']; + }; + }; + }; + }; + }; + '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}-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']; + /** @description will only appear with successful coupon token checks */ + Plans?: (components['schemas']['Plan'] & { + /** @example 2 */ + MaxTier?: number; + /** @example 1 */ + Quantity?: number; + })[]; + }; + }; + }; + }; + }; + '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?: 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']; + }; + }; + }; + /** @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; + }; + }; + }; + 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[]; + }; + }; + }; + }; + 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-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; + }; + }; + }; + }; + }; + '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?: Record; + CreatedAt?: string; + BrandID?: Record; + }; + 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; + /** @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?: Record; + }; + 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: { + [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?: Record; + ConversationCounts?: Record; + }; + 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 (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; + }; + }; + }; + }; + }; + '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; + }; + }; + }; + 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; + /** @example http://protonmail.ch */ + RedirectURI?: 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; + /** @example http://protonmail.ch */ + RedirectURI?: 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}-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/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts new file mode 100644 index 00000000..0c775cc9 --- /dev/null +++ b/js/cli/src/api/apiClient.ts @@ -0,0 +1,160 @@ +import ky, { type AfterResponseHook, type KyInstance } from 'ky'; + +import { Logger } from '../../../sdk/src'; +import type { Config } from '../config'; +import { Credentials } from '../credentials'; +import type { paths as AuthPaths } from './api-auth-types'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +type RefreshResponseBody = + AuthPaths['/auth/{_version}/refresh']['post']['responses']['200']['content']['application/json']; + +export class ApiClient { + private authenticatedClientBase: KyInstance; + private authenticatedClient: KyInstance; + private unauthenticatedClient: KyInstance; + + private activeRefreshPromise: Promise | null = null; + + readonly baseUrlWithProtocol: string; + + constructor( + private readonly config: Config, + private readonly credentials: Credentials, + private readonly logger: Logger, + ) { + const baseUrl = this.config.baseUrl; + this.baseUrlWithProtocol = baseUrl.match(/^https?:\/\//) ? baseUrl : `https://${baseUrl}`; + + const baseClientOptions = { + headers: { + 'x-pm-appversion': this.config.appVersion, + }, + timeout: DEFAULT_TIMEOUT_MS, + }; + this.authenticatedClientBase = ky.create({ + ...baseClientOptions, + hooks: { + afterResponse: [this.createRefreshSessionAfterResponseHook()], + }, + }); + this.authenticatedClient = this.authenticatedClientBase; + this.unauthenticatedClient = ky.create(baseClientOptions); + this.updateAuthenticatedClientHeaders(); + + credentials.on('sessionInfoChanged', () => this.updateAuthenticatedClientHeaders()); + } + + private updateAuthenticatedClientHeaders() { + this.authenticatedClient = this.authenticatedClientBase.extend({ + headers: { + ...(this.credentials.uid && { 'x-pm-uid': this.credentials.uid }), + ...(this.credentials.accessToken && { Authorization: `Bearer ${this.credentials.accessToken}` }), + }, + }); + } + + get authenticatedRequest(): KyInstance { + return this.authenticatedClient; + } + + get unauthenticatedRequest(): KyInstance { + return this.unauthenticatedClient; + } + + private createRefreshSessionAfterResponseHook(): AfterResponseHook { + return async (request, options, response) => { + if (response.status !== 401 || shouldSkipAuthRefreshForUrl(request.url)) { + return; + } + + this.logger.info('Refreshing session'); + + const refreshed = await this.refreshSessionIfPossible(); + if (!refreshed) { + return; + } + + const uid = this.credentials.uid; + const accessToken = this.credentials.accessToken; + if (!uid || !accessToken) { + return; + } + + const headers = new Headers(options.headers); + headers.set('x-pm-appversion', this.config.appVersion); + headers.set('x-pm-uid', uid); + headers.set('Authorization', `Bearer ${accessToken}`); + options.headers = headers; + + return this.authenticatedClient(request, options); + }; + } + + async refreshSessionIfPossible(): Promise { + this.activeRefreshPromise ??= this.performTokenRefresh().finally(() => { + this.activeRefreshPromise = null; + }); + return this.activeRefreshPromise; + } + + private async performTokenRefresh(): Promise { + const refreshToken = this.credentials.refreshToken; + if (!refreshToken) { + this.logger.warn('Failed to refresh session: missing RefreshToken'); + return false; + } + + const response = await this.authenticatedClient.post(`${this.baseUrlWithProtocol}/auth/v4/refresh`, { + json: { + ResponseType: 'token', + GrantType: 'refresh_token', + RefreshToken: refreshToken, + }, + throwHttpErrors: false, + }); + + if (!response.ok) { + this.logger.error('Failed to refresh session', response); + if (response.status >= 400 && response.status < 500 && response.status !== 429) { + await this.credentials.signOut(); + } + return false; + } + + const data = (await response.json()) as RefreshResponseBody; + const uid = data.UID ?? this.credentials.uid; + const accessToken = data.AccessToken; + if (!uid || !accessToken) { + this.logger.error('Failed to refresh session: missing UID or AccessToken', data); + return false; + } + + await this.credentials.setSessionInfo({ + uid, + accessToken, + refreshToken: data.RefreshToken ?? refreshToken, + }); + return true; + } +} + +function shouldSkipAuthRefreshForUrl(url: string): boolean { + let pathname: string; + try { + pathname = new URL(url).pathname.toLowerCase(); + } catch { + pathname = url.toLowerCase(); + } + if (pathname.includes('/auth/v4/refresh')) { + return true; + } + if (pathname.includes('/auth/v4/sessions')) { + return true; + } + if (pathname.includes('/core/v4/auth')) { + return true; + } + return false; +} diff --git a/js/cli/src/api/auth.ts b/js/cli/src/api/auth.ts new file mode 100644 index 00000000..13f19afc --- /dev/null +++ b/js/cli/src/api/auth.ts @@ -0,0 +1,220 @@ +import bcrypt from 'bcryptjs'; + +import { Logger } from '../../../sdk/src'; +import { Credentials } from '../credentials'; +import { VERIFICATION_STATUS } from '../crypto/lib'; +import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; +import { uint8ArrayToBinaryString, binaryStringToUint8Array, mergeUint8Arrays } from '../crypto/lib/utils'; +import { generateProofs } from '../srp/lib/srp'; +import { expandHash } from '../srp/lib/passwords'; +import { BCRYPT_PREFIX, SRP_LEN, SRP_MODULUS_KEY } from '../srp/lib/constants'; +import { AccountApi, AccountApiError } from './accountApi'; +import { + FORK_INITIAL_DELAY_MS, + FORK_MAX_POLL_TIME_MS, + FORK_POLL_INTERVAL_MS, + generateSignInUrl, + parseUserKeyPassword, +} from './authWeb'; +import { sleepMs } from './sleep'; + +export class Auth { + constructor( + private readonly cryptoApi: CryptoApiInterface, + private readonly accountApi: AccountApi, + private readonly credentials: Credentials, + private readonly logger: Logger, + ) {} + + isLoggedIn(): boolean { + return this.credentials.isLoggedIn(); + } + + async loadSession(): Promise { + await this.credentials.load(); + } + + async logout(): Promise { + await this.credentials.signOut(); + } + + async authViaPassword( + username: string, + password: string, + ): Promise<{ + uid: string; + accessToken: string; + refreshToken?: string; + }> { + this.logger.debug('Getting auth info'); + const info = await this.accountApi.info(username); + + const publicKey = await this.cryptoApi.importPublicKey({ armoredKey: SRP_MODULUS_KEY }); + const modulusResult = await this.cryptoApi.verifyCleartextMessage({ + armoredCleartextMessage: info.Modulus || '', + verificationKeys: publicKey, + }); + + if (modulusResult.verificationStatus !== VERIFICATION_STATUS.SIGNED_AND_VALID) { + throw new Error('Failed to verify auth response'); + } + + this.logger.debug('Generating proofs'); + const results = await this.generateAuthProofs({ + loginPassword: password, + modulusData: modulusResult.data, + serverEphemeral: info.ServerEphemeral || '', + salt: info.Salt || '', + }); + + this.logger.debug('Authenticating'); + const authResponse = await this.accountApi.auth({ + Username: username, + SRPSession: info.SRPSession || '', + PersistentCookies: 1, + Payload: {}, + ClientProof: results.clientProof, + ClientEphemeral: results.clientEphemeral, + }); + + if (!authResponse.ServerProof) { + throw new Error('Missing ServerProof'); + } + if (authResponse.ServerProof !== results.expectedServerProof) { + throw new Error('Server proof verification failed'); + } + if (!authResponse.UID || !authResponse.AccessToken) { + throw new Error('Missing UID or AccessToken'); + } + + await this.credentials.setSessionInfo({ + uid: authResponse.UID, + accessToken: authResponse.AccessToken, + ...(authResponse.RefreshToken !== undefined && { refreshToken: authResponse.RefreshToken }), + }); + + this.logger.debug(`Getting user key password`); + const userKeyPassword = await this.getUserKeyPassword(password); + + await this.credentials.setUserKeyPassword(userKeyPassword); + + return { + uid: authResponse.UID, + accessToken: authResponse.AccessToken, + refreshToken: authResponse.RefreshToken, + }; + } + + private async generateAuthProofs(params: { + loginPassword: string; + modulusData: string; + serverEphemeral: string; + salt: string; + }): Promise<{ + clientEphemeral: string; + clientProof: string; + expectedServerProof: string; + }> { + const modulusArray = Uint8Array.fromBase64(params.modulusData) as Uint8Array; + const serverEphemeralArray = Uint8Array.fromBase64(params.serverEphemeral) as Uint8Array; + const saltBytes = Uint8Array.fromBase64(params.salt); + const saltBinary = binaryStringToUint8Array(`${uint8ArrayToBinaryString(saltBytes)}proton`); + const unexpandedHash = await bcrypt.hash( + params.loginPassword, + BCRYPT_PREFIX + bcrypt.encodeBase64(saltBinary, 16), + ); + const hashedPasswordArray = await expandHash( + this.cryptoApi, + mergeUint8Arrays([binaryStringToUint8Array(unexpandedHash), modulusArray]), + ); + + const { clientEphemeral, clientProof, expectedServerProof } = await generateProofs(this.cryptoApi, { + byteLength: SRP_LEN, + modulusArray, + hashedPasswordArray, + serverEphemeralArray, + }); + + const results = { + clientEphemeral: clientEphemeral.toBase64(), + clientProof: clientProof.toBase64(), + expectedServerProof: expectedServerProof.toBase64(), + }; + + return results; + } + + private async getUserKeyPassword(loginPassword: string): Promise { + const salts = await this.accountApi.salts(); + const userKeySalt = salts.KeySalts?.at(0)?.KeySalt; + if (!userKeySalt) { + throw new Error('Missing KeySalt'); + } + + const keyPassword = ( + await bcrypt.hash( + loginPassword, + BCRYPT_PREFIX + bcrypt.encodeBase64(Uint8Array.fromBase64(userKeySalt), 16), + ) + ).slice(29); + return keyPassword; + } + + async authViaWeb( + onSignInUrl: (signInUrl: string) => void | Promise, + signal?: AbortSignal, + ): Promise<{ + uid: string; + accessToken: string; + refreshToken?: string; + }> { + this.logger.debug('Authenticating via web'); + const forkResponse = await this.accountApi.sessionForksInit(); + + const { encryptionKey, signInUrl } = generateSignInUrl(forkResponse.UserCode); + + await onSignInUrl(signInUrl); + + await sleepMs(FORK_INITIAL_DELAY_MS, signal); + + const startTime = Date.now(); + while (true) { + if (Date.now() - startTime > FORK_MAX_POLL_TIME_MS) { + throw new Error('Authentication timed out'); + } + + this.logger.debug('Checking authentication status'); + + let response; + try { + response = await this.accountApi.sessionForksStatus(forkResponse.Selector); + } catch (error) { + // The API returns 422 if the authentication is not yet ready. + if (error instanceof AccountApiError && error.httpCode === 422) { + this.logger.debug('Authentication not yet ready'); + await sleepMs(FORK_POLL_INTERVAL_MS, signal); + continue; + } + + throw error; + } + + const userKeyPassword = parseUserKeyPassword(encryptionKey, response.Payload); + + this.logger.debug('Authentication successful'); + + await this.credentials.setUserKeyPassword(userKeyPassword); + await this.credentials.setSessionInfo({ + uid: response.UID, + accessToken: response.AccessToken, + refreshToken: response.RefreshToken, + }); + + return { + uid: response.UID, + accessToken: response.AccessToken, + refreshToken: response.RefreshToken, + }; + } + } +} diff --git a/js/cli/src/api/authWeb.ts b/js/cli/src/api/authWeb.ts new file mode 100644 index 00000000..5602dbfd --- /dev/null +++ b/js/cli/src/api/authWeb.ts @@ -0,0 +1,60 @@ +import { createDecipheriv, randomBytes } from 'node:crypto'; + +const PROTON_ACCOUNT_URL = 'https://account.proton.me'; +const PROTON_SSO_CHILD_CLIENT_ID = 'external-drive'; +const FORK_AAD = Buffer.from('fork', 'utf8'); +const GCM_NONCE_LENGTH = 12; +const GCM_TAG_LENGTH = 16; + +export const FORK_POLL_INTERVAL_MS = 5000; +export const FORK_INITIAL_DELAY_MS = 5000; +export const FORK_MAX_POLL_TIME_MS = 10 * 60 * 1000; // 10 minutes + +type ForkPayloadJson = { + type?: string; + keyPassword?: string; +}; + +export function generateSignInUrl(userCode: string): { + encryptionKey: Buffer; + signInUrl: string; +} { + const encryptionKey = randomBytes(32); + const base64EncodedKey = encryptionKey.toString('base64'); + const payload = `0:${userCode}:${base64EncodedKey}:${PROTON_SSO_CHILD_CLIENT_ID}`; + const signInUrl = `${PROTON_ACCOUNT_URL}/desktop/login?app=drive&pv=3#payload=${encodeURIComponent(payload)}`; + + return { + encryptionKey, + signInUrl, + }; +} + +export function parseUserKeyPassword(encryptionKey: Buffer, encryptedPayload: string): string { + const decryptedPayload = decryptForkPayload(encryptedPayload, encryptionKey); + const userKeyPassword = parseForkUserKeyPassword(decryptedPayload); + return userKeyPassword; +} + +function decryptForkPayload(encodedPayload: string, encryptionKey: Buffer): string { + const blob = Buffer.from(encodedPayload, 'base64'); + if (blob.length < GCM_NONCE_LENGTH + GCM_TAG_LENGTH) { + throw new Error('Invalid fork payload blob length'); + } + const nonce = blob.subarray(0, GCM_NONCE_LENGTH); + const tag = blob.subarray(blob.length - GCM_TAG_LENGTH); + const ciphertext = blob.subarray(GCM_NONCE_LENGTH, blob.length - GCM_TAG_LENGTH); + const decipher = createDecipheriv('aes-256-gcm', encryptionKey, nonce); + decipher.setAuthTag(tag); + decipher.setAAD(FORK_AAD); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +function parseForkUserKeyPassword(decryptedPayloadJson: string): string { + const payload = JSON.parse(decryptedPayloadJson) as ForkPayloadJson; + const keyPassword = payload.keyPassword; + if (typeof keyPassword !== 'string') { + throw new Error('Failed to deserialize the fork payload'); + } + return keyPassword; +} diff --git a/js/cli/src/api/httpClient.ts b/js/cli/src/api/httpClient.ts new file mode 100644 index 00000000..6e5f2ab1 --- /dev/null +++ b/js/cli/src/api/httpClient.ts @@ -0,0 +1,29 @@ +import { ProtonDriveHTTPClientJsonRequest, ProtonDriveHTTPClientBlobRequest } from '../../../sdk/src'; +import { ApiClient } from './apiClient'; + +export class HTTPClient { + constructor(private readonly apiClient: ApiClient) {} + + async fetchJson(options: ProtonDriveHTTPClientJsonRequest): Promise { + return this.apiClient.authenticatedRequest(options.url, { + method: options.method, + ...(options.json !== undefined ? { json: options.json } : {}), + ...(options.body !== undefined && options.json === undefined ? { body: options.body } : {}), + headers: options.headers, + timeout: options.timeoutMs, + signal: options.signal, + throwHttpErrors: false, + }); + } + + async fetchBlob(options: ProtonDriveHTTPClientBlobRequest): Promise { + return this.apiClient.authenticatedRequest(options.url, { + method: options.method, + body: options.body, + headers: options.headers, + timeout: options.timeoutMs, + signal: options.signal, + throwHttpErrors: false, + }); + } +} diff --git a/js/cli/src/api/index.ts b/js/cli/src/api/index.ts new file mode 100644 index 00000000..af7467de --- /dev/null +++ b/js/cli/src/api/index.ts @@ -0,0 +1,33 @@ +import { Logger } from '../../../sdk/src'; +import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; +import type { Config } from '../config'; +import type { Credentials } from '../credentials'; +import { AccountApi } from './accountApi'; +import { Addresses } from './addresses'; +import { ApiClient } from './apiClient'; +import { Auth } from './auth'; +import { HTTPClient } from './httpClient'; +import { Srp } from './srp'; + +export type { Addresses } from './addresses'; +export type { Auth } from './auth'; +export type { Srp } from './srp'; + +export async function initApi(config: Config, cryptoApi: CryptoApiInterface, credentials: Credentials, logger: Logger) { + const apiClient = new ApiClient(config, credentials, logger); + const accountApi = new AccountApi(apiClient); + const addresses = new Addresses(cryptoApi, accountApi, credentials, logger); + const auth = new Auth(cryptoApi, accountApi, credentials, logger); + const srp = new Srp(cryptoApi, accountApi); + const httpClient = new HTTPClient(apiClient); + + await auth.loadSession(); + + return { + credentials, + addresses, + auth, + srp, + httpClient, + }; +} diff --git a/js/cli/src/api/sleep.ts b/js/cli/src/api/sleep.ts new file mode 100644 index 00000000..bc2bce04 --- /dev/null +++ b/js/cli/src/api/sleep.ts @@ -0,0 +1,20 @@ +export function sleepMs(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error('Aborted')); + return; + } + + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + reject(signal?.reason ?? new Error('Aborted')); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/js/cli/src/api/srp.ts b/js/cli/src/api/srp.ts new file mode 100644 index 00000000..90a674a9 --- /dev/null +++ b/js/cli/src/api/srp.ts @@ -0,0 +1,59 @@ +import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; +import { getSrp, getRandomSrpVerifier } from '../srp/lib/srp'; +import { computeKeyPassword } from '../srp/lib/keys'; +import { AccountApi } from './accountApi'; + +export class Srp { + constructor( + private readonly cryptoApi: CryptoApiInterface, + private readonly accountApi: AccountApi, + ) {} + + async getSrp( + version: number, + modulus: string, + serverEphemeral: string, + salt: string, + password: string, + ): Promise<{ + expectedServerProof: string; + clientProof: string; + clientEphemeral: string; + }> { + return getSrp( + this.cryptoApi, + { + Version: version, + Modulus: modulus, + ServerEphemeral: serverEphemeral, + Salt: salt, + }, + { password }, + ); + } + + async getSrpVerifier(password: string) { + const result = await this.accountApi.modulus(); + if (!result.Modulus || !result.ModulusID) { + throw new Error('Missing modulus'); + } + + const { version, salt, verifier } = await getRandomSrpVerifier( + this.cryptoApi, + { + Modulus: result.Modulus, + }, + { password }, + ); + return { + modulusId: result.ModulusID, + version, + salt, + verifier, + }; + } + + async computeKeyPassword(password: string, salt: string) { + return await computeKeyPassword(password, salt); + } +} diff --git a/js/cli/src/cache/crypto.ts b/js/cli/src/cache/crypto.ts new file mode 100644 index 00000000..9a4be56a --- /dev/null +++ b/js/cli/src/cache/crypto.ts @@ -0,0 +1,41 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'; + +const CACHE_ENCRYPTION_CONTEXT = Buffer.from('Drive.EncryptedCacheRepository', 'utf8'); +const IV_BYTE_COUNT = 12; +const SALT_BYTE_COUNT = 16; +const TAG_BYTE_COUNT = 16; +const KEY_BYTE_COUNT = 32; + +export function encryptCacheValue(cacheKey: string, plaintext: string, ikm: Buffer): string { + const plaintextBytes = Buffer.from(plaintext, 'utf8'); + const salt = randomBytes(SALT_BYTE_COUNT); + const info = concatBuffers(CACHE_ENCRYPTION_CONTEXT, Buffer.from(cacheKey, 'utf8')); + const derived = Buffer.from(hkdfSync('sha256', ikm, salt, info, KEY_BYTE_COUNT + IV_BYTE_COUNT)); + const aesKey = derived.subarray(0, KEY_BYTE_COUNT); + const iv = derived.subarray(KEY_BYTE_COUNT); + const cipher = createCipheriv('aes-256-gcm', aesKey, iv, { authTagLength: TAG_BYTE_COUNT }); + const ciphertext = Buffer.concat([cipher.update(plaintextBytes), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([salt, ciphertext, tag]).toString('base64'); +} + +export function decryptCacheValue(cacheKey: string, encryptedBase64: string, ikm: Buffer): string { + const combined = Buffer.from(encryptedBase64, 'base64'); + if (combined.length < SALT_BYTE_COUNT + TAG_BYTE_COUNT) { + throw new Error('Invalid encrypted data format'); + } + const salt = combined.subarray(0, SALT_BYTE_COUNT); + const ciphertext = combined.subarray(SALT_BYTE_COUNT, combined.length - TAG_BYTE_COUNT); + const tag = combined.subarray(combined.length - TAG_BYTE_COUNT); + const info = concatBuffers(CACHE_ENCRYPTION_CONTEXT, Buffer.from(cacheKey, 'utf8')); + const derived = Buffer.from(hkdfSync('sha256', ikm, salt, info, KEY_BYTE_COUNT + IV_BYTE_COUNT)); + const aesKey = derived.subarray(0, KEY_BYTE_COUNT); + const iv = derived.subarray(KEY_BYTE_COUNT); + const decipher = createDecipheriv('aes-256-gcm', aesKey, iv, { authTagLength: TAG_BYTE_COUNT }); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} + +function concatBuffers(a: Buffer, b: Buffer): Buffer { + return Buffer.concat([a, b]); +} diff --git a/js/cli/src/cache/encryptedCache.ts b/js/cli/src/cache/encryptedCache.ts new file mode 100644 index 00000000..1d75dc2e --- /dev/null +++ b/js/cli/src/cache/encryptedCache.ts @@ -0,0 +1,51 @@ +import { Logger } from '../../../sdk/src'; +import { encryptCacheValue, decryptCacheValue } from './crypto'; +import { SQLiteEntititesCache } from './sqliteCache'; + +/** + * SQLite entity cache that encrypts values at rest (HKDF-SHA256 + AES-256-GCM), + * matching `Proton.Sdk.Caching.EncryptedCacheRepository` in the C# SDK. + */ +export class EncryptedSQLiteEntitiesCache extends SQLiteEntititesCache { + private encryptionKeyMaterialPromise?: Promise; + + constructor( + cacheDir: string, + private readonly getEncryptionPassword: () => Promise, + private readonly logger: Logger, + ) { + super(cacheDir); + } + + override async setEntity(key: string, data: string, tags?: string[]) { + const encrypted = encryptCacheValue(key, data, await this.getEncryptionKeyMaterial()); + await super.setEntity(key, encrypted, tags); + } + + override async getEntity(key: string) { + const encrypted = await super.getEntity(key); + try { + return decryptCacheValue(key, encrypted, await this.getEncryptionKeyMaterial()); + } catch (error: unknown) { + this.logger.error(`Failed to decrypt entity ${key}, clearing cache`, error); + await this.clear(); + throw Error(`Entity ${key} not found`); + } + } + + private async getEncryptionKeyMaterial(): Promise { + if (!this.encryptionKeyMaterialPromise) { + this.encryptionKeyMaterialPromise = (async () => { + try { + const keyMaterial = Buffer.from(await this.getEncryptionPassword(), 'base64'); + return keyMaterial; + } catch (error: unknown) { + this.logger.error(`Failed to get encryption key material`, error); + this.encryptionKeyMaterialPromise = undefined; + throw error; + } + })(); + } + return this.encryptionKeyMaterialPromise; + } +} diff --git a/js/cli/src/cache/index.ts b/js/cli/src/cache/index.ts new file mode 100644 index 00000000..b3c0ae53 --- /dev/null +++ b/js/cli/src/cache/index.ts @@ -0,0 +1,22 @@ +import type { Logger, ProtonDriveCache } from '../../../sdk/src'; +import { Credentials } from '../credentials'; +import { Config } from '../config'; +import { SQLiteEntititesCache } from './sqliteCache'; +import { EncryptedSQLiteEntitiesCache } from './encryptedCache'; + +export function createEntitiesCache( + config: Config, + credentials: Credentials, + logger: Logger, +): ProtonDriveCache { + if (config.unsafeCache) { + return new SQLiteEntititesCache(config.cacheDir); + } + return new EncryptedSQLiteEntitiesCache( + config.cacheDir, + async () => { + return credentials.getCachePassword(); + }, + logger, + ); +} diff --git a/js/cli/src/cache/sqliteCache.ts b/js/cli/src/cache/sqliteCache.ts new file mode 100644 index 00000000..20d63c17 --- /dev/null +++ b/js/cli/src/cache/sqliteCache.ts @@ -0,0 +1,73 @@ +import { Database } from 'bun:sqlite'; +import path from 'node:path'; + +import type { ProtonDriveCache, EntityResult } from '../../../sdk/src'; + +export class SQLiteEntititesCache implements ProtonDriveCache { + private db: Database; + + constructor(cacheDir: string) { + const cacheFile = path.join(cacheDir, 'cache-entities.sqlite'); + this.db = new Database(cacheFile, { create: true }); + this.db.run('CREATE TABLE IF NOT EXISTS entities (key TEXT PRIMARY KEY, value TEXT)'); + this.db.run('CREATE TABLE IF NOT EXISTS entities_labels (label TEXT, key TEXT, UNIQUE (label, key))'); + } + + async clear() { + this.db.run('DELETE FROM entities'); + this.db.run('DELETE FROM entities_labels'); + } + + async setEntity(key: string, data: string, tags?: string[]) { + const query = this.db.query('INSERT OR REPLACE INTO entities (key, value) VALUES ($key, $data)'); + query.run({ $key: key, $data: data }); + + // Remove previous tags. + const deleteQuery = this.db.query('DELETE FROM entities_labels WHERE key = $key'); + deleteQuery.run({ $key: key }); + + for (const tag of tags || []) { + const insertQuery = this.db.query( + 'INSERT OR REPLACE INTO entities_labels (label, key) VALUES ($tag, $key)', + ); + insertQuery.run({ $tag: tag, $key: key }); + } + } + + async getEntity(key: string) { + const query = this.db.query('SELECT value FROM entities WHERE key = $key'); + const result = query.get({ $key: key }); + if (!result) { + throw Error(`Entity ${key} not found`); + } + // @ts-expect-error - SQLite returns unknown type. + return result['value']; + } + + async *iterateEntities(keys: string[]): AsyncGenerator> { + for (const key of keys) { + try { + const value = await this.getEntity(key); + yield { key, ok: true, value }; + } catch (error) { + yield { key, ok: false, error: `${error}` }; + } + } + } + + async *iterateEntitiesByTag(tag: string): AsyncGenerator> { + const query = this.db.query('SELECT key FROM entities_labels WHERE label = $tag'); + const result = query.all({ $tag: tag }); + // @ts-expect-error - SQLite returns unknown type. + yield* this.iterateEntities(result.map((row) => row['key'])); + } + + async removeEntities(keys: string[]) { + for (const key of keys) { + const query1 = this.db.query('DELETE FROM entities WHERE key = $key'); + query1.run({ $key: key }); + const query2 = this.db.query('DELETE FROM entities_labels WHERE key = $key'); + query2.run({ $key: key }); + } + } +} diff --git a/js/cli/src/cli/auth/commandAuthLogin.ts b/js/cli/src/cli/auth/commandAuthLogin.ts index df998bf8..a26b77d8 100644 --- a/js/cli/src/cli/auth/commandAuthLogin.ts +++ b/js/cli/src/cli/auth/commandAuthLogin.ts @@ -1,6 +1,9 @@ import { ParseArgsConfig } from 'util'; +import { printObject } from '../formatters'; import { Command, ActionArgs } from '../interface'; +import { readPasswordLine } from '../readPasswordLine'; +import { openBrowserUrl } from '../openBrowserUrl'; export class CommandAuthLogin implements Command { group = 'auth'; @@ -12,18 +15,27 @@ export class CommandAuthLogin implements Command { type: 'string', default: '', }, + web: { + type: 'boolean', + default: false, + }, }; - async action({ account, args: [username], options: { password, json } }: ActionArgs) { - if (!password) { - console.log('Password:'); - // FIXME hide password when typing - for await (const line of console) { - password = line.trim(); - break; + async action({ auth, args: [username], options: { password, web, json } }: ActionArgs) { + if (web) { + const session = await auth.authViaWeb((signInUrl) => { + openBrowserUrl(signInUrl); + console.log('Sign in in your browser (URL also printed if it did not open automatically):'); + console.log(signInUrl); + }); + printObject(session, json); + } else { + if (!password) { + password = await readPasswordLine('Password: '); } + + const session = await auth.authViaPassword(username, password); + printObject(session, json); } - await account.auth(username, password, !json); - console.log(account.session); } } diff --git a/js/cli/src/cli/auth/commandAuthLogout.ts b/js/cli/src/cli/auth/commandAuthLogout.ts index 088ce5ef..e79dd1a7 100644 --- a/js/cli/src/cli/auth/commandAuthLogout.ts +++ b/js/cli/src/cli/auth/commandAuthLogout.ts @@ -5,7 +5,7 @@ export class CommandAuthLogout implements Command { name = 'logout'; isAuthAction = true; - async action({ account }: ActionArgs) { - await account.logout(); + async action({ auth }: ActionArgs) { + await auth.logout(); } } diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 5bce05ca..46a82991 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -2,7 +2,7 @@ import { ParseArgsConfig } from 'util'; import { ProtonDriveClient } from '../../../sdk/src'; import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; import { Diagnostic } from '../../../sdk/src/diagnostic'; -import { Account } from '../account/account'; +import { Auth } from '../api'; import { Paths } from './paths'; export interface Command { @@ -17,7 +17,7 @@ export interface Command { } export interface ActionArgs { - account: Account; + auth: Auth; sdk: ProtonDriveClient; photosSdk: ProtonDrivePhotosClient; sdkDiagnostic: Diagnostic; diff --git a/js/cli/src/cli/openBrowserUrl.ts b/js/cli/src/cli/openBrowserUrl.ts new file mode 100644 index 00000000..a14a896e --- /dev/null +++ b/js/cli/src/cli/openBrowserUrl.ts @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process'; + +/** + * Opens `url` in the system default browser. Fails silently (e.g. no GUI, SSH). + * Only http(s) URLs are opened; invalid or other schemes are ignored. + */ +export function openBrowserUrl(rawUrl: string): void { + const url = getAllowedBrowserUrl(rawUrl); + if (!url) { + return; + } + + const child = + process.platform === 'darwin' + ? spawn('open', [url], { detached: true, stdio: 'ignore' }) + : process.platform === 'win32' + ? // /d: skip auto run + // /s: make quoting rules more strict + // /c: execute command and exit + spawn('start', ['', url], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) + : spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }); + + child.on('error', () => {}); + child.unref(); +} + +function getAllowedBrowserUrl(url: string): string | undefined { + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + return parsed.href; + } + } catch {} +} diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index e53db678..03fd5fb7 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -24,8 +24,8 @@ export class Paths { constructor( private sdk: ProtonDriveClient, private photosSdk: ProtonDrivePhotosClient, - private account: { - isLoggedIn(): Promise; + private auth: { + isLoggedIn(): boolean; }, ) { this.sdk = sdk; @@ -65,7 +65,7 @@ export class Paths { throw new Error('Custom password is required'); } - const isAnonymousContext = !(await this.account.isLoggedIn()); + const isAnonymousContext = !this.auth.isLoggedIn(); this.publicLinkSdk = await this.sdk.experimental.authPublicLink(url, customPassword, isAnonymousContext); return this.publicLinkSdk; } diff --git a/js/cli/src/cli/readPasswordLine.ts b/js/cli/src/cli/readPasswordLine.ts new file mode 100644 index 00000000..1e1c09b0 --- /dev/null +++ b/js/cli/src/cli/readPasswordLine.ts @@ -0,0 +1,88 @@ +import * as readline from 'readline'; + +/** + * Read one line from stdin without echoing (TTY). Non-TTY falls back to visible readline. + */ +export function readPasswordLine(prompt: string): Promise { + const stdin = process.stdin; + const stdout = process.stdout; + + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { + const rl = readline.createInterface({ input: stdin, output: stdout }); + return new Promise((resolve) => { + let settled = false; + const finish = (answer: string) => { + if (settled) { + return; + } + settled = true; + rl.close(); + resolve(answer.trim()); + }; + + rl.question(prompt, (answer) => finish(answer)); + rl.once('close', () => finish('')); + }); + } + + return new Promise((resolve) => { + stdout.write(prompt); + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + + let password = ''; + let settled = false; + + const cleanup = () => { + stdin.setRawMode(wasRaw); + stdin.removeListener('data', onData); + stdin.removeListener('end', onEnd); + }; + + const finish = (value: string) => { + if (settled) { + return; + } + settled = true; + cleanup(); + stdout.write('\n'); + resolve(value); + }; + + const onEnd = () => { + finish(password); + }; + + const onData = (chunk: Buffer | string) => { + const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + for (const char of s) { + // Enter, new line + if (char === '\n' || char === '\r') { + finish(password); + return; + } + // Ctrl+D + if (char === '\u0004') { + finish(password); + return; + } + // Ctrl+C + if (char === '\u0003') { + cleanup(); + process.exit(130); + } + // Backspace (delete 0x7F, backspace 0x08) + if (char === '\u007f' || char === '\b') { + password = password.slice(0, -1); + continue; + } + password += char; + } + }; + + stdin.on('end', onEnd); + stdin.on('data', onData); + }); +} diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts new file mode 100644 index 00000000..469535d1 --- /dev/null +++ b/js/cli/src/config.ts @@ -0,0 +1,26 @@ +export interface Config { + appVersion: string; + baseUrl: string; + cacheDir: string; + enableConsoleLog: boolean; + unsafeSecrets: boolean; + unsafeCache: boolean; +} + +export function getConfig(debug: boolean): Config { + const unsafeSecrets = ['yes', 'y', '1', 'true'].includes( + process.env.PROTON_DRIVE_UNSAFE_SECRETS?.toLowerCase() ?? '', + ); + + return { + // App version must have two or three parts, no more or less, separated by dash. + // First part is platform name, second is product name, third is optional section. + // That's why we have sdkclijs instead of sdk-cli-js. + appVersion: 'external-drive-sdkclijs@1.0.0', + baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', + cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), + enableConsoleLog: debug, + unsafeSecrets, + unsafeCache: unsafeSecrets, + }; +} diff --git a/js/cli/src/credentials/credentials.ts b/js/cli/src/credentials/credentials.ts new file mode 100644 index 00000000..233665be --- /dev/null +++ b/js/cli/src/credentials/credentials.ts @@ -0,0 +1,96 @@ +import { randomBytes } from 'node:crypto'; + +import { Logger } from '../../../sdk/src'; +import type { SessionInfo, CredentialsStore } from './interface'; + +export class Credentials { + private cachePassword?: string; + private userKeyPassword?: string; + private sessionInfo?: SessionInfo; + + private readonly sessionInfoChangedCallbacks = new Set<() => void>(); + + constructor( + private readonly store: CredentialsStore, + private readonly logger: Logger, + ) {} + + on(_: 'sessionInfoChanged', callback: () => void): void { + this.sessionInfoChangedCallbacks.add(callback as () => void); + } + + isLoggedIn(): boolean { + return !!this.userKeyPassword && !!this.sessionInfo; + } + + getUserKeyPassword(): string | undefined { + return this.userKeyPassword; + } + + async getCachePassword(): Promise { + if (!this.cachePassword) { + this.cachePassword = randomBytes(32).toString('base64'); + await this.persistCredentials(); + } + return this.cachePassword; + } + + get uid(): string | undefined { + return this.sessionInfo?.uid; + } + + get accessToken(): string | undefined { + return this.sessionInfo?.accessToken; + } + + get refreshToken(): string | undefined { + return this.sessionInfo?.refreshToken; + } + + async load(): Promise { + const raw = await this.store.load(); + if (!raw) { + this.logger.debug(`No session loaded`); + return; + } + this.cachePassword = raw.cachePassword; + this.userKeyPassword = raw.userKeyPassword; + this.sessionInfo = raw.session; + this.notifySessionInfoChanged(); + } + + async setUserKeyPassword(userKeyPassword: string): Promise { + this.userKeyPassword = userKeyPassword; + await this.persistCredentials(); + this.notifySessionInfoChanged(); + } + + async setSessionInfo(info: SessionInfo): Promise { + this.sessionInfo = info; + await this.persistCredentials(); + this.notifySessionInfoChanged(); + } + + async signOut(): Promise { + this.logger.debug(`Signing out`); + this.userKeyPassword = undefined; + this.sessionInfo = undefined; + await this.store.remove(); + this.notifySessionInfoChanged(); + } + + private async persistCredentials(): Promise { + if (!this.userKeyPassword || !this.sessionInfo) { + return; + } + await this.store.save({ + cachePassword: this.cachePassword, + userKeyPassword: this.userKeyPassword, + session: this.sessionInfo, + }); + } + + private notifySessionInfoChanged(): void { + this.sessionInfoChangedCallbacks.forEach((callback) => callback()); + } +} diff --git a/js/cli/src/credentials/fileCredentialsStore.ts b/js/cli/src/credentials/fileCredentialsStore.ts new file mode 100644 index 00000000..b801bc5c --- /dev/null +++ b/js/cli/src/credentials/fileCredentialsStore.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { unlink } from 'node:fs/promises'; + +import { Logger } from '../../../sdk/src'; +import type { Credentials, CredentialsStore } from './interface'; +import { parseStoredSnapshot } from './parseCredentials'; + +const UNSAFE_SESSION_FILENAME = 'auth-session.json'; + +export class PlaintextFileSessionStore implements CredentialsStore { + private readonly filePath: string; + + constructor(private readonly cacheDir: string, private readonly logger: Logger) { + this.filePath = path.join(this.cacheDir, UNSAFE_SESSION_FILENAME); + } + + async load(): Promise { + const file = Bun.file(this.filePath); + if (!(await file.exists())) { + this.logger.debug(`Session file does not exist: ${this.filePath}`); + return null; + } + this.logger.debug(`Loading session from file: ${this.filePath}`); + return parseStoredSnapshot(await file.text()); + } + + async save(snapshot: Credentials): Promise { + this.logger.debug(`Saving session to file: ${this.filePath}`); + await Bun.write(this.filePath, JSON.stringify(snapshot), { mode: 0o600 }); + } + + async remove(): Promise { + this.logger.debug(`Removing session file: ${this.filePath}`); + try { + await unlink(this.filePath); + } catch (err: unknown) { + const code = err && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + if (code !== 'ENOENT') { + throw err; + } + } + } +} diff --git a/js/cli/src/credentials/index.ts b/js/cli/src/credentials/index.ts new file mode 100644 index 00000000..9da86acf --- /dev/null +++ b/js/cli/src/credentials/index.ts @@ -0,0 +1,20 @@ +import { Logger } from '../../../sdk/src'; +import type { Config } from '../config'; +import type { CredentialsStore } from './interface'; +import { PlaintextFileSessionStore } from './fileCredentialsStore'; +import { SecretsSessionStore } from './secretCredentialsStore'; +import { Credentials } from './credentials'; + +export type { Credentials } from './credentials'; + +export function initCredentials(config: Config, logger: Logger): Credentials { + const credentialsStore = createAuthSessionStore(config, logger); + return new Credentials(credentialsStore, logger); +} + +function createAuthSessionStore(config: Config, logger: Logger): CredentialsStore { + if (config.unsafeSecrets) { + return new PlaintextFileSessionStore(config.cacheDir, logger); + } + return new SecretsSessionStore(logger); +} diff --git a/js/cli/src/credentials/interface.ts b/js/cli/src/credentials/interface.ts new file mode 100644 index 00000000..586b0218 --- /dev/null +++ b/js/cli/src/credentials/interface.ts @@ -0,0 +1,17 @@ +export type Credentials = { + cachePassword?: string; + userKeyPassword: string; + session: SessionInfo; +}; + +export type SessionInfo = { + uid: string; + accessToken: string; + refreshToken?: string; +}; + +export interface CredentialsStore { + load(): Promise; + save(snapshot: Credentials): Promise; + remove(): Promise; +} diff --git a/js/cli/src/credentials/parseCredentials.test.ts b/js/cli/src/credentials/parseCredentials.test.ts new file mode 100644 index 00000000..4605bf9a --- /dev/null +++ b/js/cli/src/credentials/parseCredentials.test.ts @@ -0,0 +1,88 @@ +import { parseStoredSnapshot } from './parseCredentials'; + +function validSnapshot(overrides: Record = {}) { + return JSON.stringify({ + userKeyPassword: 'user-key-pass', + session: { + uid: 'uid-1', + accessToken: 'access-token', + }, + ...overrides, + }); +} + +describe('parseStoredSnapshot', () => { + it('returns null for invalid inputs', () => { + expect(parseStoredSnapshot(null)).toBeNull(); + expect(parseStoredSnapshot('')).toBeNull(); + expect(parseStoredSnapshot('{not json')).toBeNull(); + }); + + it('parses a minimal valid snapshot', () => { + const raw = validSnapshot(); + expect(parseStoredSnapshot(raw)).toEqual({ + cachePassword: undefined, + userKeyPassword: 'user-key-pass', + session: { + uid: 'uid-1', + accessToken: 'access-token', + refreshToken: undefined, + }, + }); + }); + + it('parses a valid snapshot with optional cachePassword and refreshToken', () => { + const raw = validSnapshot({ + cachePassword: 'cached', + session: { + uid: 'uid-1', + accessToken: 'access-token', + refreshToken: 'refresh', + }, + }); + expect(parseStoredSnapshot(raw)).toEqual({ + cachePassword: 'cached', + userKeyPassword: 'user-key-pass', + session: { + uid: 'uid-1', + accessToken: 'access-token', + refreshToken: 'refresh', + }, + }); + }); + + it('returns null when cachePassword is present but not a string', () => { + expect(parseStoredSnapshot(validSnapshot({ cachePassword: true }))).toBeNull(); + }); + + it('returns null when userKeyPassword is missing or not a non-empty string', () => { + expect(parseStoredSnapshot(validSnapshot({ userKeyPassword: undefined }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ userKeyPassword: '' }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ userKeyPassword: 1 }))).toBeNull(); + }); + + it('returns null when session is missing or not an object', () => { + expect(parseStoredSnapshot(validSnapshot({ session: undefined }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ session: 'not an object' }))).toBeNull(); + }); + + it('returns null when uid is missing or not a non-empty string', () => { + expect(parseStoredSnapshot(validSnapshot({ session: { accessToken: 'a' } }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ session: { uid: '', accessToken: 'a' } }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ session: { uid: 1, accessToken: 'a' } }))).toBeNull(); + }); + + it('returns null when accessToken is missing or not a non-empty string', () => { + expect(parseStoredSnapshot(validSnapshot({ session: { uid: 'u' } }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ session: { uid: 'u', accessToken: '' } }))).toBeNull(); + expect(parseStoredSnapshot(validSnapshot({ session: { uid: 'u', accessToken: false } }))).toBeNull(); + }); + + it('returns null when refreshToken is present but not a string', () => { + expect( + parseStoredSnapshot( + validSnapshot({ session: { uid: 'u', accessToken: 'a', refreshToken: ['x'] } }), + ), + ).toBeNull(); + }); +}); diff --git a/js/cli/src/credentials/parseCredentials.ts b/js/cli/src/credentials/parseCredentials.ts new file mode 100644 index 00000000..853ce61e --- /dev/null +++ b/js/cli/src/credentials/parseCredentials.ts @@ -0,0 +1,33 @@ +import type { Credentials } from "./interface"; + +export function parseStoredSnapshot(raw: string | null): Credentials | null { + if (raw == null || raw === '') { + return null; + } + try { + const session = JSON.parse(raw) as Credentials; + if ( + (session.cachePassword && typeof session.cachePassword !== 'string') || + !session.userKeyPassword || + typeof session.userKeyPassword !== 'string' || + !session.session?.uid || + typeof session.session.uid !== 'string' || + !session.session?.accessToken || + typeof session.session.accessToken !== 'string' || + (session.session?.refreshToken && typeof session.session.refreshToken !== 'string') + ) { + return null; + } + return { + cachePassword: session.cachePassword, + userKeyPassword: session.userKeyPassword, + session: { + uid: session.session.uid, + accessToken: session.session.accessToken, + refreshToken: session.session.refreshToken, + }, + }; + } catch { + return null; + } +} diff --git a/js/cli/src/credentials/secretCredentialsStore.ts b/js/cli/src/credentials/secretCredentialsStore.ts new file mode 100644 index 00000000..2e0c50bd --- /dev/null +++ b/js/cli/src/credentials/secretCredentialsStore.ts @@ -0,0 +1,34 @@ +import { Logger } from '../../../sdk/src'; +import type { Credentials, CredentialsStore } from './interface'; +import { parseStoredSnapshot } from './parseCredentials'; + +const SECRET_SERVICE = 'ch.proton.drive-sdk-cli'; +const SECRET_NAME = 'auth-session'; + +export class SecretsSessionStore implements CredentialsStore { + constructor(private readonly logger: Logger) {} + + async load(): Promise { + this.logger.debug(`Loading session ${SECRET_NAME} from secrets`); + const raw = + // @ts-expect-error: Bun.secrets is not typed. + (await Bun.secrets.get({ service: SECRET_SERVICE, name: SECRET_NAME })) as string | null; + return parseStoredSnapshot(raw); + } + + async save(snapshot: Credentials): Promise { + this.logger.debug(`Saving session ${SECRET_NAME} to secrets`); + // @ts-expect-error: Bun.secrets is not typed. + await Bun.secrets.set({ + service: SECRET_SERVICE, + name: SECRET_NAME, + value: JSON.stringify(snapshot), + }); + } + + async remove(): Promise { + this.logger.debug(`Removing session ${SECRET_NAME} from secrets`); + // @ts-expect-error: Bun.secrets is not typed. + await Bun.secrets.delete({ service: SECRET_SERVICE, name: SECRET_NAME }); + } +} diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 3decac09..fc34df04 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -1,164 +1,70 @@ -import { Account } from './account/account'; -import { Srp } from './account/srp'; -import { SQLiteEntititesCache } from './cache'; -import { Paths } from './cli/paths'; -import { Api as CryptoApi } from './crypto/lib/worker/api'; -import { HTTPClient } from './httpClient'; -import { initTelemetry } from './telemetry'; - import { ProtonDriveClient, MemoryCache, CachedCryptoMaterial, OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy, - MetricEvent, - Logger, FeatureFlags, } from '../../sdk/src'; -import { Telemetry } from '../../sdk/src/telemetry'; import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; import { initDiagnostic } from '../../sdk/src/diagnostic'; -interface Config { - appVersion: string; - baseUrl: string; - cacheDir: string; - enableConsoleLog: boolean; - uid?: string; - accessToken?: string; -} +import { initApi } from './api'; +import { createEntitiesCache } from './cache'; +import { getConfig } from './config'; +import { initCredentials } from './credentials'; +import { Paths } from './cli/paths'; +import { Api as CryptoApi } from './crypto/lib/worker/api'; +import { initTelemetry } from './telemetry'; export async function init(debug: boolean) { - const cryptoApi = initCrypto(); const config = getConfig(debug); const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); - const account = await initAccount(cryptoApi, config, telemetry.getLogger('account')); - const srp = await initSrp(cryptoApi, config); - const sdk = await initSDK(cryptoApi, config, account, srp, telemetry); - const photosSdk = await initPhotosSDK(cryptoApi, config, account, srp, telemetry); - const sdkDiagnostic = await initSDKDiagnostic(cryptoApi, config, account, srp); - const paths = new Paths(sdk, photosSdk, account); - return { - account, - sdk, - photosSdk, - sdkDiagnostic, - paths, - }; -} + const logger = telemetry.getLogger('cli'); -function initCrypto() { - CryptoApi.init(); - return new CryptoApi(); -} - -function getConfig(debug: boolean): Config { - return { - // App version must have two or three parts, no more or less, separated by dash. - // First part is platform name, second is product name, third is optional section. - // That's why we have sdkclijs instead of sdk-cli-js. - appVersion: 'external-drive-sdkclijs@5.0.999.999', - baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', - cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), - enableConsoleLog: debug, - }; -} - -async function initAccount(cryptoApi: CryptoApi, config: Config, logger: Logger) { - const account = new Account(cryptoApi, config, logger); - await account.loadSession(); - return account; -} - -async function initSrp(cryptoApi: CryptoApi, config: Config) { - return new Srp(cryptoApi, config); -} - -async function initSDK( - cryptoApi: CryptoApi, - config: Config, - account: Account, - srp: Srp, - telemetry: Telemetry, -) { - const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider, featureFlagProvider } = - await createSDKDependencies(config, account, cryptoApi); + const cryptoApi = initCrypto(); + const credentials = initCredentials(config, logger); + const { auth, addresses, srp, httpClient } = await initApi(config, cryptoApi, credentials, logger); - const sdk = new ProtonDriveClient({ - httpClient, - entitiesCache, - cryptoCache, + const entitiesCache = createEntitiesCache(config, credentials, logger); + const sdkDependencies = { config: { baseUrl: config.baseUrl, clientUid: 'proton-drive-sdk-js-cli-test', }, - telemetry, - account, - openPGPCryptoModule, - srpModule: srp, - latestEventIdProvider, - featureFlagProvider, - }); - return sdk; -} - -async function initPhotosSDK( - cryptoApi: CryptoApi, - config: Config, - account: Account, - srp: Srp, - telemetry: Telemetry, -) { - const { httpClient, entitiesCache, cryptoCache, openPGPCryptoModule, latestEventIdProvider, featureFlagProvider } = - await createSDKDependencies(config, account, cryptoApi); - - return new ProtonDrivePhotosClient({ httpClient, entitiesCache, - cryptoCache, - config: { - baseUrl: config.baseUrl, - clientUid: 'proton-drive-photos-sdk-js-cli-test', - }, + cryptoCache: new MemoryCache(), telemetry, - account, - openPGPCryptoModule, + openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi as OpenPGPCryptoProxy), + account: addresses, srpModule: srp, - latestEventIdProvider, - featureFlagProvider, - }); -} + latestEventIdProvider: new NoLatestEventIdProvider(), + featureFlagProvider: await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), + }; + const sdk = new ProtonDriveClient(sdkDependencies); + const photosSdk = new ProtonDrivePhotosClient(sdkDependencies); + const sdkDiagnostic = initDiagnostic(sdkDependencies); -async function initSDKDiagnostic(cryptoApi: CryptoApi, config: Config, account: Account, srp: Srp) { - const { httpClient, openPGPCryptoModule } = await createSDKDependencies(config, account, cryptoApi); + const paths = new Paths(sdk, photosSdk, auth); - return initDiagnostic({ - httpClient, - config: { baseUrl: config.baseUrl }, - account, - openPGPCryptoModule, - srpModule: srp, - }); -} - -async function createSDKDependencies(config: Config, account: Account, cryptoApi: CryptoApi) { return { - httpClient: new HTTPClient({ - ...config, - uid: account.session?.uid || '', - accessToken: account.session?.accessToken || '', - }), - entitiesCache: new SQLiteEntititesCache(config.cacheDir), - cryptoCache: new MemoryCache(), - openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi as OpenPGPCryptoProxy), - latestEventIdProvider: new NoLatestEventIdProvider(), - featureFlagProvider: await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), + auth, + addresses, + sdk, + photosSdk, + sdkDiagnostic, + paths, }; } +function initCrypto() { + CryptoApi.init(); + return new CryptoApi(); +} + class NoLatestEventIdProvider { - getLatestEventId(): string | null { + async getLatestEventId(): Promise { return null; } } diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 9ee02699..c7967215 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,7 +1,5 @@ import { parseArgs } from 'util'; -import { VERSION } from '../../sdk/src'; - import { init } from './init'; import { getCommand, validateCommandArguments, formatReadableJson } from './cli'; @@ -21,34 +19,19 @@ validateCommandArguments(command, args, options); const debug = !options.json; -const { account, sdk, photosSdk, sdkDiagnostic, paths } = await init(debug); - -if (debug) { - console.log(`Proton Drive SDK for web v${VERSION}`); -} +const { auth, sdk, photosSdk, sdkDiagnostic, paths } = await init(debug); -if (!command.isAuthAction && !command.isPublicAction && !account.session) { +if (!command.isAuthAction && !command.isPublicAction && !auth.isLoggedIn()) { throw new Error('You need to login first'); } -if (!command.isAuthAction && account.session) { - if (debug) { - console.time('Account initialization'); - console.log('Initializing user keys...'); - } - await account.loadPrimaryKeys(); - if (debug) { - console.timeEnd('Account initialization'); - } -} - if (debug) { console.time('Command execution'); } try { await command.action({ - account, + auth, sdk, photosSdk, sdkDiagnostic, 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 9afe1946db94602e812f8f04da53d03cb6ae22de Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 7 Apr 2026 08:46:36 +0200 Subject: [PATCH 573/732] Add public CLI --- .gitignore | 1 + js/cli/README.md | 20 +- js/cli/bun.lock | 1235 ++++++++++++++++- js/cli/jest.config.js | 2 + js/cli/package.json | 4 +- js/cli/src/api/addresses.ts | 3 +- js/cli/src/api/apiClient.ts | 3 +- js/cli/src/api/auth.ts | 3 +- js/cli/src/api/httpClient.ts | 3 +- js/cli/src/api/index.ts | 5 +- js/cli/src/cache/encryptedCache.ts | 3 +- js/cli/src/cache/index.ts | 3 +- js/cli/src/cache/sqliteCache.ts | 2 +- js/cli/src/cli/auth/commandAuthLogin.ts | 41 - .../src/cli/bookmark/commandBookmarkCreate.ts | 16 - .../src/cli/bookmark/commandBookmarkList.ts | 11 - .../src/cli/bookmark/commandBookmarkRemove.ts | 14 - js/cli/src/cli/device/commandDeviceCreate.ts | 21 - js/cli/src/cli/device/commandDeviceDelete.ts | 14 - js/cli/src/cli/device/commandDeviceRename.ts | 13 - .../commandDiagnosticGetTreeStructure.ts | 24 - .../commandDiagnosticPhotosTimeline.ts | 50 - .../cli/diagnostic/commandDiagnosticTree.ts | 56 - js/cli/src/cli/downloader.ts | 49 - js/cli/src/cli/errors.ts | 11 + js/cli/src/cli/events.ts | 44 - js/cli/src/cli/events/commandEventsDrive.ts | 13 - .../src/cli/events/commandEventsMyVolume.ts | 22 - js/cli/src/cli/events/commandEventsPath.ts | 39 - .../cli/fileSystem/commandFileSystemCopy.ts | 38 - .../commandFileSystemCreateFolder.ts | 22 - .../cli/fileSystem/commandFileSystemDelete.ts | 17 - .../fileSystem/commandFileSystemDownload.ts | 33 - .../commandFileSystemDownloadSeeking.ts | 154 -- .../commandFileSystemDownloadThumbnails.ts | 63 - .../fileSystem/commandFileSystemEmptyTrash.ts | 11 - .../commandFileSystemGetAvailableName.ts | 20 - .../cli/fileSystem/commandFileSystemInfo.ts | 14 - .../cli/fileSystem/commandFileSystemList.ts | 105 -- .../cli/fileSystem/commandFileSystemMove.ts | 31 - .../cli/fileSystem/commandFileSystemRename.ts | 16 - .../fileSystem/commandFileSystemRestore.ts | 17 - .../cli/fileSystem/commandFileSystemTrash.ts | 17 - .../cli/fileSystem/commandFileSystemUpload.ts | 74 - .../src/cli/fileSystem/generateThumbnails.ts | 59 - js/cli/src/cli/formatters.ts | 2 +- js/cli/src/cli/index.ts | 246 +--- js/cli/src/cli/interface.ts | 8 +- js/cli/src/cli/node.ts | 2 +- js/cli/src/cli/paths.ts | 8 +- .../src/cli/photos/CommandPhotoEmptyTrash.ts | 11 - js/cli/src/cli/photos/commandAlbumAddPhoto.ts | 28 - js/cli/src/cli/photos/commandAlbumCreate.ts | 13 - js/cli/src/cli/photos/commandAlbumDelete.ts | 28 - js/cli/src/cli/photos/commandAlbumList.ts | 11 - js/cli/src/cli/photos/commandAlbumPhotos.ts | 39 - .../src/cli/photos/commandAlbumRemovePhoto.ts | 28 - js/cli/src/cli/photos/commandAlbumUpdate.ts | 51 - .../src/cli/photos/commandPhotoDuplicate.ts | 31 - js/cli/src/cli/photos/commandPhotoRoot.ts | 12 - js/cli/src/cli/photos/commandPhotoTimeline.ts | 34 - js/cli/src/cli/photos/commandPhotoUpdate.ts | 93 -- js/cli/src/cli/public/base.ts | 13 - .../cli/public/commandPublicCreateDocument.ts | 35 - .../cli/public/commandPublicCreateFolder.ts | 27 - js/cli/src/cli/public/commandPublicDelete.ts | 23 - .../src/cli/public/commandPublicDownload.ts | 40 - .../public/commandPublicDownloadThumbnails.ts | 66 - .../public/commandPublicGetAvailableName.ts | 31 - js/cli/src/cli/public/commandPublicInfo.ts | 21 - js/cli/src/cli/public/commandPublicList.ts | 38 - js/cli/src/cli/public/commandPublicRename.ts | 22 - .../src/cli/public/commandPublicScanHashes.ts | 23 - js/cli/src/cli/public/commandPublicUpload.ts | 70 - js/cli/src/cli/registryCore.ts | 109 ++ js/cli/src/cli/repl.ts | 67 + .../src/cli/revision/commandRevisionDelete.ts | 14 - .../cli/revision/commandRevisionDownload.ts | 19 - .../src/cli/revision/commandRevisionList.ts | 27 - .../cli/revision/commandRevisionRestore.ts | 14 - js/cli/src/cli/run.ts | 100 ++ .../cli/sharing/commandInvitationAccept.ts | 28 - .../src/cli/sharing/commandInvitationList.ts | 24 - .../cli/sharing/commandInvitationReject.ts | 28 - ...ommandSharingConvertNonProtonInvitation.ts | 25 - .../src/cli/sharing/commandSharingInvite.ts | 52 - .../src/cli/sharing/commandSharingRemove.ts | 37 - .../cli/sharing/commandSharingRemoveUrl.ts | 19 - .../src/cli/sharing/commandSharingSetUrl.ts | 47 - .../src/cli/sharing/commandSharingStatus.ts | 17 - js/cli/src/cli/splitQuotedLine.test.ts | 28 + js/cli/src/cli/splitQuotedLine.ts | 63 + js/cli/src/commands/auth/commandAuthLogin.ts | 20 + .../auth/commandAuthLogout.ts | 2 +- js/cli/src/commands/index.ts | 1 + js/cli/src/commands/registry.ts | 9 + js/cli/src/config.ts | 17 +- js/cli/src/credentials/credentials.ts | 3 +- .../src/credentials/fileCredentialsStore.ts | 3 +- js/cli/src/credentials/index.ts | 5 +- .../src/credentials/secretCredentialsStore.ts | 5 +- js/cli/src/init.ts | 16 +- js/cli/src/proton-drive.ts | 61 +- js/cli/tsconfig.json | 5 + 104 files changed, 1754 insertions(+), 2581 deletions(-) delete mode 100644 js/cli/src/cli/auth/commandAuthLogin.ts delete mode 100644 js/cli/src/cli/bookmark/commandBookmarkCreate.ts delete mode 100644 js/cli/src/cli/bookmark/commandBookmarkList.ts delete mode 100644 js/cli/src/cli/bookmark/commandBookmarkRemove.ts delete mode 100644 js/cli/src/cli/device/commandDeviceCreate.ts delete mode 100644 js/cli/src/cli/device/commandDeviceDelete.ts delete mode 100644 js/cli/src/cli/device/commandDeviceRename.ts delete mode 100644 js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts delete mode 100644 js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts delete mode 100644 js/cli/src/cli/diagnostic/commandDiagnosticTree.ts delete mode 100644 js/cli/src/cli/downloader.ts create mode 100644 js/cli/src/cli/errors.ts delete mode 100644 js/cli/src/cli/events.ts delete mode 100644 js/cli/src/cli/events/commandEventsDrive.ts delete mode 100644 js/cli/src/cli/events/commandEventsMyVolume.ts delete mode 100644 js/cli/src/cli/events/commandEventsPath.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemCopy.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemDelete.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemDownload.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemInfo.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemList.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemMove.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemRename.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemRestore.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemTrash.ts delete mode 100644 js/cli/src/cli/fileSystem/commandFileSystemUpload.ts delete mode 100644 js/cli/src/cli/fileSystem/generateThumbnails.ts delete mode 100644 js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumAddPhoto.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumCreate.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumDelete.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumList.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumPhotos.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumRemovePhoto.ts delete mode 100644 js/cli/src/cli/photos/commandAlbumUpdate.ts delete mode 100644 js/cli/src/cli/photos/commandPhotoDuplicate.ts delete mode 100644 js/cli/src/cli/photos/commandPhotoRoot.ts delete mode 100644 js/cli/src/cli/photos/commandPhotoTimeline.ts delete mode 100644 js/cli/src/cli/photos/commandPhotoUpdate.ts delete mode 100644 js/cli/src/cli/public/base.ts delete mode 100644 js/cli/src/cli/public/commandPublicCreateDocument.ts delete mode 100644 js/cli/src/cli/public/commandPublicCreateFolder.ts delete mode 100644 js/cli/src/cli/public/commandPublicDelete.ts delete mode 100644 js/cli/src/cli/public/commandPublicDownload.ts delete mode 100644 js/cli/src/cli/public/commandPublicDownloadThumbnails.ts delete mode 100644 js/cli/src/cli/public/commandPublicGetAvailableName.ts delete mode 100644 js/cli/src/cli/public/commandPublicInfo.ts delete mode 100644 js/cli/src/cli/public/commandPublicList.ts delete mode 100644 js/cli/src/cli/public/commandPublicRename.ts delete mode 100644 js/cli/src/cli/public/commandPublicScanHashes.ts delete mode 100644 js/cli/src/cli/public/commandPublicUpload.ts create mode 100644 js/cli/src/cli/registryCore.ts create mode 100644 js/cli/src/cli/repl.ts delete mode 100644 js/cli/src/cli/revision/commandRevisionDelete.ts delete mode 100644 js/cli/src/cli/revision/commandRevisionDownload.ts delete mode 100644 js/cli/src/cli/revision/commandRevisionList.ts delete mode 100644 js/cli/src/cli/revision/commandRevisionRestore.ts create mode 100644 js/cli/src/cli/run.ts delete mode 100644 js/cli/src/cli/sharing/commandInvitationAccept.ts delete mode 100644 js/cli/src/cli/sharing/commandInvitationList.ts delete mode 100644 js/cli/src/cli/sharing/commandInvitationReject.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingInvite.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingRemove.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingRemoveUrl.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingSetUrl.ts delete mode 100644 js/cli/src/cli/sharing/commandSharingStatus.ts create mode 100644 js/cli/src/cli/splitQuotedLine.test.ts create mode 100644 js/cli/src/cli/splitQuotedLine.ts create mode 100644 js/cli/src/commands/auth/commandAuthLogin.ts rename js/cli/src/{cli => commands}/auth/commandAuthLogout.ts (77%) create mode 100644 js/cli/src/commands/index.ts create mode 100644 js/cli/src/commands/registry.ts 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 diff --git a/js/cli/README.md b/js/cli/README.md index f0513f6a..29c9ae92 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -4,7 +4,6 @@ The app implements and integrates dependencies for the SDK, such as HTTP client, The integration implements only limited capabilities to run simplest run of the SDK. For example, 2FA or other than simple username and password authentication is not supported, crypto module is copy-paste from the clients monorepo without using the web workers. -This module is intended for testing purposes mainly and is in no way recommended to use for any real users. ## Installation @@ -18,33 +17,36 @@ bun install ## Build -To build the executable, run the following command. +From `js/cli`: ```bash bun run build ``` -The executable will be located in the same directory as `proton-drive`. +Artifacts are written to the **current working directory** (typically `js/cli/` when you run the commands above). ## Run -First, you will need to log in. +Sign in via the browser (no username/password arguments needed): ```bash -./proton-drive auth login USER +./proton-drive auth login ``` -Then, you can call any command, for example list nodes in _My files_ section. +Start the interactive shell: ```bash -./proton-drive fs list /my-files +./proton-drive ``` -Run the executable without any parameter to get help. +Get help: + +```bash +./proton-drive help +``` ## Environment variables | Name | Description | Default Value | |--------------------------|-------------------------------------------------------------------|-----------------------------| -| `PROTON_DRIVE_BASE_URL` | URL for the API. | `drive-api.proton.me` | | `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | diff --git a/js/cli/bun.lock b/js/cli/bun.lock index d8a402a8..a37bc3bb 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -13,6 +13,7 @@ "@jimp/js-tiff": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", + "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "ky": "^1.7.2", @@ -53,15 +54,33 @@ "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], @@ -69,10 +88,36 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ=="], + "@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="], "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q=="], + + "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA=="], + + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA=="], + + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g=="], + + "@babel/plugin-proposal-class-properties": ["@babel/plugin-proposal-class-properties@7.18.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ=="], + + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="], + + "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw=="], + + "@babel/plugin-proposal-nullish-coalescing-operator": ["@babel/plugin-proposal-nullish-coalescing-operator@7.18.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA=="], + + "@babel/plugin-proposal-object-rest-spread": ["@babel/plugin-proposal-object-rest-spread@7.20.7", "", { "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" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg=="], + + "@babel/plugin-proposal-optional-chaining": ["@babel/plugin-proposal-optional-chaining@7.6.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/plugin-syntax-optional-chaining": "^7.2.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-kj4gkZ6qUggkprRq3Uh5KP8XnE1MdIO0J7MhdDX8+rAbB6dJ2UrensGIS+0NPZAaaJ1Vr0PN6oLUgXMU1uMcSg=="], + + "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], + "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], @@ -81,7 +126,15 @@ "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="], - "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="], + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew=="], + + "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw=="], + + "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw=="], "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="], @@ -107,6 +160,134 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], + "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], + + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], + + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", "@babel/traverse": "^7.29.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w=="], + + "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g=="], + + "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-replace-supers": "^7.28.6", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q=="], + + "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/template": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], + + "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.28.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg=="], + + "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q=="], + + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw=="], + + "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg=="], + + "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ=="], + + "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-flow": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw=="], + + "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.27.1", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ=="], + + "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw=="], + + "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A=="], + + "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ=="], + + "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.0", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.29.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ=="], + + "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.0", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ=="], + + "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg=="], + + "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.6", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA=="], + + "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA=="], + + "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ=="], + + "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="], + + "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="], + + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog=="], + + "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.28.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg=="], + + "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw=="], + + "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="], + + "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA=="], + + "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g=="], + + "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="], + + "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg=="], + + "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.28.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A=="], + + "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="], + + "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.28.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q=="], + + "@babel/preset-env": ["@babel/preset-env@7.29.2", "", { "dependencies": { "@babel/compat-data": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.28.6", "@babel/plugin-syntax-import-attributes": "^7.28.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-async-generator-functions": "^7.29.0", "@babel/plugin-transform-async-to-generator": "^7.28.6", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", "@babel/plugin-transform-block-scoping": "^7.28.6", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-class-static-block": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-computed-properties": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-dotall-regex": "^7.28.6", "@babel/plugin-transform-duplicate-keys": "^7.27.1", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-dynamic-import": "^7.27.1", "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-exponentiation-operator": "^7.28.6", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-for-of": "^7.27.1", "@babel/plugin-transform-function-name": "^7.27.1", "@babel/plugin-transform-json-strings": "^7.28.6", "@babel/plugin-transform-literals": "^7.27.1", "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.28.6", "@babel/plugin-transform-modules-systemjs": "^7.29.0", "@babel/plugin-transform-modules-umd": "^7.27.1", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-numeric-separator": "^7.28.6", "@babel/plugin-transform-object-rest-spread": "^7.28.6", "@babel/plugin-transform-object-super": "^7.27.1", "@babel/plugin-transform-optional-catch-binding": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/plugin-transform-private-methods": "^7.28.6", "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-property-literals": "^7.27.1", "@babel/plugin-transform-regenerator": "^7.29.0", "@babel/plugin-transform-regexp-modifiers": "^7.28.6", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-spread": "^7.28.6", "@babel/plugin-transform-sticky-regex": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-typeof-symbol": "^7.27.1", "@babel/plugin-transform-unicode-escapes": "^7.27.1", "@babel/plugin-transform-unicode-property-regex": "^7.28.6", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw=="], + + "@babel/preset-flow": ["@babel/preset-flow@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-flow-strip-types": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg=="], + + "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "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" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], + + "@babel/preset-react": ["@babel/preset-react@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-react-display-name": "^7.28.0", "@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" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.7.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/plugin-transform-typescript": "^7.7.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WZ3qvtAJy8w/i6wqq5PuDnkCUXaLUTHIlJujfGHmHxsT5veAbEdEjl3cC/3nXfyD0bzlWsIiMdUhZgrXjd9QWg=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], @@ -149,10 +330,16 @@ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], @@ -169,12 +356,16 @@ "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "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" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], @@ -237,6 +428,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@mdn/browser-compat-data": ["@mdn/browser-compat-data@4.2.1", "", {}, "sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA=="], + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.50.1", "", { "dependencies": { "@microsoft/api-extractor-model": "7.30.3", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0", "@rushstack/rig-package": "0.5.3", "@rushstack/terminal": "0.15.0", "@rushstack/ts-command-line": "4.23.5", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", "typescript": "5.7.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-L18vz0ARLNaBLKwWe0DdEf7eijDsb7ERZspgZK7PxclLoQrc+9hJZo8y4OVfCHxNVyxlwVywY2WdE/3pOFViLQ=="], "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.30.3", "", { "dependencies": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0" } }, "sha512-yEAvq0F78MmStXdqz9TTT4PZ05Xu5R8nqgwI5xmUmQjWBQ9E6R2n8HB/iZMRciG4rf9iwI2mtuQwIzDXBvHn1w=="], @@ -255,6 +448,8 @@ "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.1.3", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ=="], + "@protontech/drive-sdk": ["@protontech/drive-sdk@file:../sdk", { "dependencies": { "@noble/hashes": "^1.8.0", "bcryptjs": "^2.4.3", "ttag": "^1.8.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", "@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.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" } }], + "@protontech/pmcrypto": ["@protontech/pmcrypto@8.8.1", "", { "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.2.2" } }, "sha512-iYrCgHDL3m+xt9oZD1oSFG73mrQFIfqL3xfRA++O1Zv2PEfsQ1d7SXefHCzG5QZjWKVlHXL8uI7ucl2+yesTpg=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "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" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], @@ -311,6 +506,16 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@4.23.5", "", { "dependencies": { "@rushstack/terminal": "0.15.0", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-jg70HfoK44KfSP3MTiL5rxsZH7X1ktX3cZs9Sl8eDu1/LxJSbPsh0MOFRC710lIuYYSgxWjI5AjbCBAl7u3RxA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], @@ -347,6 +552,8 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -359,12 +566,32 @@ "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/content-disposition": ["@types/content-disposition@0.5.9", "", {}, "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ=="], + + "@types/cookies": ["@types/cookies@0.9.2", "", { "dependencies": { "@types/connect": "*", "@types/express": "*", "@types/keygrip": "*", "@types/node": "*" } }, "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A=="], + "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="], + + "@types/formidable": ["@types/formidable@1.2.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-6psvrUy5VDYb+yaPJReF1WrRsz+FBwyJutK9Twz1Efa27tm07bARNIkK2B8ZPWq80dXqpKfrxTO96xrtPp+AuA=="], + "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/http-assert": ["@types/http-assert@1.5.6", "", {}, "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], @@ -373,14 +600,36 @@ "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], + "@types/keygrip": ["@types/keygrip@1.0.6", "", {}, "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ=="], + + "@types/koa": ["@types/koa@2.15.0", "", { "dependencies": { "@types/accepts": "*", "@types/content-disposition": "*", "@types/cookies": "*", "@types/http-assert": "*", "@types/http-errors": "*", "@types/keygrip": "*", "@types/koa-compose": "*", "@types/node": "*" } }, "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g=="], + + "@types/koa-compose": ["@types/koa-compose@3.2.9", "", { "dependencies": { "@types/koa": "*" } }, "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA=="], + + "@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="], + "@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "@types/parse5": ["@types/parse5@6.0.3", "", {}, "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g=="], + + "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/sharedworker": ["@types/sharedworker@0.0.130", "", {}, "sha512-P8arPQcRUW+vs1xyrnqkFGblb8XABsHs+HnTAsOzEoxe4/jzrpY8pye8WWbvwtCegh4dyAOnUmqspdRByWkHNQ=="], "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], @@ -425,10 +674,18 @@ "@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="], + "@web/dev-server-core": ["@web/dev-server-core@0.7.5", "", { "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" } }, "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw=="], + + "@web/dev-server-esbuild": ["@web/dev-server-esbuild@1.0.5", "", { "dependencies": { "@mdn/browser-compat-data": "^4.0.0", "@web/dev-server-core": "^0.7.4", "esbuild": "^0.27.0", "parse5": "^6.0.1", "ua-parser-js": "^1.0.33" } }, "sha512-QkKfnBuo+ogcMXLE9kKhw8+UwfbuIgiKKATY26ssgSwtMmAhgq+/SSG+QM55VMkVVzDDLi97aMDEcdr3V2sYvQ=="], + + "@web/parse5-utils": ["@web/parse5-utils@2.1.1", "", { "dependencies": { "@types/parse5": "^6.0.1", "parse5": "^6.0.1" } }, "sha512-7rBVZEMGfrq2iPcAEwJ0KSNSvmA2a6jT2CK8/gyIOHgn4reg7bSSRbzyWIEYWyIkeRoYEukX/aW+nAeCgSSqhQ=="], + "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -459,10 +716,24 @@ "babel-jest": ["babel-jest@29.7.0", "", { "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" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], + "babel-plugin-const-enum": ["babel-plugin-const-enum@1.2.0", "", { "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" } }, "sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg=="], + "babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "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" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="], "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@29.6.3", "", { "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" } }, "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg=="], + "babel-plugin-macros": ["babel-plugin-macros@2.8.0", "", { "dependencies": { "@babel/runtime": "^7.7.2", "cosmiconfig": "^6.0.0", "resolve": "^1.12.0" } }, "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + + "babel-plugin-ttag": ["babel-plugin-ttag@1.8.16", "", { "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" } }, "sha512-UmA4KAvg3K1nzTBaqWox945CS3C1zjJu6lGZjmbOYW3NO2ps6mlIm8fnj9wjzNm2Y2nzUuD73aiAK9Rd3vTZgQ=="], + + "babel-preset-const-enum": ["babel-preset-const-enum@1.0.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "babel-plugin-const-enum": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DHfcv3mkgIqPaFODzig3Esb91cCqZlnImSl7VAwJDtIsqJvx4H08kpl051um68gjqnAXg5up5nnn6NK+Cq0blA=="], + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "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" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], @@ -471,6 +742,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], @@ -489,6 +762,10 @@ "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "cache-content-type": ["cache-content-type@1.0.1", "", { "dependencies": { "mime-types": "^2.1.18", "ylru": "^1.2.0" } }, "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -507,14 +784,24 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], + + "cli-spinners": ["cli-spinners@1.3.1", "", {}, "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="], + "co-body": ["co-body@5.2.0", "", { "dependencies": { "inflation": "^2.0.0", "qs": "^6.4.0", "raw-body": "^2.2.0", "type-is": "^1.6.14" } }, "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ=="], + "collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -529,8 +816,18 @@ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookies": ["cookies@0.9.1", "", { "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" } }, "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw=="], + + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + + "cosmiconfig": ["cosmiconfig@6.0.0", "", { "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" } }, "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg=="], + "create-jest": ["create-jest@29.7.0", "", { "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" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -541,14 +838,24 @@ "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + "deep-equal": ["deep-equal@1.0.1", "", {}, "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], @@ -557,12 +864,18 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.179", "", {}, "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ=="], "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -571,12 +884,16 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@8.57.1", "", { "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" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], @@ -601,6 +918,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -641,6 +960,14 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "foreachasync": ["foreachasync@3.0.0", "", {}, "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formidable": ["formidable@1.2.6", "", {}, "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], @@ -649,6 +976,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -661,9 +990,11 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "gettext-parser": ["gettext-parser@6.0.0", "", { "dependencies": { "content-type": "^1.0.4", "encoding": "^0.1.13", "readable-stream": "^4.1.0", "safe-buffer": "^5.2.1" } }, "sha512-eWFsR78gc/eKnzDgc919Us3cbxQbzxK1L8vAIZrKMQqOUgULyeqmczNlBjTlVTk2FaB7nV9C1oobd/PGBOqNmg=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -675,22 +1006,34 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-assert": ["http-assert@1.5.0", "", { "dependencies": { "deep-equal": "~1.0.1", "http-errors": "~1.8.0" } }, "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w=="], + + "http-errors": ["http-errors@1.8.1", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.1" } }, "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + "hunspell-spellchecker": ["hunspell-spellchecker@1.0.2", "", { "bin": { "hunspell-tojson": "./bin/hunspell-tojson.js" } }, "sha512-4DwmFAvlz+ChsqLDsZT2cwBsYNXh+oWboemxXtafwKIyItq52xfR4e4kr017sLAoPaSYVofSOvPUfmOAhXyYvw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -707,6 +1050,8 @@ "index-to-position": ["index-to-position@0.1.2", "", {}, "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g=="], + "inflation": ["inflation@2.1.0", "", {}, "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -723,18 +1068,24 @@ "is-generator-fn": ["is-generator-fn@2.1.0", "", {}, "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -747,6 +1098,8 @@ "istanbul-reports": ["istanbul-reports@3.1.7", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jest": ["jest@29.7.0", "", { "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", "import-local": "^3.0.2", "jest-cli": "^29.7.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw=="], "jest-changed-files": ["jest-changed-files@29.7.0", "", { "dependencies": { "execa": "^5.0.0", "jest-util": "^29.7.0", "p-limit": "^3.1.0" } }, "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w=="], @@ -831,12 +1184,30 @@ "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + "keygrip": ["keygrip@1.1.0", "", { "dependencies": { "tsscmp": "1.0.6" } }, "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "koa": ["koa@2.16.4", "", { "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" } }, "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw=="], + + "koa-body": ["koa-body@4.2.0", "", { "dependencies": { "@types/formidable": "^1.0.31", "co-body": "^5.1.1", "formidable": "^1.1.1" } }, "sha512-wdGu7b9amk4Fnk/ytH8GuWwfs4fsB5iNkY8kZPpgQVb04QZSv85T0M8reb+cJmvLE8cjPYvBzRikD3s6qz8OoA=="], + + "koa-compose": ["koa-compose@4.1.0", "", {}, "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw=="], + + "koa-convert": ["koa-convert@2.0.0", "", { "dependencies": { "co": "^4.6.0", "koa-compose": "^4.1.0" } }, "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA=="], + + "koa-etag": ["koa-etag@4.0.0", "", { "dependencies": { "etag": "^1.8.1" } }, "sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg=="], + + "koa-router": ["koa-router@9.4.0", "", { "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" } }, "sha512-RO/Y8XqSNM2J5vQeDaBI/7iRpL50C9QEudY4d3T4D1A2VMKLH0swmfjxDFPiIpVDLuNN6mVD9zBI1eFTHB6QaA=="], + + "koa-send": ["koa-send@5.0.1", "", { "dependencies": { "debug": "^4.1.1", "http-errors": "^1.7.3", "resolve-path": "^1.4.0" } }, "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ=="], + + "koa-static": ["koa-static@5.0.0", "", { "dependencies": { "debug": "^3.1.0", "koa-send": "^5.0.0" } }, "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "ky": ["ky@1.7.5", "", {}, "sha512-HzhziW6sc5m0pwi5M196+7cEBtbt0lCYi67wNsiwMUmz833wloE0gbzJPWKs1gliFKQb34huItDQX97LyOdPdA=="], @@ -847,38 +1218,64 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lodash.mapvalues": ["lodash.mapvalues@4.6.0", "", {}, "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "log-symbols": ["log-symbols@1.0.2", "", { "dependencies": { "chalk": "^1.0.0" } }, "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + "markdown-it": ["markdown-it@14.1.1", "", { "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" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -889,6 +1286,10 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], @@ -897,14 +1298,20 @@ "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "only": ["only@0.0.2", "", {}, "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="], + "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.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" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], @@ -913,18 +1320,28 @@ "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "ora": ["ora@1.3.0", "", { "dependencies": { "chalk": "^1.1.1", "cli-cursor": "^2.1.0", "cli-spinners": "^1.0.0", "log-symbols": "^1.0.2" } }, "sha512-6DFzEwRJxz7o/0K+7ecOLwSaWT5M0xuvb+1knfQbyi+GFk4O9bMX9NdDizLaORMcEy8kZyu3OjYNFItRa4MNOw=="], + + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], + "parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -937,6 +1354,12 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], @@ -969,30 +1392,60 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "pseudomap": ["pseudomap@1.0.2", "", {}, "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], + + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.1", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-path": ["resolve-path@1.4.0", "", { "dependencies": { "http-errors": "~1.6.2", "path-is-absolute": "1.0.1" } }, "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w=="], + "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], + "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], @@ -1003,15 +1456,33 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "serialize-javascript": ["serialize-javascript@4.0.0", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "side-channel": ["side-channel@1.1.0", "", { "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" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1027,6 +1498,8 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], @@ -1049,6 +1522,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svelte": ["svelte@3.59.2", "", {}, "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA=="], + "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], @@ -1063,26 +1538,52 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], + "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], + "ttag-cli": ["ttag-cli@1.11.2", "", { "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" } }, "sha512-unLFfw4ZxRcEjO7rqwESwiuchtd8BdzyvnGVDtqGLUvdZFvm2zLu311aBcTNS31pwKt5JMQB03G3Or3LlEe4/g=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "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 || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1093,16 +1594,28 @@ "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="], "vite-plugin-dts": ["vite-plugin-dts@4.5.0", "", { "dependencies": { "@microsoft/api-extractor": "^7.49.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^0.5.1", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-M1lrPTdi7gilLYRZoLmGYnl4fbPryVYsehPN9JgaxjJKTs8/f7tuAlvCCvOLB5gRDQTTKnptBcB0ACsaw2wNLw=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "vue-sfc-parser": ["vue-sfc-parser@0.1.2", "", { "dependencies": { "lodash.mapvalues": "^4.6.0" } }, "sha512-fvYu4i5oxK4J25qYblmsotMINSY0KhP1LW/ElKaMin4CXQ2UqyjeUgAZaE2Zs1zYpIKGoEuMjEY+lmBghls1WQ=="], + + "walk": ["walk@2.3.9", "", { "dependencies": { "foreachasync": "^3.0.0" } }, "sha512-nEvC/LocusNlMqpnY76juQYCx7H/ZNhtEQ3qTI+Kbh9zw8nc8jp5v0C3g+V1RNTH7TXrp4YC8qtzk6FN03+lMg=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1111,6 +1624,8 @@ "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], + "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -1123,6 +1638,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "ylru": ["ylru@1.4.0", "", {}, "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1131,12 +1648,130 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-define-polyfill-provider/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-define-polyfill-provider/resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "@babel/helper-remap-async-to-generator/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/helper-wrap-function/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-wrap-function/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-wrap-function/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-proposal-optional-chaining/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-async-generators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-bigint/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-class-properties/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-class-static-block/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-import-meta/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-json-strings/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-jsx/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-logical-assignment-operators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-nullish-coalescing-operator/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-numeric-separator/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-object-rest-spread/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-optional-catch-binding/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-optional-chaining/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-private-property-in-object/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-top-level-await/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-syntax-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-classes/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/plugin-transform-classes/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-computed-properties/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-destructuring/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-function-name/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/plugin-transform-function-name/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-react-jsx/@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-transform-react-jsx/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-typescript/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/preset-env/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/preset-env/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/preset-modules/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/preset-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/template/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -1157,6 +1792,8 @@ "@jest/core/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/reporters/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@jest/reporters/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "@jest/test-sequencer/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -1221,6 +1858,14 @@ "@vue/language-core/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@web/dev-server-core/@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], + + "@web/dev-server-core/lru-cache": ["lru-cache@8.0.5", "", {}, "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA=="], + + "@web/dev-server-core/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "@web/dev-server-esbuild/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "ajv-draft-04/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], @@ -1231,18 +1876,48 @@ "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "babel-plugin-const-enum/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "babel-plugin-const-enum/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "babel-plugin-istanbul/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "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" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], + "babel-plugin-polyfill-corejs2/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "babel-plugin-ttag/ajv": ["ajv@6.12.3", "", { "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" } }, "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA=="], + + "babel-plugin-ttag/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "babel-preset-current-node-syntax/@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "core-js-compat/browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "has-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + + "http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "istanbul-lib-instrument/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], @@ -1251,6 +1926,8 @@ "jest-circus/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-config/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "jest-config/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "jest-config/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -1259,6 +1936,8 @@ "jest-resolve/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "jest-runtime/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "jest-runtime/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "jest-snapshot/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], @@ -1267,26 +1946,302 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "koa/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "koa-router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "koa-send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "koa-static/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "log-symbols/chalk": ["chalk@1.1.3", "", { "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" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], + "make-dir/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "ora/chalk": ["chalk@1.1.3", "", { "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" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], + "parse-json/type-fest": ["type-fest@4.35.0", "", {}, "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A=="], + "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "resolve-cwd/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-path/http-errors": ["http-errors@1.6.3", "", { "dependencies": { "depd": "~1.1.2", "inherits": "2.0.3", "setprototypeof": "1.1.0", "statuses": ">= 1.4.0 < 2" } }, "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A=="], + + "restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], + + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "rimraf/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "test-exclude/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "ttag-cli/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "ttag-cli/cross-spawn": ["cross-spawn@5.1.0", "", { "dependencies": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A=="], + + "ttag-cli/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "ttag-cli/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], + + "ttag-cli/plural-forms": ["plural-forms@0.5.3", "", {}, "sha512-t/hkjsTeDwaK9n/z6tUiSHySTC8sPnTiS5YF3Y5p4L+eomzXh7O0vEemkjwb68/82w0Rjw4uED3X84X7vXf9lg=="], + + "ttag-cli/tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + + "ttag-cli/yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "typedoc/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "typedoc/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/helper-replace-supers/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-wrap-function/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-wrap-function/@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-wrap-function/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-wrap-function/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-wrap-function/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-wrap-function/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/helper-wrap-function/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-classes/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/plugin-transform-classes/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/plugin-transform-classes/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-classes/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-computed-properties/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-computed-properties/@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-computed-properties/@babel/template/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-function-name/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/plugin-transform-react-jsx/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/preset-env/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/preset-modules/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], "@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], @@ -1321,14 +2276,248 @@ "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@web/dev-server-esbuild/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@web/dev-server-esbuild/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "babel-plugin-const-enum/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "babel-plugin-const-enum/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "babel-plugin-const-enum/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "babel-plugin-const-enum/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "babel-plugin-const-enum/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "babel-plugin-const-enum/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "babel-preset-current-node-syntax/@babel/plugin-syntax-import-attributes/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "core-js-compat/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + + "core-js-compat/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], + + "core-js-compat/browserslist/node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "core-js-compat/browserslist/update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "log-symbols/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], + + "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "log-symbols/chalk/strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], + + "log-symbols/chalk/supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="], + + "ora/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], + + "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "ora/chalk/strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], + + "ora/chalk/supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "resolve-path/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], + + "resolve-path/http-errors/inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="], + + "resolve-path/http-errors/setprototypeof": ["setprototypeof@1.1.0", "", {}, "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="], + + "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], + + "ttag-cli/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + + "ttag-cli/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "ttag-cli/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + + "ttag-cli/cross-spawn/lru-cache": ["lru-cache@4.1.5", "", { "dependencies": { "pseudomap": "^1.0.2", "yallist": "^2.1.2" } }, "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g=="], + + "ttag-cli/cross-spawn/shebang-command": ["shebang-command@1.2.0", "", { "dependencies": { "shebang-regex": "^1.0.0" } }, "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg=="], + + "ttag-cli/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + + "ttag-cli/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], + + "ttag-cli/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "ttag-cli/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "ttag-cli/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "ttag-cli/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "typedoc/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-wrap-function/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-wrap-function/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-classes/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-classes/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-computed-properties/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-computed-properties/@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-function-name/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@babel/preset-env/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1339,10 +2528,48 @@ "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + "babel-plugin-const-enum/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "babel-plugin-const-enum/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "log-symbols/chalk/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + + "ora/chalk/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "ttag-cli/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "ttag-cli/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "ttag-cli/cross-spawn/lru-cache/yallist": ["yallist@2.1.2", "", {}, "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="], + + "ttag-cli/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], + + "ttag-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "ttag-cli/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "ttag-cli/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "typedoc/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "ttag-cli/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + + "ttag-cli/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ttag-cli/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "ttag-cli/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js index c99a8cbc..34c8359c 100644 --- a/js/cli/jest.config.js +++ b/js/cli/jest.config.js @@ -11,6 +11,8 @@ module.exports = { }, moduleNameMapper: { '^@openpgp/web-stream-tools$': '/node_modules/@openpgp/web-stream-tools/lib/index.js', + '^@protontech/drive-sdk$': '/../sdk/src/index.ts', + '^@protontech/drive-sdk/(.*)$': '/../sdk/src/$1', }, reporters: ['default'], testEnvironment: 'node', diff --git a/js/cli/package.json b/js/cli/package.json index d7d44432..8a43fbca 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,8 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --minify-identifiers --conditions=browser", + "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --outfile=proton-drive --minify-identifiers --conditions=browser", + "build:internal": "NODE_ENV=production bun build --compile ./src/internal-cli/proton-drive-internal.ts --outfile=proton-drive-internal --minify-identifiers --conditions=browser", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", @@ -41,6 +42,7 @@ "@jimp/js-tiff": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", + "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "ky": "^1.7.2", diff --git a/js/cli/src/api/addresses.ts b/js/cli/src/api/addresses.ts index 3193d968..991b8dbd 100644 --- a/js/cli/src/api/addresses.ts +++ b/js/cli/src/api/addresses.ts @@ -1,4 +1,5 @@ -import { ProtonDriveAccountAddress, Logger } from '../../../sdk/src'; +import { ProtonDriveAccountAddress, Logger } from '@protontech/drive-sdk'; + import { Credentials } from '../credentials'; import { PrivateKeyReference, PublicKeyReference, VERIFICATION_STATUS } from '../crypto/lib'; import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index 0c775cc9..bf75b54e 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -1,6 +1,7 @@ import ky, { type AfterResponseHook, type KyInstance } from 'ky'; -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import type { Config } from '../config'; import { Credentials } from '../credentials'; import type { paths as AuthPaths } from './api-auth-types'; diff --git a/js/cli/src/api/auth.ts b/js/cli/src/api/auth.ts index 13f19afc..c7d3aeb7 100644 --- a/js/cli/src/api/auth.ts +++ b/js/cli/src/api/auth.ts @@ -1,6 +1,7 @@ import bcrypt from 'bcryptjs'; -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import { Credentials } from '../credentials'; import { VERIFICATION_STATUS } from '../crypto/lib'; import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; diff --git a/js/cli/src/api/httpClient.ts b/js/cli/src/api/httpClient.ts index 6e5f2ab1..47ceec3b 100644 --- a/js/cli/src/api/httpClient.ts +++ b/js/cli/src/api/httpClient.ts @@ -1,4 +1,5 @@ -import { ProtonDriveHTTPClientJsonRequest, ProtonDriveHTTPClientBlobRequest } from '../../../sdk/src'; +import { ProtonDriveHTTPClientJsonRequest, ProtonDriveHTTPClientBlobRequest } from '@protontech/drive-sdk'; + import { ApiClient } from './apiClient'; export class HTTPClient { diff --git a/js/cli/src/api/index.ts b/js/cli/src/api/index.ts index af7467de..e338d890 100644 --- a/js/cli/src/api/index.ts +++ b/js/cli/src/api/index.ts @@ -1,7 +1,8 @@ -import { Logger } from '../../../sdk/src'; -import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; +import { Logger } from '@protontech/drive-sdk'; + import type { Config } from '../config'; import type { Credentials } from '../credentials'; +import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; import { AccountApi } from './accountApi'; import { Addresses } from './addresses'; import { ApiClient } from './apiClient'; diff --git a/js/cli/src/cache/encryptedCache.ts b/js/cli/src/cache/encryptedCache.ts index 1d75dc2e..b0c92fd9 100644 --- a/js/cli/src/cache/encryptedCache.ts +++ b/js/cli/src/cache/encryptedCache.ts @@ -1,4 +1,5 @@ -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import { encryptCacheValue, decryptCacheValue } from './crypto'; import { SQLiteEntititesCache } from './sqliteCache'; diff --git a/js/cli/src/cache/index.ts b/js/cli/src/cache/index.ts index b3c0ae53..5dd04d9b 100644 --- a/js/cli/src/cache/index.ts +++ b/js/cli/src/cache/index.ts @@ -1,4 +1,5 @@ -import type { Logger, ProtonDriveCache } from '../../../sdk/src'; +import type { Logger, ProtonDriveCache } from '@protontech/drive-sdk'; + import { Credentials } from '../credentials'; import { Config } from '../config'; import { SQLiteEntititesCache } from './sqliteCache'; diff --git a/js/cli/src/cache/sqliteCache.ts b/js/cli/src/cache/sqliteCache.ts index 20d63c17..914205ab 100644 --- a/js/cli/src/cache/sqliteCache.ts +++ b/js/cli/src/cache/sqliteCache.ts @@ -1,7 +1,7 @@ import { Database } from 'bun:sqlite'; import path from 'node:path'; -import type { ProtonDriveCache, EntityResult } from '../../../sdk/src'; +import type { ProtonDriveCache, EntityResult } from '@protontech/drive-sdk'; export class SQLiteEntititesCache implements ProtonDriveCache { private db: Database; diff --git a/js/cli/src/cli/auth/commandAuthLogin.ts b/js/cli/src/cli/auth/commandAuthLogin.ts deleted file mode 100644 index a26b77d8..00000000 --- a/js/cli/src/cli/auth/commandAuthLogin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { readPasswordLine } from '../readPasswordLine'; -import { openBrowserUrl } from '../openBrowserUrl'; - -export class CommandAuthLogin implements Command { - group = 'auth'; - name = 'login'; - isAuthAction = true; - args = ['username']; - options: ParseArgsConfig['options'] = { - password: { - type: 'string', - default: '', - }, - web: { - type: 'boolean', - default: false, - }, - }; - - async action({ auth, args: [username], options: { password, web, json } }: ActionArgs) { - if (web) { - const session = await auth.authViaWeb((signInUrl) => { - openBrowserUrl(signInUrl); - console.log('Sign in in your browser (URL also printed if it did not open automatically):'); - console.log(signInUrl); - }); - printObject(session, json); - } else { - if (!password) { - password = await readPasswordLine('Password: '); - } - - const session = await auth.authViaPassword(username, password); - printObject(session, json); - } - } -} diff --git a/js/cli/src/cli/bookmark/commandBookmarkCreate.ts b/js/cli/src/cli/bookmark/commandBookmarkCreate.ts deleted file mode 100644 index fe700cfb..00000000 --- a/js/cli/src/cli/bookmark/commandBookmarkCreate.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ParseArgsConfig } from 'util'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from '../public/base'; - -export class CommandBookmarkCreate implements Command { - group = 'bookmark'; - name = 'create'; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ sdk, options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - await sdk.createBookmark(url, customPassword); - if (!json) { - console.log(`Created bookmark: ${url}`); - } - } -} diff --git a/js/cli/src/cli/bookmark/commandBookmarkList.ts b/js/cli/src/cli/bookmark/commandBookmarkList.ts deleted file mode 100644 index dccd1fc6..00000000 --- a/js/cli/src/cli/bookmark/commandBookmarkList.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandBookmarkList implements Command { - group = 'bookmark'; - name = 'list'; - - async action({ sdk, options: { json } }: ActionArgs) { - await printIterable(sdk.iterateBookmarks(), json); - } -} diff --git a/js/cli/src/cli/bookmark/commandBookmarkRemove.ts b/js/cli/src/cli/bookmark/commandBookmarkRemove.ts deleted file mode 100644 index 5a2d4c5b..00000000 --- a/js/cli/src/cli/bookmark/commandBookmarkRemove.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandBookmarkRemove implements Command { - group = 'bookmark'; - name = 'remove'; - args = ['bookmarkUid']; - - async action({ sdk, args: [bookmarkUid], options: { json } }: ActionArgs) { - await sdk.removeBookmark(bookmarkUid); - if (!json) { - console.log(`Deleted bookmark: ${bookmarkUid}`); - } - } -} diff --git a/js/cli/src/cli/device/commandDeviceCreate.ts b/js/cli/src/cli/device/commandDeviceCreate.ts deleted file mode 100644 index 599b34f3..00000000 --- a/js/cli/src/cli/device/commandDeviceCreate.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandDeviceCreate implements Command { - group = 'device'; - name = 'create'; - args = ['name']; - options: ParseArgsConfig['options'] = { - type: { - type: 'string', - short: 't', - }, - }; - - async action({ sdk, args: [name], options: { type, json } }: ActionArgs) { - const device = await sdk.createDevice(name, type); - printObject(device, json); - } -} diff --git a/js/cli/src/cli/device/commandDeviceDelete.ts b/js/cli/src/cli/device/commandDeviceDelete.ts deleted file mode 100644 index 825b1d81..00000000 --- a/js/cli/src/cli/device/commandDeviceDelete.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandDeviceDelete implements Command { - group = 'device'; - name = 'delete'; - args = ['deviceUid']; - - async action({ sdk, args: [deviceUid], options: { json } }: ActionArgs) { - await sdk.deleteDevice(deviceUid); - if (!json) { - console.log(`Deleted device: ${deviceUid}`); - } - } -} diff --git a/js/cli/src/cli/device/commandDeviceRename.ts b/js/cli/src/cli/device/commandDeviceRename.ts deleted file mode 100644 index 9a37faec..00000000 --- a/js/cli/src/cli/device/commandDeviceRename.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandDeviceRename implements Command { - group = 'device'; - name = 'rename'; - args = ['deviceUid', 'name']; - - async action({ sdk, args: [deviceUid, name], options: { json } }: ActionArgs) { - const device = await sdk.renameDevice(deviceUid, name); - printObject(device, json); - } -} diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts b/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts deleted file mode 100644 index 2d96d3e7..00000000 --- a/js/cli/src/cli/diagnostic/commandDiagnosticGetTreeStructure.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; -import { PathType } from '../paths'; - -export class CommandDiagnosticGetTreeStructure implements Command { - group = 'diagnostic'; - name = 'get-tree-structure'; - args = ['path']; - - async action({ sdkDiagnostic, paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - - if ([PathType.MyFiles, PathType.Devices, PathType.SharedWithMe].includes(nodePath.type)) { - const node = await nodePath.getNode(); - const structure = await sdkDiagnostic.getNodeTreeStructure(node); - printObject(structure, json); - } else if ([PathType.Photos].includes(nodePath.type)) { - const structure = await sdkDiagnostic.getPhotosTimelineStructure(); - printObject(structure, json); - } else { - throw new Error(`Not supported path type: ${nodePath.type}`); - } - } -} diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts b/js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts deleted file mode 100644 index 8feb34a0..00000000 --- a/js/cli/src/cli/diagnostic/commandDiagnosticPhotosTimeline.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandDiagnosticPhotosTimeline implements Command { - group = 'diagnostic'; - name = 'photos-timeline'; - options: ParseArgsConfig['options'] = { - content: { - type: 'boolean', - short: 'c', - default: false, - }, - 'content-peak': { - type: 'boolean', - short: 'p', - default: false, - }, - thumbnails: { - type: 'boolean', - short: 't', - default: false, - }, - 'local-structure': { - type: 'string', - short: 's', - default: '', - }, - }; - - async action({ - sdkDiagnostic, - options: { json, content, 'content-peak': contentPeak, thumbnails, 'local-structure': localStructure }, - }: ActionArgs) { - const expectedStructure = localStructure ? JSON.parse(await Bun.file(localStructure).text()) : undefined; - - const options = { - verifyContent: contentPeak ? 'peakOnly' : content, - verifyThumbnails: thumbnails, - expectedStructure, - }; - - for await (const result of sdkDiagnostic.verifyPhotosTimeline(options, (progress) => { - printObject(progress, json); - })) { - printObject(result, json); - } - } -} diff --git a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts b/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts deleted file mode 100644 index 255232c0..00000000 --- a/js/cli/src/cli/diagnostic/commandDiagnosticTree.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandDiagnosticTree implements Command { - group = 'diagnostic'; - name = 'tree'; - args = ['path']; - options: ParseArgsConfig['options'] = { - content: { - type: 'boolean', - short: 'c', - default: false, - }, - 'content-peak': { - type: 'boolean', - short: 'p', - default: false, - }, - thumbnails: { - type: 'boolean', - short: 't', - default: false, - }, - 'local-structure': { - type: 'string', - short: 's', - default: '', - }, - }; - - async action({ - sdkDiagnostic, - paths, - args: [pathString], - options: { json, content, 'content-peak': contentPeak, thumbnails, 'local-structure': localStructure }, - }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const expectedStructure = localStructure ? JSON.parse(await Bun.file(localStructure).text()) : undefined; - - const options = { - verifyContent: contentPeak ? 'peakOnly' : content, - verifyThumbnails: thumbnails, - expectedStructure, - }; - - for await (const result of sdkDiagnostic.verifyNodeTree(node, options, (progress) => { - printObject(progress, json); - })) { - printObject(result, json); - } - } -} diff --git a/js/cli/src/cli/downloader.ts b/js/cli/src/cli/downloader.ts deleted file mode 100644 index aa87d854..00000000 --- a/js/cli/src/cli/downloader.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { FileDownloader } from '../../../sdk/src/interface'; - -export async function download({ - downloader, - downloadingName, - localPath, - json, -}: { - downloader: FileDownloader; - downloadingName: string; - localPath: string; - json: boolean; -}) { - - const claimedSize = downloader.getClaimedSizeInBytes(); - - if (!json) { - console.log(`Downloading ${downloadingName} (${claimedSize || 'N/A'} bytes) to ${localPath}`); - } - - const file = Bun.file(localPath); - const writer = file.writer(); - const writableStream: WritableStream = { - // @ts-expect-error: Bun's FileSink writer is not fully compatible with the WritableStream interface, but this is good enough for testing purposes. - getWriter: () => writer, - close: async () => { - await writer.end(); - }, - abort: async () => { - await writer.end(); - }, - locked: false, - }; - - const controller = downloader.downloadToStream(writableStream, (writtenBytes) => { - if (!json) { - console.log(`Downloaded ${writtenBytes} bytes`); - } - }); - - try { - await controller.completion(); - await writer.end(); - } catch (error: unknown) { - // When error is passed, Bun still flushes the buffer and closes the file, but marks operation as failed. - await writer.end(error instanceof Error ? error : new Error('Unknown error')); - throw error; - } -} diff --git a/js/cli/src/cli/errors.ts b/js/cli/src/cli/errors.ts new file mode 100644 index 00000000..cc7968d7 --- /dev/null +++ b/js/cli/src/cli/errors.ts @@ -0,0 +1,11 @@ +export class CommandError extends Error {} + +export class CommandNotFoundError extends CommandError {} + +export class InvalidCommandArgumentsError extends CommandError {} + +export class AuthRequiredError extends CommandError { + constructor() { + super('You need to login first'); + } +} diff --git a/js/cli/src/cli/events.ts b/js/cli/src/cli/events.ts deleted file mode 100644 index 0e754350..00000000 --- a/js/cli/src/cli/events.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { DriveEvent, DriveEventType } from '../../../sdk/src'; - -export async function runForever() { - return new Promise(() => {}); -} - -function mapEventToIcon(eventType: DriveEventType): string { - switch (eventType) { - case DriveEventType.NodeUpdated: - return '♻️'; - case DriveEventType.NodeCreated: - return '🐤'; - case DriveEventType.NodeDeleted: - return '❌'; - case DriveEventType.TreeRefresh: - return '🐤'; - case DriveEventType.SharedWithMeUpdated: - return '👪'; - case DriveEventType.TreeRemove: - return '🪓'; - case DriveEventType.FastForward: - return '⏩'; - } -} - -export function eventsReady(json: boolean) { - if (json) { - console.log('{"ready": true}'); - } else { - console.log('🟢 Events ready'); - } -} - -export function eventsCallback(json: boolean, filter: (event: DriveEvent) => boolean, event: DriveEvent) { - if (!filter(event)) { - return; - } - if (json) { - console.log(JSON.stringify(event)); - } else { - const icon = mapEventToIcon(event.type); - console.log(icon, event); - } -} diff --git a/js/cli/src/cli/events/commandEventsDrive.ts b/js/cli/src/cli/events/commandEventsDrive.ts deleted file mode 100644 index 538c531a..00000000 --- a/js/cli/src/cli/events/commandEventsDrive.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Command, ActionArgs } from '../interface'; -import { runForever, eventsCallback, eventsReady } from '../events'; - -export class CommandEventsDrive implements Command { - group = 'events'; - name = 'drive'; - - async action({ sdk, options: { json } }: ActionArgs) { - await sdk.subscribeToDriveEvents(async (event) => eventsCallback(json, () => true, event)); - eventsReady(json); - await runForever(); - } -} diff --git a/js/cli/src/cli/events/commandEventsMyVolume.ts b/js/cli/src/cli/events/commandEventsMyVolume.ts deleted file mode 100644 index 98fee3c2..00000000 --- a/js/cli/src/cli/events/commandEventsMyVolume.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DriveEvent } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { runForever, eventsCallback, eventsReady } from '../events'; - -export class CommandEventsMyVolume implements Command { - group = 'events'; - name = 'my-volume'; - - async action({ sdk, options: { json } }: ActionArgs) { - const maybeNode = await sdk.getMyFilesRootFolder(); - if (!maybeNode.ok) { - throw Error('My Files not found'); - } - const node = maybeNode.value; - const filter = () => true; - await sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => - eventsCallback(json, filter, event), - ); - eventsReady(json); - await runForever(); - } -} diff --git a/js/cli/src/cli/events/commandEventsPath.ts b/js/cli/src/cli/events/commandEventsPath.ts deleted file mode 100644 index ee1f531c..00000000 --- a/js/cli/src/cli/events/commandEventsPath.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DriveEvent, DriveEventType } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { runForever, eventsCallback, eventsReady } from '../events'; - -export class CommandEventsPath implements Command { - group = 'events'; - name = 'path'; - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const maybeNode = await nodePath.getNode(); - if (!maybeNode.ok) { - throw Error('Folder not found'); - } - const node = maybeNode.value; - - const filter = (event: DriveEvent) => { - if ( - ![DriveEventType.NodeCreated, DriveEventType.NodeUpdated, DriveEventType.TreeRefresh].includes( - event.type, - ) - ) { - return false; - } - if ('parentNodeUid' in event) { - // If node is deleted, parent uid may no longer exist, so better to return all deletions - return event.parentNodeUid === node.uid || event.parentNodeUid === null; - } - return false; - }; - - await nodePath.sdk.subscribeToTreeEvents(node.treeEventScopeId, async (event: DriveEvent) => - eventsCallback(json, filter, event), - ); - eventsReady(json); - await runForever(); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts b/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts deleted file mode 100644 index 50b55b66..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemCopy.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ParseArgsConfig } from 'node:util'; - -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PathType } from '../paths'; - -export class CommandFileSystemCopy implements Command { - group = 'filesystem'; - name = 'copy'; - // FIXME: support copy of multiple files - args = ['sourcePath', 'targetPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - }; - - async action({ sdk, paths, args: [sourcePathString, targetPathString], options: { name, json } }: ActionArgs) { - const sourcePath = paths.getPath(sourcePathString); - const sourceNode = await sourcePath.getNode(); - - const targetPath = paths.getPath(targetPathString); - const targetNode = await targetPath.getNode(); - - if (sourcePath.type === PathType.Photos || targetPath.type === PathType.Photos) { - throw new Error('Copying photos is not supported'); - } - - const nodeUid = sourceNode.ok ? sourceNode.value.uid : sourceNode.error.uid; - const param = name ? { uid: nodeUid, name } : sourceNode; - - await printIterable(sdk.copyNodes([param], targetNode), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts deleted file mode 100644 index 9d79d529..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemCreateFolder.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PathType } from '../paths'; - -export class CommandFileSystemCreateFolder implements Command { - group = 'filesystem'; - name = 'create-folder'; - args = ['path', 'name']; - - async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const parent = await nodePath.getNode(); - - if (nodePath.type === PathType.Photos) { - throw new Error('Creating folders in photos is not supported'); - } - - const folder = await sdk.createFolder(parent, name, new Date()); - - printObject(folder, json); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDelete.ts b/js/cli/src/cli/fileSystem/commandFileSystemDelete.ts deleted file mode 100644 index 11afa8ad..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemDelete.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemDelete implements Command { - group = 'filesystem'; - name = 'delete'; - // FIXME: support delete of multiple files - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - await printIterable(nodePath.sdk.deleteNodes([node]), json, (result) => - console.log(result.ok ? `Deleted ${result.uid}` : `Failed to delete ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts deleted file mode 100644 index 87d4dc74..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemDownload.ts +++ /dev/null @@ -1,33 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { download } from '../downloader'; -import { getName } from '../node'; - -export class CommandFileSystemDownload implements Command { - group = 'filesystem'; - name = 'download'; - // FIXME: support download of multiple files - args = ['path', 'localParentPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - }; - - async action({ paths, args: [pathString, localParentPath], options: { name, json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - const downloader = await nodePath.sdk.getFileDownloader(node); - - await download({ - downloader, - downloadingName: getName(node), - localPath: path.join(localParentPath, name || getName(node)), - json, - }); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts deleted file mode 100644 index f48bd98e..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemDownloadSeeking.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as readline from 'readline'; - -import { SeekableReadableStream } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { runForever } from '../events'; - -export class CommandFileSystemDownloadSeeking implements Command { - group = 'filesystem'; - name = 'download-seeking'; - args = ['path']; - - async action({ paths, args: [pathString] }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - const downloader = await nodePath.sdk.getFileDownloader(node); - - const claimedSize = downloader.getClaimedSizeInBytes(); - console.log(`Claimed size: ${claimedSize || 'N/A'} bytes`); - - const stream = downloader.getSeekableStream(); - - this.startInteractiveShell(stream); - - await runForever(); - } - - private startInteractiveShell(stream: SeekableReadableStream) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: '> ', - }); - - console.log('Interactive download shell started. Type "help" for available commands.'); - rl.prompt(); - rl.on('line', async (input: string) => { - await this.processCommand(input, stream, () => rl.close()); - rl.prompt(); - }); - rl.on('close', () => { - process.exit(0); - }); - } - - private async processCommand(input: string, stream: SeekableReadableStream, onExit: () => void) { - const trimmed = input.trim(); - if (!trimmed) { - return; - } - - const parts = trimmed.split(/\s+/); - const command = parts[0].toLowerCase(); - const arg = parts[1]; - - try { - switch (command) { - case 'help': - this.printHelp(); - break; - - case 'read': - const bytesToRead = parseInt(arg); - if (isNaN(bytesToRead)) { - console.log('Error: Please provide a valid number of bytes to read'); - break; - } - - await this.readBytes(stream, bytesToRead); - break; - - case 'seek': - const position = parseInt(arg); - if (isNaN(position)) { - console.log('Error: Please provide a valid byte position to seek to'); - break; - } - - await stream.seek(position); - break; - - case 'exit': - onExit(); - return; - - default: - console.log(`Unknown command: ${command}. Type "help" for available commands.`); - break; - } - } catch (error) { - console.error(`Error executing command: ${error instanceof Error ? error.message : error}`); - } - } - - private printHelp() { - console.log('Available commands:'); - console.log(' help - Show this help'); - console.log(' read NUMBER - Read NUMBER bytes from current position'); - console.log(' seek NUMBER - Seek to byte position NUMBER'); - console.log(' exit - Exit the interactive shell'); - } - - private async readBytes(stream: SeekableReadableStream, bytesToRead: number) { - const { value, done } = await stream.read(bytesToRead); - console.log(this.formatBytes(value)); - if (done) { - console.log('End of stream reached'); - } - } - - private formatBytes(bytes: Uint8Array): string { - const maxDisplayBytes = 200; // Limit display to avoid overwhelming output - const displayBytes = bytes.slice(0, maxDisplayBytes); - - let textDisplay = ''; - let isPrintable = true; - - for (let i = 0; i < displayBytes.length; i++) { - const byte = displayBytes[i]; - if (byte >= 32 && byte <= 126) { - textDisplay += String.fromCharCode(byte); - } else if (byte === 10 || byte === 13 || byte === 9) { - // Allow newlines, carriage returns, and tabs - textDisplay += String.fromCharCode(byte); - } else { - isPrintable = false; - break; - } - } - - let result = ''; - - if (isPrintable && textDisplay.length > 0) { - result += 'Text representation:\n' + textDisplay + '\n\n'; - } - - result += 'Hex representation:\n'; - const hexLines = []; - for (let i = 0; i < displayBytes.length; i += 16) { - const line = displayBytes.slice(i, i + 16); - const hex = Array.from(line, (byte) => byte.toString(16).padStart(2, '0')).join(' '); - const ascii = Array.from(line, (byte) => - byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : '.', - ).join(''); - hexLines.push(`${i.toString(16).padStart(8, '0')}: ${hex.padEnd(47)} |${ascii}|`); - } - result += hexLines.join('\n'); - - if (bytes.length > maxDisplayBytes) { - result += `\n... (${bytes.length - maxDisplayBytes} more bytes)`; - } - - return result; - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts b/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts deleted file mode 100644 index 2842cee7..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemDownloadThumbnails.ts +++ /dev/null @@ -1,63 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'node:util'; - -import { MaybeNode, ProtonDriveClient, ThumbnailType } from '../../../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; -import { Command, ActionArgs } from '../interface'; -import { printIterable } from '../formatters'; - -export class CommandFileSystemDownloadThumbnails implements Command { - group = 'filesystem'; - name = 'download-thumbnails'; - // FIXME: support download of multiple thumbnails - args = ['path', 'parentLocalPath']; - options: ParseArgsConfig['options'] = { - 'thumbnail-type': { - type: 'string', - short: 't', - default: ThumbnailType.Type1.toString(), - }, - }; - - async action({ - paths, - args: [pathString, parentLocalPath], - options: { json, 'thumbnail-type': thumbnailType }, - }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const thumbnailsIterator = this.processThumbnails(nodePath.sdk, node, parseInt(thumbnailType), parentLocalPath); - await printIterable(thumbnailsIterator, json, (result) => { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - }); - } - - private async *processThumbnails( - sdk: ProtonDriveClient | ProtonDrivePhotosClient, - node: MaybeNode, - thumbnailType: ThumbnailType, - parentLocalPath: string, - ) { - for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { - // Thumbnail can be jpeg or webp. All new code should produce webp. - const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; - const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - - if (result.ok) { - await Bun.write(thumbnailFilePath, result.thumbnail); - } - - yield { - ok: result.ok, - nodeUid: result.nodeUid, - error: !result.ok ? result.error : undefined, - thumbnailFileName, - }; - } - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts b/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts deleted file mode 100644 index 0addb31b..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemEmptyTrash.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemEmptyTrash implements Command { - group = 'filesystem'; - name = 'empty-trash'; - - async action({ sdk }: ActionArgs) { - await sdk.emptyTrash(); - console.log('Trash emptied'); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts b/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts deleted file mode 100644 index 022e4ec7..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemGetAvailableName.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemGetAvailableName implements Command { - group = 'filesystem'; - name = 'get-available-name'; - args = ['path', 'name']; - - async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); - const availableName = await sdk.getAvailableName(node, name); - printObject( - { - availableName, - }, - json, - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemInfo.ts b/js/cli/src/cli/fileSystem/commandFileSystemInfo.ts deleted file mode 100644 index 83febc8b..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemInfo.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemInfo implements Command { - group = 'filesystem'; - name = 'info'; - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - const node = await path.getNode(); - printObject(node, json); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemList.ts b/js/cli/src/cli/fileSystem/commandFileSystemList.ts deleted file mode 100644 index c4940411..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemList.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ParseArgsConfig } from 'node:util'; - -import { ProtonDriveClient, MaybeNode, Device, NodeType } from '../../../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; -import { Command, ActionArgs } from '../interface'; -import { PathType, Path } from '../paths'; -import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from '../formatters'; -import { getName, getClaimedSize, getNode } from '../node'; - -export class CommandFileSystemList implements Command { - group = 'filesystem'; - name = 'list'; - args = ['path']; - options: ParseArgsConfig['options'] = { - type: { - type: 'string', - short: 't', - default: '', - }, - }; - - async action({ sdk, photosSdk, paths, args: [pathString], options: { json, type } }: ActionArgs) { - const path = paths.getPath(pathString); - - const nodeType = type ? Object.entries(NodeType).find(([, value]) => value === type)?.[1] : undefined; - if (type && !nodeType) { - throw new Error(`Invalid node type: ${type}`); - } - - if (path.type === PathType.Root) { - if (json) { - throw new Error('Cannot use --json option with root path'); - } - paths.rootPaths.forEach((path) => console.log(path)); - } else if (path.type === PathType.MyFiles) { - await this.printChildren(sdk, path, { json, nodeType }); - } else if (path.type === PathType.Devices) { - if (path.fullPath === '/devices') { - await this.printDevices(sdk, { json }); - } else { - await this.printChildren(sdk, path, { json }); - } - } else if (path.type === PathType.SharedByMe) { - await this.printSharedNodes(sdk, { json }); - } else if (path.type === PathType.SharedWithMe) { - if (path.fullPath === '/shared-with-me') { - await this.printSharedWithMe(sdk, { json }); - } else { - await this.printChildren(sdk, path, { json }); - } - } else if (path.type === PathType.Trash) { - await this.printTrashedNodes(sdk, { json }); - } else if (path.type === PathType.PhotosSharedByMe) { - await this.printSharedNodes(photosSdk, { json }); - } else if (path.type === PathType.PhotosSharedWithMe) { - await this.printSharedWithMe(photosSdk, { json }); - } else if (path.type === PathType.PhotosTrash) { - await this.printTrashedNodes(photosSdk, { json }); - } else { - throw new Error(`Not supported path type: ${path.type}`); - } - } - - private async printDevices(sdk: ProtonDriveClient, options: { json: boolean }) { - await printIterable(sdk.iterateDevices(), options.json, (device) => this.printDeviceHuman(device)); - } - - private async printChildren(sdk: ProtonDriveClient, path: Path, options: { json: boolean; nodeType?: NodeType }) { - const parentNode = await path.getNode(); - const filterOptions = options.nodeType ? { type: options.nodeType } : undefined; - const childrenIterator = sdk.iterateFolderChildren(parentNode, filterOptions); - await printIterable(childrenIterator, options.json, (node) => this.printNodeHuman(node)); - } - - private async printSharedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { - await printIterable(sdk.iterateSharedNodes(), options.json, (node) => this.printNodeHuman(node)); - } - - private async printSharedWithMe(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { - await printIterable(sdk.iterateSharedNodesWithMe(), options.json, (node) => this.printNodeHuman(node)); - } - - private async printTrashedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { - await printIterable(sdk.iterateTrashedNodes(), options.json, (node) => this.printNodeHuman(node)); - } - - private printNodeHuman(maybeNode: MaybeNode): void { - const node = getNode(maybeNode); - - const type = node.type === 'file' ? '📄' : '🗂️'; - const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. - const permissionFlag = formatMemberRole(node.directRole); - const author = formatAuthor(node.keyAuthor); - const created = formatDate(node.creationTime, true); - const claimedSize = getClaimedSize(maybeNode); - const size = claimedSize ? formatSize(claimedSize) : '-'; - const id = node.uid.split('~')[1]; - const name = getName(maybeNode); - console.log(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${id} ${name}`); - } - - private printDeviceHuman(device: Device): void { - console.log(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemMove.ts b/js/cli/src/cli/fileSystem/commandFileSystemMove.ts deleted file mode 100644 index 75eb2fb3..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemMove.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ProtonDriveClient } from '../../../../sdk/src'; -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { Path, PathType } from '../paths'; - -export class CommandFileSystemMove implements Command { - group = 'filesystem'; - name = 'move'; - // FIXME: support move of multiple files - args = ['sourcePath', 'targetPath']; - - async action({ sdk, paths, args, options: { json } }: ActionArgs) { - const [sourcePathString, targetPathString] = args; - - const sourcePath = paths.getPath(sourcePathString); - const targetPath = paths.getPath(targetPathString); - - if (sourcePath.type === PathType.MyFiles && targetPath.type === PathType.MyFiles) { - return this.moveNode(sdk, sourcePath, targetPath, json); - } - throw new Error(`Move from ${sourcePath.type} to ${targetPath.type} not supported`); - } - - private async moveNode(sdk: ProtonDriveClient, sourcePath: Path, targetPath: Path, json: boolean) { - const sourceNode = await sourcePath.getNode(); - const targetNode = await targetPath.getNode(); - await printIterable(sdk.moveNodes([sourceNode], targetNode), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemRename.ts b/js/cli/src/cli/fileSystem/commandFileSystemRename.ts deleted file mode 100644 index ed22f5b9..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemRename.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemRename implements Command { - group = 'filesystem'; - name = 'rename'; - args = ['path', 'newName']; - - async action({ paths, args: [pathString, newName], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - const renamedNode = await nodePath.sdk.renameNode(node, newName); - - printObject(renamedNode, json); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemRestore.ts b/js/cli/src/cli/fileSystem/commandFileSystemRestore.ts deleted file mode 100644 index d2609f28..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemRestore.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemRestore implements Command { - group = 'filesystem'; - name = 'restore'; - // FIXME: support restore of multiple files - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - await printIterable(nodePath.sdk.restoreNodes([node]), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemTrash.ts b/js/cli/src/cli/fileSystem/commandFileSystemTrash.ts deleted file mode 100644 index 6c7cc8f2..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemTrash.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandFileSystemTrash implements Command { - group = 'filesystem'; - name = 'trash'; - // FIXME: support trahs of multiple files - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - await printIterable(nodePath.sdk.trashNodes([node]), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts b/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts deleted file mode 100644 index b29ebb46..00000000 --- a/js/cli/src/cli/fileSystem/commandFileSystemUpload.ts +++ /dev/null @@ -1,74 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { PathType } from '../paths'; -import { printObject } from '../formatters'; -import { generateThumbnails } from './generateThumbnails'; - -export class CommandFileSystemUpload implements Command { - group = 'filesystem'; - name = 'upload'; - // FIXME: support upload of multiple files - args = ['localPath', 'parentPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - 'new-revision': { - type: 'boolean', - short: 'r', - default: false, - }, - }; - - async action({ - sdk, - photosSdk, - paths, - args: [localPath, parentPath], - options: { json, name, 'new-revision': newRevision }, - }: ActionArgs) { - name = name || path.basename(localPath); - - const file = Bun.file(localPath); - const metadata = { - mediaType: file.type, - expectedSize: file.size, - }; - - let uploader; - if (newRevision) { - const parentNodePath = paths.getPath(parentPath); - if (parentNodePath.type === PathType.Photos) { - throw new Error('Uploading revisions to photos is not supported'); - } - - const node = await parentNodePath.getChild(name); - uploader = await sdk.getFileRevisionUploader(node, metadata); - } else { - const parentNodePath = paths.getPath(parentPath); - uploader = - parentNodePath.type === PathType.Photos - ? await photosSdk.getFileUploader(name, metadata) - : await sdk.getFileUploader(await parentNodePath.getNode(), name, metadata); - } - - if (!json) { - console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); - } - - const thumbnails = await generateThumbnails(metadata.mediaType || '', localPath); - const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { - if (!json) { - console.log(`Uploaded ${writtenBytes} bytes`); - } - }); - - const result = await controller.completion(); - - printObject(result, json); - } -} diff --git a/js/cli/src/cli/fileSystem/generateThumbnails.ts b/js/cli/src/cli/fileSystem/generateThumbnails.ts deleted file mode 100644 index 0c4d3231..00000000 --- a/js/cli/src/cli/fileSystem/generateThumbnails.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createJimp } from '@jimp/core'; -import bmp, { msBmp } from '@jimp/js-bmp'; -import gif from '@jimp/js-gif'; -import jpeg from '@jimp/js-jpeg'; -import png from '@jimp/js-png'; -import tiff from '@jimp/js-tiff'; -import * as resize from '@jimp/plugin-resize'; - -import { Thumbnail, ThumbnailType } from '../../../../sdk/src'; - -const Jimp = createJimp({ - formats: [bmp, msBmp, gif, jpeg, png, tiff], - plugins: [resize.methods], -}); - -const MAX_UPLOAD_THUMBNAIL_SIDE = 512; -const MAX_THUMBNAIL_BYTES = 64 * 1024 - 512; - -const IMAGE_MEDIA_TYPES = new Set([ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/bmp', - 'image/tiff', -]); - -export async function generateThumbnails(mediaType: string, localPath: string): Promise { - if (!IMAGE_MEDIA_TYPES.has(mediaType.trim().toLowerCase())) { - return []; - } - - try { - const file = await Bun.file(localPath).arrayBuffer(); - const image = await Jimp.read(Buffer.from(file)); - image.scaleToFit({ w: MAX_UPLOAD_THUMBNAIL_SIDE, h: MAX_UPLOAD_THUMBNAIL_SIDE }); - - let quality = 90; - let thumbnailBuffer: Buffer | undefined; - while (quality > 0) { - thumbnailBuffer = await image.getBuffer('image/jpeg', { quality }); - if (thumbnailBuffer.length <= MAX_THUMBNAIL_BYTES || quality <= 10) { - break; - } - quality -= 20; - } - if (!thumbnailBuffer) { - return []; - } - return [ - { - type: ThumbnailType.Type1, - thumbnail: new Uint8Array(thumbnailBuffer), - }, - ]; - } catch { - return []; - } -} diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index 88acb7d3..708df2fc 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -1,6 +1,6 @@ import { inspect } from 'util'; -import { Author, MemberRole } from '../../../sdk/src'; +import { Author, MemberRole } from '@protontech/drive-sdk'; export function printObject(object: object | undefined, json: boolean) { if (json) { diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 555c30c6..24f453d5 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -1,223 +1,33 @@ -import { ParseArgsConfig } from 'util'; - -import { CommandAuthLogin } from './auth/commandAuthLogin'; -import { CommandAuthLogout } from './auth/commandAuthLogout'; -import { CommandBookmarkRemove } from './bookmark/commandBookmarkRemove'; -import { CommandBookmarkList } from './bookmark/commandBookmarkList'; -import { CommandDeviceCreate } from './device/commandDeviceCreate'; -import { CommandDeviceDelete } from './device/commandDeviceDelete'; -import { CommandDeviceRename } from './device/commandDeviceRename'; -import { CommandDiagnosticPhotosTimeline } from './diagnostic/commandDiagnosticPhotosTimeline'; -import { CommandDiagnosticTree } from './diagnostic/commandDiagnosticTree'; -import { CommandDiagnosticGetTreeStructure } from './diagnostic/commandDiagnosticGetTreeStructure'; -import { CommandEventsDrive } from './events/commandEventsDrive'; -import { CommandEventsMyVolume } from './events/commandEventsMyVolume'; -import { CommandEventsPath } from './events/commandEventsPath'; -import { CommandFileSystemCopy } from './fileSystem/commandFileSystemCopy'; -import { CommandFileSystemCreateFolder } from './fileSystem/commandFileSystemCreateFolder'; -import { CommandFileSystemDelete } from './fileSystem/commandFileSystemDelete'; -import { CommandFileSystemDownload } from './fileSystem/commandFileSystemDownload'; -import { CommandFileSystemDownloadSeeking } from './fileSystem/commandFileSystemDownloadSeeking'; -import { CommandFileSystemDownloadThumbnails } from './fileSystem/commandFileSystemDownloadThumbnails'; -import { CommandFileSystemEmptyTrash } from './fileSystem/commandFileSystemEmptyTrash'; -import { CommandFileSystemGetAvailableName } from './fileSystem/commandFileSystemGetAvailableName'; -import { CommandFileSystemInfo } from './fileSystem/commandFileSystemInfo'; -import { CommandFileSystemList } from './fileSystem/commandFileSystemList'; -import { CommandFileSystemMove } from './fileSystem/commandFileSystemMove'; -import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; -import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; -import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; -import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; -import { CommandAlbumAddPhoto } from './photos/commandAlbumAddPhoto'; -import { CommandAlbumCreate } from './photos/commandAlbumCreate'; -import { CommandAlbumDelete } from './photos/commandAlbumDelete'; -import { CommandAlbumList } from './photos/commandAlbumList'; -import { CommandAlbumPhotos } from './photos/commandAlbumPhotos'; -import { CommandAlbumRemovePhoto } from './photos/commandAlbumRemovePhoto'; -import { CommandAlbumUpdate } from './photos/commandAlbumUpdate'; -import { CommandPhotoDuplicate } from './photos/commandPhotoDuplicate'; -import { CommandPhotoRoot } from './photos/commandPhotoRoot'; -import { CommandPhotoTimeline } from './photos/commandPhotoTimeline'; -import { CommandPhotoUpdate } from './photos/commandPhotoUpdate'; -import { CommandPublicCreateFolder } from './public/commandPublicCreateFolder'; -import { CommandPublicGetAvailableName } from './public/commandPublicGetAvailableName'; -import { CommandPublicCreateDocument } from './public/commandPublicCreateDocument'; -import { CommandPublicDelete } from './public/commandPublicDelete'; -import { CommandPublicDownload } from './public/commandPublicDownload'; -import { CommandPublicDownloadThumbnails } from './public/commandPublicDownloadThumbnails'; -import { CommandPublicInfo } from './public/commandPublicInfo'; -import { CommandPublicList } from './public/commandPublicList'; -import { CommandPublicRename } from './public/commandPublicRename'; -import { CommandPublicUpload } from './public/commandPublicUpload'; -import { CommandPublicScanHashes } from './public/commandPublicScanHashes'; -import { CommandRevisionDelete } from './revision/commandRevisionDelete'; -import { CommandRevisionDownload } from './revision/commandRevisionDownload'; -import { CommandRevisionList } from './revision/commandRevisionList'; -import { CommandRevisionRestore } from './revision/commandRevisionRestore'; -import { CommandSharingConvertNonProtonInvitation } from './sharing/commandSharingConvertNonProtonInvitation'; -import { CommandSharingInvite } from './sharing/commandSharingInvite'; -import { CommandSharingRemove } from './sharing/commandSharingRemove'; -import { CommandSharingRemoveUrl } from './sharing/commandSharingRemoveUrl'; -import { CommandSharingSetUrl } from './sharing/commandSharingSetUrl'; -import { CommandSharingStatus } from './sharing/commandSharingStatus'; -import { CommandInvitationAccept } from './sharing/commandInvitationAccept'; -import { CommandInvitationList } from './sharing/commandInvitationList'; -import { CommandInvitationReject } from './sharing/commandInvitationReject'; +import { InitConfig } from '../config'; import { Command } from './interface'; -import { CommandBookmarkCreate } from './bookmark/commandBookmarkCreate'; -import { CommandPhotoEmptyTrash } from './photos/CommandPhotoEmptyTrash'; - -export { formatReadableJson } from './formatters'; - -const COMMANDS = [ - // CLI Account - new CommandAuthLogin(), - new CommandAuthLogout(), - // ProtonDriveClient - new CommandEventsDrive(), - new CommandEventsMyVolume(), - new CommandEventsPath(), - new CommandFileSystemList(), - new CommandFileSystemInfo(), - new CommandFileSystemCreateFolder(), - new CommandFileSystemRename(), - new CommandFileSystemMove(), - new CommandFileSystemCopy(), - new CommandFileSystemTrash(), - new CommandFileSystemDelete(), - new CommandFileSystemRestore(), - new CommandFileSystemEmptyTrash(), - new CommandFileSystemDownload(), - new CommandFileSystemDownloadSeeking(), - new CommandFileSystemDownloadThumbnails(), - new CommandFileSystemUpload(), - new CommandFileSystemGetAvailableName(), - new CommandRevisionList(), - new CommandRevisionDownload(), - new CommandRevisionRestore(), - new CommandRevisionDelete(), - new CommandSharingStatus(), - new CommandSharingInvite(), - new CommandSharingConvertNonProtonInvitation(), - new CommandSharingRemove(), - new CommandSharingSetUrl(), - new CommandSharingRemoveUrl(), - new CommandInvitationList(), - new CommandInvitationAccept(), - new CommandInvitationReject(), - new CommandBookmarkCreate(), - new CommandBookmarkList(), - new CommandBookmarkRemove(), - new CommandDeviceCreate(), - new CommandDeviceRename(), - new CommandDeviceDelete(), - // ProtonDrivePublicLinkClient - new CommandPublicList(), - new CommandPublicInfo(), - new CommandPublicCreateFolder(), - new CommandPublicCreateDocument(), - new CommandPublicGetAvailableName(), - new CommandPublicRename(), - new CommandPublicDelete(), - new CommandPublicDownload(), - new CommandPublicDownloadThumbnails(), - new CommandPublicUpload(), - new CommandPublicScanHashes(), - // ProtonDrivePhotosClient - new CommandPhotoRoot(), - new CommandPhotoTimeline(), - new CommandPhotoDuplicate(), - new CommandPhotoUpdate(), - new CommandPhotoEmptyTrash(), - new CommandAlbumList(), - new CommandAlbumCreate(), - new CommandAlbumUpdate(), - new CommandAlbumDelete(), - new CommandAlbumPhotos(), - new CommandAlbumAddPhoto(), - new CommandAlbumRemovePhoto(), - // Diagnostic - new CommandDiagnosticTree(), - new CommandDiagnosticPhotosTimeline(), - new CommandDiagnosticGetTreeStructure(), -].map((command: Command) => { - command.options = command.options || {}; - command.options['help'] = { - type: 'boolean', - default: false, - }; - command.options['json'] = { - type: 'boolean', - short: 'j', - default: false, - }; - return command; -}); - -export function getCommand(groupName: string, commandName: string): Command { - if (groupName === 'fs') { - groupName = 'filesystem'; +import { startRepl } from './repl'; +import { runSingleInvocation } from './run'; + +export type { Command, ActionArgs } from './interface'; +export { + formatAuthor, + formatDate, + formatMemberRole, + formatReadableJson, + formatSize, + printIterable, + printObject, +} from './formatters'; +export { getClaimedSize, getName, getNode } from './node'; +export { Path, Paths, PathType } from './paths'; +export { openBrowserUrl } from './openBrowserUrl'; +export { readPasswordLine } from './readPasswordLine'; +export { applyDefaultCliOptions } from './registryCore'; +export type { CliSession } from './run'; + +export async function run(commands: Command[], initOptions: InitConfig) { + if (shouldStartRepl(Bun.argv)) { + await startRepl(commands, initOptions); + } else { + await runSingleInvocation(commands, Bun.argv, initOptions); } - - let commands = COMMANDS.filter((command) => command.group.startsWith(groupName) && command.name === commandName); - if (commands.length === 1) { - return commands[0]; - } - - commands = COMMANDS.filter( - (command) => command.group.startsWith(groupName) && command.name.startsWith(commandName), - ); - if (commands.length === 1) { - return commands[0]; - } - - printUsage(); - throw new Error(`Command not found`); -} - -export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { - if (command.args) { - if (args.length !== command.args.length) { - printCommandUsage(command); - throw new Error(`Expected ${command.args.length} arguments, got ${args.length}`); - } - } - - Object.entries((command.options as ParseArgsConfig['options']) || {}).forEach(([key, option]) => { - if (option.default !== undefined) { - values[key] = values[key] || option.default; - return; - } - if (values[key] === undefined) { - printCommandUsage(command); - throw new Error(`Missing required option: ${key}`); - } - }); -} - -function printUsage() { - console.log('Usage:'); - COMMANDS.map((command) => { - printCommandManual(command); - }); - console.log('General options:'); - console.log(' --help: Show this help'); - console.log(' -j, --json: Output in JSON format'); -} - -function printCommandUsage(command: Command) { - console.log('Usage:'); - printCommandManual(command); } -function printCommandManual(command: Command) { - const args = (command.args || []).map((arg) => `<${arg}>`).join(' '); - const options = Object.entries(command.options || {}) - .filter(([name]) => name !== 'help' && name !== 'json') - .map(([name, { type, short }]) => { - const flag = short ? `-${short}|--${name}` : `--${name}`; - return type === 'boolean' ? flag : `${flag} <${type}>`; - }) - .join(' '); - console.log(` ${command.group} ${command.name} ${args} ${options}`); +function shouldStartRepl(argv: string[]): boolean { + return argv[2] == null || argv[2] === ''; } diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 46a82991..60c1fd1a 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,7 +1,9 @@ import { ParseArgsConfig } from 'util'; -import { ProtonDriveClient } from '../../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; -import { Diagnostic } from '../../../sdk/src/diagnostic'; + +import { ProtonDriveClient } from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; +import { Diagnostic } from '@protontech/drive-sdk/diagnostic'; + import { Auth } from '../api'; import { Paths } from './paths'; diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index f62a2db4..65e78a32 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -1,4 +1,4 @@ -import { MaybeNode, NodeEntity, DegradedNode } from '../../../sdk/src'; +import { MaybeNode, NodeEntity, DegradedNode } from '@protontech/drive-sdk'; export function getNode(maybeNode: MaybeNode): NodeEntity | DegradedNode { return maybeNode.ok ? maybeNode.value : maybeNode.error; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 03fd5fb7..8f118833 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,8 +1,10 @@ import path from 'node:path'; -import { ProtonDriveClient, MaybeNode, MaybeMissingNode } from '../../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../../sdk/src/protonDrivePhotosClient'; + +import { ProtonDriveClient, MaybeNode, MaybeMissingNode } from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; +import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePublicLinkClient'; + import { getName } from './node'; -import { ProtonDrivePublicLinkClient } from '../../../sdk/src/protonDrivePublicLinkClient'; export enum PathType { Root = 'root', diff --git a/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts b/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts deleted file mode 100644 index 8df728bc..00000000 --- a/js/cli/src/cli/photos/CommandPhotoEmptyTrash.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandPhotoEmptyTrash implements Command { - group = 'photo'; - name = 'trash'; - - async action({ photosSdk }: ActionArgs) { - await photosSdk.emptyTrash(); - console.log('Photo volume trash emptied'); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumAddPhoto.ts b/js/cli/src/cli/photos/commandAlbumAddPhoto.ts deleted file mode 100644 index 2b33dc1a..00000000 --- a/js/cli/src/cli/photos/commandAlbumAddPhoto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumAddPhoto implements Command { - group = 'albums'; - name = 'add-photo'; - args = ['albumPath', 'photoPath']; - - async action({ paths, photosSdk, args: [albumPathString, ...photoPathStrings], options: { json } }: ActionArgs) { - if (photoPathStrings.length === 0) { - throw new Error('At least one photo identifier must be provided'); - } - - const albumNodePath = paths.getPath(albumPathString); - const albumNode = await albumNodePath.getNode(); - - const photoNodes = await Promise.all( - photoPathStrings.map(async (photoPathString) => { - const photoPath = paths.getPath(photoPathString); - return photoPath.getNode(); - }), - ); - - await printIterable(photosSdk.addPhotosToAlbum(albumNode, photoNodes), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumCreate.ts b/js/cli/src/cli/photos/commandAlbumCreate.ts deleted file mode 100644 index 6c4b59e9..00000000 --- a/js/cli/src/cli/photos/commandAlbumCreate.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumCreate implements Command { - group = 'albums'; - name = 'create'; - args = ['name']; - - async action({ photosSdk, args: [name], options: { json } }: ActionArgs) { - const album = await photosSdk.createAlbum(name); - printObject(album, json); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumDelete.ts b/js/cli/src/cli/photos/commandAlbumDelete.ts deleted file mode 100644 index 1848c614..00000000 --- a/js/cli/src/cli/photos/commandAlbumDelete.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumDelete implements Command { - group = 'albums'; - name = 'delete'; - args = ['pathString']; - options: ParseArgsConfig['options'] = { - force: { - type: 'boolean', - short: 'f', - default: false, - }, - save: { - type: 'boolean', - short: 's', - default: false, - }, - }; - - async action({ paths, photosSdk, args: [pathString], options: { force, save } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - await photosSdk.deleteAlbum(node, { force, saveToTimeline: save }); - console.log(`Album deleted successfully`); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumList.ts b/js/cli/src/cli/photos/commandAlbumList.ts deleted file mode 100644 index f920f856..00000000 --- a/js/cli/src/cli/photos/commandAlbumList.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumList implements Command { - group = 'albums'; - name = 'list'; - - async action({ photosSdk, options: { json } }: ActionArgs) { - await printIterable(photosSdk.iterateAlbums(), json); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumPhotos.ts b/js/cli/src/cli/photos/commandAlbumPhotos.ts deleted file mode 100644 index e6e4b524..00000000 --- a/js/cli/src/cli/photos/commandAlbumPhotos.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumPhotos implements Command { - group = 'albums'; - name = 'photos'; - args = ['path']; - options: ParseArgsConfig['options'] = { - 'load-details': { - type: 'boolean', - short: 'd', - default: false, - }, - }; - - async action({ paths, photosSdk, args: [pathString], options: { json, 'load-details': loadDetails } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - const albumUid = node.ok ? node.value.uid : node.error.uid; - - if (loadDetails) { - await this.listWithDetails(photosSdk, albumUid, json); - } else { - await this.list(photosSdk, albumUid, json); - } - } - - async list(photosSdk: ProtonDrivePhotosClient, albumNodeUid: string, json: boolean) { - await printIterable(photosSdk.iterateAlbum(albumNodeUid), json); - } - - async listWithDetails(photosSdk: ProtonDrivePhotosClient, albumNodeUid: string, json: boolean) { - const nodeUids = await Array.fromAsync(photosSdk.iterateAlbum(albumNodeUid), (photo) => photo.nodeUid); - await printIterable(photosSdk.iterateNodes(nodeUids), json); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts b/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts deleted file mode 100644 index d4d4be7a..00000000 --- a/js/cli/src/cli/photos/commandAlbumRemovePhoto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumRemovePhoto implements Command { - group = 'albums'; - name = 'remove-photo'; - args = ['albumPath', 'photoPath']; - - async action({ paths, photosSdk, args: [albumPathString, ...photoPathStrings], options: { json } }: ActionArgs) { - if (photoPathStrings.length === 0) { - throw new Error('At least one photo identifier must be provided'); - } - - const albumNodePath = paths.getPath(albumPathString); - const albumNode = await albumNodePath.getNode(); - - const photoNodes = await Promise.all( - photoPathStrings.map(async (photoPathString) => { - const photoPath = paths.getPath(photoPathString); - return photoPath.getNode(); - }), - ); - - await printIterable(photosSdk.removePhotosFromAlbum(albumNode, photoNodes), json, (result) => - console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/photos/commandAlbumUpdate.ts b/js/cli/src/cli/photos/commandAlbumUpdate.ts deleted file mode 100644 index 94ecc97d..00000000 --- a/js/cli/src/cli/photos/commandAlbumUpdate.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandAlbumUpdate implements Command { - group = 'albums'; - name = 'update'; - args = ['pathString']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - 'cover-photo-uid': { - type: 'string', - short: 'c', - default: '', - }, - }; - - async action({ - paths, - photosSdk, - args: [pathString], - options: { json, name, 'cover-photo-uid': coverPhotoUid }, - }: ActionArgs) { - if (!name && !coverPhotoUid) { - throw new Error('At least one update option must be provided: --name, --cover-photo-uid'); - } - - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const updates: { - name?: string; - coverPhotoNodeUid?: string; - } = {}; - - if (name) { - updates.name = name; - } - if (coverPhotoUid) { - updates.coverPhotoNodeUid = coverPhotoUid; - } - - const album = await photosSdk.updateAlbum(node, updates); - printObject(album, json); - } -} diff --git a/js/cli/src/cli/photos/commandPhotoDuplicate.ts b/js/cli/src/cli/photos/commandPhotoDuplicate.ts deleted file mode 100644 index ba80e0f7..00000000 --- a/js/cli/src/cli/photos/commandPhotoDuplicate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'node:path'; - -import { sha1 } from '@noble/hashes/sha1'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandPhotoDuplicate implements Command { - group = 'photo'; - name = 'duplicate'; - args = ['localPath']; - - - async action({ photosSdk, args: [localPath], options: { json } }: ActionArgs) { - const name = path.basename(localPath); - - const getSha1 = async () => { - const file = Bun.file(localPath); - const fileBytes = await file.bytes(); - - const sha1Instance = sha1.create(); - sha1Instance.update(fileBytes); - const sha1Hash = sha1Instance.digest() as Uint8Array; - - return sha1Hash.toHex(); - } - - const duplicates = await photosSdk.findPhotoDuplicates(name, getSha1) - printObject({ duplicates }, json); - } -} diff --git a/js/cli/src/cli/photos/commandPhotoRoot.ts b/js/cli/src/cli/photos/commandPhotoRoot.ts deleted file mode 100644 index 3a896531..00000000 --- a/js/cli/src/cli/photos/commandPhotoRoot.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandPhotoRoot implements Command { - group = 'photo'; - name = 'root'; - - async action({ photosSdk, options: { json } }: ActionArgs) { - const root = await photosSdk.getMyPhotosRootFolder(); - printObject(root, json); - } -} diff --git a/js/cli/src/cli/photos/commandPhotoTimeline.ts b/js/cli/src/cli/photos/commandPhotoTimeline.ts deleted file mode 100644 index 3897819d..00000000 --- a/js/cli/src/cli/photos/commandPhotoTimeline.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { ProtonDrivePhotosClient } from '../../../../sdk/src/protonDrivePhotosClient'; -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandPhotoTimeline implements Command { - group = 'photo'; - name = 'timeline'; - options: ParseArgsConfig['options'] = { - 'load-details': { - type: 'boolean', - short: 'd', - default: false, - }, - }; - - async action({ photosSdk, options: { json, 'load-details': loadDetails } }: ActionArgs) { - if (loadDetails) { - await this.listWithDetails(photosSdk, json); - } else { - await this.list(photosSdk, json); - } - } - - async list(photosSdk: ProtonDrivePhotosClient, json: boolean) { - await printIterable(photosSdk.iterateTimeline(), json); - } - - async listWithDetails(photosSdk: ProtonDrivePhotosClient, json: boolean) { - const nodeUids = await Array.fromAsync(photosSdk.iterateTimeline(), (photo) => photo.nodeUid); - await printIterable(photosSdk.iterateNodes(nodeUids), json); - } -} diff --git a/js/cli/src/cli/photos/commandPhotoUpdate.ts b/js/cli/src/cli/photos/commandPhotoUpdate.ts deleted file mode 100644 index d44e725f..00000000 --- a/js/cli/src/cli/photos/commandPhotoUpdate.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { PhotoTag } from '../../../../sdk/src'; -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -const TAG_NAME_TO_ENUM: Record = { - favorites: PhotoTag.Favorites, - screenshots: PhotoTag.Screenshots, - videos: PhotoTag.Videos, - 'live-photos': PhotoTag.LivePhotos, - 'motion-photos': PhotoTag.MotionPhotos, - selfies: PhotoTag.Selfies, - portraits: PhotoTag.Portraits, - bursts: PhotoTag.Bursts, - panoramas: PhotoTag.Panoramas, - raw: PhotoTag.Raw, -}; - -export class CommandPhotoUpdate implements Command { - group = 'photo'; - name = 'update'; - args = ['path'] - options: ParseArgsConfig['options'] = { - 'add-tags': { - type: 'string', - short: 'a', - default: '', - }, - 'remove-tags': { - type: 'string', - short: 'r', - default: '', - }, - }; - - async action({ - paths, - photosSdk, - args: pathStrings, - options: { json, 'add-tags': addTagsOpt, 'remove-tags': removeTagsOpt }, - }: ActionArgs) { - if (pathStrings.length === 0) { - throw new Error('At least one photo path must be provided'); - } - - const addTags = parseTagOption(addTagsOpt || ''); - const removeTags = parseTagOption(removeTagsOpt || ''); - - if (addTags.length === 0 && removeTags.length === 0) { - throw new Error('At least one of --add-tags or --remove-tags must be provided'); - } - - const photoNodes = await Promise.all( - pathStrings.map(async (pathString) => { - const photoPath = paths.getPath(pathString); - return photoPath.getNode(); - }), - ); - - await printIterable( - photosSdk.updatePhotos( - photoNodes.map((node) => ({ nodeUid: node, tagsToAdd: addTags, tagsToRemove: removeTags })), - ), - json, - (result) => console.log(result.ok ? `✅ ${result.uid}` : `❌ ${result.uid}: ${result.error}`), - ); - } -} - -function parseTagOption(value: string): PhotoTag[] { - if (!value.trim()) { - return []; - } - const tags: PhotoTag[] = []; - const parts = value.split(',').map((s) => s.trim().toLowerCase()); - for (const part of parts) { - const asEnum = TAG_NAME_TO_ENUM[part]; - if (asEnum !== undefined) { - tags.push(asEnum); - } else { - const asNum = parseInt(part, 10); - if (Number.isInteger(asNum) && asNum >= 0 && asNum <= 9) { - tags.push(asNum as PhotoTag); - } else { - throw new Error( - `Invalid tag: "${part}". Use comma-separated names (e.g. favorites,screenshots) or numbers 0-9.`, - ); - } - } - } - return tags; -} diff --git a/js/cli/src/cli/public/base.ts b/js/cli/src/cli/public/base.ts deleted file mode 100644 index 8e3c2d20..00000000 --- a/js/cli/src/cli/public/base.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -export const PUBLIC_OPTIONS: ParseArgsConfig['options'] = { - url: { - type: 'string', - short: 'u', - }, - 'custom-password': { - type: 'string', - short: 'c', - default: '', - }, -}; diff --git a/js/cli/src/cli/public/commandPublicCreateDocument.ts b/js/cli/src/cli/public/commandPublicCreateDocument.ts deleted file mode 100644 index 7aedc6be..00000000 --- a/js/cli/src/cli/public/commandPublicCreateDocument.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicCreateDocument implements Command { - group = 'public'; - name = 'create-document'; - isPublicAction = true; - args = ['path', 'name']; - options: ParseArgsConfig['options'] = { - ...PUBLIC_OPTIONS, - type: { - type: 'string', - short: 't', - default: 'docs', - }, - }; - - async action({ - paths, - args: [pathString, name], - options: { json, url, 'custom-password': customPassword, type }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const parent = await nodePath.getNode(); - - const documentType = type === 'docs' ? 1 : 2; - const document = await client.experimental.createDocument(parent, name, documentType); - - printObject(document, json); - } -} diff --git a/js/cli/src/cli/public/commandPublicCreateFolder.ts b/js/cli/src/cli/public/commandPublicCreateFolder.ts deleted file mode 100644 index ca8dcbb0..00000000 --- a/js/cli/src/cli/public/commandPublicCreateFolder.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicCreateFolder implements Command { - group = 'public'; - name = 'create-folder'; - isPublicAction = true; - args = ['path', 'name']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ - paths, - args: [pathString, name], - options: { json, url, 'custom-password': customPassword }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const parent = await nodePath.getNode(); - - const folder = await client.createFolder(parent, name, new Date()); - - printObject(folder, json); - } -} diff --git a/js/cli/src/cli/public/commandPublicDelete.ts b/js/cli/src/cli/public/commandPublicDelete.ts deleted file mode 100644 index 803e0cb3..00000000 --- a/js/cli/src/cli/public/commandPublicDelete.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicDelete implements Command { - group = 'public'; - name = 'delete'; - isPublicAction = true; - args = ['path']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const node = await nodePath.getNode(); - - await printIterable(client.deleteNodes([node]), json, (result) => - console.log(result.ok ? `✅ Deleted ${result.uid}` : `❌ Failed to delete ${result.uid}: ${result.error}`), - ); - } -} diff --git a/js/cli/src/cli/public/commandPublicDownload.ts b/js/cli/src/cli/public/commandPublicDownload.ts deleted file mode 100644 index dd849258..00000000 --- a/js/cli/src/cli/public/commandPublicDownload.ts +++ /dev/null @@ -1,40 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { download } from '../downloader'; -import { getName } from '../node'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicDownload implements Command { - group = 'public'; - name = 'download'; - isPublicAction = true; - args = ['path', 'localParentPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - ...PUBLIC_OPTIONS, - }; - - async action({ - paths, - args: [pathString, localParentPath], - options: { name, json, url, 'custom-password': customPassword }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const node = await nodePath.getNode(); - const downloader = await client.getFileDownloader(node); - - await download({ - downloader, - downloadingName: getName(node), - localPath: path.join(localParentPath, name || getName(node)), - json, - }); - } -} diff --git a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts b/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts deleted file mode 100644 index 4e0786b3..00000000 --- a/js/cli/src/cli/public/commandPublicDownloadThumbnails.ts +++ /dev/null @@ -1,66 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'node:util'; - -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { MaybeNode, ThumbnailType } from '../../../../sdk/src'; -import { PUBLIC_OPTIONS } from './base'; -import { ProtonDrivePublicLinkClient } from '../../../../sdk/src/protonDrivePublicLinkClient'; - -export class CommandPublicDownloadThumbnails implements Command { - group = 'public'; - name = 'download-thumbnails'; - isPublicAction = true; - args = ['path', 'parentLocalPath']; - options: ParseArgsConfig['options'] = { - 'thumbnail-type': { - type: 'string', - short: 't', - default: ThumbnailType.Type1.toString(), - }, - ...PUBLIC_OPTIONS, - }; - - async action({ - paths, - args: [pathString, parentLocalPath], - options: { json, 'thumbnail-type': thumbnailType, url, 'custom-password': customPassword }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const node = await nodePath.getNode(); - - const thumbnailsIterator = this.processThumbnails(client, node, parseInt(thumbnailType), parentLocalPath); - await printIterable(thumbnailsIterator, json, (result) => { - console.log( - result.ok - ? `Downloaded thumbnail for ${result.nodeUid}` - : `Failed to download thumbnail for ${result.nodeUid}: ${result.error}`, - ); - }); - } - - private async *processThumbnails( - sdk: ProtonDrivePublicLinkClient, - node: MaybeNode, - thumbnailType: ThumbnailType, - parentLocalPath: string, - ) { - for await (const result of sdk.iterateThumbnails([node], thumbnailType)) { - // Thumbnail can be jpeg or webp. All new code should produce webp. - const thumbnailFileName = `thumbnail-${result.nodeUid}.webp`; - const thumbnailFilePath = path.join(parentLocalPath, thumbnailFileName); - - if (result.ok) { - await Bun.write(thumbnailFilePath, result.thumbnail); - } - - yield { - ok: result.ok, - nodeUid: result.nodeUid, - error: !result.ok ? result.error : undefined, - thumbnailFileName, - }; - } - } -} diff --git a/js/cli/src/cli/public/commandPublicGetAvailableName.ts b/js/cli/src/cli/public/commandPublicGetAvailableName.ts deleted file mode 100644 index 0fdc3016..00000000 --- a/js/cli/src/cli/public/commandPublicGetAvailableName.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicGetAvailableName implements Command { - group = 'public'; - name = 'get-available-name'; - isPublicAction = true; - args = ['path', 'name']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ - paths, - args: [pathString, name], - options: { json, url, 'custom-password': customPassword }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const parent = await nodePath.getNode(); - const availableName = await client.getAvailableName(parent, name); - - printObject( - { - availableName, - }, - json, - ); - } -} diff --git a/js/cli/src/cli/public/commandPublicInfo.ts b/js/cli/src/cli/public/commandPublicInfo.ts deleted file mode 100644 index de6e4ec9..00000000 --- a/js/cli/src/cli/public/commandPublicInfo.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicInfo implements Command { - group = 'public'; - name = 'info'; - isPublicAction = true; - args = ['path']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - await paths.authPublicLinkSession(url, customPassword); - const path = paths.getPublicLinkPath(pathString); - const node = await path.getNode(); - - printObject(node, json); - } -} diff --git a/js/cli/src/cli/public/commandPublicList.ts b/js/cli/src/cli/public/commandPublicList.ts deleted file mode 100644 index b504b9b6..00000000 --- a/js/cli/src/cli/public/commandPublicList.ts +++ /dev/null @@ -1,38 +0,0 @@ - -import { ParseArgsConfig } from 'util'; - -import { MaybeNode } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { formatAuthor, formatDate, formatSize, formatMemberRole, printIterable } from '../formatters'; -import { getName, getClaimedSize, getNode } from '../node'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicList implements Command { - group = 'public'; - name = 'list'; - isPublicAction = true; - args = ['path']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ paths, args: [pathString], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const path = paths.getPublicLinkPath(pathString); - const folder = await path.getNode(); - - await printIterable(client.iterateFolderChildren(folder), json, (node) => this.printNodeHuman(node)); - } - - private printNodeHuman(maybeNode: MaybeNode): void { - const node = getNode(maybeNode); - - const type = node.type === 'file' ? '📄' : '🗂️'; - const permissionFlag = formatMemberRole(node.directRole); - const author = formatAuthor(node.keyAuthor); - const created = formatDate(node.creationTime, true); - const claimedSize = getClaimedSize(maybeNode); - const size = claimedSize ? formatSize(claimedSize) : '-'; - const id = node.uid.split('~')[1]; - const name = getName(maybeNode); - console.log(`${type}${permissionFlag} ${author} ${created} ${size} ${id} ${name}`); - } -} diff --git a/js/cli/src/cli/public/commandPublicRename.ts b/js/cli/src/cli/public/commandPublicRename.ts deleted file mode 100644 index 2fe69bd8..00000000 --- a/js/cli/src/cli/public/commandPublicRename.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; - -export class CommandPublicRename implements Command { - group = 'public'; - name = 'rename'; - isPublicAction = true; - args = ['path', 'newName']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ paths, args: [pathString, newName], options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - const nodePath = paths.getPublicLinkPath(pathString); - const node = await nodePath.getNode(); - const renamedNode = await client.renameNode(node, newName); - - printObject(renamedNode, json); - } -} diff --git a/js/cli/src/cli/public/commandPublicScanHashes.ts b/js/cli/src/cli/public/commandPublicScanHashes.ts deleted file mode 100644 index bda578cb..00000000 --- a/js/cli/src/cli/public/commandPublicScanHashes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; -import { printObject } from '../formatters'; - -export class CommandPublicScanHashes implements Command { - group = 'public'; - name = 'scan-hashes'; - isPublicAction = true; - args = ['hash']; - options: ParseArgsConfig['options'] = PUBLIC_OPTIONS; - - async action({ paths, args, options: { json, url, 'custom-password': customPassword } }: ActionArgs) { - if (args.length === 0) { - throw new Error('At least one hash must be provided'); - } - - const client = await paths.authPublicLinkSession(url, customPassword); - const result = await client.experimental.scanHashes(args); - printObject(result, json); - } -} diff --git a/js/cli/src/cli/public/commandPublicUpload.ts b/js/cli/src/cli/public/commandPublicUpload.ts deleted file mode 100644 index 8dbaf05f..00000000 --- a/js/cli/src/cli/public/commandPublicUpload.ts +++ /dev/null @@ -1,70 +0,0 @@ -import path from 'node:path'; -import { ParseArgsConfig } from 'util'; - -import { Thumbnail } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { PUBLIC_OPTIONS } from './base'; -import { printObject } from '../formatters'; - -export class CommandPublicUpload implements Command { - group = 'public'; - name = 'upload'; - isPublicAction = true; - // FIXME: support upload of multiple files - args = ['localPath', 'parentPath']; - options: ParseArgsConfig['options'] = { - name: { - type: 'string', - short: 'n', - default: '', - }, - 'new-revision': { - type: 'boolean', - short: 'r', - default: false, - }, - ...PUBLIC_OPTIONS, - }; - - async action({ - paths, - args: [localPath, parentPath], - options: { json, name, 'new-revision': newRevision, url, 'custom-password': customPassword }, - }: ActionArgs) { - const client = await paths.authPublicLinkSession(url, customPassword); - - name = name || path.basename(localPath); - - const file = Bun.file(localPath); - const metadata = { - mediaType: file.type, - expectedSize: file.size, - }; - - let uploader; - if (newRevision) { - const parentNodePath = paths.getPublicLinkPath(parentPath); - const node = await parentNodePath.getChild(name); - uploader = await client.getFileRevisionUploader(node, metadata); - } else { - const parentNodePath = paths.getPublicLinkPath(parentPath); - const parentNode = await parentNodePath.getNode(); - uploader = await client.getFileUploader(parentNode, name, metadata); - } - - if (!json) { - console.log(`Uploading ${name} (${metadata.expectedSize || 'N/A'} bytes)`); - } - - const thumbnails: Thumbnail[] = []; // TODO - const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (writtenBytes) => { - if (!json) { - console.log(`Uploaded ${writtenBytes} bytes`); - } - }); - - const result = await controller.completion(); - - printObject(result, json); - } -} diff --git a/js/cli/src/cli/registryCore.ts b/js/cli/src/cli/registryCore.ts new file mode 100644 index 00000000..ae8517eb --- /dev/null +++ b/js/cli/src/cli/registryCore.ts @@ -0,0 +1,109 @@ +import { ParseArgsConfig } from 'util'; + +import { CommandNotFoundError, InvalidCommandArgumentsError } from './errors'; +import { Command } from './interface'; + +export function applyDefaultCliOptions(commands: Command[]): Command[] { + for (const command of commands) { + command.options = command.options || {}; + command.options['help'] = { + type: 'boolean', + default: false, + }; + command.options['json'] = { + type: 'boolean', + short: 'j', + default: false, + }; + } + return commands; +} + +export function getCommand(commands: Command[], groupName: string, commandName: string): Command { + if (groupName === 'help') { + return new CommandHelp(commands); + } + + if (groupName === 'fs') { + groupName = 'filesystem'; + } + + let matches = commands.filter((command) => command.group.startsWith(groupName) && command.name === commandName); + if (matches.length === 1) { + return matches[0]; + } + + matches = commands.filter( + (command) => command.group.startsWith(groupName) && command.name.startsWith(commandName), + ); + if (matches.length === 1) { + return matches[0]; + } + + printUsage(commands); + throw new CommandNotFoundError(); +} + +export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { + // Do not validate arguments when help is requested. + if (values['help']) { + return; + } + + if (command.args) { + if (args.length !== command.args.length) { + printCommandUsage(command); + throw new InvalidCommandArgumentsError(`Expected ${command.args.length} arguments, got ${args.length}`); + } + } + + Object.entries((command.options as ParseArgsConfig['options']) || {}).forEach(([key, option]) => { + if (option.default !== undefined) { + values[key] = values[key] || option.default; + return; + } + if (values[key] === undefined) { + printCommandUsage(command); + throw new InvalidCommandArgumentsError(`Missing required option: ${key}`); + } + }); +} + +class CommandHelp implements Command { + group = 'help'; + name = 'help'; + isAuthAction = true; + + constructor(private commands: Command[]) {} + + async action() { + printUsage(this.commands); + } +} + +function printUsage(commands: Command[]) { + console.log('Usage:'); + commands.map((command) => { + printCommandManual(command); + }); + console.log('General options:'); + console.log(' --help: Show help for a command'); + console.log(' -j, --json: Output in JSON format'); +} + +export function printCommandUsage(command: Command) { + console.log('Usage:'); + printCommandManual(command); +} + +function printCommandManual(command: Command) { + const args = (command.args || []).map((arg) => `<${arg}>`).join(' '); + const options = Object.entries(command.options || {}) + .filter(([name]) => name !== 'help' && name !== 'json') + .map(([name, { type, short }]) => { + const flag = short ? `-${short}|--${name}` : `--${name}`; + return type === 'boolean' ? flag : `${flag} <${type}>`; + }) + .join(' '); + console.log(` ${command.group} ${command.name} ${args} ${options}`); +} diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts new file mode 100644 index 00000000..734b1da0 --- /dev/null +++ b/js/cli/src/cli/repl.ts @@ -0,0 +1,67 @@ +import * as readline from 'node:readline/promises'; + +import { ProtonDriveError } from '@protontech/drive-sdk'; + +import { AccountApiError } from '../api/accountApi'; +import { InitConfig } from '../config'; +import { init } from '../init'; +import { CommandError } from './errors'; +import { Command } from './interface'; +import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; +import { runCommand } from './run'; + +export async function startRepl(commands: Command[], initOptions: InitConfig): Promise { + const session = await init(initOptions); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + try { + while (true) { + const line = await rl.question('proton-drive> '); + const trimmed = line.trim(); + if (trimmed === '') { + continue; + } + if (trimmed === 'exit' || trimmed === 'quit') { + break; + } + let parts: string[]; + try { + parts = splitQuotedLine(trimmed); + } catch (error: unknown) { + if (isRecoverableReplError(error)) { + console.error(error); + continue; + } + throw error; + } + const syntheticArgv = ['', '', ...parts]; + try { + await runCommand(commands, syntheticArgv, initOptions, session); + } catch (error: unknown) { + if (isRecoverableReplError(error)) { + console.error(error); + continue; + } + throw error; + } + } + } finally { + rl.close(); + } +} + +function isRecoverableReplError(error: unknown): boolean { + if ( + error instanceof ProtonDriveError || + error instanceof CommandError || + error instanceof AccountApiError || + error instanceof ReplUnclosedQuoteError + ) { + return true; + } + if (error instanceof TypeError) { + const code = (error as NodeJS.ErrnoException).code; + return typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS_'); + } + return false; +} diff --git a/js/cli/src/cli/revision/commandRevisionDelete.ts b/js/cli/src/cli/revision/commandRevisionDelete.ts deleted file mode 100644 index 6032b3dd..00000000 --- a/js/cli/src/cli/revision/commandRevisionDelete.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandRevisionDelete implements Command { - group = 'revision'; - name = 'delete'; - args = ['revisionUid']; - - async action({ sdk, args: [revisionUid], options: { json } }: ActionArgs) { - await sdk.deleteRevision(revisionUid); - if (!json) { - console.log(`Deleted revision: ${revisionUid}`); - } - } -} diff --git a/js/cli/src/cli/revision/commandRevisionDownload.ts b/js/cli/src/cli/revision/commandRevisionDownload.ts deleted file mode 100644 index de3f939c..00000000 --- a/js/cli/src/cli/revision/commandRevisionDownload.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Command, ActionArgs } from '../interface'; -import { download } from '../downloader'; - -export class CommandRevisionDownload implements Command { - group = 'revision'; - name = 'download'; - args = ['revisionUid', 'localPath']; - - async action({ sdk, args: [revisionUid, localPath], options: { json } }: ActionArgs) { - const downloader = await sdk.getFileRevisionDownloader(revisionUid); - - await download({ - downloader, - downloadingName: `revision`, - localPath, - json, - }); - } -} diff --git a/js/cli/src/cli/revision/commandRevisionList.ts b/js/cli/src/cli/revision/commandRevisionList.ts deleted file mode 100644 index cfc45afe..00000000 --- a/js/cli/src/cli/revision/commandRevisionList.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NodeType, Revision } from '../../../../sdk/src'; -import { Command, ActionArgs } from '../interface'; -import { formatAuthor, formatDate, formatSize, printIterable } from '../formatters'; -import { getNode } from '../node'; - -export class CommandRevisionList implements Command { - group = 'revision'; - name = 'list'; - args = ['path']; - - async action({ sdk, paths, args: [pathString], options: { json } }: ActionArgs) { - const path = paths.getPath(pathString); - const maybeNode = await path.getNode(); - - if (getNode(maybeNode).type === NodeType.Folder) { - throw new Error('Cannot list revisions of a folder'); - } - await printIterable(sdk.iterateRevisions(maybeNode), json, (revision) => this.printRevisionHuman(revision)); - } - - private printRevisionHuman(revision: Revision): void { - const author = formatAuthor(revision.contentAuthor); - const created = formatDate(revision.creationTime, true); - const size = formatSize(revision.claimedSize, true); - console.log(`${author} ${created} ${size} ${revision.uid}`); - } -} diff --git a/js/cli/src/cli/revision/commandRevisionRestore.ts b/js/cli/src/cli/revision/commandRevisionRestore.ts deleted file mode 100644 index 7203f4f8..00000000 --- a/js/cli/src/cli/revision/commandRevisionRestore.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command, ActionArgs } from '../interface'; - -export class CommandRevisionRestore implements Command { - group = 'revision'; - name = 'restore'; - args = ['revisionUid']; - - async action({ sdk, args: [revisionUid], options: { json } }: ActionArgs) { - await sdk.restoreRevision(revisionUid); - if (!json) { - console.log(`Restored revision: ${revisionUid}`); - } - } -} diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts new file mode 100644 index 00000000..31c2c529 --- /dev/null +++ b/js/cli/src/cli/run.ts @@ -0,0 +1,100 @@ +import { parseArgs } from 'util'; + +import { init } from '../init'; +import { InitConfig } from '../config'; +import { AuthRequiredError } from './errors'; +import { formatReadableJson } from './formatters'; +import { Command } from './interface'; +import { getCommand, printCommandUsage, validateCommandArguments } from './registryCore'; + +export type CliSession = Awaited>; + +export async function runSingleInvocation( + commands: Command[], + argv: string[], + initOptions: InitConfig, + session?: CliSession, +): Promise { + try { + await runCommand(commands, argv, initOptions, session); + } catch (error: unknown) { + reportFatalError(error); + process.exit(1); + } +} + +export async function runCommand( + commands: Command[], + argv: string[], + initOptions: InitConfig, + session?: CliSession, +): Promise { + const { command, options, args } = parseCliInvocation(commands, argv); + + if (options['help']) { + printCommandUsage(command); + return; + } + + const debug = initOptions.debug === undefined ? !options.json : initOptions.debug; + session = session ?? await init({ ...initOptions, debug }); + verifyAuthentication(command, session); + await dispatchAction(command, session, args, options, debug); +} + +function parseCliInvocation(commands: Command[], argv: string[]) { + const groupName = argv[2]!; + const commandName = argv[3]!; + const command = getCommand(commands, groupName, commandName); + const { values: options, positionals } = parseArgs({ + args: argv, + options: command.options || {}, + strict: true, + allowPositionals: true, + }); + const args = positionals.slice(4); + validateCommandArguments(command, args, options); + return { command, options, args }; +} + +function verifyAuthentication(command: Command, session: CliSession) { + if (!command.isAuthAction && !command.isPublicAction && !session.auth.isLoggedIn()) { + throw new AuthRequiredError(); + } +} + +async function dispatchAction( + command: Command, + session: CliSession, + args: string[], + options: { [name: string]: unknown }, + debug: boolean, +) { + if (debug) { + console.time('Command execution'); + } + try { + await command.action({ + auth: session.auth, + sdk: session.sdk, + photosSdk: session.photosSdk, + sdkDiagnostic: session.sdkDiagnostic, + paths: session.paths, + args, + options, + }); + } finally { + if (debug) { + console.timeEnd('Command execution'); + } + } +} + +function reportFatalError(error: unknown) { + console.error('==============================================='); + console.trace(error); + if (error != null && typeof error === 'object') { + console.debug('Error details:'); + console.debug(formatReadableJson(Object.fromEntries(Object.entries(error)))); + } +} diff --git a/js/cli/src/cli/sharing/commandInvitationAccept.ts b/js/cli/src/cli/sharing/commandInvitationAccept.ts deleted file mode 100644 index 14c6d47e..00000000 --- a/js/cli/src/cli/sharing/commandInvitationAccept.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; - -export class CommandInvitationAccept implements Command { - group = 'invitation'; - name = 'accept'; - args = ['invitationUid']; - options: ParseArgsConfig['options'] = { - photos: { - type: 'boolean', - short: 'p', - default: false, - }, - }; - - async action({ sdk, photosSdk, args: [invitationUid], options: { photos, json } }: ActionArgs) { - if (photos) { - await photosSdk.acceptInvitation(invitationUid); - } else { - await sdk.acceptInvitation(invitationUid); - } - - if (!json) { - console.log(`Invitation accepted: ${invitationUid}`); - } - } -} diff --git a/js/cli/src/cli/sharing/commandInvitationList.ts b/js/cli/src/cli/sharing/commandInvitationList.ts deleted file mode 100644 index 41d56d0f..00000000 --- a/js/cli/src/cli/sharing/commandInvitationList.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { printIterable } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandInvitationList implements Command { - group = 'invitation'; - name = 'list'; - options: ParseArgsConfig['options'] = { - photos: { - type: 'boolean', - short: 'p', - default: false, - }, - }; - - async action({ sdk, photosSdk, options: { photos, json } }: ActionArgs) { - if (photos) { - await printIterable(photosSdk.iterateInvitations(), json); - } else { - await printIterable(sdk.iterateInvitations(), json); - } - } -} diff --git a/js/cli/src/cli/sharing/commandInvitationReject.ts b/js/cli/src/cli/sharing/commandInvitationReject.ts deleted file mode 100644 index b22d7ea2..00000000 --- a/js/cli/src/cli/sharing/commandInvitationReject.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; - -export class CommandInvitationReject implements Command { - group = 'invitation'; - name = 'reject'; - args = ['invitationUid']; - options: ParseArgsConfig['options'] = { - photos: { - type: 'boolean', - short: 'p', - default: false, - }, - }; - - async action({ sdk, photosSdk, args: [invitationUid], options: { photos, json } }: ActionArgs) { - if (photos) { - await photosSdk.rejectInvitation(invitationUid); - } else { - await sdk.rejectInvitation(invitationUid); - } - - if (!json) { - console.log(`Invitation rejected: ${invitationUid}`); - } - } -} diff --git a/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts b/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts deleted file mode 100644 index accde5b9..00000000 --- a/js/cli/src/cli/sharing/commandSharingConvertNonProtonInvitation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandSharingConvertNonProtonInvitation implements Command { - group = 'sharing'; - name = 'convert-non-proton-invitation'; - args = ['path']; - options: ParseArgsConfig['options'] = { - 'invitation-uid': { - type: 'string', - short: 'i', - }, - }; - - async action({ paths, args: [pathString], options: { 'invitation-uid': invitationUid, json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const invitation = await nodePath.sdk.convertNonProtonInvitation(node, invitationUid as string); - - printObject(invitation, json); - } -} diff --git a/js/cli/src/cli/sharing/commandSharingInvite.ts b/js/cli/src/cli/sharing/commandSharingInvite.ts deleted file mode 100644 index 70ef2010..00000000 --- a/js/cli/src/cli/sharing/commandSharingInvite.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandSharingInvite implements Command { - group = 'sharing'; - name = 'invite'; - args = ['path']; - options: ParseArgsConfig['options'] = { - user: { - type: 'string', - short: 'u', - multiple: true, - default: [], - }, - role: { - type: 'string', - short: 'r', - default: 'viewer', - }, - message: { - type: 'string', - short: 'm', - default: '', - }, - 'include-node-name': { - type: 'boolean', - short: 'n', - default: false, - }, - }; - - async action({ - paths, - args: [pathString], - options: { user: userEmails, role, message, 'include-node-name': includeNodeName, json }, - }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const sharingInfo = await nodePath.sdk.shareNode(node, { - users: userEmails.map((email: string) => ({ email, role })), - emailOptions: { - message: message || undefined, - includeNodeName, - }, - }); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/sharing/commandSharingRemove.ts b/js/cli/src/cli/sharing/commandSharingRemove.ts deleted file mode 100644 index c11dd3cd..00000000 --- a/js/cli/src/cli/sharing/commandSharingRemove.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandSharingRemove implements Command { - group = 'sharing'; - name = 'remove'; - args = ['path']; - options: ParseArgsConfig['options'] = { - email: { - type: 'string', - short: 'e', - multiple: true, - default: [], - }, - everyone: { - type: 'boolean', - short: 'a', - default: false, - }, - }; - - async action({ paths, args: [pathString], options: { email: emails, everyone, json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - // FIXME: when supporting public links, everyone should keep it - const sharingInfo = everyone - ? await nodePath.sdk.unshareNode(node) - : await nodePath.sdk.unshareNode(node, { - users: emails, - }); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts b/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts deleted file mode 100644 index 23cce248..00000000 --- a/js/cli/src/cli/sharing/commandSharingRemoveUrl.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandSharingRemoveUrl implements Command { - group = 'sharing'; - name = 'remove-url'; - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const sharingInfo = await nodePath.sdk.unshareNode(node, { - publicLink: 'remove', - }); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/sharing/commandSharingSetUrl.ts b/js/cli/src/cli/sharing/commandSharingSetUrl.ts deleted file mode 100644 index ebf2be2b..00000000 --- a/js/cli/src/cli/sharing/commandSharingSetUrl.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ParseArgsConfig } from 'util'; - -import { Command, ActionArgs } from '../interface'; -import { printObject } from '../formatters'; - -export class CommandSharingSetUrl implements Command { - group = 'sharing'; - name = 'set-url'; - args = ['path']; - options: ParseArgsConfig['options'] = { - role: { - type: 'string', - default: 'viewer', - }, - password: { - type: 'string', - default: '', - }, - expiration: { - type: 'string', - default: '', - }, - }; - - async action({ paths, args: [pathString], options: { json, role, password, expiration } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - if (role !== 'viewer' && role !== 'editor') { - throw new Error(`Invalid role: ${role}`); - } - - if (expiration && isNaN(new Date(expiration).getTime())) { - throw new Error(`Invalid expiration date: ${expiration}`); - } - - const sharingInfo = await nodePath.sdk.shareNode(node, { - publicLink: { - role, - customPassword: password || undefined, - expiration: expiration ? new Date(expiration) : undefined, - }, - }); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/sharing/commandSharingStatus.ts b/js/cli/src/cli/sharing/commandSharingStatus.ts deleted file mode 100644 index c5a59bfc..00000000 --- a/js/cli/src/cli/sharing/commandSharingStatus.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { printObject } from '../formatters'; -import { Command, ActionArgs } from '../interface'; - -export class CommandSharingStatus implements Command { - group = 'sharing'; - name = 'status'; - args = ['path']; - - async action({ paths, args: [pathString], options: { json } }: ActionArgs) { - const nodePath = paths.getPath(pathString); - const node = await nodePath.getNode(); - - const sharingInfo = await nodePath.sdk.getSharingInfo(node); - - printObject(sharingInfo, json); - } -} diff --git a/js/cli/src/cli/splitQuotedLine.test.ts b/js/cli/src/cli/splitQuotedLine.test.ts new file mode 100644 index 00000000..3418c6de --- /dev/null +++ b/js/cli/src/cli/splitQuotedLine.test.ts @@ -0,0 +1,28 @@ +import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; + +describe('splitQuotedLine', () => { + it('parses escaped quotes inside double quotes as one argument', () => { + const line = '"' + 'ab \\"c\\" d' + '"'; + expect(splitQuotedLine(line)).toEqual(['ab "c" d']); + }); + + it('throws on unclosed quote after text', () => { + expect(() => splitQuotedLine('abc"')).toThrow(ReplUnclosedQuoteError); + }); + + it('throws on lone opening quote', () => { + expect(() => splitQuotedLine('"')).toThrow(ReplUnclosedQuoteError); + }); + + it('splits unquoted words on whitespace', () => { + expect(splitQuotedLine('one two three')).toEqual(['one', 'two', 'three']); + }); + + it('treats adjacent quoted and unquoted segments as one word', () => { + expect(splitQuotedLine('ab"c d"ef')).toEqual(['abc def']); + }); + + it('produces an empty argument for empty quotes', () => { + expect(splitQuotedLine('a "" b')).toEqual(['a', '', 'b']); + }); +}); diff --git a/js/cli/src/cli/splitQuotedLine.ts b/js/cli/src/cli/splitQuotedLine.ts new file mode 100644 index 00000000..54202308 --- /dev/null +++ b/js/cli/src/cli/splitQuotedLine.ts @@ -0,0 +1,63 @@ +export class ReplUnclosedQuoteError extends Error { + override name = 'ReplUnclosedQuoteError'; + + constructor() { + super('Unclosed quote in command line'); + } +} + +/** + * Split a line into arguments like a POSIX shell: double quotes group text; spaces inside quotes are literal. + * Inside double quotes, `\"` and `\\` are escapes. Adjacent quoted and unquoted segments form one word (e.g. `ab"c d"` → `abc d`). + */ +export function splitQuotedLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuote = false; + let i = 0; + + while (i < line.length && (line[i] === ' ' || line[i] === '\t')) { + i++; + } + + while (i < line.length) { + const c = line[i]; + if (inQuote) { + if (c === '\\' && i + 1 < line.length && (line[i + 1] === '"' || line[i + 1] === '\\')) { + current += line[i + 1]; + i += 2; + continue; + } + if (c === '"') { + inQuote = false; + i++; + continue; + } + current += c; + i++; + continue; + } + if (c === '"') { + inQuote = true; + i++; + continue; + } + if (c === ' ' || c === '\t') { + result.push(current); + current = ''; + i++; + while (i < line.length && (line[i] === ' ' || line[i] === '\t')) { + i++; + } + continue; + } + current += c; + i++; + } + + if (inQuote) { + throw new ReplUnclosedQuoteError(); + } + result.push(current); + return result; +} diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts new file mode 100644 index 00000000..48c8647c --- /dev/null +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -0,0 +1,20 @@ +import { Auth } from '../../api'; +import { openBrowserUrl, type ActionArgs, type Command } from '../../cli'; + +export class CommandAuthLogin implements Command { + group = 'auth'; + name = 'login'; + isAuthAction = true; + + async action({ auth }: ActionArgs) { + await this.handleAuthViaWeb(auth); + } + + protected async handleAuthViaWeb(auth: Auth) { + return auth.authViaWeb((signInUrl) => { + openBrowserUrl(signInUrl); + console.log('Sign in in your browser (URL also printed if it did not open automatically):'); + console.log(signInUrl); + }); + } +} diff --git a/js/cli/src/cli/auth/commandAuthLogout.ts b/js/cli/src/commands/auth/commandAuthLogout.ts similarity index 77% rename from js/cli/src/cli/auth/commandAuthLogout.ts rename to js/cli/src/commands/auth/commandAuthLogout.ts index e79dd1a7..1a360197 100644 --- a/js/cli/src/cli/auth/commandAuthLogout.ts +++ b/js/cli/src/commands/auth/commandAuthLogout.ts @@ -1,4 +1,4 @@ -import { Command, ActionArgs } from '../interface'; +import { type Command, type ActionArgs } from '../../cli'; export class CommandAuthLogout implements Command { group = 'auth'; diff --git a/js/cli/src/commands/index.ts b/js/cli/src/commands/index.ts new file mode 100644 index 00000000..6aaa2d13 --- /dev/null +++ b/js/cli/src/commands/index.ts @@ -0,0 +1 @@ +export { COMMANDS } from './registry'; diff --git a/js/cli/src/commands/registry.ts b/js/cli/src/commands/registry.ts new file mode 100644 index 00000000..4e326a6d --- /dev/null +++ b/js/cli/src/commands/registry.ts @@ -0,0 +1,9 @@ +import { applyDefaultCliOptions } from '../cli'; + +import { CommandAuthLogin } from './auth/commandAuthLogin'; +import { CommandAuthLogout } from './auth/commandAuthLogout'; + +export const COMMANDS = applyDefaultCliOptions([ + new CommandAuthLogin(), + new CommandAuthLogout(), +]); diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 469535d1..3370e056 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -1,4 +1,11 @@ +export interface InitConfig { + debug?: boolean; + appVersion: string; + clientUid: string; +} + export interface Config { + clientUid: string; appVersion: string; baseUrl: string; cacheDir: string; @@ -7,19 +14,17 @@ export interface Config { unsafeCache: boolean; } -export function getConfig(debug: boolean): Config { +export function getConfig(options: InitConfig): Config { const unsafeSecrets = ['yes', 'y', '1', 'true'].includes( process.env.PROTON_DRIVE_UNSAFE_SECRETS?.toLowerCase() ?? '', ); return { - // App version must have two or three parts, no more or less, separated by dash. - // First part is platform name, second is product name, third is optional section. - // That's why we have sdkclijs instead of sdk-cli-js. - appVersion: 'external-drive-sdkclijs@1.0.0', + clientUid: options.clientUid, + appVersion: options.appVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), - enableConsoleLog: debug, + enableConsoleLog: options.debug ?? false, unsafeSecrets, unsafeCache: unsafeSecrets, }; diff --git a/js/cli/src/credentials/credentials.ts b/js/cli/src/credentials/credentials.ts index 233665be..a4189233 100644 --- a/js/cli/src/credentials/credentials.ts +++ b/js/cli/src/credentials/credentials.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import type { SessionInfo, CredentialsStore } from './interface'; export class Credentials { diff --git a/js/cli/src/credentials/fileCredentialsStore.ts b/js/cli/src/credentials/fileCredentialsStore.ts index b801bc5c..fa6037cc 100644 --- a/js/cli/src/credentials/fileCredentialsStore.ts +++ b/js/cli/src/credentials/fileCredentialsStore.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { unlink } from 'node:fs/promises'; -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import type { Credentials, CredentialsStore } from './interface'; import { parseStoredSnapshot } from './parseCredentials'; diff --git a/js/cli/src/credentials/index.ts b/js/cli/src/credentials/index.ts index 9da86acf..c6c21d70 100644 --- a/js/cli/src/credentials/index.ts +++ b/js/cli/src/credentials/index.ts @@ -1,9 +1,10 @@ -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import type { Config } from '../config'; +import { Credentials } from './credentials'; import type { CredentialsStore } from './interface'; import { PlaintextFileSessionStore } from './fileCredentialsStore'; import { SecretsSessionStore } from './secretCredentialsStore'; -import { Credentials } from './credentials'; export type { Credentials } from './credentials'; diff --git a/js/cli/src/credentials/secretCredentialsStore.ts b/js/cli/src/credentials/secretCredentialsStore.ts index 2e0c50bd..b69081b9 100644 --- a/js/cli/src/credentials/secretCredentialsStore.ts +++ b/js/cli/src/credentials/secretCredentialsStore.ts @@ -1,8 +1,9 @@ -import { Logger } from '../../../sdk/src'; +import { Logger } from '@protontech/drive-sdk'; + import type { Credentials, CredentialsStore } from './interface'; import { parseStoredSnapshot } from './parseCredentials'; -const SECRET_SERVICE = 'ch.proton.drive-sdk-cli'; +const SECRET_SERVICE = 'ch.proton.drive/drive-sdk-cli'; const SECRET_NAME = 'auth-session'; export class SecretsSessionStore implements CredentialsStore { diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index fc34df04..e5011784 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -5,20 +5,20 @@ import { OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy, FeatureFlags, -} from '../../sdk/src'; -import { ProtonDrivePhotosClient } from '../../sdk/src/protonDrivePhotosClient'; -import { initDiagnostic } from '../../sdk/src/diagnostic'; +} from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; +import { initDiagnostic } from '@protontech/drive-sdk/diagnostic'; import { initApi } from './api'; import { createEntitiesCache } from './cache'; -import { getConfig } from './config'; +import { Paths } from './cli'; +import { getConfig, InitConfig } from './config'; import { initCredentials } from './credentials'; -import { Paths } from './cli/paths'; import { Api as CryptoApi } from './crypto/lib/worker/api'; import { initTelemetry } from './telemetry'; -export async function init(debug: boolean) { - const config = getConfig(debug); +export async function init(configOptions: InitConfig) { + const config = getConfig(configOptions); const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); const logger = telemetry.getLogger('cli'); @@ -30,7 +30,7 @@ export async function init(debug: boolean) { const sdkDependencies = { config: { baseUrl: config.baseUrl, - clientUid: 'proton-drive-sdk-js-cli-test', + clientUid: config.clientUid, }, httpClient, entitiesCache, diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index c7967215..3d3ce7de 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,57 +1,10 @@ -import { parseArgs } from 'util'; +import { run } from './cli'; +import { COMMANDS } from './commands'; -import { init } from './init'; -import { getCommand, validateCommandArguments, formatReadableJson } from './cli'; +const CLIENT_UID = 'proton-drive-sdk-js-cli'; +// Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). +const APP_VERSION = 'external-drive-sdkclijs@1.0.0'; -const groupName = Bun.argv[2]; -const commandName = Bun.argv[3]; -const command = getCommand(groupName, commandName); +await run(COMMANDS, { clientUid: CLIENT_UID, appVersion: APP_VERSION, debug: false }); -const { values: options, positionals } = parseArgs({ - args: Bun.argv, - options: command.options || {}, - strict: true, - allowPositionals: true, -}); - -const args = positionals.slice(4); -validateCommandArguments(command, args, options); - -const debug = !options.json; - -const { auth, sdk, photosSdk, sdkDiagnostic, paths } = await init(debug); - -if (!command.isAuthAction && !command.isPublicAction && !auth.isLoggedIn()) { - throw new Error('You need to login first'); -} - -if (debug) { - console.time('Command execution'); -} - -try { - await command.action({ - auth, - sdk, - photosSdk, - sdkDiagnostic, - paths, - args, - options, - }); -} catch (error: unknown) { - console.error('==============================================='); - console.trace(error); - - // Get all the object properties and convert through JSON to avoid custom object interpretation. - console.debug('Error details:'); - console.debug(formatReadableJson(Object.fromEntries(Object.entries(error as object)))); - - process.exit(1); -} - -if (debug) { - console.timeEnd('Command execution'); -} - -process.exit(); +process.exit(0); diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json index 5f202837..ed05e30c 100644 --- a/js/cli/tsconfig.json +++ b/js/cli/tsconfig.json @@ -1,11 +1,16 @@ { "compilerOptions": { "allowJs": false, + "baseUrl": ".", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "incremental": true, "module": "esnext", "moduleResolution": "node", + "paths": { + "@protontech/drive-sdk": ["../sdk/src/index.ts"], + "@protontech/drive-sdk/*": ["../sdk/src/*"] + }, "noEmit": false, "noImplicitAny": true, "strict": true, From 812977a65140d31789a96f73898539985d26bc66 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 9 Apr 2026 09:34:22 +0200 Subject: [PATCH 574/732] Add commands for basic node operations --- js/cli/src/cli/formatters.ts | 2 +- js/cli/src/cli/index.ts | 2 +- js/cli/src/cli/node.ts | 13 ++ js/cli/src/cli/paths.ts | 71 +++++++-- js/cli/src/cli/registryCore.ts | 8 +- js/cli/src/cli/run.ts | 7 + .../fileSystem/commandFileSystemCopy.ts | 51 +++++++ .../commandFileSystemCreateFolder.ts | 15 ++ .../fileSystem/commandFileSystemDelete.ts | 19 +++ .../fileSystem/commandFileSystemEmptyTrash.ts | 13 ++ .../fileSystem/commandFileSystemInfo.ts | 12 ++ .../fileSystem/commandFileSystemList.ts | 143 ++++++++++++++++++ .../fileSystem/commandFileSystemMove.ts | 28 ++++ .../fileSystem/commandFileSystemRename.ts | 16 ++ .../fileSystem/commandFileSystemRestore.ts | 19 +++ .../fileSystem/commandFileSystemTrash.ts | 19 +++ js/cli/src/commands/registry.ts | 20 +++ 17 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemCopy.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemDelete.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemInfo.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemList.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemMove.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemRename.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemRestore.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemTrash.ts diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index 708df2fc..d20c2a2a 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -11,7 +11,7 @@ export function printObject(object: object | undefined, json: boolean) { } export async function printIterable( - iterable: AsyncIterable, + iterable: AsyncIterable | T[], json: boolean, humanReadableWriter: (item: T) => void = (item) => console.log(formatReadableJson(item)), jsonTransform: (item: T) => object = (json) => json, diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 24f453d5..736519fa 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -13,7 +13,7 @@ export { printIterable, printObject, } from './formatters'; -export { getClaimedSize, getName, getNode } from './node'; +export { getClaimedSize, getName, findName, getNode, getNodeUid } from './node'; export { Path, Paths, PathType } from './paths'; export { openBrowserUrl } from './openBrowserUrl'; export { readPasswordLine } from './readPasswordLine'; diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index 65e78a32..745b5769 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -1,9 +1,22 @@ import { MaybeNode, NodeEntity, DegradedNode } from '@protontech/drive-sdk'; +export function getNodeUid(maybeNode: MaybeNode): string { + return maybeNode.ok ? maybeNode.value.uid : maybeNode.error.uid; +} + export function getNode(maybeNode: MaybeNode): NodeEntity | DegradedNode { return maybeNode.ok ? maybeNode.value : maybeNode.error; } +export function findName(maybeNodes: MaybeNode[], uid: string): string { + for (const maybeNode of maybeNodes) { + if (getNodeUid(maybeNode) === uid) { + return getName(maybeNode); + } + } + return uid; +} + export function getName(maybeNode: MaybeNode): string { let name; let uid; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 8f118833..1466af4f 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { ProtonDriveClient, MaybeNode, MaybeMissingNode } from '@protontech/drive-sdk'; +import { ProtonDriveClient, MaybeNode, MaybeMissingNode, ValidationError } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePublicLinkClient'; @@ -49,8 +49,49 @@ export class Paths { ].map((path) => `/${path}`); } - getPath(path: string): Path { - return new Path(this.sdk, this.photosSdk, path); + async getNodes(pathStrings: string[], supportedTypes?: PathType[]): Promise { + const paths = this.getPaths(pathStrings, supportedTypes); + const nodes = []; + for (const path of paths) { + nodes.push(await path.getNode()); + } + return nodes; + } + + getPaths(pathStrings: string[], supportedTypes?: PathType[]): Path[] { + let pathType: PathType | undefined; + + if (pathStrings.length === 0) { + throw new ValidationError('At least one path is required'); + } + + const paths = []; + for (const pathString of pathStrings) { + const path = this.getPath(pathString, supportedTypes); + + if (pathType === undefined) { + pathType = path.type; + } else if (pathType !== path.type) { + throw new ValidationError(`Operation across Drive and Photos is not supported`); + } + + paths.push(path); + } + + return paths; + } + + async getNode(pathString: string, supportedTypes?: PathType[]): Promise { + const path = this.getPath(pathString, supportedTypes); + return await path.getNode(); + } + + getPath(path: string, supportedTypes?: PathType[]): Path { + const p = new Path(this.sdk, this.photosSdk, path); + if (supportedTypes && !supportedTypes.includes(p.type)) { + throw new ValidationError(`Path "${path}" is not supported`); + } + return p; } getPublicLinkPath(path: string): PublicLinkPath { @@ -64,7 +105,7 @@ export class Paths { const { isCustomPasswordProtected } = await this.sdk.experimental.getPublicLinkInfo(url); if (isCustomPasswordProtected && !customPassword) { - throw new Error('Custom password is required'); + throw new ValidationError('Custom password is required'); } const isAnonymousContext = !this.auth.isLoggedIn(); @@ -118,7 +159,7 @@ export class Path { if (this.fullPath.startsWith(`${path.sep}albums`)) { return PathType.Albums; } - throw new Error(`Path "${this.fullPath}" not supported`); + throw new ValidationError(`Path "${this.fullPath}" not supported`); } get parentPath() { @@ -155,7 +196,7 @@ export class Path { if (this.type === PathType.Trash || this.type === PathType.PhotosTrash) { const parts = this.sectionPath.split(path.sep); if (parts.length > 1) { - throw new Error('Browsing trashed folders is not supported'); + throw new ValidationError('Browsing trashed folders is not supported'); } return this.getTrashedNode(parts[0]); } @@ -169,7 +210,7 @@ export class Path { if (this.type === PathType.Albums) { return this.getAlbumNodeByPath(this.sectionPath); } - throw new Error('Not implemented'); + throw new ValidationError('Not implemented'); } async getChild(name: string) { @@ -198,7 +239,7 @@ export class Path { return maybeNode; } } - throw new Error('Root node not found'); + throw new ValidationError('Root node not found'); } private async getTrashedNode(name: string) { @@ -207,7 +248,7 @@ export class Path { return maybeNode; } } - throw new Error('Trashed node not found'); + throw new ValidationError('Trashed node not found'); } private async getDevicesRootFolder(): Promise { @@ -217,12 +258,12 @@ export class Path { const [maybeMissingNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); if (!maybeNode) { - throw new Error(`Node not found`); + throw new ValidationError(`Node not found`); } return maybeNode; } } - throw new Error('Device not found'); + throw new ValidationError('Device not found'); } private async getNodeByPath(parentNode: MaybeNode, pathString: string) { @@ -247,7 +288,7 @@ export class Path { return maybeChild; } } - throw new Error(`Node not found: ${name}`); + throw new ValidationError(`Node not found: ${name}`); } private async getPhotoNodeByPath(pathString: string): Promise { @@ -268,7 +309,7 @@ export class Path { return maybeNode; } } - throw new Error(`Photo not found: ${name}`); + throw new ValidationError(`Photo not found: ${name}`); } private async getAlbumNodeByPath(pathString: string): Promise { @@ -284,7 +325,7 @@ export class Path { return maybeAlbum; } } - throw new Error(`Album not found: ${name}`); + throw new ValidationError(`Album not found: ${name}`); } } @@ -330,7 +371,7 @@ export class PublicLinkPath { return maybeChild; } } - throw new Error(`Node not found: ${name}`); + throw new ValidationError(`Node not found: ${name}`); } } diff --git a/js/cli/src/cli/registryCore.ts b/js/cli/src/cli/registryCore.ts index ae8517eb..e6639168 100644 --- a/js/cli/src/cli/registryCore.ts +++ b/js/cli/src/cli/registryCore.ts @@ -41,7 +41,7 @@ export function getCommand(commands: Command[], groupName: string, commandName: } printUsage(commands); - throw new CommandNotFoundError(); + throw new CommandNotFoundError(`Command not found: ${groupName} ${commandName}`); } export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { @@ -51,7 +51,11 @@ export function validateCommandArguments(command: Command, args: string[], value } if (command.args) { - if (args.length !== command.args.length) { + const hasVariableArgs = command.args.some((arg) => arg.endsWith('...')); + const hasExpectedArgs = hasVariableArgs + ? args.length >= command.args.length + : args.length === command.args.length; + if (!hasExpectedArgs) { printCommandUsage(command); throw new InvalidCommandArgumentsError(`Expected ${command.args.length} arguments, got ${args.length}`); } diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 31c2c529..7e417ad2 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -1,5 +1,7 @@ import { parseArgs } from 'util'; +import { ProtonDriveError } from '@protontech/drive-sdk'; + import { init } from '../init'; import { InitConfig } from '../config'; import { AuthRequiredError } from './errors'; @@ -18,6 +20,11 @@ export async function runSingleInvocation( try { await runCommand(commands, argv, initOptions, session); } catch (error: unknown) { + if (error instanceof ProtonDriveError) { + console.error(error.message); + process.exit(1); + return; + } reportFatalError(error); process.exit(1); } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts new file mode 100644 index 00000000..aff65b6a --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -0,0 +1,51 @@ +import { ParseArgsConfig } from 'node:util'; + +import { ProtonDriveClient, MaybeNode, ValidationError } from '@protontech/drive-sdk'; + +import { printIterable, type Command, type ActionArgs, PathType, getNodeUid, getName, findName } from '../../cli'; + +const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; + +export class CommandFileSystemCopy implements Command { + group = 'filesystem'; + name = 'copy'; + args = ['sourcePath...', 'targetPath']; + options: ParseArgsConfig['options'] = { + name: { + type: 'string', + short: 'n', + default: '', + }, + }; + + async action({ sdk, paths, args, options: { name, json } }: ActionArgs) { + const sourcePathStrings = args.slice(0, -1); + const targetPathString = args[args.length - 1]; + + if (sourcePathStrings.length > 1 && name !== '') { + throw new ValidationError('Cannot specify name when copying multiple files'); + } + + const sourceNodes = await paths.getNodes(sourcePathStrings, SUPPORTED_PATH_TYPES); + const targetNode = await paths.getNode(targetPathString, SUPPORTED_PATH_TYPES); + + if (sourceNodes.length === 1) { + await this.copyNode(sdk, sourceNodes[0], targetNode, json, name || getName(sourceNodes[0])); + } else { + await this.copyNodes(sdk, sourceNodes, targetNode, json); + } + } + + private async copyNode(sdk: ProtonDriveClient, sourceNode: MaybeNode, targetNode: MaybeNode, json: boolean, name: string) { + await printIterable(sdk.copyNodes([{ uid: getNodeUid(sourceNode), name }], targetNode), json, (result) => { + console.log(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error}`); + }); + } + + private async copyNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { + await printIterable(sdk.copyNodes(sourceNodes, targetNode), json, (result) => { + const nodeName = findName(sourceNodes, result.uid); + console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + }); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts new file mode 100644 index 00000000..1df754d3 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts @@ -0,0 +1,15 @@ +import { printObject, type Command, type ActionArgs, PathType } from '../../cli'; + +export class CommandFileSystemCreateFolder implements Command { + group = 'filesystem'; + name = 'create-folder'; + args = ['path', 'name']; + + async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { + const parent = await paths.getNode(pathString, [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]); + + const createdFolder = await sdk.createFolder(parent, name); + + printObject(createdFolder, json); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts new file mode 100644 index 00000000..5f03b5d1 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts @@ -0,0 +1,19 @@ +import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; + +const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; + +export class CommandFileSystemDelete implements Command { + group = 'filesystem'; + name = 'delete'; + args = ['path...']; + + async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { + const nodePaths = paths.getPaths(pathStrings, SUPPORTED_PATH_TYPES); + const nodes = await paths.getNodes(pathStrings, SUPPORTED_PATH_TYPES); + + await printIterable(nodePaths[0].sdk.deleteNodes(nodes), json, (result) => { + const nodeName = findName(nodes, result.uid); + console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + }); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts new file mode 100644 index 00000000..0d184ceb --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts @@ -0,0 +1,13 @@ +import { type Command, type ActionArgs } from '../../cli'; + +export class CommandFileSystemEmptyTrash implements Command { + group = 'filesystem'; + name = 'empty-trash'; + + async action({ sdk, options: { json } }: ActionArgs) { + await sdk.emptyTrash(); + if (!json) { + console.log('✅ Trash emptied'); + } + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts new file mode 100644 index 00000000..bbad5feb --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts @@ -0,0 +1,12 @@ +import { printObject, type Command, type ActionArgs } from '../../cli'; + +export class CommandFileSystemInfo implements Command { + group = 'filesystem'; + name = 'info'; + args = ['path']; + + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { + const node = await paths.getNode(pathString); + printObject(node, json); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts new file mode 100644 index 00000000..3f7136fe --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -0,0 +1,143 @@ +import { ParseArgsConfig } from 'node:util'; + +import { Device, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; + +import { + formatAuthor, + formatDate, + formatMemberRole, + formatSize, + getClaimedSize, + getName, + getNode, + Paths, + PathType, + printIterable, + type ActionArgs, + type Command, +} from '../../cli'; + +export class CommandFileSystemList implements Command { + group = 'filesystem'; + name = 'list'; + args = ['path']; + options: ParseArgsConfig['options'] = { + type: { + type: 'string', + short: 't', + default: '', + }, + }; + + async action({ sdk, photosSdk, paths, args: [pathString], options: { json, type } }: ActionArgs) { + const path = paths.getPath(pathString); + + const nodeType = type ? Object.entries(NodeType).find(([, value]) => value === type)?.[1] : undefined; + if (type && !nodeType) { + throw new ValidationError(`Invalid node type: ${type}`); + } + + switch (path.type) { + case PathType.Root: + const rootPaths = paths.rootPaths.map((path) => ({ path })); + await printIterable(rootPaths, json, (item) => console.log(item.path)); + break; + case PathType.MyFiles: + await this.printChildren(sdk, paths, pathString, { json, nodeType }); + break; + case PathType.Devices: + if (path.fullPath === `/${PathType.Devices}`) { + await this.printDevices(sdk, { json }); + } else { + await this.printChildren(sdk, paths, pathString, { json }); + } + break; + case PathType.SharedByMe: + if (path.fullPath === `/${PathType.SharedByMe}`) { + await this.printSharedNodes(sdk, { json }); + } else { + throw new ValidationError(`Use direct path to list children of shared node`); + } + break; + case PathType.SharedWithMe: + if (path.fullPath === `/${PathType.SharedWithMe}`) { + await this.printSharedWithMe(sdk, { json }); + } else { + await this.printChildren(sdk, paths, pathString, { json }); + } + break; + case PathType.Trash: + if (path.fullPath === `/${PathType.Trash}`) { + await this.printTrashedNodes(sdk, { json }); + } else { + throw new ValidationError(`Listing children of trashed folder is not supported`); + } + break; + case PathType.PhotosSharedByMe: + if (path.fullPath === `/${PathType.PhotosSharedByMe}`) { + await this.printSharedNodes(photosSdk, { json }); + } else { + throw new ValidationError(`Use direct path to list children of shared node`); + } + break; + case PathType.PhotosSharedWithMe: + if (path.fullPath === `/${PathType.PhotosSharedWithMe}`) { + await this.printSharedWithMe(photosSdk, { json }); + } else { + throw new ValidationError(`Use albums photos command to list photos in shared albums`); + } + break; + case PathType.PhotosTrash: + if (path.fullPath === `/${PathType.PhotosTrash}`) { + await this.printTrashedNodes(photosSdk, { json }); + } else { + throw new ValidationError(`Listing photos of trashed albums is not supported`); + } + break; + default: + throw new ValidationError(`Path type ${path.type} is not supported`); + } + } + + private async printDevices(sdk: ProtonDriveClient, options: { json: boolean }) { + await printIterable(sdk.iterateDevices(), options.json, (device) => this.printDeviceHuman(device)); + } + + private async printChildren(sdk: ProtonDriveClient, paths: Paths, pathString: string, options: { json: boolean; nodeType?: NodeType }) { + const parentNode = await paths.getNode(pathString); + const filterOptions = options.nodeType ? { type: options.nodeType } : undefined; + const childrenIterator = sdk.iterateFolderChildren(parentNode, filterOptions); + await printIterable(childrenIterator, options.json, (node) => this.printNodeHuman(node)); + } + + private async printSharedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { + await printIterable(sdk.iterateSharedNodes(), options.json, (node) => this.printNodeHuman(node)); + } + + private async printSharedWithMe(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { + await printIterable(sdk.iterateSharedNodesWithMe(), options.json, (node) => this.printNodeHuman(node)); + } + + private async printTrashedNodes(sdk: ProtonDriveClient | ProtonDrivePhotosClient, options: { json: boolean }) { + await printIterable(sdk.iterateTrashedNodes(), options.json, (node) => this.printNodeHuman(node)); + } + + private printNodeHuman(maybeNode: MaybeNode): void { + const node = getNode(maybeNode); + + const type = node.type === 'file' ? '📄' : '🗂️'; + const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. + const permissionFlag = formatMemberRole(node.directRole); + const author = formatAuthor(node.keyAuthor); + const created = formatDate(node.creationTime, true); + const claimedSize = getClaimedSize(maybeNode); + const size = claimedSize ? formatSize(claimedSize) : '-'; + const name = getName(maybeNode); + console.log(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${name}`); + } + + private printDeviceHuman(device: Device): void { + console.log(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts new file mode 100644 index 00000000..196abe9d --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -0,0 +1,28 @@ +import { ProtonDriveClient, MaybeNode } from '@protontech/drive-sdk'; + +import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; + +const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices]; + +export class CommandFileSystemMove implements Command { + group = 'filesystem'; + name = 'move'; + args = ['sourcePath...', 'targetPath']; + + async action({ sdk, paths, args, options: { json } }: ActionArgs) { + const sourcePathStrings = args.slice(0, -1); + const targetPathString = args[args.length - 1]; + + const sourceNodes = await paths.getNodes(sourcePathStrings, SUPPORTED_PATH_TYPES); + const targetNode = await paths.getNode(targetPathString, SUPPORTED_PATH_TYPES); + + await this.moveNodes(sdk, sourceNodes, targetNode, json); + } + + private async moveNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { + await printIterable(sdk.moveNodes(sourceNodes, targetNode), json, (result) => { + const nodeName = findName(sourceNodes, result.uid); + console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + }); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRename.ts b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts new file mode 100644 index 00000000..ea2e01f1 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts @@ -0,0 +1,16 @@ +import { printObject, type Command, type ActionArgs } from '../../cli'; + +export class CommandFileSystemRename implements Command { + group = 'filesystem'; + name = 'rename'; + args = ['path', 'newName']; + + async action({ paths, args: [pathString, newName], options: { json } }: ActionArgs) { + const path = paths.getPath(pathString); + const node = await path.getNode(); + + const renamedNode = await path.sdk.renameNode(node, newName); + + printObject(renamedNode, json); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts new file mode 100644 index 00000000..12bc8d70 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts @@ -0,0 +1,19 @@ +import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; + +const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; + +export class CommandFileSystemRestore implements Command { + group = 'filesystem'; + name = 'restore'; + args = ['path...']; + + async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { + const nodePaths = paths.getPaths(pathStrings, SUPPORTED_PATH_TYPES); + const nodes = await paths.getNodes(pathStrings, SUPPORTED_PATH_TYPES); + + await printIterable(nodePaths[0].sdk.restoreNodes(nodes), json, (result) => { + const nodeName = findName(nodes, result.uid); + console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + }); + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts new file mode 100644 index 00000000..9d0a92a6 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts @@ -0,0 +1,19 @@ +import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; + +const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.Photos]; + +export class CommandFileSystemTrash implements Command { + group = 'filesystem'; + name = 'trash'; + args = ['path...']; + + async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { + const nodePaths = paths.getPaths(pathStrings, SUPPORTED_PATH_TYPES); + const nodes = await paths.getNodes(pathStrings, SUPPORTED_PATH_TYPES); + + await printIterable(nodePaths[0].sdk.trashNodes(nodes), json, (result) => { + const nodeName = findName(nodes, result.uid); + console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + }); + } +} diff --git a/js/cli/src/commands/registry.ts b/js/cli/src/commands/registry.ts index 4e326a6d..e10166d1 100644 --- a/js/cli/src/commands/registry.ts +++ b/js/cli/src/commands/registry.ts @@ -2,8 +2,28 @@ import { applyDefaultCliOptions } from '../cli'; import { CommandAuthLogin } from './auth/commandAuthLogin'; import { CommandAuthLogout } from './auth/commandAuthLogout'; +import { CommandFileSystemCopy } from './fileSystem/commandFileSystemCopy'; +import { CommandFileSystemCreateFolder } from './fileSystem/commandFileSystemCreateFolder'; +import { CommandFileSystemDelete } from './fileSystem/commandFileSystemDelete'; +import { CommandFileSystemEmptyTrash } from './fileSystem/commandFileSystemEmptyTrash'; +import { CommandFileSystemInfo } from './fileSystem/commandFileSystemInfo'; +import { CommandFileSystemList } from './fileSystem/commandFileSystemList'; +import { CommandFileSystemMove } from './fileSystem/commandFileSystemMove'; +import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; +import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; +import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; export const COMMANDS = applyDefaultCliOptions([ new CommandAuthLogin(), new CommandAuthLogout(), + new CommandFileSystemList(), + new CommandFileSystemInfo(), + new CommandFileSystemCreateFolder(), + new CommandFileSystemRename(), + new CommandFileSystemCopy(), + new CommandFileSystemMove(), + new CommandFileSystemTrash(), + new CommandFileSystemRestore(), + new CommandFileSystemDelete(), + new CommandFileSystemEmptyTrash(), ]); From 3660cb0102344f9d6e3e4925cf05af1764aeaf1c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 05:58:02 +0000 Subject: [PATCH 575/732] 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 6f6a38800a9fa3dd855695578c8ac46b19603b48 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 07:38:46 +0000 Subject: [PATCH 576/732] 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 09ddba1fe4da71425b5aa60cfbddbd324907006c Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 20 Mar 2026 13:21:33 +0100 Subject: [PATCH 577/732] 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 85fc1aa1d15cd52f02f28c2eee58d86d4228be39 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 16:22:32 +0800 Subject: [PATCH 578/732] 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 079b9700f8fe2342469fbda58fd05fd0736aca84 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 09:28:41 +0000 Subject: [PATCH 579/732] 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 090f89303b11dadbaf9dec0632cce9f60c5887d6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 15:40:40 +0200 Subject: [PATCH 580/732] 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 f9de4fcab2429a3a0811e06036a14b8f91da06fb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 15:20:00 +0000 Subject: [PATCH 581/732] 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 9dbcf985c40526749e3c9aa1b211bddf46dc201c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 04:29:57 +0000 Subject: [PATCH 582/732] 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 1046ce01477548764f7e02aefecf2e1da1eb7312 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 16:10:25 +0200 Subject: [PATCH 583/732] 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 1d7bc1c2bbac9aff93954a5a0e455e0de5dbc966 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 07:28:30 +0000 Subject: [PATCH 584/732] 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 e553406fafb54a581865e1d5d0e9cae2d5b6b9a7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 15:35:18 +0200 Subject: [PATCH 585/732] 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 92cfd80f347d60a96d179fe4038797211949d386 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 13:54:28 +0000 Subject: [PATCH 586/732] 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 12903dfb1bcb9cea8bbf25b28b2301dd2d6127b9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 23 Apr 2026 16:18:19 +0200 Subject: [PATCH 587/732] 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 9d0e5c79b0e94c0554c39293776feabb1490ca89 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Apr 2026 11:55:51 +0000 Subject: [PATCH 588/732] 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 6277d1c22a11b3a903a76011a738bdd6ae3f6383 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 24 Apr 2026 11:54:40 +0200 Subject: [PATCH 589/732] 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 a4bc9ddd4ed9ac18e8438c9c69eb0ade58b21999 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 21 Apr 2026 17:08:39 +0200 Subject: [PATCH 590/732] 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 9c3c90c80babd46a07e9385bbfdd93ebdd2a9da4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:31:21 +0000 Subject: [PATCH 591/732] 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 c4d259a984e37257657441ddbc1b04ff2c32869a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:35:55 +0000 Subject: [PATCH 592/732] 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 7add8cc21ffbf2db14b45d4e15a6362e3cd53dc2 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 05:41:41 +0000 Subject: [PATCH 593/732] 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 4d53a2c4f2aad278ca4d565eef7923829027e6cf Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 10:09:57 +0200 Subject: [PATCH 594/732] 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 9785a3d362931020079e21bfbb887db33268fea9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 12:53:52 +0000 Subject: [PATCH 595/732] 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 21dd1ff399b89955422efeee2c68a7d67826736e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 12:56:45 +0000 Subject: [PATCH 596/732] 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 8ad8190613ea00021097061709cb94f9eebfe6b7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 27 Apr 2026 16:53:20 +0200 Subject: [PATCH 597/732] 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 2d9d7fb4ad679bc4092dd4cdc7519676f672d64c Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 8 Apr 2026 15:58:02 +0200 Subject: [PATCH 598/732] 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 31754b1a978c2ab998088ae850bc9520e2e2b7b9 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 09:23:09 +0200 Subject: [PATCH 599/732] 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 718c629856467f8288f2c4804a8eb2d0e51c6e92 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 11:50:55 +0200 Subject: [PATCH 600/732] 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 315175f82c807835fb71a4cd2d31b04ebd02927d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:04:01 +0000 Subject: [PATCH 601/732] Implement upgrade hints --- js/cli/src/api/apiClient.ts | 45 +++++++- js/cli/src/api/apiRequirements.test.ts | 152 +++++++++++++++++++++++++ js/cli/src/api/apiRequirements.ts | 146 ++++++++++++++++++++++++ js/cli/src/api/messageEmitter.test.ts | 35 ++++++ js/cli/src/api/messageEmitter.ts | 18 +++ 5 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 js/cli/src/api/apiRequirements.test.ts create mode 100644 js/cli/src/api/apiRequirements.ts create mode 100644 js/cli/src/api/messageEmitter.test.ts create mode 100644 js/cli/src/api/messageEmitter.ts diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index bf75b54e..e9ea0d73 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -1,10 +1,12 @@ import ky, { type AfterResponseHook, type KyInstance } from 'ky'; -import { Logger } from '@protontech/drive-sdk'; +import { Logger, VERSION } from '@protontech/drive-sdk'; import type { Config } from '../config'; import { Credentials } from '../credentials'; import type { paths as AuthPaths } from './api-auth-types'; +import { SUPPORTED_REQUIREMENT_MASK_BY_SCOPE, processDriveRequirementHeaders } from './apiRequirements'; +import { MessageEmitter } from './messageEmitter'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -17,6 +19,7 @@ export class ApiClient { private unauthenticatedClient: KyInstance; private activeRefreshPromise: Promise | null = null; + private readonly driveRequirementNoticeOnce = new MessageEmitter(); readonly baseUrlWithProtocol: string; @@ -34,14 +37,20 @@ export class ApiClient { }, timeout: DEFAULT_TIMEOUT_MS, }; + const driveApiRequirementsHook = this.createDriveApiRequirementsAfterResponseHook(); this.authenticatedClientBase = ky.create({ ...baseClientOptions, hooks: { - afterResponse: [this.createRefreshSessionAfterResponseHook()], + afterResponse: [this.createRefreshSessionAfterResponseHook(), driveApiRequirementsHook], }, }); this.authenticatedClient = this.authenticatedClientBase; - this.unauthenticatedClient = ky.create(baseClientOptions); + this.unauthenticatedClient = ky.create({ + ...baseClientOptions, + hooks: { + afterResponse: [driveApiRequirementsHook], + }, + }); this.updateAuthenticatedClientHeaders(); credentials.on('sessionInfoChanged', () => this.updateAuthenticatedClientHeaders()); @@ -64,6 +73,36 @@ export class ApiClient { return this.unauthenticatedClient; } + private createDriveApiRequirementsAfterResponseHook(): AfterResponseHook { + return (_request, _options, response) => { + processDriveRequirementHeaders(response.headers, { + clientSdkVersion: VERSION, + supportedRequirementMasksByScope: SUPPORTED_REQUIREMENT_MASK_BY_SCOPE, + onUnsupportedFeature: (scope, requiredMask) => { + const message = `Update needed: unsupported feature for ${scope}`; + this.driveRequirementNoticeOnce.emitOnce(message, (msg) => { + this.logger.warn(`${msg} (required feature bit mask: ${requiredMask})`); + process.stderr.write(msg + '\n'); + }); + }, + onRequiredUpdate: (requiredVersion) => { + const message = `Update required: required SDK version ${requiredVersion} or newer (currently using ${VERSION})`; + this.driveRequirementNoticeOnce.emitOnce(message, (msg) => { + this.logger.warn(msg); + process.stderr.write(msg + '\n'); + }); + }, + onSuggestedUpdate: (suggestedVersion) => { + const message = `Update recommended: suggested SDK version ${suggestedVersion} or newer (currently using ${VERSION})`; + this.driveRequirementNoticeOnce.emitOnce(message, (msg) => { + this.logger.warn(msg); + process.stderr.write(msg + '\n'); + }); + }, + }); + }; + } + private createRefreshSessionAfterResponseHook(): AfterResponseHook { return async (request, options, response) => { if (response.status !== 401 || shouldSkipAuthRefreshForUrl(request.url)) { diff --git a/js/cli/src/api/apiRequirements.test.ts b/js/cli/src/api/apiRequirements.test.ts new file mode 100644 index 00000000..3312d3ad --- /dev/null +++ b/js/cli/src/api/apiRequirements.test.ts @@ -0,0 +1,152 @@ +import { + processDriveRequirementHeaders, + RequirementScope, +} from './apiRequirements'; + +const TEST_FEATURE_MASK = 1 << 0; +const TEST_FEATURE_MASK_2 = 1 << 1; + +function generateHeaders(options?: { + features?: string, + platform?: string, +}): Headers { + const headers: Record = {}; + if (options?.features) { + headers['x-pm-drive-requirements'] = options.features; + } + if (options?.platform) { + headers['x-pm-drive-platform-requirements'] = options.platform; + } + return new Headers(headers); +} + +describe('processDriveRequirementHeaders', () => { + const options = { + clientSdkVersion: '1.0.0', + supportedRequirementMasksByScope: { + [RequirementScope.Drive]: TEST_FEATURE_MASK | TEST_FEATURE_MASK_2, + [RequirementScope.Photos]: TEST_FEATURE_MASK, + [RequirementScope.Docs]: TEST_FEATURE_MASK, + }, + onUnsupportedFeature: jest.fn(), + onRequiredUpdate: jest.fn(), + onSuggestedUpdate: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not invoke callbacks when headers are absent', () => { + processDriveRequirementHeaders(generateHeaders(), options); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + + describe('feature requirements', () => { + it('does not invoke callbacks when feature requirements are met', () => { + processDriveRequirementHeaders( + generateHeaders({ features: `drive=${TEST_FEATURE_MASK} photos=${TEST_FEATURE_MASK} docs=${TEST_FEATURE_MASK}` }), + options, + ); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + + it('does not invoke callbacks when the feature requirements for only one scope are met', () => { + processDriveRequirementHeaders( + generateHeaders({ features: `drive=${TEST_FEATURE_MASK | TEST_FEATURE_MASK_2}` }), + options, + ); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + + it('calls onUnsupportedFeature when the API requires unknown feature bits for a known scope that is known for other scope', () => { + processDriveRequirementHeaders( + generateHeaders({ features: `photos=${TEST_FEATURE_MASK | TEST_FEATURE_MASK_2}` }), + options, + ); + expect(options.onUnsupportedFeature).toHaveBeenCalledTimes(1); + expect(options.onUnsupportedFeature).toHaveBeenCalledWith(RequirementScope.Photos, TEST_FEATURE_MASK | TEST_FEATURE_MASK_2); + }); + + it('calls onUnsupportedFeature twice when the API requires unknown feature bits for a multiple scopes', () => { + const driveMask = 1 << 7; + const photosMask = 1 << 8; + processDriveRequirementHeaders( + generateHeaders({ features: `drive=${driveMask} photos=${photosMask} ` }), + options, + ); + expect(options.onUnsupportedFeature).toHaveBeenCalledTimes(2); + expect(options.onUnsupportedFeature).toHaveBeenCalledWith(RequirementScope.Drive, driveMask); + expect(options.onUnsupportedFeature).toHaveBeenCalledWith(RequirementScope.Photos, photosMask); + }); + + it('ignores requirement scopes that are not in supportedRequirementMasksByScope', () => { + processDriveRequirementHeaders( + generateHeaders({ features: `newscope=${TEST_FEATURE_MASK}` }), + options, + ); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + }); + + it('ignores required scope when not correct header', () => { + processDriveRequirementHeaders(generateHeaders({ features: `drive5` }), options); + processDriveRequirementHeaders(generateHeaders({ features: `drive=` }), options); + processDriveRequirementHeaders(generateHeaders({ features: `=` }), options); + processDriveRequirementHeaders(generateHeaders({ features: `drive=-1` }), options); + processDriveRequirementHeaders(generateHeaders({ features: `drive=NaN` }), options); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('platform requirements', () => { + it('does not invoke callbacks when platform requirements are met', () => { + processDriveRequirementHeaders( + generateHeaders({ platform: 'required=1.0.0 suggested=1.0.0' }), + options, + ); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + + it('calls onRequiredUpdate when client SDK is below required and skips suggested in the same response', () => { + processDriveRequirementHeaders( + generateHeaders({ platform: 'required=2.0.0 suggested=3.0.0' }), + options, + ); + expect(options.onRequiredUpdate).toHaveBeenCalledTimes(1); + expect(options.onRequiredUpdate).toHaveBeenCalledWith('2.0.0'); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + + it('calls onSuggestedUpdate when required is met but suggested is higher', () => { + processDriveRequirementHeaders( + generateHeaders({ platform: 'required=1.0.0 suggested=2.3.4' }), + options, + ); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).toHaveBeenCalledTimes(1); + expect(options.onSuggestedUpdate).toHaveBeenCalledWith('2.3.4'); + }); + + it('ignores required platform when not correct header', () => { + processDriveRequirementHeaders(generateHeaders({ platform: `required2.0.0` }), options); + processDriveRequirementHeaders(generateHeaders({ platform: `required=` }), options); + processDriveRequirementHeaders(generateHeaders({ platform: `=` }), options); + processDriveRequirementHeaders(generateHeaders({ platform: `required=1.x` }), options); + processDriveRequirementHeaders(generateHeaders({ platform: `required=1.2` }), options); + processDriveRequirementHeaders(generateHeaders({ platform: `required=1.` }), options); + expect(options.onUnsupportedFeature).not.toHaveBeenCalled(); + expect(options.onRequiredUpdate).not.toHaveBeenCalled(); + expect(options.onSuggestedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/js/cli/src/api/apiRequirements.ts b/js/cli/src/api/apiRequirements.ts new file mode 100644 index 00000000..e7096069 --- /dev/null +++ b/js/cli/src/api/apiRequirements.ts @@ -0,0 +1,146 @@ +/** + * Parses and evaluates `x-pm-drive-requirements` and `x-pm-drive-platform-requirements` + * response headers. Intended to run after each HTTP response. + */ + +export enum RequirementScope { + Drive = 'drive', + Photos = 'photos', + Docs = 'docs', +} + +// For now only test feature is supported. In the future we will add more +// features as they are defined and implemented by the SDK. +enum KnownFeatures { + TestFeature = 1 << 0, +} + +export const SUPPORTED_REQUIREMENT_MASK_BY_SCOPE: Readonly> = { + [RequirementScope.Drive]: KnownFeatures.TestFeature, + [RequirementScope.Photos]: KnownFeatures.TestFeature, + [RequirementScope.Docs]: KnownFeatures.TestFeature, +}; + +export type ProcessDriveRequirementHeadersOptions = { + clientSdkVersion: string; + supportedRequirementMasksByScope: Readonly>; + onUnsupportedFeature: (scope: RequirementScope, requiredMask: number) => void; + onRequiredUpdate: (requiredVersion: string) => void; + onSuggestedUpdate: (suggestedVersion: string) => void; +}; + +export function processDriveRequirementHeaders( + headers: Headers, + options: ProcessDriveRequirementHeadersOptions, +): void { + const requirements = headers.get('x-pm-drive-requirements'); + if (requirements) { + processFeatureRequirements(options, requirements); + } + + const platform = headers.get('x-pm-drive-platform-requirements'); + if (platform) { + processPlatformRequirements(options, platform); + } +} + +function processFeatureRequirements( + options: Pick, + raw: string, +): void { + // Example of requirement header: "drive=1 photos=2" + const fields = parseKeyValuePairs(raw); + for (const [scopeStr, valueStr] of fields) { + const scope = scopeStr as RequirementScope; + const supported = options.supportedRequirementMasksByScope[scope]; + if (supported === undefined) { + continue; + } + + const requiredMask = Number.parseInt(valueStr, 10); + if (isNaN(requiredMask) || requiredMask <= 0) { + continue; + } + + const missing = requiredMask & ~supported; + if (missing !== 0) { + options.onUnsupportedFeature(scope, requiredMask); + } + } +} + +function processPlatformRequirements( + options: Pick, + raw: string, +): void { + // Example of platform requirement header: "required=2.0.0 suggested=9.9.9" + const fields = parseKeyValuePairs(raw); + const required = fields.get('required'); + const suggested = fields.get('suggested'); + + if (required && isUpdateNeeded(options.clientSdkVersion, required)) { + options.onRequiredUpdate(required); + return; + } + if (suggested && isUpdateNeeded(options.clientSdkVersion, suggested)) { + options.onSuggestedUpdate(suggested); + } +} + +function isUpdateNeeded(clientVersion: string, minVersion: string): boolean { + if (!clientVersion || !minVersion) { + return false; + } + + const cmp = compareSemverLoose(clientVersion, minVersion); + if (cmp === null) { + return false; + } + return cmp < 0; +} + +function parseKeyValuePairs(raw: string): Map { + const out = new Map(); + for (const token of raw.trim().split(/\s+/).filter(Boolean)) { + const split = token.split('='); + if (split.length !== 2) { + continue; + } + + const key = split[0].trim().toLowerCase(); + if (!key) { + continue; + } + + const value = split[1].trim(); + if (!value) { + continue; + } + + out.set(key, value); + } + return out; +} + +function compareSemverLoose(a: string, b: string): number | null { + const pa = parseSemverParts(a); + const pb = parseSemverParts(b); + if (!pa || !pb) { + return null; + } + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) { + return pa[i] < pb[i] ? -1 : 1; + } + } + return 0; +} + +function parseSemverParts(v: string): [number, number, number] | null { + const trimmed = v.trim(); + const m = trimmed.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) { + return null; + } + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} diff --git a/js/cli/src/api/messageEmitter.test.ts b/js/cli/src/api/messageEmitter.test.ts new file mode 100644 index 00000000..a1419dd7 --- /dev/null +++ b/js/cli/src/api/messageEmitter.test.ts @@ -0,0 +1,35 @@ +import { MessageEmitter } from './messageEmitter'; + +describe('MessageEmitter', () => { + it('invokes emit only once for an identical message', () => { + const deduper = new MessageEmitter(); + const emit = jest.fn(); + + deduper.emitOnce('same', emit); + deduper.emitOnce('same', emit); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('same'); + }); + + it('invokes emit once per distinct message', () => { + const deduper = new MessageEmitter(); + const emit = jest.fn(); + + deduper.emitOnce('a', emit); + deduper.emitOnce('b', emit); + + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenNthCalledWith(1, 'a'); + expect(emit).toHaveBeenNthCalledWith(2, 'b'); + }); + + it('does not dedupe across separate instances', () => { + const emit = jest.fn(); + + new MessageEmitter().emitOnce('x', emit); + new MessageEmitter().emitOnce('x', emit); + + expect(emit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/js/cli/src/api/messageEmitter.ts b/js/cli/src/api/messageEmitter.ts new file mode 100644 index 00000000..270e9dd7 --- /dev/null +++ b/js/cli/src/api/messageEmitter.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto'; + +export class MessageEmitter { + private readonly seenMessageKeys = new Set(); + + emitOnce(message: string, emit: (message: string) => void): void { + const key = messageKey(message); + if (this.seenMessageKeys.has(key)) { + return; + } + this.seenMessageKeys.add(key); + emit(message); + } +} + +function messageKey(message: string): string { + return createHash('sha256').update(message).digest('hex'); +} From 702e37230d2fad10328ac35cc2d6f19b07ba108d Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:01:40 +0000 Subject: [PATCH 602/732] 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 cfed74e377754656c13da1eb4b449b7cf01b45da Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:44:33 +0000 Subject: [PATCH 603/732] Add upload and download commands --- js/cli/bun.lock | 4 +- js/cli/package.json | 7 +- js/cli/src/cli/interface.ts | 3 +- js/cli/src/cli/readPasswordLine.ts | 21 +- js/cli/src/cli/readline.test.ts | 103 +++++ js/cli/src/cli/readline.ts | 27 ++ js/cli/src/cli/repl.ts | 51 +-- js/cli/src/cli/run.ts | 1 + .../fileSystem/commandFileSystemDownload.ts | 266 ++++++++++++ .../fileSystem/commandFileSystemUpload.ts | 249 ++++++++++++ .../fileSystem/downloadPathValidation.test.ts | 61 +++ .../fileSystem/downloadPathValidation.ts | 81 ++++ .../transferConflictResolver.test.ts | 109 +++++ .../fileSystem/transferConflictResolver.ts | 177 ++++++++ .../commands/fileSystem/transferProgress.ts | 196 +++++++++ .../commands/fileSystem/transferQueue.test.ts | 384 ++++++++++++++++++ .../src/commands/fileSystem/transferQueue.ts | 153 +++++++ js/cli/src/commands/registry.ts | 4 + js/cli/src/init.ts | 2 + js/sdk/src/interface/account.ts | 2 +- js/sdk/src/internal/upload/apiService.ts | 12 +- js/sdk/src/protonDrivePhotosClient.ts | 10 + 22 files changed, 1865 insertions(+), 58 deletions(-) create mode 100644 js/cli/src/cli/readline.test.ts create mode 100644 js/cli/src/cli/readline.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemDownload.ts create mode 100644 js/cli/src/commands/fileSystem/commandFileSystemUpload.ts create mode 100644 js/cli/src/commands/fileSystem/downloadPathValidation.test.ts create mode 100644 js/cli/src/commands/fileSystem/downloadPathValidation.ts create mode 100644 js/cli/src/commands/fileSystem/transferConflictResolver.test.ts create mode 100644 js/cli/src/commands/fileSystem/transferConflictResolver.ts create mode 100644 js/cli/src/commands/fileSystem/transferProgress.ts create mode 100644 js/cli/src/commands/fileSystem/transferQueue.test.ts create mode 100644 js/cli/src/commands/fileSystem/transferQueue.ts diff --git a/js/cli/bun.lock b/js/cli/bun.lock index a37bc3bb..65ccd8c4 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -16,7 +16,7 @@ "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", - "ky": "^1.7.2", + "ky": "^1.14.3", "ttag": "^1.8.7", }, "devDependencies": { @@ -1210,7 +1210,7 @@ "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - "ky": ["ky@1.7.5", "", {}, "sha512-HzhziW6sc5m0pwi5M196+7cEBtbt0lCYi67wNsiwMUmz833wloE0gbzJPWKs1gliFKQb34huItDQX97LyOdPdA=="], + "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], diff --git a/js/cli/package.json b/js/cli/package.json index 8a43fbca..17d2af44 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -11,8 +11,9 @@ "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", "postinstall": "patch-package", - "test": "jest", - "test:watch": "jest --watch --coverage=false" + "test": "jest src", + "test:watch": "jest src --watch --coverage=false", + "test:integration": "jest integration-tests" }, "devDependencies": { "@swc/core": "^1.12.3", @@ -45,7 +46,7 @@ "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", - "ky": "^1.7.2", + "ky": "^1.14.3", "ttag": "^1.8.7" }, "patchedDependencies": { diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 60c1fd1a..9f2da8ab 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,6 +1,6 @@ import { ParseArgsConfig } from 'util'; -import { ProtonDriveClient } from '@protontech/drive-sdk'; +import { ProtonDriveClient, Logger } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { Diagnostic } from '@protontech/drive-sdk/diagnostic'; @@ -19,6 +19,7 @@ export interface Command { } export interface ActionArgs { + logger: Logger; auth: Auth; sdk: ProtonDriveClient; photosSdk: ProtonDrivePhotosClient; diff --git a/js/cli/src/cli/readPasswordLine.ts b/js/cli/src/cli/readPasswordLine.ts index 1e1c09b0..275ea4e7 100644 --- a/js/cli/src/cli/readPasswordLine.ts +++ b/js/cli/src/cli/readPasswordLine.ts @@ -1,28 +1,15 @@ -import * as readline from 'readline'; +import { question } from './readline'; /** * Read one line from stdin without echoing (TTY). Non-TTY falls back to visible readline. */ -export function readPasswordLine(prompt: string): Promise { +export async function readPasswordLine(prompt: string): Promise { const stdin = process.stdin; const stdout = process.stdout; if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { - const rl = readline.createInterface({ input: stdin, output: stdout }); - return new Promise((resolve) => { - let settled = false; - const finish = (answer: string) => { - if (settled) { - return; - } - settled = true; - rl.close(); - resolve(answer.trim()); - }; - - rl.question(prompt, (answer) => finish(answer)); - rl.once('close', () => finish('')); - }); + const answer = await question(prompt); + return answer.trim(); } return new Promise((resolve) => { diff --git a/js/cli/src/cli/readline.test.ts b/js/cli/src/cli/readline.test.ts new file mode 100644 index 00000000..702da818 --- /dev/null +++ b/js/cli/src/cli/readline.test.ts @@ -0,0 +1,103 @@ +jest.mock('node:readline/promises', () => { + const actual = jest.requireActual('node:readline/promises'); + return { + ...actual, + createInterface: jest.fn(actual.createInterface), + }; +}); + +import type { Interface as ReadlinePromisesInterface } from 'node:readline/promises'; +import * as readline from 'node:readline/promises'; + +import { question, resetForTests } from './readline'; + +const mockedCreateInterface = readline.createInterface as jest.MockedFunction; + +describe('readline.question', () => { + beforeEach(() => { + resetForTests(); + mockedCreateInterface.mockReset(); + const actual = jest.requireActual('node:readline/promises'); + mockedCreateInterface.mockImplementation(actual.createInterface); + }); + + function stubInterface(questionImpl: ReadlinePromisesInterface['question'], closeMock = jest.fn()) { + return { + question: questionImpl, + close: closeMock, + } as unknown as ReadlinePromisesInterface; + } + + async function flushUntil(predicate: () => boolean, maxTicks = 30): Promise { + for (let i = 0; i < maxTicks; i++) { + if (predicate()) { + return; + } + await Promise.resolve(); + } + throw new Error('async progress did not complete in time'); + } + + it('serializes overlapping calls — second prompt runs after the first completes', async () => { + const events: string[] = []; + let releaseFirst!: () => void; + const firstFinished = new Promise((resolve) => { + releaseFirst = resolve; + }); + + mockedCreateInterface + .mockImplementationOnce(() => + stubInterface( + jest.fn(async (prompt: string) => { + events.push(`start:${prompt}`); + await firstFinished; + events.push(`end:${prompt}`); + return 'first-answer'; + }), + ), + ) + .mockImplementationOnce(() => + stubInterface( + jest.fn(async (prompt: string) => { + events.push(`start:${prompt}`); + events.push(`end:${prompt}`); + return 'second-answer'; + }), + ), + ); + + const p1 = question('first>'); + const p2 = question('second>'); + + await flushUntil(() => events.some((e) => e.startsWith('start:first'))); + expect(events).toEqual(['start:first>']); + + releaseFirst(); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toBe('first-answer'); + expect(r2).toBe('second-answer'); + expect(events).toEqual([ + 'start:first>', + 'end:first>', + 'start:second>', + 'end:second>', + ]); + expect(mockedCreateInterface).toHaveBeenCalledTimes(2); + }); + + it('propagates rejection but still allows following questions', async () => { + mockedCreateInterface + .mockImplementationOnce(() => + stubInterface( + jest.fn(async () => { + throw new Error('boom'); + }), + ), + ) + .mockImplementationOnce(() => stubInterface(jest.fn(async () => 'ok'))); + + await expect(question('bad')).rejects.toThrow('boom'); + await expect(question('good')).resolves.toBe('ok'); + }); +}); diff --git a/js/cli/src/cli/readline.ts b/js/cli/src/cli/readline.ts new file mode 100644 index 00000000..e4f7dc72 --- /dev/null +++ b/js/cli/src/cli/readline.ts @@ -0,0 +1,27 @@ +import * as readline from 'node:readline/promises'; + +let chain: Promise = Promise.resolve(); + +/** + * Prompt user for input. Only one prompt can be active at a time. When any + * another question comes in, it waits for the previous input to be processed. + */ +export async function question(prompt: string): Promise { + const task = chain.then(async (): Promise => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + return await rl.question(prompt); + } finally { + rl.close(); + } + }); + chain = task.catch(() => undefined); + return task; +} + +export function resetForTests(): void { + chain = Promise.resolve(); +} diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index 734b1da0..c715567c 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -1,5 +1,3 @@ -import * as readline from 'node:readline/promises'; - import { ProtonDriveError } from '@protontech/drive-sdk'; import { AccountApiError } from '../api/accountApi'; @@ -8,45 +6,32 @@ import { init } from '../init'; import { CommandError } from './errors'; import { Command } from './interface'; import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; +import { question } from './readline'; import { runCommand } from './run'; export async function startRepl(commands: Command[], initOptions: InitConfig): Promise { const session = await init(initOptions); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - try { - while (true) { - const line = await rl.question('proton-drive> '); - const trimmed = line.trim(); - if (trimmed === '') { - continue; - } - if (trimmed === 'exit' || trimmed === 'quit') { - break; - } - let parts: string[]; - try { - parts = splitQuotedLine(trimmed); - } catch (error: unknown) { - if (isRecoverableReplError(error)) { - console.error(error); - continue; - } - throw error; - } + while (true) { + const line = await question('proton-drive> '); + const trimmed = line.trim(); + if (trimmed === '') { + continue; + } + if (trimmed === 'exit' || trimmed === 'quit') { + break; + } + try { + const parts = splitQuotedLine(trimmed); const syntheticArgv = ['', '', ...parts]; - try { - await runCommand(commands, syntheticArgv, initOptions, session); - } catch (error: unknown) { - if (isRecoverableReplError(error)) { - console.error(error); - continue; - } - throw error; + await runCommand(commands, syntheticArgv, initOptions, session); + } catch (error: unknown) { + if (isRecoverableReplError(error)) { + console.error(error); + continue; } + throw error; } - } finally { - rl.close(); } } diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 7e417ad2..739d93d3 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -82,6 +82,7 @@ async function dispatchAction( } try { await command.action({ + logger: session.logger, auth: session.auth, sdk: session.sdk, photosSdk: session.photosSdk, diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts new file mode 100644 index 00000000..11461751 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -0,0 +1,266 @@ +import { mkdir, readdir, rm, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { ParseArgsConfig } from 'node:util'; + +import { FileDownloader, MaybeNode, ValidationError, type ProtonDriveClient } from '@protontech/drive-sdk'; + +import { type ActionArgs, type Command, getClaimedSize, PathType } from '../../cli'; +import { + ConflictChoice, + ConflictTargetKind, + TransferConflictResolver, +} from './transferConflictResolver'; +import { createTransferProgress, TransferProgressInterface } from './transferProgress'; +import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; +import { DownloadQueue, type QueueItemDirectory, type QueueItemFile } from './transferQueue'; + +const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; + +const FILE_DOWNLOAD_CONFLICT_STRATEGIES = [ + ConflictChoice.Skip, + ConflictChoice.Replace, + ConflictChoice.KeepBoth, +]; + +type DownloadContext = { + sdk: ProtonDriveClient; + json: boolean; + progress?: TransferProgressInterface; + downloadQueue: DownloadQueue; + conflictResolver: TransferConflictResolver; + downloadRoot: string; +}; + +export class CommandFileSystemDownload implements Command { + group = 'filesystem'; + name = 'download'; + args = ['remotes...', 'localFolder']; + options: ParseArgsConfig['options'] = { + 'conflict-strategy': { + type: 'string', + short: 'c', + default: '', + }, + 'file-conflict-strategy': { + type: 'string', + short: 'f', + default: '', + }, + 'folder-conflict-strategy': { + type: 'string', + short: 'd', + default: '', + }, + }; + + async action({ + logger, + sdk, + paths, + args, + options: { + json, + 'conflict-strategy': conflictStrategy, + 'file-conflict-strategy': fileConflictStrategy, + 'folder-conflict-strategy': folderConflictStrategy, + }, + }: ActionArgs) { + const remotePathStrings = args.slice(0, -1); + const localFolder = args[args.length - 1]!; + + if (remotePathStrings.length === 0) { + throw new ValidationError('At least one remote path and a local folder are required'); + } + + const downloadRoot = assertValidDownloadRoot(localFolder); + await mkdir(downloadRoot, { recursive: true }); + + const progress = json ? undefined : createTransferProgress(); + + const conflictResolver = new TransferConflictResolver(logger, { + fileStrategyChoices: FILE_DOWNLOAD_CONFLICT_STRATEGIES, + forcedFileStrategy: fileConflictStrategy || conflictStrategy, + forcedFolderStrategy: folderConflictStrategy || conflictStrategy, + disableInteractiveResolution: json, + onInteractivePromptBegin: () => progress?.pause(), + onInteractivePromptEnd: () => progress?.resume(), + }); + + const downloadQueue = new DownloadQueue(logger, sdk, { + onDirectory: async (item) => { + const createdPath = await this.createLocalFolder(ctx, item); + if (createdPath) { + await ctx.downloadQueue.enqueueRemoteFolderChildren(item.remoteNode, createdPath); + } + }, + startFile: async (item) => { + await this.downloadFile(ctx, item); + }, + }); + + const ctx: DownloadContext = { + sdk, + json, + progress, + downloadQueue, + conflictResolver, + downloadRoot, + }; + + try { + await downloadQueue.enqueueRemotePaths(remotePathStrings, downloadRoot, (pathString) => + paths.getNode(pathString, SUPPORTED_REMOTE_PATH_TYPES), + ); + + await downloadQueue.processQueue(); + } finally { + progress?.dispose(); + } + } + + private async createLocalFolder( + ctx: DownloadContext, + item: QueueItemDirectory<{ remoteNode: MaybeNode }>, + ): Promise { + const parentPath = path.dirname(item.localPath); + let targetPath = item.localPath; + let name = item.baseName; + + while (true) { + assertValidPathSegment(name); + assertDownloadDestination(ctx.downloadRoot, targetPath); + + try { + await mkdir(targetPath); + return targetPath; + } catch (error: unknown) { + if (!isEexistError(error)) { + throw error; + } + + const choice = await ctx.conflictResolver.resolve(name, ConflictTargetKind.Folder); + switch (choice) { + case ConflictChoice.Skip: + return; + case ConflictChoice.Merge: + return targetPath; + case ConflictChoice.Replace: + await rm(targetPath, { recursive: true, force: true }); + continue; + case ConflictChoice.KeepBoth: + name = await getAvailableLocalName(parentPath, name); + targetPath = path.join(parentPath, name); + continue; + default: + throw new ValidationError(`Unexpected conflict choice: ${choice}`); + } + } + } + } + + private async downloadFile(ctx: DownloadContext, item: QueueItemFile<{ remoteNode: MaybeNode }>): Promise { + const parentPath = path.dirname(item.localPath); + let targetPath = item.localPath; + let name = item.baseName; + + assertDownloadDestination(ctx.downloadRoot, parentPath); + + await mkdir(parentPath, { recursive: true }); + + while (true) { + assertValidPathSegment(name); + assertDownloadDestination(ctx.downloadRoot, targetPath); + + const st = await stat(targetPath).catch(() => undefined); + if (st) { + const choice = await ctx.conflictResolver.resolve(name, ConflictTargetKind.File); + switch (choice) { + case ConflictChoice.Skip: + return; + case ConflictChoice.Replace: + await unlink(targetPath); + break; + case ConflictChoice.KeepBoth: + name = await getAvailableLocalName(parentPath, name); + targetPath = path.join(parentPath, name); + continue; + default: + throw new ValidationError(`Unexpected conflict choice: ${choice}`); + } + } + + const downloader = await ctx.sdk.getFileDownloader(item.remoteNode); + await this.downloadToPath(ctx, item, downloader, targetPath); + return; + } + } + + private async downloadToPath( + ctx: DownloadContext, + item: QueueItemFile<{ remoteNode: MaybeNode }>, + downloader: FileDownloader, + localPath: string, + ): Promise { + assertDownloadDestination(ctx.downloadRoot, localPath); + + const file = Bun.file(localPath); + const writer = file.writer(); + const writableStream: WritableStream = { + // @ts-expect-error: Bun's FileSink writer is not fully compatible with WritableStream. + getWriter: () => writer, + close: async () => { + await writer.end(); + }, + abort: async () => { + await writer.end(); + await unlink(localPath).catch(() => {}); + }, + locked: false, + }; + + const claimedSize = getClaimedSize(item.remoteNode); + const progressTracker = ctx.progress?.trackItem(item.baseName, claimedSize); + + const controller = downloader.downloadToStream(writableStream, (downloadedBytes) => { + progressTracker?.onProgress?.(downloadedBytes); + }); + + try { + await controller.completion(); + await writer.end(); + } catch (error: unknown) { + await writer.end(error instanceof Error ? error : new Error('Unknown error', { cause: error })); + await unlink(localPath).catch(() => {}); + throw error; + } finally { + progressTracker?.onFinished(); + } + } +} + +function isEexistError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST'; +} + +async function getAvailableLocalName(parentDir: string, baseName: string): Promise { + let entries: string[]; + try { + entries = await readdir(parentDir); + } catch { + return baseName; + } + if (!entries.includes(baseName)) { + return baseName; + } + const dot = baseName.lastIndexOf('.'); + const stem = dot > 0 ? baseName.slice(0, dot) : baseName; + const ext = dot > 0 ? baseName.slice(dot) : ''; + let i = 1; + while (true) { + const candidate = `${stem} (${i})${ext}`; + if (!entries.includes(candidate)) { + return candidate; + } + i++; + } +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts new file mode 100644 index 00000000..f71772b8 --- /dev/null +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -0,0 +1,249 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { ParseArgsConfig } from 'node:util'; + +import { + NodeWithSameNameExistsValidationError, + Thumbnail, + ValidationError, + MaybeNode, + type ProtonDriveClient, +} from '@protontech/drive-sdk'; + +import { type ActionArgs, type Command, PathType } from '../../cli'; +import { + ConflictChoice, + ConflictTargetKind, + TransferConflictResolver, +} from './transferConflictResolver'; +import { createTransferProgress, TransferProgressInterface } from './transferProgress'; +import { + UploadQueue, + type QueueItemDirectory, + type QueueItemFile, +} from './transferQueue'; + +const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; + +type UploadContext = { + sdk: ProtonDriveClient; + json: boolean; + progress?: TransferProgressInterface; + uploadQueue: UploadQueue; + conflictResolver: TransferConflictResolver; +}; + +export class CommandFileSystemUpload implements Command { + group = 'filesystem'; + name = 'upload'; + args = ['sources...', 'parentPath']; + options: ParseArgsConfig['options'] = { + 'conflict-strategy': { + type: 'string', + short: 'c', + default: '', + }, + 'file-conflict-strategy': { + type: 'string', + short: 'f', + default: '', + }, + 'folder-conflict-strategy': { + type: 'string', + short: 'd', + default: '', + }, + }; + + async action({ + logger, + sdk, + paths, + args, + options: { + json, + 'conflict-strategy': conflictStrategy, + 'file-conflict-strategy': fileConflictStrategy, + 'folder-conflict-strategy': folderConflictStrategy, + }, + }: ActionArgs) { + if (args.length < 2) { + throw new ValidationError('At least one local source and a remote parent path are required'); + } + + const localSources = args.slice(0, -1); + const parentPathString = args[args.length - 1]!; + + if (!parentPathString.trim()) { + throw new ValidationError('Remote parent path must not be empty'); + } + + if (localSources.some((p) => !p.trim())) { + throw new ValidationError('Local source paths must not be empty'); + } + + const parentNode = await paths.getNode(parentPathString, SUPPORTED_REMOTE_PATH_TYPES); + + const progress = json ? undefined : createTransferProgress(); + + const conflictResolver = new TransferConflictResolver(logger, { + forcedFileStrategy: fileConflictStrategy || conflictStrategy, + forcedFolderStrategy: folderConflictStrategy || conflictStrategy, + disableInteractiveResolution: json, + onInteractivePromptBegin: () => progress?.pause(), + onInteractivePromptEnd: () => progress?.resume(), + }); + + const uploadQueue = new UploadQueue(logger, { + onDirectory: async (item) => { + const pending = await this.createFolder(ctx, item); + if (pending) { + await ctx.uploadQueue.enqueueLocalDirectoryChildren(item.localPath, pending.node); + } + }, + startFile: async (item) => { + await this.uploadFile(ctx, item); + }, + }); + + const ctx: UploadContext = { + sdk, + json, + progress, + uploadQueue, + conflictResolver, + }; + + try { + await ctx.uploadQueue.enqueueLocalPaths(localSources, parentNode); + await ctx.uploadQueue.processQueue(); + } finally { + progress?.dispose(); + } + } + + private async createFolder( + ctx: UploadContext, + item: QueueItemDirectory<{ parentNode: MaybeNode }>, + ): Promise<{ node: MaybeNode } | undefined> { + let name = item.baseName; + + while (true) { + try { + const createdFolder = await ctx.sdk.createFolder(item.parentNode, name); + return { node: createdFolder }; + } catch (error: unknown) { + if (!(error instanceof NodeWithSameNameExistsValidationError)) { + throw error; + } + const existingNodeUid = error.existingNodeUid; + if (!existingNodeUid) { + throw error; + } + + const existingNode = await ctx.sdk.getNode(existingNodeUid); + + const choice = await ctx.conflictResolver.resolve(item.baseName, ConflictTargetKind.Folder); + switch (choice) { + case ConflictChoice.Skip: + return; + case ConflictChoice.Merge: + return { node: existingNode }; + case ConflictChoice.Replace: + await this.trashConflictingNode(ctx, existingNode); + continue; + case ConflictChoice.KeepBoth: + name = await ctx.sdk.getAvailableName(item.parentNode, item.baseName); + continue; + default: + throw new ValidationError(`Unexpected conflict choice: ${choice}`); + } + } + } + } + + private async uploadFile( + ctx: UploadContext, + item: QueueItemFile<{ parentNode: MaybeNode }>, + ): Promise { + const expectedSha1 = await this.getSha1(item.localPath); + const file = Bun.file(item.localPath); + const metadata = { + mediaType: file.type, + expectedSize: file.size, + expectedSha1, + modificationTime: file.lastModified && file.lastModified !== 0 ? new Date(file.lastModified) : undefined, + // additionalMetadata: TODO Implement before reusing for photos. + }; + + let name = item.baseName; + let newRevisionForNodeUid: string | undefined; + + while (true) { + const progressTracker = ctx.progress?.trackItem(item.baseName, file.size); + + try { + const uploader = newRevisionForNodeUid + ? await ctx.sdk.getFileRevisionUploader(newRevisionForNodeUid, metadata) + : await ctx.sdk.getFileUploader(item.parentNode, name, metadata); + + const thumbnails: Thumbnail[] = []; + const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (uploadedBytes) => { + // file.size is raw size while uploadedBytes is encrypted + // size. Encrypted size will be a bit higher. It is enough + // to cap the progress to 100%. + if (uploadedBytes <= file.size) { + progressTracker?.onProgress?.(uploadedBytes); + } + }); + + await controller.completion(); + return; + } catch (error: unknown) { + if (!(error instanceof NodeWithSameNameExistsValidationError)) { + throw error; + } + const existingNodeUid = error.existingNodeUid; + if (!existingNodeUid) { + throw error; + } + const existingNode = await ctx.sdk.getNode(existingNodeUid); + + const choice = await ctx.conflictResolver.resolve(item.baseName, ConflictTargetKind.File); + switch (choice) { + case ConflictChoice.Skip: + return; + case ConflictChoice.Merge: + newRevisionForNodeUid = existingNodeUid; + break; + case ConflictChoice.Replace: + await this.trashConflictingNode(ctx, existingNode); + break; + case ConflictChoice.KeepBoth: + name = await ctx.sdk.getAvailableName(item.parentNode, item.baseName); + break; + default: + throw new ValidationError(`Unexpected conflict choice: ${choice}`); + } + } finally { + progressTracker?.onFinished(); + } + } + } + + private async getSha1(localPath: string): Promise { + const hash = createHash('sha1'); + for await (const chunk of createReadStream(localPath)) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); + } + + private async trashConflictingNode(ctx: UploadContext, node: MaybeNode): Promise { + for await (const result of ctx.sdk.trashNodes([node])) { + if (!result.ok) { + throw new ValidationError(result.error); + } + } + } +} diff --git a/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts b/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts new file mode 100644 index 00000000..3a1ccdd0 --- /dev/null +++ b/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; + +import { ValidationError } from '@protontech/drive-sdk'; + +import { + assertDownloadDestination, + assertValidDownloadRoot, + assertValidPathSegment, +} from './downloadPathValidation'; + +describe('downloadPathValidation', () => { + describe('assertValidDownloadRoot', () => { + it('returns resolved path for normal folders', () => { + expect(assertValidDownloadRoot('/home/user/dl')).toBe(path.resolve('/home/user/dl')); + }); + + it('rejects empty string', () => { + expect(() => assertValidDownloadRoot(' ')).toThrow(new ValidationError('Local folder path must not be empty')); + }); + + it('rejects POSIX filesystem root', () => { + expect(() => assertValidDownloadRoot('/')).toThrow(new ValidationError('Refusing to use filesystem root as download destination')); + }); + }); + + describe('assertValidPathSegment', () => { + it('allows typical names', () => { + expect(() => assertValidPathSegment('report.pdf')).not.toThrow(new ValidationError('Invalid empty path segment')); + expect(() => assertValidPathSegment('folder name')).not.toThrow(new ValidationError('Invalid empty path segment')); + }); + + it('rejects separators and traversal-like segments', () => { + expect(() => assertValidPathSegment('a/b')).toThrow(new ValidationError('Invalid character in path segment: "a/b"')); + expect(() => assertValidPathSegment('..')).toThrow(new ValidationError('Invalid path segment: ".."')); + expect(() => assertValidPathSegment('.')).toThrow(new ValidationError('Invalid path segment: "."')); + }); + + it('rejects reserved Windows names', () => { + expect(() => assertValidPathSegment('CON')).toThrow(new ValidationError('Reserved path segment name: "CON"')); + expect(() => assertValidPathSegment('COM1')).toThrow(new ValidationError('Reserved path segment name: "COM1"')); + }); + }); + + describe('assertDownloadDestination', () => { + const root = path.resolve('/safe/root'); + + it('allows paths inside root', () => { + expect(() => + assertDownloadDestination(root, path.join(root, 'sub', 'file.txt')), + ).not.toThrow(); + }); + + it('allows destination equal to root', () => { + expect(() => assertDownloadDestination(root, root)).not.toThrow(); + }); + + it('rejects paths outside root', () => { + expect(() => assertDownloadDestination(root, '/etc/passwd')).toThrow(new ValidationError('Download path escapes destination folder: /etc/passwd')); + }); + }); +}); diff --git a/js/cli/src/commands/fileSystem/downloadPathValidation.ts b/js/cli/src/commands/fileSystem/downloadPathValidation.ts new file mode 100644 index 00000000..5d9e4887 --- /dev/null +++ b/js/cli/src/commands/fileSystem/downloadPathValidation.ts @@ -0,0 +1,81 @@ +import path from 'node:path'; + +import { ValidationError } from '@protontech/drive-sdk'; + +/** Characters invalid in a single path segment on common platforms. */ +const ILLEGAL_SEGMENT_CHARS = /[\x00-\x1f\x7f<>:"|?*\\/]/; + +/** Windows reserved device names (case-insensitive), with optional extension. */ +const WIN_RESERVED_NAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + +/** + * Refuses empty input, POSIX `/`, and Windows drive roots. + */ +export function assertValidDownloadRoot(localFolder: string): string { + const trimmed = localFolder.trim(); + if (!trimmed) { + throw new ValidationError('Local folder path must not be empty'); + } + + const resolved = path.resolve(trimmed); + + if (resolved === '/') { + throw new ValidationError('Refusing to use filesystem root as download destination'); + } + + if (process.platform === 'win32') { + const parsed = path.parse(resolved); + const rest = resolved.slice(parsed.root.length).replace(/[/\\]/g, ''); + if (rest.length === 0) { + throw new ValidationError('Refusing to use drive root as download destination'); + } + } + + return resolved; +} + +/** + * Ensures the absolute destination stays under `downloadRootResolved` and each relative segment passes {@link assertValidPathSegment}. + */ +export function assertDownloadDestination(downloadRoot: string, destinationPath: string): void { + const root = path.resolve(downloadRoot); + const dest = path.resolve(destinationPath); + const rel = path.relative(root, dest); + + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new ValidationError(`Download path escapes destination folder: ${destinationPath}`); + } + + if (rel === '') { + return; + } + + const segments = rel.split(/[/\\]/u).filter(Boolean); + for (const segment of segments) { + assertValidPathSegment(segment); + } +} + +/** + * Ensures the path segment is valid (not empty, not a traversal, not a reserved name, not a special character). + */ +export function assertValidPathSegment(name: string): void { + if (name.length === 0) { + throw new ValidationError('Invalid empty path segment'); + } + if (name === '.' || name === '..') { + throw new ValidationError(`Invalid path segment: "${name}"`); + } + if (ILLEGAL_SEGMENT_CHARS.test(name)) { + throw new ValidationError(`Invalid character in path segment: "${name}"`); + } + if (WIN_RESERVED_NAME.test(name)) { + throw new ValidationError(`Reserved path segment name: "${name}"`); + } + if (process.platform === 'win32') { + const trailing = /[.\u0020]$/; + if (trailing.test(name)) { + throw new ValidationError(`Invalid path segment (trailing dot or space): "${name}"`); + } + } +} diff --git a/js/cli/src/commands/fileSystem/transferConflictResolver.test.ts b/js/cli/src/commands/fileSystem/transferConflictResolver.test.ts new file mode 100644 index 00000000..52c67937 --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferConflictResolver.test.ts @@ -0,0 +1,109 @@ +import { ValidationError } from '@protontech/drive-sdk'; +import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; + +import { question } from '../../cli/readline'; +import { + ConflictChoice, + ConflictTargetKind, + resolveConflictStrategy, + TransferConflictResolver, +} from './transferConflictResolver'; + +jest.mock('../../cli/readline', () => ({ + question: jest.fn(), +})); + +const mockedQuestion = question as jest.MockedFunction; + +function mockReadlineAnswers(...lines: string[]) { + const queue = [...lines]; + mockedQuestion.mockImplementation(() => Promise.resolve(queue.shift() ?? '')); + return { question: mockedQuestion }; +} + +describe('TransferConflictResolver', () => { + let consoleLogSpy: jest.SpyInstance>; + + beforeEach(() => { + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockedQuestion.mockImplementation(() => { + throw new Error('question: use mockReadlineAnswers() in this test'); + }); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + describe('forced strategies', () => { + it('returns forced file strategy for file conflicts', async () => { + const resolver = new TransferConflictResolver(getMockLogger(), { forcedFileStrategy: 'replace' }); + await expect(resolver.resolve('x', ConflictTargetKind.File)).resolves.toBe(ConflictChoice.Replace); + }); + + it('returns forced folder strategy for folder conflicts', async () => { + const resolver = new TransferConflictResolver(getMockLogger(), { forcedFolderStrategy: 'skip' }); + await expect(resolver.resolve('x', ConflictTargetKind.Folder)).resolves.toBe(ConflictChoice.Skip); + }); + }); + + describe('disableInteractiveResolution', () => { + it('throws when no global strategy and interactive is disabled', async () => { + const resolver = new TransferConflictResolver(getMockLogger(), { disableInteractiveResolution: true }); + await expect(resolver.resolve('foo.txt', ConflictTargetKind.File)).rejects.toMatchObject({ + message: expect.stringMatching(/Name conflict on "foo\.txt".*\(file\)/), + }); + }); + }); + + describe('interactive resolution', () => { + it('resolves one item', async () => { + mockReadlineAnswers('replace'); + const resolver = new TransferConflictResolver(getMockLogger(), {}); + await expect(resolver.resolve('a', ConflictTargetKind.File)).resolves.toBe(ConflictChoice.Replace); + }); + + it('resolves multiple items sequentially', async () => { + mockReadlineAnswers('merge', 'skip'); + const resolver = new TransferConflictResolver(getMockLogger(), {}); + const a = resolver.resolve('a', ConflictTargetKind.File); + const b = resolver.resolve('b', ConflictTargetKind.File); + await expect(Promise.all([a, b])).resolves.toEqual([ConflictChoice.Merge, ConflictChoice.Skip]); + }); + + it('apply-to-all sets global strategy for subsequent conflicts of the same kind', async () => { + mockReadlineAnswers('a', 'skip', 'merge'); + const resolver = new TransferConflictResolver(getMockLogger(), {}); + + await expect(resolver.resolve('first', ConflictTargetKind.File)).resolves.toBe(ConflictChoice.Skip); + await expect(resolver.resolve('second', ConflictTargetKind.File)).resolves.toBe(ConflictChoice.Skip); + await expect(resolver.resolve('third', ConflictTargetKind.Folder)).resolves.toBe(ConflictChoice.Merge); + }); + }); +}); + +describe('resolveConflictStrategy', () => { + const allChoices = Object.values(ConflictChoice); + + it('returns undefined for blank input', () => { + expect(resolveConflictStrategy(' ', allChoices)).toBeUndefined(); + }); + + it('normalizes underscores to hyphens', () => { + expect(resolveConflictStrategy('keep_both', allChoices)).toBe(ConflictChoice.KeepBoth); + }); + + it('resolves a unique prefix', () => { + expect(resolveConflictStrategy('rep', allChoices)).toBe(ConflictChoice.Replace); + }); + + it('throws when no strategy matches', () => { + expect(() => resolveConflictStrategy('nope', allChoices)).toThrow(ValidationError); + }); + + it('throws when the prefix matches more than one choice', () => { + expect(() => + resolveConflictStrategy('m', [ConflictChoice.Merge, 'mess' as ConflictChoice]), + ).toThrow(ValidationError); + }); +}); diff --git a/js/cli/src/commands/fileSystem/transferConflictResolver.ts b/js/cli/src/commands/fileSystem/transferConflictResolver.ts new file mode 100644 index 00000000..ea3f715d --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferConflictResolver.ts @@ -0,0 +1,177 @@ +import { ValidationError, Logger } from '@protontech/drive-sdk'; + +import { question } from '../../cli/readline'; + +export enum ConflictChoice { + Merge = 'merge', + KeepBoth = 'keep-both', + Replace = 'replace', + Skip = 'skip', +} + +export enum ConflictTargetKind { + File = 'file', + Folder = 'folder', +} + +export class TransferConflictResolver { + private readonly fileStrategyChoices: readonly ConflictChoice[]; + private readonly folderStrategyChoices: readonly ConflictChoice[]; + private globalFileStrategy?: ConflictChoice; + private globalFolderStrategy?: ConflictChoice; + private disableInteractiveResolution: boolean; + + private readonly queuedRequests: { + name: string; + kind: ConflictTargetKind; + resolve: (choice: ConflictChoice) => void; + reject: (error: unknown) => void; + }[] = []; + private strategyResolutionInProgress = false; + + constructor( + private readonly logger: Logger, + readonly options: { + fileStrategyChoices?: readonly ConflictChoice[]; + folderStrategyChoices?: readonly ConflictChoice[]; + forcedFileStrategy?: string; + forcedFolderStrategy?: string; + disableInteractiveResolution?: boolean; + onInteractivePromptBegin?: () => void; + onInteractivePromptEnd?: () => void; + }, + ) { + this.fileStrategyChoices = options.fileStrategyChoices ?? Object.values(ConflictChoice); + this.folderStrategyChoices = options.folderStrategyChoices ?? Object.values(ConflictChoice); + this.globalFileStrategy = options.forcedFileStrategy?.trim() + ? resolveConflictStrategy(options.forcedFileStrategy, this.fileStrategyChoices) + : undefined; + this.globalFolderStrategy = options.forcedFolderStrategy?.trim() + ? resolveConflictStrategy(options.forcedFolderStrategy, this.folderStrategyChoices) + : undefined; + this.disableInteractiveResolution = options.disableInteractiveResolution || false; + } + + async resolve(name: string, kind: ConflictTargetKind): Promise { + const global = kind === ConflictTargetKind.File ? this.globalFileStrategy : this.globalFolderStrategy; + if (global !== undefined) { + return global; + } + if (this.disableInteractiveResolution) { + throw new ValidationError(`Name conflict on "${name}" (${kind}) already exists`); + } + return new Promise((resolve, reject) => { + this.queuedRequests.push({ name, kind, resolve, reject }); + void this.drainQueue(); + }); + } + + private async drainQueue(): Promise { + if (this.strategyResolutionInProgress) { + return; + } + this.strategyResolutionInProgress = true; + + try { + while (this.queuedRequests.length > 0) { + const pending = this.queuedRequests.shift()!; + + const globalStrategy = + pending.kind === ConflictTargetKind.File ? this.globalFileStrategy : this.globalFolderStrategy; + if (globalStrategy) { + this.logger.debug(`Resolved conflict with global strategy: ${globalStrategy}`); + pending.resolve(globalStrategy); + continue; + } + + try { + const choice = await this.prompt(pending.name, pending.kind); + this.logger.debug(`Resolved conflict with choice: ${choice}`); + pending.resolve(choice); + } catch (error) { + pending.reject(error); + throw error; + } + } + } finally { + this.strategyResolutionInProgress = false; + } + } + + private async prompt(name: string, kind: ConflictTargetKind): Promise { + this.options.onInteractivePromptBegin?.(); + try { + return await this.promptLoop(name, kind); + } finally { + this.options.onInteractivePromptEnd?.(); + } + } + + // TODO: In some edge cases the prompt will not receive input back. + // Seems like some bug in Bun. We should investigate and find some + // workaround. When this happens, user can use apply-all strategy. + private async promptLoop(name: string, kind: ConflictTargetKind): Promise { + const choices = kind === ConflictTargetKind.File ? this.fileStrategyChoices : this.folderStrategyChoices; + const hint = `Type a strategy (${choices.join(', ')}; or abbreviations), or [a]pply-to-all`; + while (true) { + const line = await question(`Conflict on "${name}" (${kind}). ${hint}\n> `); + const trimmed = line.trim().toLowerCase(); + if (trimmed === 'a' || trimmed === 'apply' || trimmed === 'apply-to-all') { + const applyAllLine = await question(`Default for all ${kind} conflicts:\n> `); + const strategy = this.parsePromptStrategy(applyAllLine, choices); + if (!strategy) { + continue; + } + if (kind === ConflictTargetKind.File) { + this.globalFileStrategy = strategy; + } else { + this.globalFolderStrategy = strategy; + } + return strategy; + } + const strategy = this.parsePromptStrategy(line, choices); + if (strategy) { + return strategy; + } + } + } + + private parsePromptStrategy(line: string, choices: readonly ConflictChoice[]): ConflictChoice | undefined { + try { + const result = resolveConflictStrategy(line, choices); + if (!result) { + console.log('Enter a strategy name or abbreviation.'); + return; + } + return result; + } catch (error) { + if (error instanceof ValidationError) { + console.log(error.message); + return; + } + throw error; + } + } +} + +export function resolveConflictStrategy( + raw: string, + choices: readonly ConflictChoice[], +): ConflictChoice | undefined { + const input = raw.trim().toLowerCase().replace(/_/g, '-'); + if (input === '') { + return undefined; + } + const matches = choices.filter((c) => c.startsWith(input)); + if (matches.length === 0) { + throw new ValidationError( + `Invalid conflict strategy "${raw.trim()}". Expected one of: ${choices.join(', ')} (unique abbreviations are allowed).`, + ); + } + if (matches.length > 1) { + throw new ValidationError( + `Ambiguous conflict strategy "${raw.trim()}". Matches more than one strategy: ${matches.join(', ')}. Use a longer prefix.`, + ); + } + return matches[0]; +} diff --git a/js/cli/src/commands/fileSystem/transferProgress.ts b/js/cli/src/commands/fileSystem/transferProgress.ts new file mode 100644 index 00000000..3b110347 --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferProgress.ts @@ -0,0 +1,196 @@ +import { formatSize } from "src/cli/formatters"; + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export interface TransferProgressInterface { + trackItem(name: string, size?: number): TransferProgressItem; + + /** Pause the progress display. Use for prompts for the user. */ + pause(): void; + resume(): void; + + dispose(): void; +} + +interface TransferProgressItem { + onProgress: (uploadedBytes: number) => void; + onFinished: () => void; +} + +export function createTransferProgress(): TransferProgressInterface { + if (!process.stdout.isTTY) { + return noopSink; + } + return new TransferProgress(); +} + +const noopSink: TransferProgressInterface = { + trackItem: () => ({ + onProgress: () => {}, + onFinished: () => {}, + }), + pause: () => {}, + resume: () => {}, + dispose: () => {}, +}; + +class TransferProgress implements TransferProgressInterface { + private readonly active = new Map< + string, + { + name: string; + size?: number; + progress: number; + } + >(); + private nextId = 0; + private spinnerIndex = 0; + private interval: ReturnType | null = null; + private paused = false; + private previousLineCount = 0; + + trackItem(name: string, size?: number): TransferProgressItem { + const id = String(this.nextId++); + this.active.set(id, { + name, + size, + progress: 0, + }); + this.ensureTicking(); + this.redraw(); + + const onProgress = (uploadedBytes: number) => { + // Ensure the item is not re-added to the map when onProgress is + // called after onFinished. + const item = this.active.get(id); + if (!item) { + return; + } + + item.progress = uploadedBytes; + this.active.set(id, { + name, + size, + progress: uploadedBytes, + }); + + // No need to redraw here, the ticking will do it. + }; + + const onFinished = () => { + this.active.delete(id); + this.redraw(); + if (this.active.size === 0) { + this.stopTicking(); + } + }; + + return { + onProgress, + onFinished, + }; + } + + pause(): void { + if (this.paused) { + return; + } + this.paused = true; + this.stopTicking(); + this.clearBlock(); + } + + resume(): void { + if (!this.paused) { + return; + } + this.paused = false; + if (this.active.size > 0) { + this.ensureTicking(); + } + this.redraw(); + } + + dispose(): void { + this.stopTicking(); + this.clearBlock(); + this.active.clear(); + } + + private ensureTicking(): void { + if (this.interval !== null || this.paused) { + return; + } + this.interval = setInterval(() => { + this.spinnerIndex = (this.spinnerIndex + 1) % SPINNER_FRAMES.length; + this.redraw(); + }, 80); + } + + private stopTicking(): void { + if (this.interval !== null) { + clearInterval(this.interval); + this.interval = null; + } + } + + /** + * Removes the last drawn progress block from the TTY. Uses ECMA-48 (ANSI) control + * strings (`\x1b` = ESC, starts a CSI sequence): + * - `[?25l` / `[?25h` — DECTCEM: hide / show the cursor (reduces flicker while rewriting). + * - `[1A` — cursor up one line (repeat for each previously printed line). + * - `[2K` — erase the whole current line; `\r` — move to column 0 on that line. + */ + private clearBlock(): void { + if (this.previousLineCount === 0) { + return; + } + let out = '\x1b[?25l'; + for (let i = 0; i < this.previousLineCount; i++) { + out += '\x1b[1A\x1b[2K\r'; + } + out += '\x1b[?25h'; + process.stdout.write(out); + this.previousLineCount = 0; + } + + private redraw(): void { + if (this.paused) { + return; + } + + const frame = SPINNER_FRAMES[this.spinnerIndex % SPINNER_FRAMES.length]!; + const lines = + this.active.size === 0 + ? [] + : Array.from( + this.active.values(), + (item) => `${frame} ${getProgressPercentage(item)} ${item.name} (${formatSize(item.size, true)})`, + ); + + if (lines.length === 0 && this.previousLineCount === 0) { + return; + } + + // See doc for `clearBlock` for details. + let out = '\x1b[?25l'; + for (let i = 0; i < this.previousLineCount; i++) { + out += '\x1b[1A\x1b[2K\r'; + } + + this.previousLineCount = lines.length; + + for (const line of lines) { + out += `${line}\n`; + } + out += '\x1b[?25h'; + process.stdout.write(out); + } +} + +function getProgressPercentage(item: { progress: number, size?: number }): string { + if (item.size === undefined) { + return ''; + } + return `${(item.progress / item.size * 100).toFixed(2)}%`; +} diff --git a/js/cli/src/commands/fileSystem/transferQueue.test.ts b/js/cli/src/commands/fileSystem/transferQueue.test.ts new file mode 100644 index 00000000..ad81230d --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferQueue.test.ts @@ -0,0 +1,384 @@ +import { Dirent } from 'node:fs'; +import path from 'node:path'; + +import { ValidationError, NodeType, ProtonDriveClient, MaybeNode, MemberRole } from '@protontech/drive-sdk'; +import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; + +jest.mock('../../cli', () => jest.requireActual('../../cli/node')); + +jest.mock('node:fs/promises', () => ({ + readdir: jest.fn(), + lstat: jest.fn(), +})); + +import { readdir, lstat } from 'node:fs/promises'; + +import { MAX_CONCURRENT_ITEMS, UploadQueue, DownloadQueue, QueueItem } from './transferQueue'; + +const readdirMock = readdir as jest.MockedFunction; +const lstatMock = lstat as jest.MockedFunction; + +type ReaddirDirents = Awaited>; + +function mockFileLstatResult(dev = 100): Awaited> { + return { + dev, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + isSocket: () => false, + isFIFO: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + } as Awaited>; +} + +function mockDirLstatResult(dev = 100): Awaited> { + return { + dev, + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + isSocket: () => false, + isFIFO: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + } as Awaited>; +} + +const mockAuthor = { ok: true as const, value: 'a@b.c' }; + +function mockFolderMaybe(name: string, uid: string): MaybeNode { + return { + ok: true, + value: { + uid, + name, + type: NodeType.Folder, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', + }, + }; +} + +function mockFileMaybe(name: string, uid: string): MaybeNode { + return { + ok: true, + value: { + uid, + name, + type: NodeType.File, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', + }, + }; +} + +class SeededUploadQueue extends UploadQueue { + seed(items: QueueItem<{ parentNode: MaybeNode }>[]) { + this.queue.push(...items); + } +} + +describe('TransferQueue (via UploadQueue.processQueue)', () => { + const parent = mockFolderMaybe('parent', 'p1'); + + it('resolves immediately for an empty queue', async () => { + const onDirectory = jest.fn(); + const startFile = jest.fn(); + const q = new UploadQueue(getMockLogger(), { onDirectory, startFile }); + await q.processQueue(); + expect(onDirectory).not.toHaveBeenCalled(); + expect(startFile).not.toHaveBeenCalled(); + }); + + it('awaits onDirectory before processing later items', async () => { + const order: string[] = []; + const onDirectory = jest.fn(async () => { + order.push('dir-start'); + await new Promise((r) => setImmediate(r)); + order.push('dir-end'); + }); + const startFile = jest.fn(async () => { + order.push('file'); + }); + const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + q.seed([ + { kind: 'directory', localPath: '/a', baseName: 'a', parentNode: parent }, + { kind: 'file', localPath: '/f', baseName: 'f', parentNode: parent }, + ]); + await q.processQueue(); + expect(order).toEqual(['dir-start', 'dir-end', 'file']); + expect(onDirectory).toHaveBeenCalledTimes(1); + expect(startFile).toHaveBeenCalledTimes(1); + }); + + it('limits the number of concurrent file transfers', async () => { + let inFlight = 0; + let maxInFlight = 0; + const onDirectory = jest.fn(); + const startFile = jest.fn(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setImmediate(r)); + inFlight--; + }); + const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + const items: QueueItem<{ parentNode: MaybeNode }>[] = []; + for (let i = 0; i < 12; i++) { + items.push({ + kind: 'file', + localPath: `/f${i}`, + baseName: `f${i}`, + parentNode: parent, + }); + } + q.seed(items); + await q.processQueue(); + expect(maxInFlight).toBe(MAX_CONCURRENT_ITEMS); + expect(startFile).toHaveBeenCalledTimes(12); + }); + + it('waits for all in-flight file transfers before finishing', async () => { + const pending: Array<() => void> = []; + const onDirectory = jest.fn(); + const startFile = jest.fn( + () => + new Promise((resolve) => { + pending.push(resolve); + }), + ); + const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + q.seed([{ kind: 'file', localPath: '/one', baseName: 'one', parentNode: parent }]); + const done = q.processQueue(); + await new Promise((r) => setImmediate(r)); + expect(pending.length).toBe(1); + pending[0]!(); + await done; + expect(startFile).toHaveBeenCalledTimes(1); + }); +}); + +describe('UploadQueue', () => { + const parent = mockFolderMaybe('parent', 'p1'); + + beforeEach(() => { + readdirMock.mockReset(); + lstatMock.mockReset(); + }); + + it('enqueueLocalPaths enqueues a file', async () => { + lstatMock.mockResolvedValueOnce(mockFileLstatResult()); + const q = new SeededUploadQueue(getMockLogger(), { + onDirectory: jest.fn(), + startFile: jest.fn(), + }); + await q.enqueueLocalPaths(['/tmp/x.txt'], parent); + expect(q['queue']).toHaveLength(1); + expect(q['queue'][0]).toMatchObject({ + kind: 'file', + baseName: 'x.txt', + parentNode: parent, + }); + expect(path.isAbsolute((q['queue'][0] as { localPath: string }).localPath)).toBe(true); + }); + + it('enqueueLocalPaths enqueues a directory', async () => { + lstatMock.mockResolvedValueOnce(mockDirLstatResult()); + const q = new SeededUploadQueue(getMockLogger(), { + onDirectory: jest.fn(), + startFile: jest.fn(), + }); + await q.enqueueLocalPaths(['/tmp/mydir'], parent); + expect(q['queue'][0]).toMatchObject({ + kind: 'directory', + baseName: 'mydir', + parentNode: parent, + }); + }); + + it('throws ValidationError for character devices', async () => { + const charDev = { + dev: 1, + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => false, + isSocket: () => false, + isFIFO: () => false, + isCharacterDevice: () => true, + isBlockDevice: () => false, + } as Awaited>; + lstatMock.mockResolvedValue(charDev); + const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + await expect(q.enqueueLocalPaths(['/dev/null'], parent)).rejects.toThrow(ValidationError); + await expect(q.enqueueLocalPaths(['/dev/null'], parent)).rejects.toThrow( + 'Not a regular file or directory: /dev/null', + ); + }); + + it('throws ValidationError for symbolic links', async () => { + lstatMock.mockResolvedValueOnce({ + dev: 1, + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + isSocket: () => false, + isFIFO: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + } as Awaited>); + const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + await expect(q.enqueueLocalPaths(['/tmp/alink'], parent)).rejects.toThrow( + 'Not a regular file or directory: /tmp/alink', + ); + }); + + it('throws ValidationError when path is an unsupported type', async () => { + lstatMock.mockResolvedValueOnce({ + dev: 1, + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => false, + isSocket: () => false, + isFIFO: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + } as Awaited>); + const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + await expect(q.enqueueLocalPaths(['/weird'], parent)).rejects.toThrow('Not a regular file or directory'); + }); + + it('enqueueLocalDirectoryChildren enqueues each child and skips . and ..', async () => { + const dot = { name: '.', isDirectory: () => true, isFile: () => false } as Dirent; + const dotdot = { name: '..', isDirectory: () => true, isFile: () => false } as Dirent; + const child = { name: 'kid', isDirectory: () => false, isFile: () => true } as Dirent; + const resolvedParent = path.resolve('/parent'); + lstatMock.mockResolvedValueOnce(mockDirLstatResult(42)); + readdirMock.mockResolvedValueOnce([dot, dotdot, child] as unknown as ReaddirDirents); + lstatMock.mockResolvedValueOnce(mockFileLstatResult(42)); + const q = new SeededUploadQueue(getMockLogger(), { + onDirectory: jest.fn(), + startFile: jest.fn(), + }); + await q.enqueueLocalDirectoryChildren('/parent', parent); + expect(readdirMock).toHaveBeenCalledWith(resolvedParent, { withFileTypes: true }); + expect(q['queue']).toHaveLength(1); + expect(q['queue'][0]).toMatchObject({ kind: 'file', baseName: 'kid' }); + }); + + it('enqueueLocalDirectoryChildren rejects children on another device (mount point)', async () => { + const kid = { name: 'mounted', isDirectory: () => true, isFile: () => false } as Dirent; + lstatMock.mockResolvedValueOnce(mockDirLstatResult(1)); + readdirMock.mockResolvedValueOnce([kid] as unknown as ReaddirDirents); + lstatMock.mockResolvedValueOnce(mockDirLstatResult(2)); + const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + await expect(q.enqueueLocalDirectoryChildren('/parent', parent)).rejects.toThrow( + 'Cannot traverse into a different file system (mount point)', + ); + }); +}); + +describe('DownloadQueue', () => { + const folder = mockFolderMaybe('remoteDir', 'rf'); + const file = mockFileMaybe('remote.txt', 'rfile'); + + function createQueue(sdk: Pick) { + return new DownloadQueue(getMockLogger(), sdk as ProtonDriveClient, { + onDirectory: jest.fn(), + startFile: jest.fn(), + }); + } + + it('enqueueRemotePaths resolves nodes and enqueues folder and file items', async () => { + const sdk = { iterateFolderChildren: jest.fn() }; + const q = createQueue(sdk); + await q.enqueueRemotePaths(['/remoteDir', '/remote.txt'], '/local/out', async (s) => { + if (s === '/remoteDir') { + return folder; + } + return file; + }); + expect(q['queue']).toHaveLength(2); + expect(q['queue']).toEqual([ + { + kind: 'directory', + remoteNode: folder, + baseName: 'remoteDir', + localPath: path.join(path.resolve('/local/out'), 'remoteDir'), + }, + { + kind: 'file', + remoteNode: file, + baseName: 'remote.txt', + localPath: path.join(path.resolve('/local/out'), 'remote.txt'), + }, + ]); + const dirPath = (q['queue'][0] as { localPath: string }).localPath; + expect(dirPath).toBe(path.join(path.resolve('/local/out'), 'remoteDir')); + }); + + it('enqueueRemoteNode rejects unsupported node types', async () => { + const albumNode = { + ok: true, + value: { + uid: 'al', + name: 'album', + type: NodeType.Album, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', + }, + } as MaybeNode; + const sdk = { iterateFolderChildren: jest.fn() }; + const q = createQueue(sdk); + await expect( + q.enqueueRemotePaths(['/x'], '/out', async () => albumNode), + ).rejects.toThrow('Unsupported node type for download'); + }); + + it('enqueueRemoteFolderChildren enqueues each iterated child', async () => { + const subFolder = mockFolderMaybe('sub', 'subuid'); + async function* children() { + yield file; + yield subFolder; + } + const sdk = { iterateFolderChildren: jest.fn().mockReturnValue(children()) }; + const q = createQueue(sdk); + await q.enqueueRemoteFolderChildren(folder, '/local/out'); + expect(sdk.iterateFolderChildren).toHaveBeenCalledWith(folder); + expect(q['queue']).toEqual([ + { + kind: 'file', + remoteNode: file, + baseName: 'remote.txt', + localPath: path.join(path.resolve('/local/out'), 'remote.txt'), + }, + { + kind: 'directory', + remoteNode: subFolder, + baseName: 'sub', + localPath: path.join(path.resolve('/local/out'), 'sub'), + }, + ]); + }); +}); diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts new file mode 100644 index 00000000..7f429942 --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -0,0 +1,153 @@ +import type { Stats } from 'node:fs'; +import { readdir, lstat } from 'node:fs/promises'; +import path from 'node:path'; + +import { ValidationError, MaybeNode, NodeType, ProtonDriveClient, Logger } from '@protontech/drive-sdk'; + +import { getName, getNode } from '../../cli'; + +export const MAX_CONCURRENT_ITEMS = 5; + +type QueueItemBase = { + localPath: string; + baseName: string; +} & RemoteDataType; + +export type QueueItemDirectory = QueueItemBase & { + kind: 'directory'; +}; + +export type QueueItemFile = QueueItemBase & { + kind: 'file'; +}; + +export type QueueItem = QueueItemDirectory | QueueItemFile; + +type TransferQueueHandlers = { + onDirectory: (item: QueueItemDirectory) => Promise; + startFile: (item: QueueItemFile) => Promise; +}; + +class TransferQueue { + protected queue: QueueItem[] = []; + private ongoingItems = new Set>(); + + constructor( + private readonly logger: Logger, + private readonly handlers: TransferQueueHandlers, + ) {} + + async processQueue(): Promise { + while (this.queue.length > 0) { + const item = this.queue.shift()!; + if (item.kind === 'directory') { + await this.handlers.onDirectory(item); + continue; + } + + if (this.ongoingItems.size >= MAX_CONCURRENT_ITEMS) { + this.logger.debug(`Waiting for ongoing items to finish`); + await Promise.race(this.ongoingItems); + } + const promise = this.handlers.startFile(item); + this.ongoingItems.add(promise); + void promise.finally(() => { + this.ongoingItems.delete(promise); + }); + } + await Promise.all(this.ongoingItems); + } +} + +export class UploadQueue extends TransferQueue<{ parentNode: MaybeNode }> { + async enqueueLocalPaths(localPaths: string[], parentNode: MaybeNode): Promise { + for (const localPath of localPaths) { + await this.enqueueLocalPath(localPath, parentNode); + } + } + + async enqueueLocalDirectoryChildren(localPath: string, parentNode: MaybeNode): Promise { + const resolvedDir = path.resolve(localPath); + const parentStats = await lstat(resolvedDir); + assertLocalPathIsUploadable(resolvedDir, parentStats); + if (!parentStats.isDirectory()) { + throw new ValidationError(`Not a directory: ${resolvedDir}`); + } + const entries = await readdir(resolvedDir, { withFileTypes: true }); + for (const ent of entries) { + if (ent.name === '.' || ent.name === '..') { + continue; + } + const absolutePath = path.join(resolvedDir, ent.name); + await this.enqueueLocalPath(absolutePath, parentNode, parentStats.dev); + } + } + + private async enqueueLocalPath(localPath: string, parentNode: MaybeNode, parentDevice?: number): Promise { + const absolutePath = path.resolve(localPath); + const stats = await lstat(absolutePath); + assertLocalPathIsUploadable(absolutePath, stats); + if (parentDevice !== undefined && stats.dev !== parentDevice) { + throw new ValidationError(`Cannot traverse into a different file system (mount point): ${absolutePath}`); + } + const baseName = path.basename(absolutePath); + if (stats.isDirectory()) { + this.queue.push({ kind: 'directory', localPath: absolutePath, parentNode, baseName }); + } else if (stats.isFile()) { + this.queue.push({ kind: 'file', localPath: absolutePath, parentNode, baseName }); + } else { + throw new ValidationError(`Not a regular file or directory: ${absolutePath}`); + } + } +} + +function assertLocalPathIsUploadable(absolutePath: string, stats: Stats): void { + if (!stats.isFile() && !stats.isDirectory()) { + throw new ValidationError(`Not a regular file or directory: ${absolutePath}`); + } +} + +export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { + constructor( + logger: Logger, + private readonly sdk: ProtonDriveClient, + handlers: TransferQueueHandlers<{ remoteNode: MaybeNode }>, + ) { + super(logger, handlers); + } + + async enqueueRemotePaths( + remotePathStrings: string[], + localDir: string, + resolveRemoteNode: (pathString: string) => Promise, + ): Promise { + const absoluteLocalDir = path.resolve(localDir); + for (const pathString of remotePathStrings) { + const node = await resolveRemoteNode(pathString); + const baseName = getName(node); + const targetPath = path.join(absoluteLocalDir, baseName); + await this.enqueueRemoteNode(node, targetPath); + } + } + + async enqueueRemoteFolderChildren(folderRemoteNode: MaybeNode, localParentPath: string): Promise { + for await (const child of this.sdk.iterateFolderChildren(folderRemoteNode)) { + const baseName = getName(child); + const childPath = path.join(localParentPath, baseName); + await this.enqueueRemoteNode(child, childPath); + } + } + + private async enqueueRemoteNode(node: MaybeNode, localPath: string): Promise { + const absolutePath = path.resolve(localPath); + const baseName = path.basename(absolutePath); + const type = getNode(node).type; + if (type === NodeType.Folder) { + this.queue.push({ kind: 'directory', remoteNode: node, localPath: absolutePath, baseName }); + } else if (type === NodeType.File) { + this.queue.push({ kind: 'file', remoteNode: node, localPath: absolutePath, baseName }); + } else { + throw new ValidationError(`Unsupported node type for download: ${type}`); + } + } +} diff --git a/js/cli/src/commands/registry.ts b/js/cli/src/commands/registry.ts index e10166d1..23be545b 100644 --- a/js/cli/src/commands/registry.ts +++ b/js/cli/src/commands/registry.ts @@ -5,6 +5,7 @@ import { CommandAuthLogout } from './auth/commandAuthLogout'; import { CommandFileSystemCopy } from './fileSystem/commandFileSystemCopy'; import { CommandFileSystemCreateFolder } from './fileSystem/commandFileSystemCreateFolder'; import { CommandFileSystemDelete } from './fileSystem/commandFileSystemDelete'; +import { CommandFileSystemDownload } from './fileSystem/commandFileSystemDownload'; import { CommandFileSystemEmptyTrash } from './fileSystem/commandFileSystemEmptyTrash'; import { CommandFileSystemInfo } from './fileSystem/commandFileSystemInfo'; import { CommandFileSystemList } from './fileSystem/commandFileSystemList'; @@ -12,6 +13,7 @@ import { CommandFileSystemMove } from './fileSystem/commandFileSystemMove'; import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; +import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; export const COMMANDS = applyDefaultCliOptions([ new CommandAuthLogin(), @@ -19,6 +21,8 @@ export const COMMANDS = applyDefaultCliOptions([ new CommandFileSystemList(), new CommandFileSystemInfo(), new CommandFileSystemCreateFolder(), + new CommandFileSystemUpload(), + new CommandFileSystemDownload(), new CommandFileSystemRename(), new CommandFileSystemCopy(), new CommandFileSystemMove(), diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index e5011784..f6c5b067 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -5,6 +5,7 @@ import { OpenPGPCryptoWithCryptoProxy, OpenPGPCryptoProxy, FeatureFlags, + Logger, } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { initDiagnostic } from '@protontech/drive-sdk/diagnostic'; @@ -49,6 +50,7 @@ export async function init(configOptions: InitConfig) { const paths = new Paths(sdk, photosSdk, auth); return { + logger: logger as Logger, auth, addresses, sdk, 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 eb3dcef9a21d4acbe41ca94d0d63fdb2920f6ec6 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 12:48:19 +0000 Subject: [PATCH 604/732] Generate both thumbnails in CLI as WebP --- .gitignore | 3 +- js/cli/README.md | 17 ++- js/cli/bun.lock | 128 +++++++++--------- js/cli/package.json | 12 +- js/cli/scripts/build-cli.mjs | 31 +++++ .../fileSystem/commandFileSystemUpload.ts | 18 ++- .../commands/fileSystem/generateThumbnails.ts | 118 ++++++++++++++++ 7 files changed, 234 insertions(+), 93 deletions(-) create mode 100644 js/cli/scripts/build-cli.mjs create mode 100644 js/cli/src/commands/fileSystem/generateThumbnails.ts 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 diff --git a/js/cli/README.md b/js/cli/README.md index 29c9ae92..ebba583b 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -1,9 +1,6 @@ -# Slim CLI application for JavaScript version of Drive SDK - -The app implements and integrates dependencies for the SDK, such as HTTP client, logging, crypto module or account interface with authenticating user. - -The integration implements only limited capabilities to run simplest run of the SDK. For example, 2FA or other than simple username and password authentication is not supported, crypto module is copy-paste from the clients monorepo without using the web workers. +# Proton Drive CLI +The CLI provides dependencies for the SDK (such as HTTP client, logging, crypto module or account interface) and implements a simple interactive shell to run commands. The CLI is currently built using Bun and can be run on any platform that Bun supports. ## Installation @@ -11,19 +8,21 @@ First, you need to install Bun. Then you need to install CLI dependencies. +From `js/cli`: + ```bash bun install ``` -## Build - -From `js/cli`: +Then you can build the CLI. ```bash bun run build ``` -Artifacts are written to the **current working directory** (typically `js/cli/` when you run the commands above). +Executable is written to the **release** folder when you run the command above. + +Note: The CLI requires the sharp package for generating thumbnails during upload. The library is not bundled with the CLI as it requires a native module depending on the platform. You need to install the sharp package separately when running the CLI outside of the CLI directory. Either install it globally (`bun install -g sharp`) and provide `NODE_PATH` environment variable to the CLI executable, or install it locally by running `bun install sharp` in the directory with the CLI executable. ## Run diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 65ccd8c4..d38673d6 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -5,18 +5,12 @@ "": { "name": "proton-drive-cli", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-gif": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "ky": "^1.14.3", + "sharp": "^0.34.5", "ttag": "^1.8.7", }, "devDependencies": { @@ -296,6 +290,8 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -364,6 +360,56 @@ "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "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" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], @@ -400,26 +446,6 @@ "@jest/types": ["@jest/types@29.6.3", "", { "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" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], - - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], - - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], - - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], - - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], - - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], - - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], - - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], - - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], - - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -550,8 +576,6 @@ "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], @@ -712,8 +736,6 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "babel-jest": ["babel-jest@29.7.0", "", { "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" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], "babel-plugin-const-enum": ["babel-plugin-const-enum@1.2.0", "", { "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" } }, "sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg=="], @@ -746,8 +768,6 @@ "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], - "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -856,6 +876,8 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], @@ -926,8 +948,6 @@ "execa": ["execa@5.1.1", "", { "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" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], "expect": ["expect@29.7.0", "", { "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" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], @@ -948,8 +968,6 @@ "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], - "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -992,8 +1010,6 @@ "gettext-parser": ["gettext-parser@6.0.0", "", { "dependencies": { "content-type": "^1.0.4", "encoding": "^0.1.13", "readable-stream": "^4.1.0", "safe-buffer": "^5.2.1" } }, "sha512-eWFsR78gc/eKnzDgc919Us3cbxQbzxK1L8vAIZrKMQqOUgULyeqmczNlBjTlVTk2FaB7nV9C1oobd/PGBOqNmg=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1038,8 +1054,6 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], @@ -1154,8 +1168,6 @@ "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1260,8 +1272,6 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1302,8 +1312,6 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1332,8 +1340,6 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], @@ -1362,8 +1368,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1378,8 +1382,6 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1412,8 +1414,6 @@ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], @@ -1470,6 +1470,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1516,8 +1518,6 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - "supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1528,8 +1528,6 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -1540,12 +1538,12 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], @@ -1590,8 +1588,6 @@ "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1642,8 +1638,6 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1918,8 +1912,6 @@ "http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], - "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], - "istanbul-lib-instrument/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1982,6 +1974,8 @@ "rimraf/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "test-exclude/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], diff --git a/js/cli/package.json b/js/cli/package.json index 17d2af44..6f154126 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,8 +4,8 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "NODE_ENV=production bun build --compile ./src/proton-drive.ts --outfile=proton-drive --minify-identifiers --conditions=browser", - "build:internal": "NODE_ENV=production bun build --compile ./src/internal-cli/proton-drive-internal.ts --outfile=proton-drive-internal --minify-identifiers --conditions=browser", + "build": "bun ./scripts/build-cli.mjs", + "build:internal": "bun ./scripts/build-cli.mjs internal", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", @@ -35,18 +35,12 @@ "vite-plugin-dts": "^4.2.4" }, "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-gif": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-resize": "1.6.0", "@noble/hashes": "^1.8.0", "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", "ky": "^1.14.3", + "sharp": "^0.34.5", "ttag": "^1.8.7" }, "patchedDependencies": { diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs new file mode 100644 index 00000000..b5ec77ca --- /dev/null +++ b/js/cli/scripts/build-cli.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env bun + +const mode = process.argv[2] === 'internal' ? 'internal' : 'main'; + +const entry = mode === 'internal' ? 'src/internal-cli/proton-drive-internal.ts' : 'src/proton-drive.ts'; +const outfile = mode === 'internal' ? 'release/proton-drive-internal' : 'release/proton-drive'; + +const args = [ + 'build', + entry, + `--outfile=${outfile}`, + '--compile', + '--target=bun', + '--minify', + '--sourcemap=inline', + // Sharp requires a native module which we keep out of the bundle. + // User must install sharp separately by installing the package + // at the same directory as the CLI. The proton-drive CLI then must + // load the user's package.json. + '--external=sharp', + '--compile-autoload-package-json', +]; + +const proc = Bun.spawn(['bun', ...args], { + stdio: ['inherit', 'inherit', 'inherit'], + env: { ...process.env, NODE_ENV: 'production' }, +}); +const code = await proc.exited; +if (code !== 0) { + process.exit(code); +} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index f71772b8..ab8403db 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -4,7 +4,6 @@ import { ParseArgsConfig } from 'node:util'; import { NodeWithSameNameExistsValidationError, - Thumbnail, ValidationError, MaybeNode, type ProtonDriveClient, @@ -22,12 +21,14 @@ import { type QueueItemDirectory, type QueueItemFile, } from './transferQueue'; +import { generateThumbnails } from './generateThumbnails'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; type UploadContext = { sdk: ProtonDriveClient; json: boolean; + skipThumbnails: boolean; progress?: TransferProgressInterface; uploadQueue: UploadQueue; conflictResolver: TransferConflictResolver; @@ -53,6 +54,11 @@ export class CommandFileSystemUpload implements Command { short: 'd', default: '', }, + 'skip-thumbnails': { + type: 'boolean', + short: 't', + default: false, + }, }; async action({ @@ -65,6 +71,7 @@ export class CommandFileSystemUpload implements Command { 'conflict-strategy': conflictStrategy, 'file-conflict-strategy': fileConflictStrategy, 'folder-conflict-strategy': folderConflictStrategy, + 'skip-thumbnails': skipThumbnails, }, }: ActionArgs) { if (args.length < 2) { @@ -109,6 +116,7 @@ export class CommandFileSystemUpload implements Command { const ctx: UploadContext = { sdk, json, + skipThumbnails, progress, uploadQueue, conflictResolver, @@ -162,10 +170,7 @@ export class CommandFileSystemUpload implements Command { } } - private async uploadFile( - ctx: UploadContext, - item: QueueItemFile<{ parentNode: MaybeNode }>, - ): Promise { + private async uploadFile(ctx: UploadContext, item: QueueItemFile<{ parentNode: MaybeNode }>): Promise { const expectedSha1 = await this.getSha1(item.localPath); const file = Bun.file(item.localPath); const metadata = { @@ -179,6 +184,8 @@ export class CommandFileSystemUpload implements Command { let name = item.baseName; let newRevisionForNodeUid: string | undefined; + const thumbnails = ctx.skipThumbnails ? [] : await generateThumbnails(metadata.mediaType || '', item.localPath); + while (true) { const progressTracker = ctx.progress?.trackItem(item.baseName, file.size); @@ -187,7 +194,6 @@ export class CommandFileSystemUpload implements Command { ? await ctx.sdk.getFileRevisionUploader(newRevisionForNodeUid, metadata) : await ctx.sdk.getFileUploader(item.parentNode, name, metadata); - const thumbnails: Thumbnail[] = []; const controller = await uploader.uploadFromStream(file.stream(), thumbnails, (uploadedBytes) => { // file.size is raw size while uploadedBytes is encrypted // size. Encrypted size will be a bit higher. It is enough diff --git a/js/cli/src/commands/fileSystem/generateThumbnails.ts b/js/cli/src/commands/fileSystem/generateThumbnails.ts new file mode 100644 index 00000000..32b607aa --- /dev/null +++ b/js/cli/src/commands/fileSystem/generateThumbnails.ts @@ -0,0 +1,118 @@ +import type sharp from 'sharp'; + +import { Thumbnail, ThumbnailType } from '@protontech/drive-sdk'; + +const MAX_THUMBNAIL_SIDE = 512; +const MAX_THUMBNAIL_BYTES = 64 * 1024 - 512; + +const MAX_HD_THUMBNAIL_SIDE = 1920; +const MAX_HD_THUMBNAIL_BYTES = 1024 * 1024 - 512; + +const IMAGE_MEDIA_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/bmp', + 'image/tiff', + 'image/webp', +]); + +type SharpLib = typeof sharp; + +let sharpPromise: Promise | undefined; + +// Lazy load to avoid requiring the sharp package when not needed. +// It is external dependency that might be missing when running the CLI. +async function loadSharp(): Promise { + if (!sharpPromise) { + sharpPromise = import('sharp') + .then((m) => (m as { default: SharpLib }).default) + .catch((error: unknown) => { + if (error instanceof ResolveMessage) { + throw new Error( + 'Sharp package required for thumbnails generation is not installed. Please install it by running `bun install sharp` in the CLI directory.', + ); + } + throw error; + }); + } + return sharpPromise; +} + +export async function generateThumbnails(mediaType: string, localPath: string): Promise { + if (!IMAGE_MEDIA_TYPES.has(mediaType.trim().toLowerCase())) { + return []; + } + + const sharp = await loadSharp(); + + const metadata = await sharp(localPath, { failOn: 'none' }).metadata(); + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; + if (width === 0 || height === 0) { + return []; + } + + const type1Buffer = await webpFitMaxBytes(sharp, localPath, MAX_THUMBNAIL_SIDE, MAX_THUMBNAIL_BYTES); + if (!type1Buffer) { + return []; + } + + const thumbnails: Thumbnail[] = [ + { + type: ThumbnailType.Type1, + thumbnail: new Uint8Array(type1Buffer), + }, + ]; + + if (shouldGenerateHdThumbnail(width, height, mediaType)) { + const type2Buffer = await webpFitMaxBytes(sharp, localPath, MAX_HD_THUMBNAIL_SIDE, MAX_HD_THUMBNAIL_BYTES); + if (type2Buffer) { + thumbnails.push({ + type: ThumbnailType.Type2, + thumbnail: new Uint8Array(type2Buffer), + }); + } + } + + return thumbnails; +} + +function shouldGenerateHdThumbnail(width: number, height: number, mediaType: string): boolean { + const mt = mediaType.trim().toLowerCase(); + const isJpeg = mt === 'image/jpeg' || mt === 'image/jpg'; + const isWebp = mt === 'image/webp'; + if (Math.max(width, height) > MAX_HD_THUMBNAIL_SIDE) { + return true; + } + return !isJpeg && !isWebp; +} + +async function webpFitMaxBytes( + sharp: SharpLib, + localPath: string, + maxSide: number, + maxBytes: number, +): Promise { + let quality = 90; + let out: Buffer | undefined; + while (quality > 0) { + out = await sharp(localPath, { failOn: 'none' }) + .rotate() + .resize(maxSide, maxSide, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality, effort: 4 }) + .toBuffer(); + if (out.length <= maxBytes) { + break; + } + if (quality <= 10) { + return undefined; + } + quality -= 20; + } + if (out === undefined) { + return undefined; + } + return out; +} From 9fb215dcf804110302d5db8f5e6afdb8cda702fb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 07:08:53 +0000 Subject: [PATCH 605/732] 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 c54573fa43b23b63a3cc77ebf3f996da21c2b766 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 09:49:54 +0000 Subject: [PATCH 606/732] Integrate @protontech/crypto --- js/cli/.eslintrc.js | 35 +- js/cli/bun.lock | 1046 +- js/cli/jest.config.js | 6 +- js/cli/package.json | 9 +- .../patches/@protontech%2Fcrypto@2.0.0.patch | 29 + .../@protontech%2Fpmcrypto@8.8.1.patch | 35 - .../patches/@protontech+pmcrypto+8.8.1.patch | 29 - js/cli/src/api/addresses.ts | 30 +- js/cli/src/api/api-auth-types.ts | 691 +- js/cli/src/api/api-core-types.ts | 21638 +++++++++------- js/cli/src/api/apiClient.ts | 2 +- js/cli/src/api/auth.ts | 92 +- js/cli/src/api/httpClient.ts | 2 +- js/cli/src/api/index.ts | 9 +- js/cli/src/api/srp.ts | 12 +- js/cli/src/cache/encryptedCache.ts | 2 +- js/cli/src/cache/index.ts | 4 +- js/cli/src/cache/sqliteCache.ts | 4 +- js/cli/src/cli/index.ts | 6 +- js/cli/src/cli/interface.ts | 4 +- js/cli/src/cli/node.ts | 2 +- js/cli/src/cli/paths.ts | 2 +- js/cli/src/cli/repl.ts | 2 +- js/cli/src/cli/run.ts | 2 +- js/cli/src/commands/auth/commandAuthLogin.ts | 2 +- js/cli/src/commands/auth/commandAuthLogout.ts | 2 +- .../fileSystem/commandFileSystemCopy.ts | 4 +- .../commandFileSystemCreateFolder.ts | 2 +- .../fileSystem/commandFileSystemDelete.ts | 2 +- .../fileSystem/commandFileSystemDownload.ts | 4 +- .../fileSystem/commandFileSystemEmptyTrash.ts | 2 +- .../fileSystem/commandFileSystemInfo.ts | 2 +- .../fileSystem/commandFileSystemList.ts | 4 +- .../fileSystem/commandFileSystemMove.ts | 4 +- .../fileSystem/commandFileSystemRename.ts | 2 +- .../fileSystem/commandFileSystemRestore.ts | 2 +- .../fileSystem/commandFileSystemTrash.ts | 2 +- .../fileSystem/commandFileSystemUpload.ts | 8 +- .../fileSystem/transferConflictResolver.ts | 2 +- .../commands/fileSystem/transferQueue.test.ts | 6 +- .../src/commands/fileSystem/transferQueue.ts | 4 +- js/cli/src/commands/registry.ts | 1 - js/cli/src/credentials/credentials.ts | 2 +- .../src/credentials/fileCredentialsStore.ts | 2 +- js/cli/src/credentials/index.ts | 2 +- .../src/credentials/secretCredentialsStore.ts | 3 - js/cli/src/init.ts | 31 +- js/cli/src/tests/polyfill.ts | 1 + js/cli/tsconfig.json | 4 +- 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 +- 174 files changed, 22167 insertions(+), 19577 deletions(-) create mode 100644 js/cli/patches/@protontech%2Fcrypto@2.0.0.patch delete mode 100644 js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch delete mode 100644 js/cli/patches/@protontech+pmcrypto+8.8.1.patch create mode 100644 js/cli/src/tests/polyfill.ts 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/cli/.eslintrc.js b/js/cli/.eslintrc.js index ba7df1c7..4d989725 100644 --- a/js/cli/.eslintrc.js +++ b/js/cli/.eslintrc.js @@ -10,34 +10,29 @@ module.exports = { sourceType: "module" }, rules: { + "simple-import-sort/imports": [ + "error", + { + groups: [ + ["^\u0000"], + ["^node:", "^bun:"], + ["^(?!@protontech/)@?\\w"], + ["^@protontech/"], + ["^\\."], + ], + }, + ], + "simple-import-sort/exports": "error", + "comma-spacing": ["error", { before: false, after: true }], "tsdoc/syntax": "warn", "no-console": "off", "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/consistent-type-exports": "error", "@typescript-eslint/no-explicit-any": "warn", }, - overrides: [ - // Crypto module is copy-paste from clients monorepo. - { - files: [ - "src/crypto/**/*", - "src/srp/**/*", - ], - rules: { - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/consistent-type-exports": "off", - "@typescript-eslint/no-empty-object-type": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-expressions": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-wrapper-object-types": "off", - "prefer-spread": "off", - "tsdoc/syntax": "off" - }, - }, - ], plugins: [ "@typescript-eslint/eslint-plugin", "eslint-plugin-tsdoc", + "simple-import-sort", ] }; diff --git a/js/cli/bun.lock b/js/cli/bun.lock index d38673d6..bd7e18eb 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "proton-drive-cli", "dependencies": { "@noble/hashes": "^1.8.0", + "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", @@ -16,17 +17,16 @@ "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bcryptjs": "^2.4.6", "@types/bun": "^1.2.18", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", "@typescript-eslint/eslint-plugin": "^8.55.0", "bun-types": "^1.1.31", "eslint": "^8.57.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", - "patch-package": "^8.0.0", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^5.4.8", @@ -35,22 +35,20 @@ }, }, "patchedDependencies": { - "@protontech/pmcrypto@8.8.1": "patches/@protontech%2Fpmcrypto@8.8.1.patch", + "@protontech/crypto@2.0.0": "patches/@protontech%2Fcrypto@2.0.0.patch", }, "packages": { - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - "@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.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" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - - "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], @@ -62,9 +60,9 @@ "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], @@ -78,15 +76,15 @@ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ=="], - "@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q=="], @@ -134,7 +132,7 @@ "@babel/plugin-syntax-json-strings": ["@babel/plugin-syntax-json-strings@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-logical-assignment-operators": ["@babel/plugin-syntax-logical-assignment-operators@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig=="], @@ -152,7 +150,7 @@ "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="], - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], @@ -282,15 +280,15 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - "@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@babel/types": ["@babel/types@7.28.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], @@ -344,7 +342,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -414,13 +412,13 @@ "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "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" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], - "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], "@jest/console": ["@jest/console@29.7.0", "", { "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" } }, "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg=="], "@jest/core": ["@jest/core@29.7.0", "", { "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" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg=="], - "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], + "@jest/create-cache-key-function": ["@jest/create-cache-key-function@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0" } }, "sha512-hTupmOWylzeyqbMNeSNi7ZDprpjrcroAOOG+qCEW66st3+Z5RnYHVYkUt+zjIcLmrTUi2lPY79hJz8mB3L2oXQ=="], "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], @@ -432,6 +430,8 @@ "@jest/globals": ["@jest/globals@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", "@jest/types": "^29.6.3", "jest-mock": "^29.7.0" } }, "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ=="], + "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "@jest/reporters": ["@jest/reporters@29.7.0", "", { "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" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg=="], "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], @@ -446,19 +446,21 @@ "@jest/types": ["@jest/types@29.6.3", "", { "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" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@mdn/browser-compat-data": ["@mdn/browser-compat-data@4.2.1", "", {}, "sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA=="], - "@microsoft/api-extractor": ["@microsoft/api-extractor@7.50.1", "", { "dependencies": { "@microsoft/api-extractor-model": "7.30.3", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0", "@rushstack/rig-package": "0.5.3", "@rushstack/terminal": "0.15.0", "@rushstack/ts-command-line": "4.23.5", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", "typescript": "5.7.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-L18vz0ARLNaBLKwWe0DdEf7eijDsb7ERZspgZK7PxclLoQrc+9hJZo8y4OVfCHxNVyxlwVywY2WdE/3pOFViLQ=="], + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.58.7", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.8", "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.23.1", "@rushstack/rig-package": "0.7.3", "@rushstack/terminal": "0.24.0", "@rushstack/ts-command-line": "5.3.9", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw=="], - "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.30.3", "", { "dependencies": { "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", "@rushstack/node-core-library": "5.11.0" } }, "sha512-yEAvq0F78MmStXdqz9TTT4PZ05Xu5R8nqgwI5xmUmQjWBQ9E6R2n8HB/iZMRciG4rf9iwI2mtuQwIzDXBvHn1w=="], + "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.33.8", "", { "dependencies": { "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.23.1" } }, "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w=="], "@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.0", "", {}, "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA=="], @@ -474,63 +476,81 @@ "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.1.3", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ=="], - "@protontech/drive-sdk": ["@protontech/drive-sdk@file:../sdk", { "dependencies": { "@noble/hashes": "^1.8.0", "bcryptjs": "^2.4.3", "ttag": "^1.8.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", "@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.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" } }], + "@protontech/crypto": ["@protontech/crypto@2.0.0", "", { "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" } }, "sha512-D3M023jLq/aMNCSr5p1KVlLV0grAeVXpUh8WyN2HITCIX83KLJo5BReNbVT1mXMvodPf/qYIxUoYXyaEW3pfOQ=="], - "@protontech/pmcrypto": ["@protontech/pmcrypto@8.8.1", "", { "dependencies": { "@noble/hashes": "^1.8.0", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.2.2" } }, "sha512-iYrCgHDL3m+xt9oZD1oSFG73mrQFIfqL3xfRA++O1Zv2PEfsQ1d7SXefHCzG5QZjWKVlHXL8uI7ucl2+yesTpg=="], + "@protontech/drive-sdk": ["@protontech/drive-sdk@file:../sdk", { "dependencies": { "@noble/hashes": "^1.8.0", "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/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.58.1", "@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.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" }, "peerDependencies": { "@protontech/crypto": "^2.0.0" } }], + + "@protontech/global-types": ["@protontech/global-types@1.1.0", "", {}, "sha512-gMqOI4Vpr57wI5Th66D6okSDdNu93dvCc3tXnLQYN4BdZy/nc6mS/ih8yiLaw7L6anSDtoBEiQiadUNNVJVH6g=="], + + "@protontech/pmcrypto": ["@protontech/pmcrypto@8.9.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.3.0" } }, "sha512-XTghHhZ9IR/VagaVYhUPyGNxhId0mKV3sw5+ggMmAYcJ7GIdflF36cc8c5fqfiNb0wCfZseGth+7M1bIJMDIcA=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "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" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], - "@redocly/config": ["@redocly/config@0.20.3", "", {}, "sha512-Nyyv1Bj7GgYwj/l46O0nkH1GTKWbO3Ixe7KFcn021aZipkZd+z8Vlu1BwkhqtVgivcKaClaExtWU/lDHkjBzag=="], + "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], + + "@redocly/openapi-core": ["@redocly/openapi-core@1.34.13", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-4Tm4ysZkexx6ZTX7knqSZTqPlNgIvXc7Ha0pd30I694/GD0KtJE2xrElycfPds0vCLFAqoKyIzBtOF1xrLo8KA=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], - "@redocly/openapi-core": ["@redocly/openapi-core@1.30.0", "", { "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", "colorette": "^1.2.0", "https-proxy-agent": "^7.0.5", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "minimatch": "^5.0.1", "pluralize": "^8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-ZZc+FXKoQXJ9cOR7qRKHxOfKOsGCj2wSodklKdtM2FofzyjzvIwn1rksD5+9iJxvHuORPOPv3ppAHcM+iMr/Ag=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.34.8", "", { "os": "android", "cpu": "arm" }, "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.34.8", "", { "os": "android", "cpu": "arm64" }, "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.34.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.34.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.34.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.34.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.34.8", "", { "os": "linux", "cpu": "arm" }, "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.34.8", "", { "os": "linux", "cpu": "arm" }, "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.34.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.34.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], - "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.34.8", "", { "os": "linux", "cpu": "none" }, "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], - "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.34.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.34.8", "", { "os": "linux", "cpu": "none" }, "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.34.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.34.8", "", { "os": "linux", "cpu": "x64" }, "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.34.8", "", { "os": "linux", "cpu": "x64" }, "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.34.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.34.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.34.8", "", { "os": "win32", "cpu": "x64" }, "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], - "@rushstack/node-core-library": ["@rushstack/node-core-library@5.11.0", "", { "dependencies": { "ajv": "~8.13.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-I8+VzG9A0F3nH2rLpPd7hF8F7l5Xb7D+ldrWVZYegXM6CsKkvWc670RlgK3WX8/AseZfXA/vVrh0bpXe2Y2UDQ=="], + "@rushstack/node-core-library": ["@rushstack/node-core-library@5.23.1", "", { "dependencies": { "ajv": "~8.18.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.7.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA=="], - "@rushstack/rig-package": ["@rushstack/rig-package@0.5.3", "", { "dependencies": { "resolve": "~1.22.1", "strip-json-comments": "~3.1.1" } }, "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow=="], + "@rushstack/problem-matcher": ["@rushstack/problem-matcher@0.2.1", "", { "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog=="], - "@rushstack/terminal": ["@rushstack/terminal@0.15.0", "", { "dependencies": { "@rushstack/node-core-library": "5.11.0", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-vXQPRQ+vJJn4GVqxkwRe+UGgzNxdV8xuJZY2zem46Y0p3tlahucH9/hPmLGj2i9dQnUBFiRnoM9/KW7PYw8F4Q=="], + "@rushstack/rig-package": ["@rushstack/rig-package@0.7.3", "", { "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" } }, "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA=="], - "@rushstack/ts-command-line": ["@rushstack/ts-command-line@4.23.5", "", { "dependencies": { "@rushstack/terminal": "0.15.0", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-jg70HfoK44KfSP3MTiL5rxsZH7X1ktX3cZs9Sl8eDu1/LxJSbPsh0MOFRC710lIuYYSgxWjI5AjbCBAl7u3RxA=="], + "@rushstack/terminal": ["@rushstack/terminal@0.24.0", "", { "dependencies": { "@rushstack/node-core-library": "5.23.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ=="], + + "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.9", "", { "dependencies": { "@rushstack/terminal": "0.24.0", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], @@ -542,39 +562,43 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], + "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@swc/core": ["@swc/core@1.12.9", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.12.9", "@swc/core-darwin-x64": "1.12.9", "@swc/core-linux-arm-gnueabihf": "1.12.9", "@swc/core-linux-arm64-gnu": "1.12.9", "@swc/core-linux-arm64-musl": "1.12.9", "@swc/core-linux-x64-gnu": "1.12.9", "@swc/core-linux-x64-musl": "1.12.9", "@swc/core-win32-arm64-msvc": "1.12.9", "@swc/core-win32-ia32-msvc": "1.12.9", "@swc/core-win32-x64-msvc": "1.12.9" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-O+LfT2JlVMsIMWG9x+rdxg8GzpzeGtCZQfXV7cKc1PjIKUkLFf1QJ7okuseA4f/9vncu37dQ2ZcRrPKy0Ndd5g=="], + "@swc/core": ["@swc/core@1.15.32", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.32", "@swc/core-darwin-x64": "1.15.32", "@swc/core-linux-arm-gnueabihf": "1.15.32", "@swc/core-linux-arm64-gnu": "1.15.32", "@swc/core-linux-arm64-musl": "1.15.32", "@swc/core-linux-ppc64-gnu": "1.15.32", "@swc/core-linux-s390x-gnu": "1.15.32", "@swc/core-linux-x64-gnu": "1.15.32", "@swc/core-linux-x64-musl": "1.15.32", "@swc/core-win32-arm64-msvc": "1.15.32", "@swc/core-win32-ia32-msvc": "1.15.32", "@swc/core-win32-x64-msvc": "1.15.32" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-/eWL0n43D64QWEUHLtTE+jDqjkJhyidjkDhv6f0uJohOUAhywxQ9wXYp845DNNds0JpCdI4Uo0a9bl+vbXf+ew=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.32", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.32", "", { "os": "darwin", "cpu": "x64" }, "sha512-KOTXJXdAhWL+hZ77MYP3z+4pcMFaQhQ74yqyN1uz093q0YnbxpqMtYpPISbYvMHzVRNNx5kN+9RZAXEaadhWVA=="], - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.12.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA=="], + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.32", "", { "os": "linux", "cpu": "arm" }, "sha512-oOoxLweljlc0A4X8ybsgxV7cVaYTwBOg2iMDJcFR3Sr48C+lsv9VzSmqdK/IVIXF4W4GjLc3VqTAdSMXlfVLuQ=="], - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.12.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-hv2kls7Ilkm2EpeJz+I9MCil7pGS3z55ZAgZfxklEuYsxpICycxeH+RNRv4EraggN44ms+FWCjtZFu0LGg2V3g=="], + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA=="], - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.12.9", "", { "os": "linux", "cpu": "arm" }, "sha512-od9tDPiG+wMU9wKtd6y3nYJdNqgDOyLdgRRcrj1/hrbHoUPOM8wZQZdwQYGarw63iLXGgsw7t5HAF9Yc51ilFA=="], + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-omcqjoZP/b8D8PuczVoRwJieC6ibj7qIxTftNYokz4/aSmKFHvsd7nIFfPk5ZvtzncbH4AY7+Dkr/Lp2gWxYeA=="], - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.12.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-6qx1ka9LHcLzxIgn2Mros+CZLkHK2TawlXzi/h7DJeNnzi8F1Hw0Yzjp8WimxNCg6s2n+o3jnmin1oXB7gg8rw=="], + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.32", "", { "os": "linux", "cpu": "ppc64" }, "sha512-KGkTMyz/Tbn3PBNu0AVZ4GTDFKnICrYcTiNPZq8DrvK42pnFsf3GNDrIG9E5AtQlTmC0YigkWKmu0eMcfTrmgA=="], - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.12.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-yghFZWKPVVGbUdqiD7ft23G0JX6YFGDJPz9YbLLAwGuKZ9th3/jlWoQDAw1Naci31LQhVC+oIji6ozihSuwB2A=="], + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.32", "", { "os": "linux", "cpu": "s390x" }, "sha512-G3Aa4tVS/3OGZBkoNIwUF9F6RAy+Osb4GOlo62SinLmDiErz/ykmM7KH0wkz6l9kM8jJq1HyAM6atJTUEbBk7g=="], - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.12.9", "", { "os": "linux", "cpu": "x64" }, "sha512-SFUxyhWLZRNL8QmgGNqdi2Q43PNyFVkRZ2zIif30SOGFSxnxcf2JNeSeBgKIGVgaLSuk6xFVVCtJ3KIeaStgRg=="], + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.32", "", { "os": "linux", "cpu": "x64" }, "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ=="], - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.12.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9FB0wM+6idCGTI20YsBNBg9xSWtkDBymnpaTCsZM3qDc0l4uOpJMqbfWhQvp17x7r/ulZfb2QY8RDvQmCL6AcQ=="], + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.32", "", { "os": "linux", "cpu": "x64" }, "sha512-N4Ggahe/8SUbTX50P6EdhbW9YWcgbZVb52R4cq6MK+zsoMjRq7rGvV5ztA05QnbaCYqMYx8rTY7KAIA3Crdo4Q=="], - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.12.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-zHOusMVbOH9ik5RtRrMiGzLpKwxrPXgXkBm3SbUCa65HAdjV33NZ0/R9Rv1uPESALtEl2tzMYLUxYA5ECFDFhA=="], + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.32", "", { "os": "win32", "cpu": "arm64" }, "sha512-01yN0o9jvo8xBTP12aPK2wW8b41jmOlGbDDlAnoynotc4pO6xA0zby9f1z6j++qXDpGBttLySq1omgVrlQKYcw=="], - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.12.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-aWZf0PqE0ot7tCuhAjRkDFf41AzzSQO0x2xRfTbnhpROp57BRJ/N5eee1VULO/UA2PIJRG7GKQky5bSGBYlFug=="], + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.32", "", { "os": "win32", "cpu": "ia32" }, "sha512-fLagI9XZYNpTcmlqAcp3KBtmj7E19WCmYD80Jxj1Kn5tGNa7yxNLd3NNdWxuZGUPl5iC0/KqZru7g08gF6Fsrw=="], - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.12.9", "", { "os": "win32", "cpu": "x64" }, "sha512-C25fYftXOras3P3anSUeXXIpxmEkdAcsIL9yrr0j1xepTZ/yKwpnQ6g3coj8UXdeJy4GTVlR6+Ow/QiBgZQNOg=="], + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.32", "", { "os": "win32", "cpu": "x64" }, "sha512-gbc2bQ/T2CiR+w0OvcVKwLOFAcPZBvmWmolbwpg1E8UrpeC03DGtyMUApOHNXNYWA3SHFrYXCQtosrcMza1YFg=="], "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], - "@swc/jest": ["@swc/jest@0.2.38", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@swc/counter": "^0.1.3", "jsonc-parser": "^3.2.0" }, "peerDependencies": { "@swc/core": "*" } }, "sha512-HMoZgXWMqChJwffdDjvplH53g9G2ALQes3HKXDEdliB/b85OQ0CTSbxG8VSeCwiAn7cOaDVEt4mwmZvbHcS52w=="], + "@swc/jest": ["@swc/jest@0.2.39", "", { "dependencies": { "@jest/create-cache-key-function": "^30.0.0", "@swc/counter": "^0.1.3", "jsonc-parser": "^3.2.0" }, "peerDependencies": { "@swc/core": "*" } }, "sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA=="], - "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], + "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="], @@ -586,13 +610,11 @@ "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], - - "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -600,7 +622,7 @@ "@types/cookies": ["@types/cookies@0.9.2", "", { "dependencies": { "@types/connect": "*", "@types/express": "*", "@types/keygrip": "*", "@types/node": "*" } }, "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A=="], - "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], @@ -632,7 +654,7 @@ "@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="], - "@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], @@ -642,8 +664,6 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], @@ -654,49 +674,49 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], - "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/typescript-estree": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.55.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.55.0", "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0" } }, "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.55.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.55.0", "", {}, "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.55.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.55.0", "@typescript-eslint/tsconfig-utils": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.55.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.55.0", "", { "dependencies": { "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@volar/language-core": ["@volar/language-core@2.4.11", "", { "dependencies": { "@volar/source-map": "2.4.11" } }, "sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg=="], + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], - "@volar/source-map": ["@volar/source-map@2.4.11", "", {}, "sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ=="], + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], - "@volar/typescript": ["@volar/typescript@2.4.11", "", { "dependencies": { "@volar/language-core": "2.4.11", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw=="], + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.13", "", { "dependencies": { "@babel/parser": "^7.25.3", "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.33", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.33", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.13", "", { "dependencies": { "@vue/compiler-core": "3.5.13", "@vue/shared": "3.5.13" } }, "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.33", "", { "dependencies": { "@vue/compiler-core": "3.5.33", "@vue/shared": "3.5.33" } }, "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA=="], "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], "@vue/language-core": ["@vue/language-core@2.2.0", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^0.4.9", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw=="], - "@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="], + "@vue/shared": ["@vue/shared@3.5.33", "", {}, "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ=="], "@web/dev-server-core": ["@web/dev-server-core@0.7.5", "", { "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" } }, "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw=="], @@ -704,19 +724,17 @@ "@web/parse5-utils": ["@web/parse5-utils@2.1.1", "", { "dependencies": { "@types/parse5": "^6.0.1", "parse5": "^6.0.1" } }, "sha512-7rBVZEMGfrq2iPcAEwJ0KSNSvmA2a6jT2CK8/gyIOHgn4reg7bSSRbzyWIEYWyIkeRoYEukX/aW+nAeCgSSqhQ=="], - "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@6.15.0", "", { "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" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -756,7 +774,7 @@ "babel-preset-const-enum": ["babel-preset-const-enum@1.0.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "babel-plugin-const-enum": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DHfcv3mkgIqPaFODzig3Esb91cCqZlnImSl7VAwJDtIsqJvx4H08kpl051um68gjqnAXg5up5nnn6NK+Cq0blA=="], - "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "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" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="], + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "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 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], @@ -764,15 +782,15 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], - "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], @@ -780,14 +798,12 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "cache-content-type": ["cache-content-type@1.0.1", "", { "dependencies": { "mime-types": "^2.1.18", "ylru": "^1.2.0" } }, "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA=="], - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -796,7 +812,7 @@ "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001726", "", {}, "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -822,7 +838,7 @@ "co-body": ["co-body@5.2.0", "", { "dependencies": { "inflation": "^2.0.0", "qs": "^6.4.0", "raw-body": "^2.2.0", "type-is": "^1.6.14" } }, "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ=="], - "collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="], + "collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -830,11 +846,13 @@ "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], @@ -844,6 +862,8 @@ "cookies": ["cookies@0.9.1", "", { "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" } }, "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], "cosmiconfig": ["cosmiconfig@6.0.0", "", { "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" } }, "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg=="], @@ -852,11 +872,9 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], @@ -868,8 +886,6 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -880,6 +896,8 @@ "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], @@ -888,7 +906,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.179", "", {}, "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], @@ -900,7 +918,7 @@ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -920,6 +938,8 @@ "eslint": ["eslint@8.57.1", "", { "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" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], + "eslint-plugin-simple-import-sort": ["eslint-plugin-simple-import-sort@13.0.0", "", { "peerDependencies": { "eslint": ">=5.0.0" } }, "sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA=="], + "eslint-plugin-tsdoc": ["eslint-plugin-tsdoc@0.3.0", "", { "dependencies": { "@microsoft/tsdoc": "0.15.0", "@microsoft/tsdoc-config": "0.17.0" } }, "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A=="], "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], @@ -930,7 +950,7 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -952,15 +972,17 @@ "expect": ["expect@29.7.0", "", { "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" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], - "fast-glob": ["fast-glob@3.3.3", "", { "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" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], @@ -972,11 +994,9 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], - "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "foreachasync": ["foreachasync@3.0.0", "", {}, "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="], @@ -986,7 +1006,7 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], @@ -1026,13 +1046,11 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], @@ -1062,7 +1080,7 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "index-to-position": ["index-to-position@0.1.2", "", {}, "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], "inflation": ["inflation@2.1.0", "", {}, "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ=="], @@ -1074,8 +1092,6 @@ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1094,9 +1110,7 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], @@ -1110,7 +1124,7 @@ "istanbul-lib-source-maps": ["istanbul-lib-source-maps@4.0.1", "", { "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", "source-map": "^0.6.1" } }, "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw=="], - "istanbul-reports": ["istanbul-reports@3.1.7", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g=="], + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], @@ -1172,7 +1186,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1184,24 +1198,18 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], - - "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "keygrip": ["keygrip@1.1.0", "", { "dependencies": { "tsscmp": "1.0.6" } }, "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], - "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "koa": ["koa@2.16.4", "", { "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" } }, "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw=="], @@ -1232,12 +1240,10 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.mapvalues": ["lodash.mapvalues@4.6.0", "", {}, "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ=="], @@ -1246,11 +1252,11 @@ "log-symbols": ["log-symbols@1.0.2", "", { "dependencies": { "chalk": "^1.0.0" } }, "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lru-cache": ["lru-cache@8.0.5", "", {}, "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA=="], "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], - "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], @@ -1266,8 +1272,6 @@ "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1278,7 +1282,7 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1286,13 +1290,13 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1302,7 +1306,7 @@ "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], - "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1310,8 +1314,6 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1320,11 +1322,11 @@ "only": ["only@0.0.2", "", {}, "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="], - "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], + "open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], - "openapi-typescript": ["openapi-typescript@7.6.1", "", { "dependencies": { "@redocly/openapi-core": "^1.28.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" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag=="], + "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], - "openpgp": ["@protontech/openpgp@6.2.2", "", {}, "sha512-gfpd+TF+Qdeu9A1P55Tl50dvZlrhQrYDgsLJ1E8mokszkMFks6I20nn7jWu3tmD5BqAtJ2yynA3077tNRmcnYg=="], + "openpgp": ["@protontech/openpgp@6.3.1-0", "", {}, "sha512-TA4wAE86bxYKxrULnnv8Uepu9hKi0e0M/zn4cwiDdgVhEzsqhaKnn3uRSY8Ba9wNcD6gSVALMlUDXdd94k85rA=="], "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -1342,14 +1344,12 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@8.1.0", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "index-to-position": "^0.1.2", "type-fest": "^4.7.1" } }, "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="], - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1370,23 +1370,23 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], - "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "plural-forms": ["plural-forms@0.5.5", "", {}, "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], @@ -1404,6 +1404,8 @@ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -1434,7 +1436,7 @@ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], @@ -1450,7 +1452,7 @@ "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "rollup": ["rollup@4.34.8", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.34.8", "@rollup/rollup-android-arm64": "4.34.8", "@rollup/rollup-darwin-arm64": "4.34.8", "@rollup/rollup-darwin-x64": "4.34.8", "@rollup/rollup-freebsd-arm64": "4.34.8", "@rollup/rollup-freebsd-x64": "4.34.8", "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", "@rollup/rollup-linux-arm-musleabihf": "4.34.8", "@rollup/rollup-linux-arm64-gnu": "4.34.8", "@rollup/rollup-linux-arm64-musl": "4.34.8", "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", "@rollup/rollup-linux-riscv64-gnu": "4.34.8", "@rollup/rollup-linux-s390x-gnu": "4.34.8", "@rollup/rollup-linux-x64-gnu": "4.34.8", "@rollup/rollup-linux-x64-musl": "4.34.8", "@rollup/rollup-win32-arm64-msvc": "4.34.8", "@rollup/rollup-win32-ia32-msvc": "4.34.8", "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1460,14 +1462,12 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "serialize-javascript": ["serialize-javascript@4.0.0", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1488,7 +1488,7 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1518,7 +1518,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "supports-color": ["supports-color@9.4.0", "", {}, "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1528,9 +1528,9 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], @@ -1540,13 +1540,13 @@ "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], - "ttag": ["ttag@1.8.7", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA=="], + "ttag": ["ttag@1.8.12", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-bg8IqDOY1MtJrWmBiY+Frl8T5dhDJ00du4jIaDh/Uwbo+UPyNPvBJcgfin/W+yTMEKLEs4Pve8YaygzJCMuvOg=="], "ttag-cli": ["ttag-cli@1.11.2", "", { "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" } }, "sha512-unLFfw4ZxRcEjO7rqwESwiuchtd8BdzyvnGVDtqGLUvdZFvm2zLu311aBcTNS31pwKt5JMQB03G3Or3LlEe4/g=="], @@ -1566,9 +1566,9 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="], + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], @@ -1582,7 +1582,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1592,9 +1592,9 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - "vite-plugin-dts": ["vite-plugin-dts@4.5.0", "", { "dependencies": { "@microsoft/api-extractor": "^7.49.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^0.5.1", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-M1lrPTdi7gilLYRZoLmGYnl4fbPryVYsehPN9JgaxjJKTs8/f7tuAlvCCvOLB5gRDQTTKnptBcB0ACsaw2wNLw=="], + "vite-plugin-dts": ["vite-plugin-dts@4.5.4", "", { "dependencies": { "@microsoft/api-extractor": "^7.50.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], @@ -1624,9 +1624,9 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yallist": ["yallist@2.1.2", "", {}, "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], @@ -1638,323 +1638,133 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-define-polyfill-provider/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-define-polyfill-provider/resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/helper-wrap-function/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-wrap-function/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/helper-wrap-function/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-proposal-optional-chaining/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-async-generators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-bigint/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-class-properties/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-class-static-block/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-import-meta/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-json-strings/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-jsx/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-logical-assignment-operators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-nullish-coalescing-operator/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-numeric-separator/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-object-rest-spread/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-optional-catch-binding/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-optional-chaining/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-private-property-in-object/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-top-level-await/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-syntax-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/plugin-transform-classes/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-computed-properties/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-destructuring/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/plugin-transform-function-name/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/plugin-transform-modules-systemjs/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-react-jsx/@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - - "@babel/plugin-transform-react-jsx/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-typescript/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - - "@babel/preset-env/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/preset-env/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/preset-modules/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/preset-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "@babel/template/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - - "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - - "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "@istanbuljs/load-nyc-config/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "@jest/console/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/create-cache-key-function/@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], - "@jest/core/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], "@jest/reporters/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "@jest/reporters/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@microsoft/api-extractor/@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], - "@jest/test-sequencer/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@microsoft/api-extractor/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg=="], - "@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@microsoft/api-extractor/minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], - "@microsoft/api-extractor/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], - "@microsoft/api-extractor/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg=="], - "@microsoft/api-extractor/minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="], + "@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], - "@microsoft/api-extractor/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@protontech/crypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@microsoft/api-extractor/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "@protontech/crypto/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], - "@microsoft/api-extractor-model/@microsoft/tsdoc": ["@microsoft/tsdoc@0.15.1", "", {}, "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="], + "@protontech/drive-sdk/@protontech/crypto": ["@protontech/crypto@2.0.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@openpgp/web-stream-tools": "~0.3.1", "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" } }, "sha512-PMvQi1L98eU9tCVNvoLIEq21Jwfs2a4LxJwoEVbEIhvZVVprCf4lRrwqce2HhPmyjQuQJDcSlqbma+o++G2LEw=="], - "@microsoft/api-extractor-model/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@protontech/pmcrypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "@protontech/pmcrypto/openpgp": ["@protontech/openpgp@6.3.0", "", {}, "sha512-30dFyC2OUSlA8p5EYv+57pANFE9sZ71Koh24i69WrEmqGTsNIhPZmbCMhmLETdd6ZBMnXItYmumWY+CxKoYh+w=="], "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "@rushstack/node-core-library/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], - - "@rushstack/node-core-library/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], - - "@rushstack/node-core-library/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@rushstack/node-core-library/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], - - "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.1" } }, "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg=="], - - "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.35.1", "", {}, "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ=="], - - "@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.1", "@typescript-eslint/tsconfig-utils": "8.35.1", "@typescript-eslint/types": "8.35.1", "@typescript-eslint/visitor-keys": "8.35.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.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.1", "", { "dependencies": { "@typescript-eslint/types": "8.35.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@vue/language-core/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], - - "@vue/language-core/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@web/dev-server-core/@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], - - "@web/dev-server-core/lru-cache": ["lru-cache@8.0.5", "", {}, "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA=="], - - "@web/dev-server-core/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@web/dev-server-core/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@web/dev-server-esbuild/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "ajv-draft-04/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], - - "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "babel-plugin-const-enum/@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - - "babel-plugin-const-enum/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "babel-plugin-istanbul/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "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" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], - "babel-plugin-polyfill-corejs2/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "babel-plugin-ttag/ajv": ["ajv@6.12.3", "", { "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" } }, "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA=="], "babel-plugin-ttag/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "babel-preset-current-node-syntax/@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "core-js-compat/browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], - "eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "has-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], "http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], - "istanbul-lib-instrument/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], - "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-circus/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "jest-config/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "jest-config/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "jest-config/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "jest-resolve/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "jest-runtime/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "jest-runtime/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - - "jest-snapshot/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], - - "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "koa/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "koa-router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "koa-send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "koa-static/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "log-symbols/chalk": ["chalk@1.1.3", "", { "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" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], - "make-dir/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "ora/chalk": ["chalk@1.1.3", "", { "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" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], - "parse-json/type-fest": ["type-fest@4.35.0", "", {}, "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], @@ -1974,301 +1784,55 @@ "rimraf/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "test-exclude/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "ttag-cli/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "ttag-cli/cross-spawn": ["cross-spawn@5.1.0", "", { "dependencies": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A=="], "ttag-cli/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "ttag-cli/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="], - "ttag-cli/plural-forms": ["plural-forms@0.5.3", "", {}, "sha512-t/hkjsTeDwaK9n/z6tUiSHySTC8sPnTiS5YF3Y5p4L+eomzXh7O0vEemkjwb68/82w0Rjw4uED3X84X7vXf9lg=="], - "ttag-cli/tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], - "ttag-cli/yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "typedoc/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "typedoc/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/helper-replace-supers/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-wrap-function/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-wrap-function/@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-wrap-function/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/helper-wrap-function/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-wrap-function/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/helper-wrap-function/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-wrap-function/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-classes/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-function-name/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-modules-systemjs/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/plugin-transform-react-jsx/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/preset-env/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/preset-modules/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "@jest/create-cache-key-function/@jest/types/@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], - "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@microsoft/api-extractor/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.1", "@typescript-eslint/types": "^8.35.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q=="], + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ=="], + "@protontech/drive-sdk/@protontech/crypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@protontech/drive-sdk/@protontech/crypto/@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.3.1", "", { "peerDependencies": { "@types/node": ">=18.0.0", "typescript": ">=4.7" }, "optionalPeers": ["@types/node", "typescript"] }, "sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "@protontech/drive-sdk/@protontech/crypto/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], - "@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@web/dev-server-esbuild/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -2316,34 +1880,10 @@ "@web/dev-server-esbuild/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "babel-plugin-const-enum/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "babel-plugin-const-enum/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "babel-preset-current-node-syntax/@babel/plugin-syntax-import-attributes/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], - - "core-js-compat/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], - - "core-js-compat/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], - - "core-js-compat/browserslist/node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], - - "core-js-compat/browserslist/update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "log-symbols/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], @@ -2354,6 +1894,8 @@ "log-symbols/chalk/supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "ora/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -2386,8 +1928,6 @@ "ttag-cli/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], - "ttag-cli/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="], - "ttag-cli/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "ttag-cli/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -2398,133 +1938,17 @@ "typedoc/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-define-polyfill-provider/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-remap-async-to-generator/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-wrap-function/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-wrap-function/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-bugfix-firefox-class-in-computed-class-key/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-async-generator-functions/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-classes/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-classes/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-computed-properties/@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-function-name/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-function-name/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-modules-amd/@babel/helper-module-transforms/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/plugin-transform-modules-systemjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@babel/plugin-transform-object-rest-spread/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-object-rest-spread/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/preset-env/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@jest/create-cache-key-function/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + "@microsoft/api-extractor-model/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@microsoft/api-extractor/@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "@microsoft/api-extractor/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "babel-plugin-const-enum/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2538,8 +1962,6 @@ "ttag-cli/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "ttag-cli/cross-spawn/lru-cache/yallist": ["yallist@2.1.2", "", {}, "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="], - "ttag-cli/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], "ttag-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2550,10 +1972,6 @@ "typedoc/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@babel/plugin-transform-async-to-generator/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/plugin-transform-react-jsx/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/js/cli/jest.config.js b/js/cli/jest.config.js index 34c8359c..2e86daa9 100644 --- a/js/cli/jest.config.js +++ b/js/cli/jest.config.js @@ -13,8 +13,12 @@ module.exports = { '^@openpgp/web-stream-tools$': '/node_modules/@openpgp/web-stream-tools/lib/index.js', '^@protontech/drive-sdk$': '/../sdk/src/index.ts', '^@protontech/drive-sdk/(.*)$': '/../sdk/src/$1', + // this rewrite affects pmcrypto and it's also covered by a bun patch, but in the CI integration tests + // npm is used instead of bun for now, hence the patch is not applied, and we also manually apply + // the change this way instead + '^openpgp/lightweight$': 'openpgp' }, reporters: ['default'], testEnvironment: 'node', - setupFilesAfterEnv: ['/jest.setup.js'], + setupFiles: ['/src/tests/polyfill.ts'] }; diff --git a/js/cli/package.json b/js/cli/package.json index 6f154126..a61e2c5e 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -7,10 +7,9 @@ "build": "bun ./scripts/build-cli.mjs", "build:internal": "bun ./scripts/build-cli.mjs internal", "check-types": "tsc --noEmit", - "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/account/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/account/api-auth-types.ts", + "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/api/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/api/api-auth-types.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", - "postinstall": "patch-package", "test": "jest src", "test:watch": "jest src --watch --coverage=false", "test:integration": "jest integration-tests" @@ -18,17 +17,16 @@ "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bcryptjs": "^2.4.6", "@types/bun": "^1.2.18", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", "@typescript-eslint/eslint-plugin": "^8.55.0", "bun-types": "^1.1.31", "eslint": "^8.57.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", "openapi-typescript": "^7.4.1", - "patch-package": "^8.0.0", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^5.4.8", @@ -36,6 +34,7 @@ }, "dependencies": { "@noble/hashes": "^1.8.0", + "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", "@protontech/pmcrypto": "^8.8.1", "bcryptjs": "^2.4.3", @@ -44,6 +43,6 @@ "ttag": "^1.8.7" }, "patchedDependencies": { - "@protontech/pmcrypto@8.8.1": "patches/@protontech%2Fpmcrypto@8.8.1.patch" + "@protontech/crypto@2.0.0": "patches/@protontech%2Fcrypto@2.0.0.patch" } } diff --git a/js/cli/patches/@protontech%2Fcrypto@2.0.0.patch b/js/cli/patches/@protontech%2Fcrypto@2.0.0.patch new file mode 100644 index 00000000..d37647ad --- /dev/null +++ b/js/cli/patches/@protontech%2Fcrypto@2.0.0.patch @@ -0,0 +1,29 @@ +diff --git a/src/pmcrypto/openpgp.ts b/src/pmcrypto/openpgp.ts +index 923c953e9ba2881fd52e976a593bf66208159565..d2b4360fdfeb3728ff77a6d66b1103b6315683fe 100644 +--- a/src/pmcrypto/openpgp.ts ++++ b/src/pmcrypto/openpgp.ts +@@ -1,4 +1,4 @@ +-import { config } from "openpgp/lightweight"; ++import { config } from "openpgp"; + + export const setConfig = () => { + config.s2kIterationCountByte = 255; +@@ -34,4 +34,4 @@ export const setConfig = () => { + config.maxArgon2MemoryExponent = 20; + }; + +-export * from "openpgp/lightweight"; ++export * from "openpgp"; +diff --git a/src/pmcrypto/pmcrypto.d.ts b/src/pmcrypto/pmcrypto.d.ts +index 82052deea448f3d6f17561335fcc2a2a0ffc6062..1da4e0bcc457450727af920106f5b94440fd90cc 100644 +--- a/src/pmcrypto/pmcrypto.d.ts ++++ b/src/pmcrypto/pmcrypto.d.ts +@@ -23,7 +23,7 @@ import { + readPrivateKey, + type PartialConfig, + type AlgorithmInfo, +-} from "openpgp/lightweight"; ++} from "openpgp"; + + import { VERIFICATION_STATUS, SIGNATURE_TYPES } from "./constants.ts"; + import type { diff --git a/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch b/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch deleted file mode 100644 index be491afd..00000000 --- a/js/cli/patches/@protontech%2Fpmcrypto@8.8.1.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/node_modules/@protontech/pmcrypto/.bun-tag-510b298809627b34 b/.bun-tag-510b298809627b34 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/node_modules/@protontech/pmcrypto/.bun-tag-bbd8917749136bea b/.bun-tag-bbd8917749136bea -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/lib/openpgp.ts b/lib/openpgp.ts -index 134ae5bae14dc0c9241f7122f6f5d033a9e9ca60..3b2b952b0da0168112b4cc16fad9dacd72544e16 100644 ---- a/lib/openpgp.ts -+++ b/lib/openpgp.ts -@@ -1,4 +1,4 @@ --import { config } from 'openpgp/lightweight'; -+import { config } from 'openpgp'; - - export const setConfig = () => { - config.s2kIterationCountByte = 255; -@@ -31,4 +31,4 @@ export const setConfig = () => { - config.enableParsingV5Entities = true; - }; - --export * from 'openpgp/lightweight'; -+export * from 'openpgp'; -diff --git a/lib/pmcrypto.d.ts b/lib/pmcrypto.d.ts -index 49868bbe9f0b8e4e254c18bfb0992bab47deb54c..dd8e98695375e4d9b90e98a0bc0519e28c456089 100644 ---- a/lib/pmcrypto.d.ts -+++ b/lib/pmcrypto.d.ts -@@ -19,7 +19,7 @@ import { - readKey, readKeys, readPrivateKey, readPrivateKeys, - type PartialConfig, - type AlgorithmInfo --} from 'openpgp/lightweight'; -+} from 'openpgp'; - - import { VERIFICATION_STATUS, SIGNATURE_TYPES } from './constants'; - import type { ContextSigningOptions, ContextVerificationOptions } from './message/context'; diff --git a/js/cli/patches/@protontech+pmcrypto+8.8.1.patch b/js/cli/patches/@protontech+pmcrypto+8.8.1.patch deleted file mode 100644 index 91c902da..00000000 --- a/js/cli/patches/@protontech+pmcrypto+8.8.1.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/@protontech/pmcrypto/lib/openpgp.ts b/node_modules/@protontech/pmcrypto/lib/openpgp.ts -index 134ae5b..3b2b952 100644 ---- a/node_modules/@protontech/pmcrypto/lib/openpgp.ts -+++ b/node_modules/@protontech/pmcrypto/lib/openpgp.ts -@@ -1,4 +1,4 @@ --import { config } from 'openpgp/lightweight'; -+import { config } from 'openpgp'; - - export const setConfig = () => { - config.s2kIterationCountByte = 255; -@@ -31,4 +31,4 @@ export const setConfig = () => { - config.enableParsingV5Entities = true; - }; - --export * from 'openpgp/lightweight'; -+export * from 'openpgp'; -diff --git a/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts b/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts -index 49868bb..dd8e986 100644 ---- a/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts -+++ b/node_modules/@protontech/pmcrypto/lib/pmcrypto.d.ts -@@ -19,7 +19,7 @@ import { - readKey, readKeys, readPrivateKey, readPrivateKeys, - type PartialConfig, - type AlgorithmInfo --} from 'openpgp/lightweight'; -+} from 'openpgp'; - - import { VERIFICATION_STATUS, SIGNATURE_TYPES } from './constants'; - import type { ContextSigningOptions, ContextVerificationOptions } from './message/context'; diff --git a/js/cli/src/api/addresses.ts b/js/cli/src/api/addresses.ts index 991b8dbd..761cc668 100644 --- a/js/cli/src/api/addresses.ts +++ b/js/cli/src/api/addresses.ts @@ -1,10 +1,9 @@ -import { ProtonDriveAccountAddress, Logger } from '@protontech/drive-sdk'; +import { CryptoProxy, PrivateKeyReference, PublicKeyReference, VERIFICATION_STATUS } from '@protontech/crypto'; +import { Logger, ProtonDriveAccountAddress } from '@protontech/drive-sdk'; import { Credentials } from '../credentials'; -import { PrivateKeyReference, PublicKeyReference, VERIFICATION_STATUS } from '../crypto/lib'; -import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; -import type { components as coreComponents } from './api-core-types'; import { AccountApi, AddressNotFoundError } from './accountApi'; +import type { components as coreComponents } from './api-core-types'; interface UserData { userPrimaryPrivateKeys: PrivateKeyReference[]; @@ -27,7 +26,6 @@ export class Addresses { > = new Map(); constructor( - private readonly cryptoApi: CryptoApiInterface, private readonly accountApi: AccountApi, private readonly credentials: Credentials, private readonly logger: Logger, @@ -155,7 +153,7 @@ export class Addresses { const publicKeys = await Promise.all( (response.Address?.Keys || []).map((key) => { - return this.cryptoApi.importPublicKey({ armoredKey: key.PublicKey }); + return CryptoProxy.importPublicKey({ armoredKey: key.PublicKey }); }), ); resolve(publicKeys); @@ -195,13 +193,13 @@ export class Addresses { const userPrimaryPublicKeys: PublicKeyReference[] = []; for (const userKey of userKeys || []) { try { - const userPrimaryPrivateKey = await this.cryptoApi.importPrivateKey({ + const userPrimaryPrivateKey = await CryptoProxy.importPrivateKey({ armoredKey: userKey?.PrivateKey, passphrase: userKeyPassword, }); - const userPrimaryPublicKey = await this.cryptoApi.importPublicKey({ - binaryKey: await this.cryptoApi.exportPublicKey({ + const userPrimaryPublicKey = await CryptoProxy.importPublicKey({ + binaryKey: await CryptoProxy.exportPublicKey({ key: userPrimaryPrivateKey, format: 'binary', }), @@ -275,7 +273,7 @@ export class Addresses { throw new Error('User key password is not set'); } - const privateKey = await this.cryptoApi.importPrivateKey({ + const privateKey = await CryptoProxy.importPrivateKey({ armoredKey: key?.PrivateKey, passphrase: userKeyPassword, }); @@ -283,14 +281,14 @@ export class Addresses { resolve({ id: keyId, privateKey, - publicKey: await this.cryptoApi.importPublicKey({ - binaryKey: await this.cryptoApi.exportPublicKey({ key: privateKey, format: 'binary' }), + publicKey: await CryptoProxy.importPublicKey({ + binaryKey: await CryptoProxy.exportPublicKey({ key: privateKey, format: 'binary' }), }), }); return promise; } - const { data: decryptedToken, verificationStatus } = await this.cryptoApi.decryptMessage({ + const { data: decryptedToken, verificationStatus } = await CryptoProxy.decryptMessage({ armoredMessage: key?.Token || '', armoredSignature: key?.Signature || '', decryptionKeys: userPrimaryPrivateKeys, @@ -301,12 +299,12 @@ export class Addresses { throw new Error('Failed to verify address key'); } - const privateKey = await this.cryptoApi.importPrivateKey({ + const privateKey = await CryptoProxy.importPrivateKey({ armoredKey: key?.PrivateKey || '', passphrase: decryptedToken.toString(), }); - const publicKey = await this.cryptoApi.importPublicKey({ - binaryKey: await this.cryptoApi.exportPublicKey({ key: privateKey, format: 'binary' }), + const publicKey = await CryptoProxy.importPublicKey({ + binaryKey: await CryptoProxy.exportPublicKey({ key: privateKey, format: 'binary' }), }); resolve({ diff --git a/js/cli/src/api/api-auth-types.ts b/js/cli/src/api/api-auth-types.ts index 5405ec0f..f1065953 100644 --- a/js/cli/src/api/api-auth-types.ts +++ b/js/cli/src/api/api-auth-types.ts @@ -4,7 +4,7 @@ */ export interface paths { - '/auth/{_version}/devices/{deviceId}': { + "/auth/{_version}/devices/{deviceId}": { parameters: { query?: never; header?: never; @@ -13,14 +13,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_auth-{_version}-devices-{deviceId}']; - delete: operations['delete_auth-{_version}-devices-{deviceId}']; + post: operations["post_auth-{_version}-devices-{deviceId}"]; + delete: operations["delete_auth-{_version}-devices-{deviceId}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/devices/{deviceId}/associate': { + "/auth/{_version}/devices/{deviceId}/associate": { parameters: { query?: never; header?: never; @@ -29,30 +29,30 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_auth-{_version}-devices-{deviceId}-associate']; + post: operations["post_auth-{_version}-devices-{deviceId}-associate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/devices': { + "/auth/{_version}/devices": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_auth-{_version}-devices']; + get: operations["get_auth-{_version}-devices"]; put?: never; - post: operations['post_auth-{_version}-devices']; - delete: operations['delete_auth-{_version}-devices']; + post: operations["post_auth-{_version}-devices"]; + delete: operations["delete_auth-{_version}-devices"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/devices/{deviceId}/admin': { + "/auth/{_version}/devices/{deviceId}/admin": { parameters: { query?: never; header?: never; @@ -60,7 +60,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_auth-{_version}-devices-{deviceId}-admin']; + put: operations["put_auth-{_version}-devices-{deviceId}-admin"]; post?: never; delete?: never; options?: never; @@ -68,7 +68,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/devices/{deviceId}/reject': { + "/auth/{_version}/devices/{deviceId}/reject": { parameters: { query?: never; header?: never; @@ -76,7 +76,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_auth-{_version}-devices-{deviceId}-reject']; + put: operations["put_auth-{_version}-devices-{deviceId}-reject"]; post?: never; delete?: never; options?: never; @@ -84,14 +84,14 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/sessions/uuid': { + "/auth/{_version}/sessions/uuid": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_auth-{_version}-sessions-uuid']; + get: operations["get_auth-{_version}-sessions-uuid"]; put?: never; post?: never; delete?: never; @@ -100,23 +100,23 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/sessions/forks': { + "/auth/{_version}/sessions/forks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_auth-{_version}-sessions-forks']; + get: operations["get_auth-{_version}-sessions-forks"]; put?: never; - post: operations['post_auth-{_version}-sessions-forks']; + post: operations["post_auth-{_version}-sessions-forks"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sessions/forks/{selector}': { + "/auth/{_version}/sessions/forks/{selector}": { parameters: { query?: never; header?: never; @@ -127,20 +127,20 @@ export interface paths { * Get a forked session. * @description Obtains a child session, and can only be used once per parent fork call */ - get: operations['get_auth-{_version}-sessions-forks-{selector}']; + get: operations["get_auth-{_version}-sessions-forks-{selector}"]; put?: never; post?: never; /** * Invalidate the selector. * @description Invalidates the forking selector, without forking the session. */ - delete: operations['delete_auth-{_version}-sessions-forks-{selector}']; + delete: operations["delete_auth-{_version}-sessions-forks-{selector}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sessions': { + "/auth/{_version}/sessions": { parameters: { query?: never; header?: never; @@ -148,18 +148,18 @@ export interface paths { cookie?: never; }; /** Get active sessions. */ - get: operations['get_auth-{_version}-sessions']; + get: operations["get_auth-{_version}-sessions"]; put?: never; /** Issues a new session (and related UID) */ - post: operations['post_auth-{_version}-sessions']; + post: operations["post_auth-{_version}-sessions"]; /** Revoke all other access tokens, locked. */ - delete: operations['delete_auth-{_version}-sessions']; + delete: operations["delete_auth-{_version}-sessions"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sessions/payload': { + "/auth/{_version}/sessions/payload": { parameters: { query?: never; header?: never; @@ -173,14 +173,14 @@ export interface paths { * @description Most clients should submit fingerprints synchronously during session creation. * Used by web client, b/c we don't want to slow down loading of the app by fingerprinting. */ - post: operations['post_auth-{_version}-sessions-payload']; + post: operations["post_auth-{_version}-sessions-payload"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sessions/{uid}': { + "/auth/{_version}/sessions/{uid}": { parameters: { query?: never; header?: never; @@ -191,13 +191,13 @@ export interface paths { put?: never; post?: never; /** Revoke a session by UID, locked. */ - delete: operations['delete_auth-{_version}-sessions-{uid}']; + delete: operations["delete_auth-{_version}-sessions-{uid}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sessions/local/key': { + "/auth/{_version}/sessions/local/key": { parameters: { query?: never; header?: never; @@ -208,9 +208,9 @@ export interface paths { * Get the local storage key. * @description Use to encrypt the local storage data */ - get: operations['get_auth-{_version}-sessions-local-key']; + get: operations["get_auth-{_version}-sessions-local-key"]; /** Set or Update the local storage key. */ - put: operations['put_auth-{_version}-sessions-local-key']; + put: operations["put_auth-{_version}-sessions-local-key"]; post?: never; delete?: never; options?: never; @@ -218,7 +218,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/sessions/local': { + "/auth/{_version}/sessions/local": { parameters: { query?: never; header?: never; @@ -232,7 +232,7 @@ export interface paths { * - The session does not require cookies; * - The client provides a valid access cookie for it (expired is also OK). */ - get: operations['get_auth-{_version}-sessions-local']; + get: operations["get_auth-{_version}-sessions-local"]; put?: never; post?: never; delete?: never; @@ -241,7 +241,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/mnemonic/validate': { + "/auth/{_version}/mnemonic/validate": { parameters: { query?: never; header?: never; @@ -249,23 +249,23 @@ export interface paths { cookie?: never; }; /** Validate mnemonic */ - get: operations['get_auth-{_version}-mnemonic-validate']; + get: operations["get_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - put: operations['put_auth-{_version}-mnemonic-validate']; + put: operations["put_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - post: operations['post_auth-{_version}-mnemonic-validate']; + post: operations["post_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - delete: operations['delete_auth-{_version}-mnemonic-validate']; + delete: operations["delete_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - options: operations['options_auth-{_version}-mnemonic-validate']; + options: operations["options_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - head: operations['head_auth-{_version}-mnemonic-validate']; + head: operations["head_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - patch: operations['patch_auth-{_version}-mnemonic-validate']; + patch: operations["patch_auth-{_version}-mnemonic-validate"]; /** Validate mnemonic */ - trace: operations['trace_auth-{_version}-mnemonic-validate']; + trace: operations["trace_auth-{_version}-mnemonic-validate"]; }; - '/auth/{_version}/info': { + "/auth/{_version}/info": { parameters: { query?: never; header?: never; @@ -275,14 +275,14 @@ export interface paths { get?: never; put?: never; /** Set up SRP authentication request. */ - post: operations['post_auth-{_version}-info']; + post: operations["post_auth-{_version}-info"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/sso/{token}': { + "/auth/{_version}/sso/{token}": { parameters: { query?: never; header?: never; @@ -290,7 +290,7 @@ export interface paths { cookie?: never; }; /** Initiate SSO flow using token from POST /auth/info */ - get: operations['get_auth-{_version}-sso-{token}']; + get: operations["get_auth-{_version}-sso-{token}"]; put?: never; post?: never; delete?: never; @@ -299,7 +299,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/saml': { + "/auth/{_version}/saml": { parameters: { query?: never; header?: never; @@ -309,14 +309,14 @@ export interface paths { get?: never; put?: never; /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ - post: operations['post_auth-{_version}-saml']; + post: operations["post_auth-{_version}-saml"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}': { + "/auth/{_version}": { parameters: { query?: never; header?: never; @@ -326,15 +326,15 @@ export interface paths { get?: never; put?: never; /** Authenticate. */ - post: operations['post_auth-{_version}']; + post: operations["post_auth-{_version}"]; /** Revoke a token. */ - delete: operations['delete_auth-{_version}']; + delete: operations["delete_auth-{_version}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/jwt': { + "/auth/{_version}/jwt": { parameters: { query?: never; header?: never; @@ -344,14 +344,14 @@ export interface paths { get?: never; put?: never; /** Authenticate using pre-issued JWT. */ - post: operations['post_auth-{_version}-jwt']; + post: operations["post_auth-{_version}-jwt"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/2fa': { + "/auth/{_version}/2fa": { parameters: { query?: never; header?: never; @@ -361,14 +361,14 @@ export interface paths { get?: never; put?: never; /** Submit second factor. */ - post: operations['post_auth-{_version}-2fa']; + post: operations["post_auth-{_version}-2fa"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/modulus': { + "/auth/{_version}/modulus": { parameters: { query?: never; header?: never; @@ -376,7 +376,7 @@ export interface paths { cookie?: never; }; /** Get random SRP modulus. */ - get: operations['get_auth-{_version}-modulus']; + get: operations["get_auth-{_version}-modulus"]; put?: never; post?: never; delete?: never; @@ -385,7 +385,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/scopes': { + "/auth/{_version}/scopes": { parameters: { query?: never; header?: never; @@ -396,7 +396,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_auth-{_version}-scopes']; + get: operations["get_auth-{_version}-scopes"]; put?: never; post?: never; delete?: never; @@ -405,7 +405,7 @@ export interface paths { patch?: never; trace?: never; }; - '/auth/{_version}/refresh': { + "/auth/{_version}/refresh": { parameters: { query?: never; header?: never; @@ -429,14 +429,14 @@ export interface paths { * } * ``` */ - post: operations['post_auth-{_version}-refresh']; + post: operations["post_auth-{_version}-refresh"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/cookies': { + "/auth/{_version}/cookies": { parameters: { query?: never; header?: never; @@ -451,14 +451,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_auth-{_version}-cookies']; + post: operations["post_auth-{_version}-cookies"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/credentialless': { + "/auth/{_version}/credentialless": { parameters: { query?: never; header?: never; @@ -468,14 +468,14 @@ export interface paths { get?: never; put?: never; /** Create and authenticate a credential-less user. */ - post: operations['post_auth-{_version}-credentialless']; + post: operations["post_auth-{_version}-credentialless"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/mnemonic/info': { + "/auth/{_version}/mnemonic/info": { parameters: { query?: never; header?: never; @@ -485,14 +485,14 @@ export interface paths { get?: never; put?: never; /** Set up Mnemonic SRP authentication request. */ - post: operations['post_auth-{_version}-mnemonic-info']; + post: operations["post_auth-{_version}-mnemonic-info"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/mnemonic': { + "/auth/{_version}/mnemonic": { parameters: { query?: never; header?: never; @@ -502,14 +502,14 @@ export interface paths { get?: never; put?: never; /** Authenticate with mnemonic. */ - post: operations['post_auth-{_version}-mnemonic']; + post: operations["post_auth-{_version}-mnemonic"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/auth/{_version}/mnemonic/reauth': { + "/auth/{_version}/mnemonic/reauth": { parameters: { query?: never; header?: never; @@ -519,7 +519,7 @@ export interface paths { get?: never; put?: never; /** Re-authenticate with mnemonic and add Password scope to the session if the authentication is successful. */ - post: operations['post_auth-{_version}-mnemonic-reauth']; + post: operations["post_auth-{_version}-mnemonic-reauth"]; delete?: never; options?: never; head?: never; @@ -536,7 +536,7 @@ export interface components { */ ResponseCodeSuccess: 1000; ProtonSuccess: { - Code: components['schemas']['ResponseCodeSuccess']; + Code: components["schemas"]["ResponseCodeSuccess"]; }; ProtonError: { /** ErrorCode */ @@ -561,7 +561,7 @@ export interface components { DownloadTokenExpirationTimeInSec?: 1800; }; ActivateAuthDeviceInput: { - EncryptedSecret: components['schemas']['BinaryString']; + EncryptedSecret: components["schemas"]["BinaryString"]; }; /** @description An encrypted ID */ Id: string; @@ -571,7 +571,7 @@ export interface components { }; CreateAuthDeviceInput: { /** @description Optional. If the user is already set-up, a 32-byte random token encoded as base64 and encrypted to the primary address key */ - ActivationToken?: components['schemas']['PGPMessage'] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; /** * @description User-facing device name * @example Fairphone 4 @@ -583,7 +583,7 @@ export interface components { Payload?: string | null; /** @description Expected ClientID of the child */ ChildClientID: string; - Independent: components['schemas']['BoolInt']; + Independent: components["schemas"]["BoolInt"]; /** @description 8-char-base32-crockford */ UserCode?: string | null; }; @@ -600,34 +600,34 @@ 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; }; }; AssociateAuthDeviceOutput: { - ID: components['schemas']['Id']; - EncryptedSecret: components['schemas']['BinaryString2']; + ID: components["schemas"]["Id"]; + EncryptedSecret: components["schemas"]["BinaryString2"]; }; 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; /** @@ -651,9 +651,9 @@ export interface components { */ LastActivityTime: string; /** @description PGP message encrypted to the AddressID containing a 64-char random hex-encoded token */ - ActivationToken?: components['schemas']['PGPMessage2'] | null; - ActivationAddressID?: components['schemas']['Id'] | null; - MemberID?: components['schemas']['Id'] | null; + ActivationToken?: components["schemas"]["PGPMessage2"] | null; + ActivationAddressID?: components["schemas"]["Id"] | null; + MemberID?: components["schemas"]["Id"] | null; /** * @description DeviceToken of the created device * @example wfih0367aa7dc0359bf5c42d15a93e6c @@ -760,22 +760,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; }; /** * @deprecated @@ -821,11 +821,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 */ @@ -834,7 +834,7 @@ export interface components { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError']; + "application/json": components["schemas"]["ProtonError"]; }; }; }; @@ -845,7 +845,7 @@ export interface components { } export type $defs = Record; export interface operations { - 'post_auth-{_version}-devices-{deviceId}': { + "post_auth-{_version}-devices-{deviceId}": { parameters: { query?: never; header?: never; @@ -854,14 +854,14 @@ export interface operations { * @description the device id * @example ACXDmTaBub14w== */ - deviceId: components['schemas']['Id']; + deviceId: components["schemas"]["Id"]; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['ActivateAuthDeviceInput']; + "application/json": components["schemas"]["ActivateAuthDeviceInput"]; }; }; responses: { @@ -871,20 +871,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_auth-{_version}-devices-{deviceId}': { + "delete_auth-{_version}-devices-{deviceId}": { parameters: { query?: never; header?: never; path: { _version: string; - deviceId: components['schemas']['Id']; + deviceId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -896,14 +896,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_auth-{_version}-devices-{deviceId}-associate': { + "post_auth-{_version}-devices-{deviceId}-associate": { parameters: { query?: never; header?: never; @@ -912,14 +912,14 @@ export interface operations { * @description the device id * @example ACXDmTaBub14w== */ - deviceId: components['schemas']['Id']; + deviceId: components["schemas"]["Id"]; _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AssociateAuthDeviceInput']; + "application/json": components["schemas"]["AssociateAuthDeviceInput"]; }; }; responses: { @@ -929,15 +929,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Device?: components['schemas']['AssociateAuthDeviceOutput']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Device?: components["schemas"]["AssociateAuthDeviceOutput"]; }; }; }; }; }; - 'get_auth-{_version}-devices': { + "get_auth-{_version}-devices": { parameters: { query?: never; header?: never; @@ -954,15 +954,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"][]; }; }; }; }; }; - 'post_auth-{_version}-devices': { + "post_auth-{_version}-devices": { parameters: { query?: never; header?: never; @@ -973,7 +973,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAuthDeviceInput']; + "application/json": components["schemas"]["CreateAuthDeviceInput"]; }; }; responses: { @@ -983,15 +983,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Device?: components['schemas']['AuthDeviceOutput']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Device?: components["schemas"]["AuthDeviceOutput"]; }; }; }; }; }; - 'delete_auth-{_version}-devices': { + "delete_auth-{_version}-devices": { parameters: { query?: never; header?: never; @@ -1008,20 +1008,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_auth-{_version}-devices-{deviceId}-admin': { + "put_auth-{_version}-devices-{deviceId}-admin": { parameters: { query?: never; header?: never; path: { _version: string; - deviceId: components['schemas']['Id']; + deviceId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -1033,14 +1033,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_auth-{_version}-devices-{deviceId}-reject': { + "put_auth-{_version}-devices-{deviceId}-reject": { parameters: { query?: never; header?: never; @@ -1049,7 +1049,7 @@ export interface operations { * @description the device id * @example ACXDmTaBub14w== */ - deviceId: components['schemas']['Id']; + deviceId: components["schemas"]["Id"]; _version: string; }; cookie?: never; @@ -1062,14 +1062,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_auth-{_version}-sessions-uuid': { + "get_auth-{_version}-sessions-uuid": { parameters: { query?: never; header?: never; @@ -1086,8 +1086,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description Apple transaction UUID for the current user * @example f81d4fae-7dec-11d0-a765-00a0c91e6bf6 @@ -1098,7 +1098,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-sessions-forks': { + "get_auth-{_version}-sessions-forks": { parameters: { query?: never; header?: never; @@ -1117,7 +1117,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-sessions-forks': { + "post_auth-{_version}-sessions-forks": { parameters: { query?: never; header?: never; @@ -1128,7 +1128,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['PushForkSessionInput']; + "application/json": components["schemas"]["PushForkSessionInput"]; }; }; responses: { @@ -1138,8 +1138,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example random-string */ Selector?: string; }; @@ -1147,7 +1147,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-sessions-forks-{selector}': { + "get_auth-{_version}-sessions-forks-{selector}": { parameters: { query?: never; header?: never; @@ -1165,8 +1165,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example base64-encrypted-payload */ Payload?: string; /** @example 1 */ @@ -1197,7 +1197,7 @@ export interface operations { }; }; }; - 'delete_auth-{_version}-sessions-forks-{selector}': { + "delete_auth-{_version}-sessions-forks-{selector}": { parameters: { query?: never; header?: never; @@ -1215,8 +1215,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -1226,7 +1226,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2001 */ Code?: number; /** @example Invalid selector */ @@ -1238,7 +1238,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-sessions': { + "get_auth-{_version}-sessions": { parameters: { query?: never; header?: never; @@ -1255,9 +1255,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== @@ -1279,7 +1279,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-sessions': { + "post_auth-{_version}-sessions": { parameters: { query?: never; header?: never; @@ -1290,7 +1290,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1299,13 +1299,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; }; }; }; @@ -1317,8 +1317,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 */ AccessToken?: string; /** @example kafe30367aa7dc09bf5c48ghaza93e65 */ @@ -1334,7 +1334,7 @@ export interface operations { }; }; }; - 'delete_auth-{_version}-sessions': { + "delete_auth-{_version}-sessions": { parameters: { query?: never; header?: never; @@ -1351,14 +1351,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_auth-{_version}-sessions-payload': { + "post_auth-{_version}-sessions-payload": { parameters: { query?: never; header?: never; @@ -1369,7 +1369,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1378,13 +1378,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; }; }; }; @@ -1396,14 +1396,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_auth-{_version}-sessions-{uid}': { + "delete_auth-{_version}-sessions-{uid}": { parameters: { query?: never; header?: never; @@ -1421,14 +1421,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_auth-{_version}-sessions-local-key': { + "get_auth-{_version}-sessions-local-key": { parameters: { query?: never; header?: never; @@ -1445,8 +1445,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example 32-byte-random-base64-encoded or null */ ClientKey?: string; }; @@ -1454,7 +1454,7 @@ export interface operations { }; }; }; - 'put_auth-{_version}-sessions-local-key': { + "put_auth-{_version}-sessions-local-key": { parameters: { query?: never; header?: never; @@ -1465,7 +1465,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 32-byte-base64-encrypted-key */ Key?: string; }; @@ -1478,14 +1478,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_auth-{_version}-sessions-local': { + "get_auth-{_version}-sessions-local": { parameters: { query?: { /** @@ -1508,8 +1508,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; Sessions?: { /** @example null */ Username?: string; @@ -1527,7 +1527,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-mnemonic-validate': { + "get_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1538,7 +1538,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1565,8 +1565,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1578,7 +1578,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1588,7 +1588,7 @@ export interface operations { }; }; }; - 'put_auth-{_version}-mnemonic-validate': { + "put_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1599,7 +1599,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1626,8 +1626,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1639,7 +1639,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1649,7 +1649,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-mnemonic-validate': { + "post_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1660,7 +1660,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1687,8 +1687,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1700,7 +1700,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1710,7 +1710,7 @@ export interface operations { }; }; }; - 'delete_auth-{_version}-mnemonic-validate': { + "delete_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1721,7 +1721,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1748,8 +1748,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1761,7 +1761,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1771,7 +1771,7 @@ export interface operations { }; }; }; - 'options_auth-{_version}-mnemonic-validate': { + "options_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1782,7 +1782,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1809,8 +1809,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1822,7 +1822,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1832,7 +1832,7 @@ export interface operations { }; }; }; - 'head_auth-{_version}-mnemonic-validate': { + "head_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1843,7 +1843,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1870,8 +1870,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1883,7 +1883,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1893,7 +1893,7 @@ export interface operations { }; }; }; - 'patch_auth-{_version}-mnemonic-validate': { + "patch_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1904,7 +1904,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1931,8 +1931,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -1944,7 +1944,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -1954,7 +1954,7 @@ export interface operations { }; }; }; - 'trace_auth-{_version}-mnemonic-validate': { + "trace_auth-{_version}-mnemonic-validate": { parameters: { query?: never; header?: never; @@ -1965,7 +1965,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -1992,8 +1992,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -2005,7 +2005,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ @@ -2015,7 +2015,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-info': { + "post_auth-{_version}-info": { parameters: { query?: never; header?: never; @@ -2026,7 +2026,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -2039,7 +2039,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 @@ -2055,40 +2055,38 @@ 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 */ @@ -2097,7 +2095,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} @@ -2116,45 +2114,42 @@ 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_auth-{_version}-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_auth-{_version}-sso-{token}": { parameters: { query?: { FinalRedirectBaseUrl?: string | null; @@ -2180,7 +2175,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-saml': { + "post_auth-{_version}-saml": { parameters: { query?: never; header?: never; @@ -2191,7 +2186,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['IdpResponseVO']; + "application/json": components["schemas"]["IdpResponseVO"]; }; }; responses: { @@ -2203,7 +2198,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}': { + "post_auth-{_version}": { parameters: { query?: never; header?: never; @@ -2214,7 +2209,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AuthInput']; + "application/json": components["schemas"]["AuthInput"]; }; }; responses: { @@ -2224,8 +2219,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 @@ -2273,7 +2268,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 @@ -2282,7 +2277,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"][]; }; }; }; @@ -2290,7 +2285,7 @@ export interface operations { }; }; }; - 'delete_auth-{_version}': { + "delete_auth-{_version}": { parameters: { query?: { /** @description if 1 log out this child only */ @@ -2312,14 +2307,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_auth-{_version}-jwt': { + "post_auth-{_version}-jwt": { parameters: { query?: never; header?: never; @@ -2330,7 +2325,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example eyJhbGciOiJIUzI1Ni...yJV_adQssw5c */ Token?: string; /** @@ -2348,8 +2343,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example 3af9b9780014cacb4b72bc5c73c1d7c4bad6c1e3 */ AccessToken?: string; /** @@ -2374,7 +2369,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-2fa': { + "post_auth-{_version}-2fa": { parameters: { query?: never; header?: never; @@ -2385,7 +2380,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description either this or the FIDO2 object * @example 123456 or recovery code @@ -2413,8 +2408,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @deprecated * @example full @@ -2426,7 +2421,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-modulus': { + "get_auth-{_version}-modulus": { parameters: { query?: never; header?: never; @@ -2443,8 +2438,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== */ @@ -2454,7 +2449,7 @@ export interface operations { }; }; }; - 'get_auth-{_version}-scopes': { + "get_auth-{_version}-scopes": { parameters: { query?: never; header?: never; @@ -2471,8 +2466,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @deprecated * @example 217017207043915776 @@ -2484,7 +2479,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-refresh': { + "post_auth-{_version}-refresh": { parameters: { query?: never; header?: never; @@ -2495,7 +2490,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example token */ ResponseType?: string; /** @example refresh_token */ @@ -2520,8 +2515,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ AccessToken?: string; /** @@ -2553,7 +2548,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-cookies': { + "post_auth-{_version}-cookies": { parameters: { query?: never; header?: never; @@ -2564,7 +2559,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example token */ ResponseType?: string; /** @example refresh_token */ @@ -2588,8 +2583,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 */ @@ -2604,7 +2599,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-credentialless': { + "post_auth-{_version}-credentialless": { parameters: { query?: never; header?: never; @@ -2615,7 +2610,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateCredentiallessUserInput']; + "application/json": components["schemas"]["CreateCredentiallessUserInput"]; }; }; responses: { @@ -2625,12 +2620,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateCredentiallessUserOutput']; + "application/json": components["schemas"]["CreateCredentiallessUserOutput"]; }; }; }; }; - 'post_auth-{_version}-mnemonic-info': { + "post_auth-{_version}-mnemonic-info": { parameters: { query?: never; header?: never; @@ -2641,7 +2636,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -2659,8 +2654,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 SIGNATURE----- */ Modulus?: string; /** @example */ @@ -2676,7 +2671,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-mnemonic': { + "post_auth-{_version}-mnemonic": { parameters: { query?: never; header?: never; @@ -2687,7 +2682,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -2716,8 +2711,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** * @description only if the session is not in cookie mode * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 @@ -2753,7 +2748,7 @@ export interface operations { /** @example 2 */ PasswordMode?: number; /** @description To notify the user if 2FA is being disabled */ - '2FA'?: { + "2FA"?: { /** * @description 0 for disabled, 1 for OTP, 2 for FIDO2, 3 for both * @example 3 @@ -2765,7 +2760,7 @@ export interface operations { }; }; }; - 'post_auth-{_version}-mnemonic-reauth': { + "post_auth-{_version}-mnemonic-reauth": { parameters: { query?: never; header?: never; @@ -2776,7 +2771,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Client-specific secret only necessary to access the admin panel * @example demopass @@ -2805,8 +2800,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -2818,7 +2813,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 8002 */ Code?: number; /** @example Incorrect login credentials. Please try again. */ diff --git a/js/cli/src/api/api-core-types.ts b/js/cli/src/api/api-core-types.ts index 3fc49db4..9afc11dd 100644 --- a/js/cli/src/api/api-core-types.ts +++ b/js/cli/src/api/api-core-types.ts @@ -1,18 +1,17 @@ -// @ts-nocheck — generated OpenAPI types; duplicates in upstream spec break tsc; consumers use `import type` only. /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ 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; @@ -21,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; @@ -30,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; @@ -38,7 +53,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys': { + "/core/{_version}/keys": { parameters: { query?: never; header?: never; @@ -53,19 +68,21 @@ 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) + /** + * 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']; + * 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': { + "/core/{_version}/keys/address": { parameters: { query?: never; header?: never; @@ -79,14 +96,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; @@ -96,14 +113,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; @@ -111,19 +128,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; @@ -131,20 +149,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; @@ -152,34 +169,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; @@ -187,7 +200,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/images/logo': { + "/core/{_version}/images/logo": { parameters: { query?: never; header?: never; @@ -195,7 +208,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; @@ -204,7 +217,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; @@ -212,7 +225,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. @@ -230,21 +243,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. @@ -262,14 +275,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; @@ -279,14 +292,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; @@ -295,7 +308,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; @@ -303,7 +333,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/setup': { + "/core/{_version}/addresses/setup": { parameters: { query?: never; header?: never; @@ -313,14 +343,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; @@ -328,7 +358,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; @@ -337,7 +367,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/addresses/{enc_id}': { + "/core/{_version}/addresses/{enc_id}": { parameters: { query?: never; header?: never; @@ -345,12 +375,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. @@ -359,13 +389,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; @@ -373,7 +403,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; @@ -382,16 +412,18 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}/claimedAddresses': { + "/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']; + /** + * 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; @@ -400,7 +432,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; @@ -414,7 +446,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; @@ -422,7 +454,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; @@ -436,7 +468,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; @@ -444,7 +476,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; @@ -458,7 +490,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; @@ -466,7 +498,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; @@ -478,7 +510,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; @@ -486,7 +518,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; @@ -495,7 +527,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; @@ -503,7 +535,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; @@ -512,7 +544,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; @@ -520,7 +552,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; @@ -532,7 +564,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; @@ -540,7 +572,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; @@ -554,7 +586,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; @@ -562,7 +594,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices/{deviceId}': { + "/core/{_version}/members/{memberId}/saml": { parameters: { query?: never; header?: never; @@ -571,14 +603,14 @@ export interface paths { }; get?: never; put?: never; - post?: never; - delete: operations['delete_core-{_version}-members-{memberId}-devices-{deviceId}']; + 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': { + "/core/{_version}/members/{memberId}/devices/{deviceId}": { parameters: { query?: never; header?: never; @@ -588,13 +620,13 @@ 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-{deviceId}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/saml': { + "/core/{_version}/members/{memberId}/devices": { parameters: { query?: never; header?: never; @@ -604,20 +636,20 @@ export interface paths { get?: never; put?: never; post?: never; - delete: operations['delete_core-{_version}-members-{memberId}-saml']; + 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; @@ -626,14 +658,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; @@ -642,7 +674,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/devices/{deviceId}/reject': { + "/core/{_version}/auth/refresh": { parameters: { query?: never; header?: never; @@ -650,15 +682,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-members-{memberId}-devices-{deviceId}-reject']; - post?: 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/reset': { + "/core/{_version}/members/{memberId}/devices/{deviceId}/reject": { parameters: { query?: never; header?: never; @@ -666,15 +698,15 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - post: operations['post_core-{_version}-members-{memberId}-devices-reset']; + 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/{enc_id}/keys': { + "/core/{_version}/members/{memberId}/devices/reset": { parameters: { query?: never; header?: never; @@ -683,139 +715,111 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-members-{enc_id}-keys']; + post: operations["post_core-{_version}-members-{memberId}-devices-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/scim': { + "/core/{_version}/auth": { 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; + get?: never; + 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}/domains': { + "/core/{_version}/auth/sso/{token}": { 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']; + get: operations["get_core-{_version}-auth-sso-{token}"]; 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']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/available': { + "/core/{_version}/organization/communities": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get available domains. */ - get: operations['get_core-{_version}-domains-available']; + get?: never; put?: never; - post?: never; + post: operations["post_core-{_version}-organization-communities"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/premium': { + "/core/{_version}/members/{enc_id}/keys": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get premium domains. */ - get: operations['get_core-{_version}-domains-premium']; + get?: never; put?: never; - post?: never; + post: operations["post_core-{_version}-members-{enc_id}-keys"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/optin': { + "/core/{_version}/keys/user": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get opt-in domain if user is eligible. */ - get: operations['get_core-{_version}-domains-optin']; + get?: never; put?: never; - post?: never; + post: operations["post_core-{_version}-keys-user"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}': { + "/core/{_version}/organization/{organization_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}']; + get?: never; 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}-organization-{organization_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/domains/{enc_id}/catchall': { + "/core/{_version}/settings/highsecurity/summary/email": { parameters: { query?: never; header?: never; @@ -823,16 +827,15 @@ export interface paths { cookie?: never; }; get?: never; - /** Set catch-all address, locked route. */ - put: operations['put_core-{_version}-domains-{enc_id}-catchall']; - post?: never; - delete?: 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}/groups/external/{jwt}': { + "/core/{_version}/domains/{enc_id}/flags": { parameters: { query?: never; header?: never; @@ -840,103 +843,134 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-external-{jwt}']; + /** Update domain flags. */ + put: operations["put_core-{_version}-domains-{enc_id}-flags"]; post?: never; - delete: operations['delete_core-{_version}-groups-external-{jwt}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members': { + "/core/{_version}/domains": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: 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; - post: operations['post_core-{_version}-groups-members']; + /** + * 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}/groups': { + "/core/{_version}/domains/available": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-groups']; + /** Get available domains. */ + get: operations["get_core-{_version}-domains-available"]; put?: never; - post: operations['post_core-{_version}-groups']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/{enc_id}': { + "/core/{_version}/domains/premium": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-groups-{enc_id}']; + /** Get premium domains. */ + get: operations["get_core-{_version}-domains-premium"]; + put?: never; post?: never; - delete: operations['delete_core-{_version}-groups-{enc_id}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{enc_id}': { + "/core/{_version}/domains/optin": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get opt-in domain if user is eligible. */ + get: operations["get_core-{_version}-domains-optin"]; put?: never; post?: never; - delete: operations['delete_core-{_version}-groups-members-{enc_id}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{groupMemberId}': { + "/core/{_version}/domains/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-groups-members-{groupMemberId}']; + /** + * Get Domain. + * @description Get a specific domains and its check DNS + */ + get: operations["get_core-{_version}-domains-{enc_id}"]; + put?: never; post?: never; - delete?: 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/v4/groups/members/external/{jwt}': { + "/core/{_version}/domains/{enc_id}/catchall": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-members-external-{jwt}']; - put?: never; + get?: never; + /** Set catch-all address, locked route. */ + put: operations["put_core-{_version}-domains-{enc_id}-catchall"]; post?: never; delete?: never; options?: never; @@ -944,14 +978,14 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/groups/{group_enc_id}/members': { + "/core/{_version}/organizations/subsidiaries": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-{group_enc_id}-members']; + get: operations["get_core-{_version}-organizations-subsidiaries"]; put?: never; post?: never; delete?: never; @@ -960,31 +994,31 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/groups/members/internal': { + "/core/{_version}/organizations/scim": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v4-groups-members-internal']; - put?: never; - post?: 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}/groups/{enc_id}/reinvite': { + "/core/{_version}/members/organizations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-groups-{enc_id}-reinvite']; + get: operations["get_core-{_version}-members-organizations"]; + put?: never; post?: never; delete?: never; options?: never; @@ -992,7 +1026,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/groups/members/{groupMemberId}/resume': { + "/core/{_version}/groups/external/{jwt}": { parameters: { query?: never; header?: never; @@ -1000,15 +1034,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-groups-members-{groupMemberId}-resume']; + put: operations["put_core-{_version}-groups-external-{jwt}"]; post?: never; - delete?: never; + delete: operations["delete_core-{_version}-groups-external-{jwt}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/invites': { + "/core/{_version}/groups/owners/accept/{enc_id}": { parameters: { query?: never; header?: never; @@ -1017,14 +1051,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites']; + post: operations["post_core-{_version}-groups-owners-accept-{enc_id}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/invites/unused': { + "/core/{_version}/groups/members": { parameters: { query?: never; header?: never; @@ -1033,14 +1067,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites-unused']; + post: operations["post_core-{_version}-groups-members"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/invites/check': { + "/core/{_version}/groups/owners/add/{enc_id}": { parameters: { query?: never; header?: never; @@ -1049,91 +1083,78 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-invites-check']; + post: operations["post_core-{_version}-groups-owners-add-{enc_id}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/all': { + "/core/{_version}/groups": { 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']; + get: operations["get_core-{_version}-groups"]; put?: never; - post?: never; + post: operations["post_core-{_version}-groups"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylists': { + "/core/{_version}/groups/unsubscribe/{jwt}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get multiple signed key lists for different epochs */ - get: operations['get_core-{_version}-keys-signedkeylists']; + get?: never; put?: never; - /** Update signed key list. */ - post: operations['post_core-{_version}-keys-signedkeylists']; + post: operations["post_core-{_version}-groups-unsubscribe-{jwt}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylist': { + "/core/{_version}/groups/{enc_id}": { 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; + get?: never; + put: operations["put_core-{_version}-groups-{enc_id}"]; post?: never; - delete?: never; + delete: operations["delete_core-{_version}-groups-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/salts': { + "/core/{_version}/groups/members/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get key salts. - * @description Locked route - */ - get: operations['get_core-{_version}-keys-salts']; + get?: never; put?: never; post?: never; - delete?: never; + delete: operations["delete_core-{_version}-groups-members-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/address/{enc_id}': { + "/core/{_version}/groups/members/{groupMemberId}": { parameters: { query?: never; header?: never; @@ -1141,8 +1162,7 @@ export interface paths { cookie?: never; }; get?: never; - /** (Migrated keys) Reactivate just an address key */ - put: operations['put_core-{_version}-keys-address-{enc_id}']; + put: operations["put_core-{_version}-groups-members-{groupMemberId}"]; post?: never; delete?: never; options?: never; @@ -1150,16 +1170,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/address/{enc_id}/subkeys': { + "/core/v4/groups/members/external/{jwt}": { 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']; + get: operations["get_core-v4-groups-members-external-{jwt}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1167,16 +1186,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/signedkeylists/signature': { + "/core/v4/groups/{group_enc_id}/members": { 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']; + get: operations["get_core-v4-groups-{group_enc_id}-members"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1184,40 +1202,31 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}/primary': { + "/core/{_version}/groups/owners/invites": { 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; + 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/{_version}/keys/{enc_id}/flags': { + "/core/v4/groups/members/internal": { 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']; + get: operations["get_core-v4-groups-members-internal"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1225,15 +1234,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/tokens': { + "/core/{_version}/groups/{group_enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-keys-tokens']; + get: operations["get_core-{_version}-groups-{group_enc_id}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1241,28 +1250,23 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/keys/user/{enc_id}': { + "/core/v4/groups/members/{group_member_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}']; + get: operations["get_core-v4-groups-members-{group_member_enc_id}"]; + put?: never; post?: never; - delete: operations['delete_core-{_version}-keys-user-{enc_id}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/private/upgrade': { + "/core/{_version}/groups/{enc_id}/reinvite": { parameters: { query?: never; header?: never; @@ -1270,22 +1274,15 @@ export interface paths { 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']; + put: operations["put_core-{_version}-groups-{enc_id}-reinvite"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/migrate': { + "/core/{_version}/groups/members/{groupMemberId}/resume": { parameters: { query?: never; header?: never; @@ -1293,20 +1290,15 @@ export interface paths { 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}-keys-migrate']; + put: operations["put_core-{_version}-groups-members-{groupMemberId}-resume"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}/activate': { + "/core/{_version}/invites": { parameters: { query?: never; header?: never; @@ -1314,17 +1306,15 @@ export interface paths { 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; + put?: never; + post: operations["post_core-{_version}-invites"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/{enc_id}': { + "/core/{_version}/invites/unused": { parameters: { query?: never; header?: never; @@ -1332,16 +1322,15 @@ export interface paths { 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; + put?: never; + post: operations["post_core-{_version}-invites-unused"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/user': { + "/core/{_version}/invites/check": { parameters: { query?: never; header?: never; @@ -1350,88 +1339,83 @@ export interface paths { }; get?: never; put?: never; - /** - * Add new user key. - * @description Add new user key by sending the encrypted PrivateKey and a value indicating whether the key should become - * primary, locked route. - */ - post: operations['post_core-{_version}-keys-user']; + post: operations["post_core-{_version}-invites-check"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/keys/reset': { + "/core/{_version}/keys/all": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: 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; - /** Install a new key for each address. */ - post: operations['post_core-{_version}-keys-reset']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members': { + "/core/{_version}/keys/signedkeylists": { 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 multiple signed key lists for different epochs */ + get: operations["get_core-{_version}-keys-signedkeylists"]; put?: never; - /** - * Create a new member. - * @description Locked route - */ - post: operations['post_core-{_version}-members']; + /** Update signed key list. */ + post: operations["post_core-{_version}-keys-signedkeylists"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/invitations': { + "/core/{_version}/keys/signedkeylist": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get a single signed key lists for a specific epoch */ + get: operations["get_core-{_version}-keys-signedkeylist"]; put?: never; - post: operations['post_core-{_version}-members-invitations']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/invitations/{enc_id}': { + "/core/{_version}/keys/salts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; /** - * Edit a pending invitation. + * Get key salts. * @description Locked route */ - put: operations['put_core-{_version}-members-invitations-{enc_id}']; + get: operations["get_core-{_version}-keys-salts"]; + put?: never; post?: never; delete?: never; options?: never; @@ -1439,7 +1423,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/disable': { + "/core/{_version}/keys/address/{enc_id}": { parameters: { query?: never; header?: never; @@ -1447,11 +1431,8 @@ export interface paths { cookie?: never; }; get?: never; - /** - * Disable a member. - * @description Locked route - */ - put: operations['put_core-{_version}-members-{enc_id}-disable']; + /** (Migrated keys) Reactivate just an address key */ + put: operations["put_core-{_version}-keys-address-{enc_id}"]; post?: never; delete?: never; options?: never; @@ -1459,7 +1440,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/enable': { + "/core/{_version}/keys/address/{enc_id}/subkeys": { parameters: { query?: never; header?: never; @@ -1467,11 +1448,8 @@ export interface paths { cookie?: never; }; get?: never; - /** - * Enable a member. - * @description Locked route - */ - put: operations['put_core-{_version}-members-{enc_id}-enable']; + /** Add subkeys to an existing keypair. */ + put: operations["put_core-{_version}-keys-address-{enc_id}-subkeys"]; post?: never; delete?: never; options?: never; @@ -1479,7 +1457,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/quota': { + "/core/{_version}/keys/signedkeylists/signature": { parameters: { query?: never; header?: never; @@ -1487,11 +1465,8 @@ export interface paths { cookie?: never; }; get?: never; - /** - * Update disk space quota in bytes. - * @description Locked route - */ - put: operations['put_core-{_version}-members-{enc_id}-quota']; + /** Update signed key list signature for a specific revision. */ + put: operations["put_core-{_version}-keys-signedkeylists-signature"]; post?: never; delete?: never; options?: never; @@ -1499,7 +1474,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/name': { + "/core/{_version}/keys/{enc_id}/flags": { parameters: { query?: never; header?: never; @@ -1508,10 +1483,10 @@ export interface paths { }; get?: never; /** - * Update member name. + * Update key flags. * @description Locked route */ - put: operations['put_core-{_version}-members-{enc_id}-name']; + put: operations["put_core-{_version}-keys-{enc_id}-flags"]; post?: never; delete?: never; options?: never; @@ -1519,7 +1494,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/role': { + "/core/{_version}/keys/tokens": { parameters: { query?: never; header?: never; @@ -1527,8 +1502,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Update member role. */ - put: operations['put_core-{_version}-members-{enc_id}-role']; + put: operations["put_core-{_version}-keys-tokens"]; post?: never; delete?: never; options?: never; @@ -1536,27 +1510,28 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/vpn': { + "/core/{_version}/keys/user/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-vpn']; + get?: never; /** - * Update max number of VPNs for member. - * @description Update number of maximum VPN connections, locked route. + * 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. */ - put: operations['put_core-{_version}-members-{enc_id}-vpn']; + put: operations["put_core-{_version}-keys-user-{enc_id}"]; post?: never; - delete?: never; + delete: operations["delete_core-{_version}-keys-user-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{memberId}/ai': { + "/core/{_version}/keys/private/upgrade": { parameters: { query?: never; header?: never; @@ -1564,16 +1539,22 @@ export interface paths { cookie?: never; }; get?: never; - /** Update AI entitlement for member. */ - put: operations['put_core-{_version}-members-{memberId}-ai']; - post?: 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}/members/{enc_id}/privatize': { + "/core/{_version}/keys/migrate": { parameters: { query?: never; header?: never; @@ -1581,28 +1562,32 @@ export interface paths { cookie?: never; }; get?: never; + put?: never; /** - * Make account private. - * @description Locked route + * 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. */ - put: operations['put_core-{_version}-members-{enc_id}-privatize']; - post?: never; + post: operations["post_core-{_version}-keys-migrate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/me': { + "/core/{_version}/keys/{enc_id}/activate": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get user's member. */ - get: operations['get_core-{_version}-members-me']; - put?: 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; @@ -1610,26 +1595,24 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/me/unprivatize': { + "/core/{_version}/keys/{enc_id}": { 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']; + 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}/members/{id}/unprivatize/resend': { + "/core/{_version}/keys/reset": { parameters: { query?: never; header?: never; @@ -1638,15 +1621,15 @@ export interface paths { }; get?: never; put?: never; - /** Resend magic link email */ - post: operations['post_core-{_version}-members-{id}-unprivatize-resend']; + /** 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/{id}/unprivatize': { + "/core/{_version}/organizations/link/{organization_id}": { parameters: { query?: never; header?: never; @@ -1654,62 +1637,69 @@ export interface paths { 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']; + put: operations["put_core-{_version}-organizations-link-{organization_id}"]; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}': { + "/core/{_version}/members": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get a specific member. */ - get: operations['get_core-{_version}-members-{enc_id}']; + /** + * Get Members. + * @description Get all members of user's organization + */ + get: operations["get_core-{_version}-members"]; put?: never; - post?: never; /** - * Delete a member. - * @description Remove member, deletes user if not PM user, locked route. + * Create a new member. + * @description Locked route + * + * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded */ - delete: operations['delete_core-{_version}-members-{enc_id}']; + post: operations["post_core-{_version}-members"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/details': { + "/core/{_version}/members/invitations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-details']; + get?: never; put?: never; - post?: never; + post: operations["post_core-{_version}-members-invitations"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/authlog': { + "/core/{_version}/members/invitations/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-members-{enc_id}-authlog']; - put?: 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; @@ -1717,7 +1707,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/require2fa': { + "/core/{_version}/members/{enc_id}/disable": { parameters: { query?: never; header?: never; @@ -1725,17 +1715,19 @@ export interface paths { 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']; + /** + * Disable a member. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-disable"]; post?: never; - /** Do not enforce two-factor for a member, locked route */ - delete: operations['delete_core-{_version}-members-{enc_id}-require2fa']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/permissions/forwarding': { + "/core/{_version}/members/{enc_id}/enable": { parameters: { query?: never; header?: never; @@ -1743,17 +1735,19 @@ export interface paths { 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']; + /** + * 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/permissions': { + "/core/{_version}/members/{enc_id}/quota": { parameters: { query?: never; header?: never; @@ -1761,8 +1755,11 @@ export interface paths { cookie?: never; }; get?: never; - /** Add or remove Permissions field for a list of MemberIDs */ - put: operations['put_core-{_version}-members-permissions']; + /** + * Update disk space quota in bytes. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-quota"]; post?: never; delete?: never; options?: never; @@ -1770,7 +1767,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/keys/setup': { + "/core/{_version}/members/{enc_id}/name": { parameters: { query?: never; header?: never; @@ -1778,19 +1775,19 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Setup Member Keys. - * @description Setup new member keys, locked route. + * Update member name. + * @description Locked route */ - post: operations['post_core-{_version}-members-{enc_id}-keys-setup']; + 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}/keys/migrate': { + "/core/{_version}/members/{enc_id}/role": { parameters: { query?: never; header?: never; @@ -1798,20 +1795,16 @@ export interface paths { 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']; + /** 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/{enc_id}/keys/signedkeylists': { + "/core/{_version}/members/{memberId}/ai": { parameters: { query?: never; header?: never; @@ -1819,16 +1812,16 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - /** Update signed key lists for a subuser. */ - post: operations['post_core-{_version}-members-{enc_id}-keys-signedkeylists']; + /** 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}/keys/unprivatize': { + "/core/{_version}/members/{enc_id}/privatize": { parameters: { query?: never; header?: never; @@ -1836,172 +1829,140 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Unprivatize member - * @description Can be called from the background provided validation of InvitationData succeeds + * Make account private. + * @description Locked route */ - post: operations['post_core-{_version}-members-{enc_id}-keys-unprivatize']; + put: operations["put_core-{_version}-members-{enc_id}-privatize"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/auth': { + "/core/{_version}/members/search": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; /** - * Create Session. - * @description Login as non-private member, password route + * 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. */ - post: operations['post_core-{_version}-members-{enc_id}-auth']; + get: operations["get_core-{_version}-members-search"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/sessions': { + "/core/{_version}/members/me": { 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 user's member. */ + get: operations["get_core-{_version}-members-me"]; 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']; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/members/{enc_id}/sessions/{uid}': { + "/core/{_version}/members/me/unprivatize": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get unprivatization info for self */ + get: operations["get_core-{_version}-members-me-unprivatize"]; put?: never; - post?: never; - /** Revoke a session by UID, locked. */ - delete: operations['delete_core-{_version}-members-{enc_id}-sessions-{uid}']; + /** 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}/organizations': { + "/core/{_version}/members/{id}/unprivatize/resend": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Get organization. - * @description Get information about current organization - */ - get: operations['get_core-{_version}-organizations']; + get?: never; put?: never; - post?: 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}/organizations/keys': { + "/core/{_version}/members/{id}/unprivatize": { 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; + 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}/organizations/keys/backup': { + "/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; /** - * 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. + * Delete a member. + * @description Remove member, deletes user if not PM user, locked route. */ - post: operations['post_core-{_version}-organizations-keys-backup']; - delete?: never; + delete: operations["delete_core-{_version}-members-{enc_id}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/name': { + "/core/{_version}/members/{enc_id}/details": { 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']; + get: operations["get_core-{_version}-members-{enc_id}-details"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2009,19 +1970,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/email': { + "/core/{_version}/members/{enc_id}/authlog": { 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']; + get: operations["get_core-{_version}-members-{enc_id}-authlog"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2029,7 +1986,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/2fa': { + "/core/{_version}/members/{enc_id}/require2fa": { parameters: { query?: never; header?: never; @@ -2037,16 +1994,17 @@ export interface paths { cookie?: never; }; get?: never; - /** Update current organization two-factor grace period setting, locked route */ - put: operations['put_core-{_version}-organizations-2fa']; + /** 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; - delete?: 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}/organizations/require2fa': { + "/core/{_version}/members/{enc_id}/permissions/forwarding": { parameters: { query?: never; header?: never; @@ -2054,17 +2012,17 @@ export interface paths { 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']; + 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}/organizations/keys/activate': { + "/core/{_version}/members/permissions": { parameters: { query?: never; header?: never; @@ -2072,11 +2030,8 @@ export interface paths { 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']; + /** Add or remove Permissions field for a list of MemberIDs */ + put: operations["put_core-{_version}-members-permissions"]; post?: never; delete?: never; options?: never; @@ -2084,7 +2039,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/organizations/membership': { + "/core/{_version}/members/{enc_id}/keys/setup": { parameters: { query?: never; header?: never; @@ -2093,18 +2048,18 @@ export interface paths { }; get?: never; put?: never; - post?: never; /** - * Leave organization. - * @description Lets a member delete themselves from an organization. + * Setup Member Keys. + * @description Setup new member keys, locked route. */ - delete: operations['delete_core-{_version}-organizations-membership']; + post: operations["post_core-{_version}-members-{enc_id}-keys-setup"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/organizations/2fa/remind': { + "/core/{_version}/members/{enc_id}/keys/migrate": { parameters: { query?: never; header?: never; @@ -2113,15 +2068,19 @@ 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']; + /** + * 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}/organizations/keys/migrate': { + "/core/{_version}/members/{enc_id}/keys/signedkeylists": { parameters: { query?: never; header?: never; @@ -2130,64 +2089,75 @@ export interface paths { }; get?: never; put?: never; - /** Migrate organization key. */ - post: operations['post_core-{_version}-organizations-keys-migrate']; + /** 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}/organizations/keys/signature': { + "/core/{_version}/members/{enc_id}/keys/unprivatize": { 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; + 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}/organizations/logo/{enc_id}': { + "/core/{_version}/members/{enc_id}/auth": { 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-{enc_id}']; + get?: never; put?: never; - post?: 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}/organizations/settings': { + "/core/{_version}/members/{enc_id}/sessions": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-organizations-settings']; - put: operations['put_core-{_version}-organizations-settings']; + get?: never; + put?: never; post?: never; - delete?: never; + /** + * 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}/organizations/settings/logo': { + "/core/{_version}/organizations/settings/logo": { parameters: { query?: never; header?: never; @@ -2196,42 +2166,38 @@ 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}/organizations/settings/highsecurity": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Captcha page. - * @deprecated - */ - get: operations['get_core-{_version}-captcha']; + get?: never; put?: never; - post?: never; - delete?: 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}/resources/captcha': { + "/core/{_version}/organizations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Captcha page. */ - get: operations['get_core-{_version}-resources-captcha']; + /** Get information of current organization */ + get: operations["get_core-{_version}-organizations"]; put?: never; post?: never; delete?: never; @@ -2240,15 +2206,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/resources/zendesk': { + "/core/{_version}/organizations/logo/{logo_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Zendesk chat. */ - get: operations['get_core-{_version}-resources-zendesk']; + /** 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; @@ -2257,7 +2223,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/fields': { + "/core/{_version}/organizations/2fa/remind": { parameters: { query?: never; header?: never; @@ -2266,14 +2232,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-saml-setup-fields']; + post: operations["post_core-{_version}-organizations-2fa-remind"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/xml': { + "/core/{_version}/organizations/settings/logauth": { parameters: { query?: never; header?: never; @@ -2281,55 +2247,83 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - post: operations['post_core-{_version}-saml-setup-xml']; + put: operations["put_core-{_version}-organizations-settings-logauth"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/saml/setup/url': { + "/core/{_version}/organizations/keys": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; - post: operations['post_core-{_version}-saml-setup-url']; + /** + * 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}/saml/configs': { + "/core/{_version}/organizations/keys/backup": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-configs']; - put?: never; - post?: 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}/saml/configs/{enc_id}': { + "/core/{_version}/organizations/name": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-configs-{enc_id}']; - put?: 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; @@ -2337,7 +2331,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/configs/{enc_id}/fields': { + "/core/{_version}/organizations/email": { parameters: { query?: never; header?: never; @@ -2345,7 +2339,11 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-saml-configs-{enc_id}-fields']; + /** + * Update organization email. + * @description Update current organization email, locked route. + */ + put: operations["put_core-{_version}-organizations-email"]; post?: never; delete?: never; options?: never; @@ -2353,7 +2351,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/configs/{enc_id}/delete': { + "/core/{_version}/organizations/2fa": { parameters: { query?: never; header?: never; @@ -2361,7 +2359,8 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-saml-configs-{enc_id}-delete']; + /** Update current organization two-factor grace period setting, locked route */ + put: operations["put_core-{_version}-organizations-2fa"]; post?: never; delete?: never; options?: never; @@ -2369,32 +2368,37 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/saml/sp/info': { + "/core/{_version}/organizations/require2fa": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-saml-sp-info']; - put?: 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; - delete?: 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}/settings': { + "/core/{_version}/organizations/keys/activate": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get general settings. */ - get: operations['get_core-{_version}-settings']; - put?: 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; @@ -2402,7 +2406,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/password': { + "/core/{_version}/organizations/membership": { parameters: { query?: never; header?: never; @@ -2410,16 +2414,19 @@ export interface paths { 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']; + put?: never; post?: never; - delete?: 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}/settings/password/upgrade': { + "/core/{_version}/organizations/keys/migrate": { parameters: { query?: never; header?: never; @@ -2427,27 +2434,24 @@ export interface paths { 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; + 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}/settings/email': { + "/core/{_version}/organizations/keys/signature": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-settings-email']; + get: operations["get_core-{_version}-organizations-keys-signature"]; + put: operations["put_core-{_version}-organizations-keys-signature"]; post?: never; delete?: never; options?: never; @@ -2455,33 +2459,37 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/email/verify': { + "/core/{_version}/organizations/settings": { 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']; + /** 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; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/email/notify': { + "/core/{_version}/captcha": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Toggle email notifications. */ - put: operations['put_core-{_version}-settings-email-notify']; + /** + * Captcha page. + * @deprecated + */ + get: operations["get_core-{_version}-captcha"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2489,16 +2497,16 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/email/reset': { + "/core/{_version}/resources/captcha": { 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']; + /** Captcha page. */ + get: operations["get_core-{_version}-resources-captcha"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2506,15 +2514,16 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/phone': { + "/core/{_version}/resources/zendesk": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-settings-phone']; + /** Zendesk chat. */ + get: operations["get_core-{_version}-resources-zendesk"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2522,7 +2531,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/verify': { + "/core/{_version}/saml/setup/fields": { parameters: { query?: never; header?: never; @@ -2531,15 +2540,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}-saml-setup-fields"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/notify': { + "/core/{_version}/saml/setup/xml": { parameters: { query?: never; header?: never; @@ -2547,16 +2555,15 @@ export interface paths { cookie?: never; }; get?: never; - /** Toggle phone notifications. */ - put: operations['put_core-{_version}-settings-phone-notify']; - post?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-xml"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/phone/reset': { + "/core/{_version}/saml/setup/url": { parameters: { query?: never; header?: never; @@ -2564,24 +2571,23 @@ export interface paths { cookie?: never; }; get?: never; - /** Enable or disable login password reset by phone. */ - put: operations['put_core-{_version}-settings-phone-reset']; - post?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-url"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/locale': { + "/core/{_version}/saml/configs": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-settings-locale']; + get: operations["get_core-{_version}-saml-configs"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2589,16 +2595,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/logauth': { + "/core/{_version}/saml/configs/{enc_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update authentication logging. */ - put: operations['put_core-{_version}-settings-logauth']; + get: operations["get_core-{_version}-saml-configs-{enc_id}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2606,7 +2611,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/devicerecovery': { + "/core/{_version}/saml/configs/{enc_id}/fields": { parameters: { query?: never; header?: never; @@ -2614,8 +2619,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Update device recovery enabled preference. */ - put: operations['put_core-{_version}-settings-devicerecovery']; + put: operations["put_core-{_version}-saml-configs-{enc_id}-fields"]; post?: never; delete?: never; options?: never; @@ -2623,7 +2627,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/news': { + "/core/{_version}/saml/configs/{enc_id}/delete": { parameters: { query?: never; header?: never; @@ -2631,51 +2635,39 @@ export interface paths { cookie?: never; }; get?: never; - /** - * Update newsletter subscription. - * @deprecated - */ - put: operations['put_core-{_version}-settings-news']; + put: operations["put_core-{_version}-saml-configs-{enc_id}-delete"]; post?: never; delete?: never; options?: never; head?: never; - /** Patch newsletter subscription. */ - patch: operations['patch_core-{_version}-settings-news']; + patch?: never; trace?: never; }; - '/core/{_version}/settings/news/external': { + "/core/{_version}/saml/sp/info": { 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']; + get: operations["get_core-{_version}-saml-sp-info"]; + put?: never; post?: never; delete?: never; options?: never; head?: never; - /** Patch newsletter subscription as external user. */ - patch: operations['patch_core-{_version}-settings-news-external']; + patch?: never; trace?: never; }; - '/core/{_version}/settings/density': { + "/core/{_version}/saml/edugain/info": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update the mail list density. */ - put: operations['put_core-{_version}-settings-density']; + get: operations["get_core-{_version}-saml-edugain-info"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2683,16 +2675,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/invoicetext': { + "/core/{_version}/saml/edugain/info/{domainName}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update invoice user-defined text. */ - put: operations['put_core-{_version}-settings-invoicetext']; + get: operations["get_core-{_version}-saml-edugain-info-{domainName}"]; + put?: never; post?: never; delete?: never; options?: never; @@ -2700,44 +2691,41 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/codes': { + "/core/{_version}/saml/metadata": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get the XML representation of the Service Provider metadata. */ + get: operations["get_core-{_version}-saml-metadata"]; put?: never; - /** - * Regenerate recovery codes. - * @description Replace current recovery codes with new ones. - */ - post: operations['post_core-{_version}-settings-2fa-codes']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/totp': { + "/core/{_version}/settings": { 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']; + /** 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/2fa': { + "/core/{_version}/settings/password": { parameters: { query?: never; header?: never; @@ -2745,17 +2733,16 @@ export interface paths { 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']; + /** 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/2fa/reset': { + "/core/{_version}/settings/password/upgrade": { parameters: { query?: never; header?: never; @@ -2763,37 +2750,35 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; /** - * Request Reset 2FA. - * @description Reset all 2FA methods to disabled state. + * Upgrade Password. + * @description Upgrade login password on login if version < 4. */ - post: operations['post_core-{_version}-settings-2fa-reset']; + put: operations["put_core-{_version}-settings-password-upgrade"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/register': { + "/core/{_version}/settings/email": { 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']; + get?: never; + put: operations["put_core-{_version}-settings-email"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/2fa/{credentialID}/remove': { + "/core/{_version}/settings/email/verify": { parameters: { query?: never; header?: never; @@ -2802,15 +2787,15 @@ export interface paths { }; get?: never; put?: never; - /** Remove a FIDO2 credential. */ - post: operations['post_core-{_version}-settings-2fa-{credentialID}-remove']; + /** 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/2fa/{credentialID}/rename': { + "/core/{_version}/settings/email/notify": { parameters: { query?: never; header?: never; @@ -2818,8 +2803,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Rename a FIDO2 credential. */ - put: operations['put_core-{_version}-settings-2fa-{credentialID}-rename']; + /** Toggle email notifications. */ + put: operations["put_core-{_version}-settings-email-notify"]; post?: never; delete?: never; options?: never; @@ -2827,7 +2812,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/hide-side-panel': { + "/core/{_version}/settings/email/reset": { parameters: { query?: never; header?: never; @@ -2835,8 +2820,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Update HideSidePanel for the current client. */ - put: operations['put_core-{_version}-settings-hide-side-panel']; + /** Enable or disable login password reset by email. */ + put: operations["put_core-{_version}-settings-email-reset"]; post?: never; delete?: never; options?: never; @@ -2844,7 +2829,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/username': { + "/core/{_version}/settings/phone": { parameters: { query?: never; header?: never; @@ -2852,8 +2837,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Set username for external ProtonAccount. */ - put: operations['put_core-{_version}-settings-username']; + put: operations["put_core-{_version}-settings-phone"]; post?: never; delete?: never; options?: never; @@ -2861,7 +2845,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/theme': { + "/core/{_version}/settings/phone/verify": { parameters: { query?: never; header?: never; @@ -2869,15 +2853,16 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-theme']; - post?: 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/themetype': { + "/core/{_version}/settings/phone/notify": { parameters: { query?: never; header?: never; @@ -2885,7 +2870,8 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-themetype']; + /** Toggle phone notifications. */ + put: operations["put_core-{_version}-settings-phone-notify"]; post?: never; delete?: never; options?: never; @@ -2893,7 +2879,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/weekstart': { + "/core/{_version}/settings/phone/reset": { parameters: { query?: never; header?: never; @@ -2901,7 +2887,8 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-weekstart']; + /** Enable or disable login password reset by phone. */ + put: operations["put_core-{_version}-settings-phone-reset"]; post?: never; delete?: never; options?: never; @@ -2909,7 +2896,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/dateformat': { + "/core/{_version}/settings/locale": { parameters: { query?: never; header?: never; @@ -2917,7 +2904,7 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-dateformat']; + put: operations["put_core-{_version}-settings-locale"]; post?: never; delete?: never; options?: never; @@ -2925,7 +2912,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/timeformat': { + "/core/{_version}/settings/logauth": { parameters: { query?: never; header?: never; @@ -2933,7 +2920,8 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-timeformat']; + /** Update authentication logging. */ + put: operations["put_core-{_version}-settings-logauth"]; post?: never; delete?: never; options?: never; @@ -2941,7 +2929,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/welcome': { + "/core/{_version}/settings/devicerecovery": { parameters: { query?: never; header?: never; @@ -2949,7 +2937,8 @@ export interface paths { cookie?: never; }; get?: never; - put: operations['put_core-{_version}-settings-welcome']; + /** Update device recovery enabled preference. */ + put: operations["put_core-{_version}-settings-devicerecovery"]; post?: never; delete?: never; options?: never; @@ -2957,7 +2946,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/earlyaccess': { + "/core/{_version}/settings/news": { parameters: { query?: never; header?: never; @@ -2965,32 +2954,42 @@ export interface paths { cookie?: never; }; get?: never; - /** Update BetaFlags. */ - put: operations['put_core-{_version}-settings-earlyaccess']; + /** + * Update newsletter subscription. + * @deprecated + */ + put: operations["put_core-{_version}-settings-news"]; post?: never; delete?: never; options?: never; head?: never; - patch?: never; + /** Patch newsletter subscription. */ + patch: operations["patch_core-{_version}-settings-news"]; trace?: never; }; - '/core/{_version}/settings/flags': { + "/core/{_version}/settings/news/external": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-settings-flags']; + /** 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?: never; + /** Patch newsletter subscription as external user. */ + patch: operations["patch_core-{_version}-settings-news-external"]; trace?: never; }; - '/core/{_version}/settings/telemetry': { + "/core/{_version}/settings/density": { parameters: { query?: never; header?: never; @@ -2998,8 +2997,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Update telemetry enabled preference. */ - put: operations['put_core-{_version}-settings-telemetry']; + /** Update the mail list density. */ + put: operations["put_core-{_version}-settings-density"]; post?: never; delete?: never; options?: never; @@ -3007,7 +3006,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/crashreports': { + "/core/{_version}/settings/invoicetext": { parameters: { query?: never; header?: never; @@ -3015,8 +3014,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Update crash reports enabled preference. */ - put: operations['put_core-{_version}-settings-crashreports']; + /** Update invoice user-defined text. */ + put: operations["put_core-{_version}-settings-invoicetext"]; post?: never; delete?: never; options?: never; @@ -3024,7 +3023,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/highsecurity': { + "/core/{_version}/settings/2fa/codes": { parameters: { query?: never; header?: never; @@ -3034,21 +3033,17 @@ export interface paths { 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 + * Regenerate recovery codes. + * @description Replace current recovery codes with new ones. */ - delete: operations['delete_core-{_version}-settings-highsecurity']; + post: operations["post_core-{_version}-settings-2fa-codes"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/breachalerts': { + "/core/{_version}/settings/2fa/totp": { parameters: { query?: never; header?: never; @@ -3056,32 +3051,45 @@ export interface paths { 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']; + 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"]; /** - * Breach Alert - disable - * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting + * Signup for TOTP. + * @deprecated */ - delete: operations['delete_core-{_version}-settings-breachalerts']; + post: operations["post_core-{_version}-settings-2fa"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/sessionaccountrecovery': { + "/core/{_version}/settings/2fa/totp/secret": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update session account recovery preference. */ - put: operations['put_core-{_version}-settings-sessionaccountrecovery']; + get: operations["get_core-{_version}-settings-2fa-totp-secret"]; + put?: never; post?: never; delete?: never; options?: never; @@ -3089,7 +3097,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/settings/ai-assistant-flags': { + "/core/{_version}/settings/2fa/reset": { parameters: { query?: never; header?: never; @@ -3097,32 +3105,37 @@ export interface paths { cookie?: never; }; get?: never; - /** Update setting to enable or disable AI Assistant. */ - put: operations['put_core-{_version}-settings-ai-assistant-flags']; - post?: 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}/support/schedulecall': { + "/core/{_version}/settings/2fa/register": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-support-schedulecall']; + /** Get a challenge for registration of a FIDO2 credential. */ + get: operations["get_core-{_version}-settings-2fa-register"]; put?: never; - post?: 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/product-disabled': { + "/core/{_version}/settings/2fa/{credentialID}/remove": { parameters: { query?: never; header?: never; @@ -3130,57 +3143,42 @@ export interface paths { 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; + 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}/users/delete': { + "/core/{_version}/settings/2fa/{credentialID}/rename": { 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']; + get?: never; + /** Rename a FIDO2 credential. */ + put: operations["put_core-{_version}-settings-2fa-{credentialID}-rename"]; post?: never; - /** Delete self, will invalidate API access token. */ - delete: operations['delete_core-{_version}-users-delete']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/reset': { + "/core/{_version}/settings/hide-side-panel": { 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; + get?: never; + /** Update HideSidePanel for the current client. */ + put: operations["put_core-{_version}-settings-hide-side-panel"]; post?: never; delete?: never; options?: never; @@ -3188,89 +3186,24 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users': { + "/core/{_version}/settings/username": { 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']; + 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}/users/external': { + "/core/{_version}/settings/theme": { parameters: { query?: never; header?: never; @@ -3278,19 +3211,15 @@ export interface paths { 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']; + put: operations["put_core-{_version}-settings-theme"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/check': { + "/core/{_version}/settings/themetype": { parameters: { query?: never; header?: never; @@ -3298,8 +3227,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Check user creation token validity. */ - put: operations['put_core-{_version}-users-check']; + put: operations["put_core-{_version}-settings-themetype"]; post?: never; delete?: never; options?: never; @@ -3307,16 +3235,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/availableExternal': { + "/core/{_version}/settings/weekstart": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Check if username already taken. */ - get: operations['get_core-{_version}-users-availableExternal']; - put?: never; + get?: never; + put: operations["put_core-{_version}-settings-weekstart"]; post?: never; delete?: never; options?: never; @@ -3324,16 +3251,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/available': { + "/core/{_version}/settings/dateformat": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Check if username already taken. */ - get: operations['get_core-{_version}-users-available']; - put?: never; + get?: never; + put: operations["put_core-{_version}-settings-dateformat"]; post?: never; delete?: never; options?: never; @@ -3341,16 +3267,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/available/{username}': { + "/core/{_version}/settings/timeformat": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** @deprecated */ - get: operations['get_core-{_version}-users-available-{username}']; - put?: never; + get?: never; + put: operations["put_core-{_version}-settings-timeformat"]; post?: never; delete?: never; options?: never; @@ -3358,16 +3283,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/direct': { + "/core/{_version}/settings/welcome": { 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; + get?: never; + put: operations["put_core-{_version}-settings-welcome"]; post?: never; delete?: never; options?: never; @@ -3375,7 +3299,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/code': { + "/core/{_version}/settings/earlyaccess": { parameters: { query?: never; header?: never; @@ -3383,16 +3307,16 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - /** Send a verification code. */ - post: operations['post_core-{_version}-users-code']; + /** Update BetaFlags. */ + put: operations["put_core-{_version}-settings-earlyaccess"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/lock': { + "/core/{_version}/settings/flags": { parameters: { query?: never; header?: never; @@ -3400,8 +3324,7 @@ export interface paths { cookie?: never; }; get?: never; - /** Lock sensitive settings for keys/organization. */ - put: operations['put_core-{_version}-users-lock']; + put: operations["put_core-{_version}-settings-flags"]; post?: never; delete?: never; options?: never; @@ -3409,7 +3332,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/unlock': { + "/core/{_version}/settings/telemetry": { parameters: { query?: never; header?: never; @@ -3417,8 +3340,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Unlock sensitive settings for keys/organization. */ - put: operations['put_core-{_version}-users-unlock']; + /** Update telemetry enabled preference. */ + put: operations["put_core-{_version}-settings-telemetry"]; post?: never; delete?: never; options?: never; @@ -3426,7 +3349,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/password': { + "/core/{_version}/settings/crashreports": { parameters: { query?: never; header?: never; @@ -3434,8 +3357,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Unlock password changes. */ - put: operations['put_core-{_version}-users-password']; + /** Update crash reports enabled preference. */ + put: operations["put_core-{_version}-settings-crashreports"]; post?: never; delete?: never; options?: never; @@ -3443,62 +3366,72 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/captcha/{token}': { + "/core/{_version}/settings/highsecurity": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get captcha (javascript) (hv1). */ - get: operations['get_core-{_version}-users-captcha-{token}']; + get?: never; put?: never; - post?: never; - delete?: 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}/users/disable/{jwt}': { + "/core/{_version}/settings/breachalerts": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-users-disable-{jwt}']; + get?: never; put?: never; - post?: never; - delete?: 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/v4/features': { + "/core/{_version}/settings/sessionaccountrecovery": { 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']; + 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/v4/features/{id}': { + "/core/{_version}/settings/ai-assistant-flags": { parameters: { query?: never; header?: never; @@ -3506,8 +3439,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Update feature configuration. */ - put: operations['put_core-v4-features-{id}']; + /** Update setting to enable or disable AI Assistant. */ + put: operations["put_core-{_version}-settings-ai-assistant-flags"]; post?: never; delete?: never; options?: never; @@ -3515,7 +3448,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/features/{featureID}': { + "/core/{_version}/settings/news/unsubscribe": { parameters: { query?: never; header?: never; @@ -3524,23 +3457,21 @@ export interface paths { }; get?: never; put?: never; - post?: never; - /** Remove a feature definition. */ - delete: operations['delete_core-v4-features-{featureID}']; + post: operations["post_core-{_version}-settings-news-unsubscribe"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{code}': { + "/core/{_version}/support/schedulecall": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get a single feature by its code. */ - get: operations['get_core-v4-features-{code}']; + get: operations["get_core-{_version}-support-schedulecall"]; put?: never; post?: never; delete?: never; @@ -3549,7 +3480,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/features/{code}/value': { + "/core/{_version}/keys/{enc_id}/primary": { parameters: { query?: never; header?: never; @@ -3557,17 +3488,20 @@ 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']; + /** + * 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; - /** Clear the value of a single feature by its code. */ - delete: operations['delete_core-v4-features-{code}-value']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/features/{code}/user/value': { + "/core/{_version}/members/{memberId}/lumo": { parameters: { query?: never; header?: never; @@ -3575,8 +3509,7 @@ 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']; + put: operations["put_core-{_version}-members-{memberId}-lumo"]; post?: never; delete?: never; options?: never; @@ -3584,21 +3517,16 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes': { + "/core/{_version}/settings/product-disabled": { 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; + 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; @@ -3606,7 +3534,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes/active': { + "/core/{_version}/users/delete": { parameters: { query?: never; header?: never; @@ -3614,32 +3542,39 @@ export interface paths { 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. + * 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}-pushes-active']; - put?: never; + get: operations["get_core-{_version}-users-delete"]; + /** Delete self, will invalidate API access token. */ + put: operations["put_core-{_version}-users-delete"]; post?: never; - delete?: 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}/pushes/active/session': { + "/core/{_version}/users/reset": { 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']; + /** Get available reset methods and account type. */ + get: operations["get_core-{_version}-users-reset"]; put?: never; post?: never; delete?: never; @@ -3648,79 +3583,136 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/pushes/{enc_id}': { + "/core/{_version}/reset/{username}/{token}": { 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. + * Validate reset token. + * @description Error response example: + * ``` + * { + * "Code": 12031, + * "Error": "Invalid reset token", + * "Details": } + * } + * ``` */ - delete: operations['delete_core-{_version}-pushes-{enc_id}']; + get: operations["get_core-{_version}-reset-{username}-{token}"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/referrals': { + "/core/{_version}/users": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List current user referrals. */ - get: operations['get_core-{_version}-referrals']; + /** + * 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; - /** Send referral invitation by email to a list of recipients. */ - post: operations['post_core-{_version}-referrals']; + /** + * 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}/referrals/status': { + "/core/{_version}/users/external": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Current user referral status. */ - get: operations['get_core-{_version}-referrals-status']; + get?: never; 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; + /** + * 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}/devices': { + "/core/{_version}/users/check": { parameters: { query?: never; header?: never; @@ -3728,67 +3720,58 @@ export interface paths { 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']; + /** 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}/betas/{client_id}': { + "/core/{_version}/users/availableExternal": { 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}']; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-availableExternal"]; + put?: never; post?: never; - /** Delete a specific beta registration. */ - delete: operations['delete_core-{_version}-betas-{client_id}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/betas': { + "/core/{_version}/users/available": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get all beta registrations. */ - get: operations['get_core-{_version}-betas']; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-available"]; put?: never; post?: never; - /** Delete all beta registrations. */ - delete: operations['delete_core-{_version}-betas']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/geofeed/geofeed.csv': { + "/core/{_version}/users/available/{username}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get a CSV export for GeoFeed. */ - get: operations['get_core-{_version}-geofeed-geofeed-csv']; + /** @deprecated */ + get: operations["get_core-{_version}-users-available-{username}"]; put?: never; post?: never; delete?: never; @@ -3797,15 +3780,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/geofeed/geofeed-public.csv': { + "/core/{_version}/users/direct": { 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']; + /** Deprecated. Placeholder left in place for handling old clients. */ + get: operations["get_core-{_version}-users-direct"]; put?: never; post?: never; delete?: never; @@ -3814,72 +3797,58 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/load': { + "/core/{_version}/users/code": { 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']; + get?: never; 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']; + /** Send a verification code. */ + post: operations["post_core-{_version}-users-code"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/logs/auth': { + "/core/{_version}/users/lock": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get authentication logs. */ - get: operations['get_core-{_version}-logs-auth']; - put?: never; + get?: never; + /** Lock sensitive settings for keys/organization. */ + put: operations["put_core-{_version}-users-lock"]; post?: never; - /** Delete all authentication logs. */ - delete: operations['delete_core-{_version}-logs-auth']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/metrics': { + "/core/{_version}/users/unlock": { 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']; + 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}/settings/recovery/secret': { + "/core/{_version}/users/password": { parameters: { query?: never; header?: never; @@ -3887,160 +3856,82 @@ export interface paths { 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']; + /** Unlock password changes. */ + put: operations["put_core-{_version}-users-password"]; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/form/{portal_id}/{form_id}': { + "/core/{_version}/users/captcha/{token}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get captcha (javascript) (hv1). */ + get: operations["get_core-{_version}-users-captcha-{token}"]; 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?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug': { + "/core/{_version}/users/disable/{jwt}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-users-disable-{jwt}"]; 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']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug/attachments': { + "/core/{_version}/users/invitations/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-users-invitations-{id}"]; 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']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/bug/{ticketId}': { + "/core/{_version}/users/invitations": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Gets organization invitations sent to a user. */ + get: operations["get_core-{_version}-users-invitations"]; put?: never; post?: never; - /** Solve ticket */ - delete: operations['delete_core-{_version}-reports-bug-{ticketId}']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/abuse': { + "/core/{_version}/users/invitations/{enc_id}/reject": { parameters: { query?: never; header?: never; @@ -4049,45 +3940,15 @@ export interface paths { }; 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']; + /** 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}/reports/crash': { + "/core/{_version}/users/invitations/{enc_id}/accept": { parameters: { query?: never; header?: never; @@ -4096,42 +3957,35 @@ export interface paths { }; get?: never; put?: never; - /** Report a client crash. */ - post: operations['post_core-{_version}-reports-crash']; + /** 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}/reports/sentry/api/{id}/{type}': { + "/core/{_version}/members/{enc_id}/vpn": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; + get: operations["get_core-{_version}-members-{enc_id}-vpn"]; /** - * 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} - *
+ * Update max number of VPNs for member. + * @description Update number of maximum VPN connections, locked route. */ - post: operations['post_core-{_version}-reports-sentry-api-{id}-{type}']; + put: operations["put_core-{_version}-members-{enc_id}-vpn"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/sentry/api/{id}/{type}/': { + "/core/{_version}/nps/dismiss": { parameters: { query?: never; header?: never; @@ -4140,25 +3994,15 @@ export interface paths { }; 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}']; + /** 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/{_version}/reports/phishing': { + "/core/{_version}/nps/submit": { parameters: { query?: never; header?: never; @@ -4167,65 +4011,71 @@ export interface paths { }; get?: never; put?: never; - /** Report a phishing email. */ - post: operations['post_core-{_version}-reports-phishing']; + /** Submit NPS Survey Feedback */ + post: operations["post_core-{_version}-nps-submit"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/spam': { + "/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; - /** Report spam. */ - post: operations['post_core-{_version}-reports-spam']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reports/cancel-plan': { + "/core/v4/features/{code}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Get a single feature by its code. */ + get: operations["get_core-v4-features-{code}"]; put?: never; - post: operations['post_core-{_version}-reports-cancel-plan']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reset/{username}/{token}': { + "/core/v4/features/{code}/value": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Validate reset token. */ - get: operations['get_core-{_version}-reset-{username}-{token}']; - put?: never; + get?: never; + /** Set the value of a single feature by its code. */ + put: operations["put_core-v4-features-{code}-value"]; post?: never; - delete?: 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}/reset': { + "/core/{_version}/auth/info": { parameters: { query?: never; header?: never; @@ -4234,15 +4084,15 @@ export interface paths { }; get?: never; put?: never; - /** Request login reset token. */ - post: operations['post_core-{_version}-reset']; + /** Set up SRP authentication request. */ + post: operations["post_core-{_version}-auth-info"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/reset/username': { + "/core/{_version}/auth/saml": { parameters: { query?: never; header?: never; @@ -4251,54 +4101,57 @@ export interface paths { }; get?: never; put?: never; - /** Send usernames to notification email. */ - post: operations['post_core-{_version}-reset-username']; + /** 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}/system/config': { + "/core/{_version}/auth/jwt": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-system-config']; + get?: never; put?: never; - post?: 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}/system/version': { + "/core/{_version}/auth/2fa": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-system-version']; + 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}/tests/exception': { + "/core/{_version}/auth/modulus": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-exception']; + /** Get random SRP modulus. */ + get: operations["get_core-{_version}-auth-modulus"]; put?: never; post?: never; delete?: never; @@ -4307,14 +4160,18 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/error': { + "/core/{_version}/auth/scopes": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-error']; + /** + * 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; @@ -4323,64 +4180,64 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/notice': { + "/core/{_version}/auth/cookies": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-notice']; + get?: never; 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; + /** + * 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}/tests/logger': { + "/core/{_version}/auth/credentialless": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-logger']; + get?: never; put?: never; - post?: 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}/tests/logger/observability': { + "/core/{_version}/settings/mnemonic": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-tests-logger-observability']; - put?: 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; @@ -4388,7 +4245,7 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/tests/ping': { + "/core/{_version}/settings/mnemonic/reset": { parameters: { query?: never; header?: never; @@ -4396,46 +4253,63 @@ export interface paths { 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 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}-tests-ping']; + get: operations["get_core-{_version}-settings-mnemonic-reset"]; put?: never; - post?: 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}/tests/version': { + "/core/{_version}/settings/mnemonic/disable": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** @deprecated */ - get: operations['get_core-{_version}-tests-version']; + get?: never; put?: never; - post?: 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}/tests/stream': { + "/core/{_version}/settings/mnemonic/reactivate": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Test endpoint to check streaming capabilities */ - get: operations['get_core-{_version}-tests-stream']; - put?: 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; @@ -4443,14 +4317,20 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/update': { + "/core/{_version}/pushes": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-update']; + /** + * 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; @@ -4459,15 +4339,19 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/invitations': { + "/core/{_version}/pushes/active": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Gets organization invitations sent to a user. */ - get: operations['get_core-{_version}-users-invitations']; + /** + * 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; @@ -4476,24 +4360,28 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/users/invitations/{enc_id}/reject': { + "/core/{_version}/pushes/active/session": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: 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; - /** Rejects an invitation. */ - post: operations['post_core-{_version}-users-invitations-{enc_id}-reject']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/users/invitations/{enc_id}/accept': { + "/core/{_version}/pushes/{enc_id}": { parameters: { query?: never; header?: never; @@ -4502,15 +4390,18 @@ export interface paths { }; get?: never; put?: never; - /** Accepts an invitation. */ - post: operations['post_core-{_version}-users-invitations-{enc_id}-accept']; - delete?: 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}/validate/email': { + "/core/{_version}/devices": { parameters: { query?: never; header?: never; @@ -4519,103 +4410,125 @@ export interface paths { }; get?: never; put?: never; - /** Validate email address. */ - post: operations['post_core-{_version}-validate-email']; - delete?: 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}/validate/phone': { + "/core/{_version}/betas/{client_id}": { 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; + /** 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}/verification/ownership/{token}': { + "/core/{_version}/betas": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get details of a given Ownership Verification. */ - get: operations['get_core-{_version}-verification-ownership-{token}']; + /** Get all beta registrations. */ + get: operations["get_core-{_version}-betas"]; put?: never; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-{token}']; - delete?: never; + post?: never; + /** Delete all beta registrations. */ + delete: operations["delete_core-{_version}-betas"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership-email/{token}': { + "/core/{_version}/load": { 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}']; + /** + * 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; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-email-{token}']; + /** + * 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}/verification/ownership-sms/{token}': { + "/core/{_version}/logs/auth": { 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}']; + /** Get authentication logs. */ + get: operations["get_core-{_version}-logs-auth"]; put?: never; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-sms-{token}']; - delete?: never; + post?: never; + /** Delete all authentication logs. */ + delete: operations["delete_core-{_version}-logs-auth"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verification/ownership/{token}/{code}': { + "/core/{_version}/metrics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Send Simple Metrics. */ + get: operations["get_core-{_version}-metrics"]; put?: never; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-{token}-{code}']; + /** + * 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}/verification/ownership-email/{token}/{code}': { + "/core/{_version}/settings/recovery/secret": { parameters: { query?: never; header?: never; @@ -4624,15 +4537,22 @@ export interface paths { }; get?: never; put?: never; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-email-{token}-{code}']; - delete?: 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}/verification/ownership-sms/{token}/{code}': { + "/core/{_version}/reports/form/{portal_id}/{form_id}": { parameters: { query?: never; header?: never; @@ -4641,88 +4561,137 @@ export interface paths { }; get?: never; put?: never; - /** Request ownership verification. */ - post: operations['post_core-{_version}-verification-ownership-sms-{token}-{code}']; + /** 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/v6/events/{id}': { + "/core/{_version}/reports/bug": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-v6-events-{id}']; + get?: never; put?: never; - post?: 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 + * ``` + * + * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded + */ + post: operations["post_core-{_version}-reports-bug"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/events/latest': { + "/core/{_version}/reports/bug/attachments": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-events-latest']; + get?: never; 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. + * 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 + * ``` */ - get: operations['get_core-{_version}-events-{id}']; - put?: never; - post?: never; + post: operations["post_core-{_version}-reports-bug-attachments"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/v4/events/{id}': { + "/core/{_version}/reports/bug/{ticketId}": { 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; - delete?: never; + /** Solve ticket */ + delete: operations["delete_core-{_version}-reports-bug-{ticketId}"]; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/feedback': { + "/core/{_version}/reports/abuse": { parameters: { query?: never; header?: never; @@ -4731,47 +4700,89 @@ export interface paths { }; 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; + /** + * 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: operations['get_core-{_version}-checklist-get-started']; + get?: never; put?: never; - post?: never; + /** Report a client crash. */ + post: operations["post_core-{_version}-reports-crash"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user': { + "/core/{_version}/reports/sentry/api/{id}/{type}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['get_core-{_version}-checklist-paying-user']; + get?: never; put?: never; - post?: 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}/checklist/get-started/seen-completed-list': { + "/core/{_version}/reports/phishing": { parameters: { query?: never; header?: never; @@ -4780,14 +4791,15 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-get-started-seen-completed-list']; + /** Report a phishing email. */ + post: operations["post_core-{_version}-reports-phishing"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/hide': { + "/core/{_version}/reports/cancel-plan": { parameters: { query?: never; header?: never; @@ -4796,14 +4808,14 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-paying-user-hide']; + post: operations["post_core-{_version}-reports-cancel-plan"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/seen-completed-list': { + "/core/{_version}/reset": { parameters: { query?: never; header?: never; @@ -4812,14 +4824,15 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-paying-user-seen-completed-list']; + /** Request login reset token. */ + post: operations["post_core-{_version}-reset"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/get-started/init': { + "/core/{_version}/reset/username": { parameters: { query?: never; header?: never; @@ -4828,38 +4841,39 @@ export interface paths { }; get?: never; put?: never; - post: operations['post_core-{_version}-checklist-get-started-init']; + /** Send usernames to notification email. */ + post: operations["post_core-{_version}-reset-username"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/paying-user/init': { + "/core/{_version}/system/config": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-system-config"]; put?: never; - post: operations['post_core-{_version}-checklist-paying-user-init']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/checklist/check-item': { + "/core/{_version}/system/version": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-checklist-check-item']; + get: operations["get_core-{_version}-system-version"]; + put?: never; post?: never; delete?: never; options?: never; @@ -4867,15 +4881,15 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/checklist/update-display': { + "/core/{_version}/tests/exception": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-checklist-update-display']; + get: operations["get_core-{_version}-tests-exception"]; + put?: never; post?: never; delete?: never; options?: never; @@ -4883,118 +4897,116 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/verify/send': { + "/core/{_version}/tests/error": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-tests-error"]; put?: never; - /** Send a verification link. */ - post: operations['post_core-{_version}-verify-send']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/validate': { + "/core/{_version}/tests/notice": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-tests-notice"]; 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}/tests/user-deprecation": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-tests-user-deprecation"]; 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/{_version}/tests/memoryLeak": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Simulate a memory leak. */ + get: operations["get_core-{_version}-tests-memoryLeak"]; put?: never; - /** Trigger ownership verification on phone number only. */ - post: operations['post_core-{_version}-verify-phone']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/reauth/email': { + "/core/{_version}/tests/logger": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-tests-logger"]; 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?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/verify/reauth/phone': { + "/core/{_version}/tests/logger/observability": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_core-{_version}-tests-logger-observability"]; 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?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/notifications': { + "/core/{_version}/tests/ping": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get all the notifications. */ - get: operations['get_core-{_version}-notifications']; + /** + * 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; @@ -5003,85 +5015,74 @@ export interface paths { patch?: never; trace?: never; }; - '/core/v4/labels/{enc_id}': { + "/core/{_version}/tests/version": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** @deprecated */ + get: operations["get_core-{_version}-tests-version"]; put?: never; 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}/tests/stream": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Test endpoint to check streaming capabilities */ + get: operations["get_core-{_version}-tests-stream"]; put?: never; - /** Get user labels by IDs. */ - post: operations['post_core-v4-labels-by-ids']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels': { + "/core/{_version}/update": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get user's labels. */ - get: operations['get_core-{_version}-labels']; + get: operations["get_core-{_version}-update"]; put?: never; - /** Create new label. */ - post: operations['post_core-{_version}-labels']; - /** Delete multiple labels. */ - delete: operations['delete_core-{_version}-labels']; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels/available': { + "/core/{_version}/validate/email": { 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']; + get?: never; put?: never; - post?: never; + /** Validate email address. */ + post: operations["post_core-{_version}-validate-email"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels/order': { + "/core/{_version}/validate/phone": { parameters: { query?: never; header?: never; @@ -5089,49 +5090,70 @@ export interface paths { cookie?: never; }; get?: never; - /** Change label priority. */ - put: operations['put_core-{_version}-labels-order']; - post?: 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}/labels/order/tree/{startLabelId}': { + "/core/{_version}/verification/ownership/{token}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put: operations['put_core-{_version}-labels-order-tree-{startLabelId}']; - post?: 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}/labels/{id}': { + "/core/{_version}/verification/ownership-email/{token}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - /** Update existing label. */ - put: operations['put_core-{_version}-labels-{id}']; - post?: 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}/labels/{enc_id}': { + "/core/{_version}/verification/ownership/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5140,15 +5162,15 @@ export interface paths { }; get?: never; put?: never; - post?: never; - /** Delete a label. */ - delete: operations['delete_core-{_version}-labels-{enc_id}']; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-{token}-{code}"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/labels/{enc_labelID}/detach': { + "/core/{_version}/verification/ownership-email/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5156,19 +5178,16 @@ 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']; - post?: 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}/auth/info': { + "/core/{_version}/verification/ownership-sms/{token}/{code}": { parameters: { query?: never; header?: never; @@ -5177,23 +5196,22 @@ export interface paths { }; get?: never; put?: never; - /** Set up SRP authentication request. */ - post: operations['post_core-{_version}-auth-info']; + /** Request ownership verification. */ + post: operations["post_core-{_version}-verification-ownership-sms-{token}-{code}"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/sso/{token}': { + "/core/v6/events/{id}": { 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: operations["get_core-v6-events-{id}"]; put?: never; post?: never; delete?: never; @@ -5202,42 +5220,48 @@ export interface paths { patch?: never; trace?: never; }; - '/core/{_version}/auth/saml': { + "/core/{_version}/events/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: 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; - /** HTTP-POST binding for SAML authentication. Only to be called by an IdP. */ - post: operations['post_core-{_version}-auth-saml']; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth': { + "/core/v4/events/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: 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; - /** Authenticate. */ - post: operations['post_core-{_version}-auth']; - /** Revoke a token. */ - delete: operations['delete_core-{_version}-auth']; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/jwt': { + "/core/{_version}/feedback": { parameters: { query?: never; header?: never; @@ -5246,15 +5270,15 @@ export interface paths { }; get?: never; put?: never; - /** Authenticate using pre-issued JWT. */ - post: operations['post_core-{_version}-auth-jwt']; + /** Log general user feedback. */ + post: operations["post_core-{_version}-feedback"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/auth/2fa': { + "/core/{_version}/checklist/seen-completed-list/{checklistType}": { parameters: { query?: never; header?: never; @@ -5263,52 +5287,49 @@ export interface paths { }; get?: never; put?: never; - /** Submit second factor. */ - post: operations['post_core-{_version}-auth-2fa']; + /** 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}/auth/modulus': { + "/core/{_version}/checklist/get-started/init": { 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; + /** 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}/auth/scopes': { + "/core/{_version}/checklist/paying-user/init": { 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?: 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}/auth/refresh': { + "/core/{_version}/checklist/check-item": { parameters: { query?: never; header?: never; @@ -5316,30 +5337,16 @@ export interface paths { 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']; + /** 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}/auth/cookies': { + "/core/{_version}/checklist/update-display": { parameters: { query?: never; header?: never; @@ -5347,21 +5354,16 @@ export interface paths { 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']; + /** 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}/auth/credentialless': { + "/core/{_version}/verify/send": { parameters: { query?: never; header?: never; @@ -5370,67 +5372,50 @@ export interface paths { }; get?: never; put?: never; - /** Create and authenticate a credential-less user. */ - post: operations['post_core-{_version}-auth-credentialless']; + /** Send a verification link. */ + post: operations["post_core-{_version}-verify-send"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/core/{_version}/settings/mnemonic': { + "/core/{_version}/verify/validate": { 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; + 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}/settings/mnemonic/reset': { + "/core/{_version}/verify/email": { 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']; + get?: never; 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']; + /** 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}/settings/mnemonic/disable': { + "/core/{_version}/verify/phone": { parameters: { query?: never; header?: never; @@ -5439,19 +5424,15 @@ export interface paths { }; 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']; + /** 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}/settings/mnemonic/reactivate': { + "/core/{_version}/verify/reauth/email": { parameters: { query?: never; header?: never; @@ -5459,2758 +5440,2748 @@ export interface paths { 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; + 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}/images': { + "/core/{_version}/verify/reauth/phone": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get image through proxy. */ - get: operations['get_core-{_version}-images']; + get?: never; put?: never; - post?: 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; }; -} -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; + "/core/{_version}/notifications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - 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; + /** 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/{_version}/connection-information": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - LogoRequest: { - /** - * The percent encoded address. Either Domain or Address are required. - * @default null - * @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; - /** - * @default null - * @enum {string|null} - */ - Format: 'png' | null; - ComputedAddress: string; + /** 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; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/labels/by-ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - 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']; + get?: never; + put?: never; + /** + * 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/v5/labels/by-ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ReorderAddressesInput: { - /** @description Will amend the order of addresses with the order of the corresponding AddressIDs */ - AddressIDs?: string[]; + get?: never; + put?: never; + /** 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}/labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - AddressListInput: { - AddressList?: components['schemas']['KTAddressListTransformer']; + /** 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; }; - ChangeAddressTypeInput: { - /** - * @description 1: original, 2: Alias, 3: Custom, 4: Premium, 5: External - * @example 3 - */ - Type?: number; - SignedKeyList?: components['schemas']['SignedKeyListInput']; + /** + * 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; }; - 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; - }[]; + 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; }; - UpdateAddressInput: { - /** - * @description Optional, if empty string - use default - * @example hi - */ - DisplayName?: string; - /** - * @description Optional, if empty string - use default - * @example signature - */ - Signature?: string; + 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; }; - UpdateEncryptionSignatureFlagsInput: { - /** @example 1 */ - Encrypt?: number; - /** @example 1 */ - Sign?: number; - SignedKeyList?: components['schemas']['KTKeyList']; + 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; }; - AddressIdsInput: { - /** @description List of encrypted addressIDs */ - IDs?: unknown[]; - /** @description Permissions bit to apply */ - Permissions?: (1 | 2 | 8 | 16)[]; + 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; }; - /** @description An encrypted ID */ - Id: string; - ResetAuthDevicesInput: { - AuthDeviceID: components['schemas']['Id2']; - 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'][]; + 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/v4/labels/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - 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; + 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/{_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; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-referrals-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/referrals/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-referrals-status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/trials/{referralIdentifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-trials-{referralIdentifier}"]; + put?: never; + post?: never; + 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}/referrals/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + 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; + 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; + }; + "/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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * ProtonResponseCode + * @constant + */ + 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: { + /** @constant */ + BlockMaxSizeInBytes?: 5300000; + /** @constant */ + ThumbnailMaxSizeInBytes?: 65536; + /** @constant */ + DraftRevisionLifetimeInSec?: 14400; + /** @constant */ + ExtendedAttributesMaxSizeInBytes?: 65535; + /** @constant */ + UploadTokenExpirationTimeInSec?: 10800; + /** @constant */ + DownloadTokenExpirationTimeInSec?: 1800; + }; + SignedKeyListInput: { + /** @example JSON.stringify([{""Fingerprint"": ""fde90483475164ec6353c93f767df53b0ca8395c"",""SHA256Fingerprints"": [""164ec63...53c93f7"", ""f767d...f53b0c""],""Primary"": 1,""Flags"": 3}]) */ + Data: 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; - }; + Signature: string; }; - CreateScimTenantInput: { - /** @description The password for the SCIM tenant, used for the integration with the IdP */ - Password: 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; }; - CreateDomainInput: { - /** @example funoccupied.com */ - Name: string; + AuthInput: { + /** @example 4 */ + Version: number; + /** @example */ + ModulusID: string; + /** @example */ + Salt: string; + /** @example */ + Verifier: string; + }; + /** Signed Key List */ + KTAddressListTransformer: { /** - * @description True if this domain is intended for Mail usage - * @default true + * @description JSON-encoded content of the SAL + * @example [{"Email": "test@example.com","Flags": 1}] */ - AllowedForMail: boolean; + Data: string; /** - * @description True if this domain is intended for SSO usage - * @default false + * @description The armored signature over the JSON-serialized data with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - AllowedForSSO: boolean; + Signature: string; }; - UpdateCatchAllAddressInput: { + SetupKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrimaryKey: string; /** - * @description or null to unset - * @example + * @description RANDOMLY generated client-side + * @example */ - AddressID?: string | null; - AddressList?: components['schemas']['KTAddressListTransformer']; - }; - OffsetPagination: { - /** The page size */ - PageSize: number; + KeySalt: string; /** - * The page index using 0-based indexing - * @default 0 + * @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----- */ - Page: number; - }; - AddGroupMemberRequest: { - Type: components['schemas']['GroupMemberType']; - GroupID: components['schemas']['Id2']; - 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; - /** - * @example 1000 - * @enum {integer} - */ - readonly Code: 1000; - }; - /** @description An encrypted ID */ - EncryptedId: string; - GroupMembersResponse: { - Members: components['schemas']['GroupMember'][]; - Total: number; + OrgPrimaryUserKey: string; /** - * @example 1000 - * @enum {integer} + * @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-----.* */ - readonly Code: 1000; - }; - InternalGroupMembershipsResponse: { - Memberships: components['schemas']['InternalGroupMembership'][]; - Total: number; + OrgActivationToken: string; + AddressKeys: components["schemas"]["AddressKeyInput"][]; + /** @description include to enable 1 password mode, otherwise 2 password mode */ + Auth: components["schemas"]["AuthInput"]; + AddressList: components["schemas"]["KTAddressListTransformer"]; /** - * @example 1000 - * @enum {integer} + * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x */ - readonly Code: 1000; + EncryptedSecret: string; }; - UpdateGroupRequest: { + /** @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 */ - 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; + GroupMemberID: Record | null; /** @default null */ - Permissions: components['schemas']['GroupPermissions'] | null; + Signature: string | null; /** @default null */ - Flags: components['schemas']['GroupFlags'] | null; + OrgToken: string | null; /** @default null */ - Description: string | null; + OrgSignature: string | null; + /** @default null */ + Token: string | null; }; - AddressKeyInput: Record; - AddressKeyInput2: Record; - AddressKeyInput3: Record; - AddressKeyInput4: Record; SignedKeyListInputWrapper: { - SignedKeyList?: components['schemas']['SignedKeyListInput']; - }; - 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'][]; - }; - 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'][]; - }; - AddNewUserKeyInput: { - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example 1 */ - Primary?: Record; - }; - 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; - }; - 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; + 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 */ + 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; - /** @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?: { - /** @example 4 */ - Version?: number; - /** @example */ - ModulusID?: string; - /** @example */ - Salt?: string; - /** @example */ - Verifier?: string; - }; - }; - 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== + * @description If org admin (legacy scheme) that can decrypt org key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - KeySalt?: string; + OrganizationKey: string; + Auth: components["schemas"]["AuthInput"]; /** - * Format: base64 * @description Optional, for inline re-authentication * @example */ - ClientEphemeral?: string; + ClientEphemeral: string; /** - * Format: base64 * @description Optional, for inline re-authentication * @example */ - ClientProof?: string; + ClientProof: string; /** - * Format: hex * @description Optional, for inline re-authentication * @example */ - SRPSession?: string; + SRPSession: string; /** * @description Optional, for inline re-authentication, either this or the FIDO2 object * @example 123456 or recovery code */ - TwoFactorCode?: string; + 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: { + FIDO2: components["schemas"]["Fido2Input"]; /** - * @description organization name, maximum 40 characters - * @example E-Corp + * @description Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key + * @example */ - Name?: string; + EncryptedSecret: string; }; - UpdateOrganizationEmailInput: { + /** @description An encrypted ID */ + Id: string; + LatestEventResponse: { + EventID?: components["schemas"]["Id"] | null; /** - * Format: email - * @description organization email, can be null - * @example contact@e-corp.com + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - Email?: string; + Code: 1000; }; - UpdateOrganizationTwoFactorGracePeriodInput: { + LogoRequest: { /** - * @description number of seconds before 2FA enforced - * @example 86400 + * The percent encoded address. Either Domain or Address are required. + * @example noreply%40amazon.com */ - GracePeriod?: number; - }; - ReplaceOrganizationKeysInput: { + Address?: string | null; /** - * Format: base64 - * @description Optional, for inline re-authentication - * @example + * Domain to get the logo for. Either Domain or Address are required. + * @default null + * @example amazon.com */ - ClientEphemeral?: string; + Domain: string | null; /** - * Format: base64 - * @description Optional, for inline re-authentication - * @example + * The size of the logo to be returned. + * @default 32 + * @example 64 */ - ClientProof?: string; + Size: number; /** - * Format: hex - * @description Optional, for inline re-authentication - * @example + * The theme being used. + * @default light + * @enum {string} */ - SRPSession?: string; + Mode: "light" | "dark"; /** - * @description Optional, for inline re-authentication, either this or the FIDO2 object - * @example 123456 or recovery code + * The bimi-selector of the message + * @default default */ - 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[]; - }; + BimiSelector: string; /** - * @description organization private key encrypted with backup password hash - * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + * The maximum factor an image can be scaled up. + * @default 2 + * @example 2 + * @enum {integer} */ - PrivateKey?: string; + MaxScaleUpFactor: 1 | 2 | 3 | 4; /** - * @description backup private key encrypted with backup password hash - * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + * Format to convert SVG images to + * @default null + * @enum {string|null} */ - 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'][]; - /** - * @description Activate new key for all other non-private admins - * @default null - */ - AdminActivations: components['schemas']['ReplaceOrganizationKeyActivationDto'][]; + Format: "png" | null; + ComputedAddress: string; }; - ActivateOrganizationKeyInput: { + CreateAddressInput: { + /** @example me */ + Local: string; /** - * @description organization private key encrypted with mailbox password hash - * @default null - * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + * @description Either custom domain or a protonmail domain + * @example funoccupied.com */ - PrivateKey: string; + Domain: string; /** - * Format: base64 - * @description For passwordless key, the key packet needed to unlock the key, encrypted to the user key - * @default null - * @example TG9yZW0gaXBzdW0... + * @description Optional, default empty + * @example hi */ - TokenKeyPacket: string; + DisplayName: string; /** - * @description For passwordless key, signature of the token key packet - * @default null - * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + * @description Optional, default empty + * @example signature */ Signature: string; + MemberID: Record; + RequesterMemberId?: number | null; + AddressList: components["schemas"]["KTAddressListTransformer"]; }; - 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'][]; - /** - * @description Activate new key for all other non-private admins - * @default null - */ - AdminActivations: components['schemas']['MigrateOrganizationKeyActivationDto'][]; + BadRequestResponse: { + Error: string; + /** ProtonErrorResponseCode */ + Code: number; }; - UpdateOrgKeyFingerprintSignatureInput: { - Signature: components['schemas']['PGPSignature']; - AddressID: components['schemas']['Id2']; + ReorderAddressesInput: { + /** @description Will amend the order of addresses with the order of the corresponding AddressIDs */ + AddressIDs: string[]; }; - OrganizationLogo: { - /** - * @description The base64 encrypted logo - * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= - */ - Image: string; + AddressListInput: { + AddressList: components["schemas"]["KTAddressListTransformer"]; }; - SsoXml: { - /** - * @description the encrypted domain id - * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== - */ - DomainID?: string; + ChangeAddressTypeInput: { /** - * @description Base64 encoded XML blob - * @example + * @description 1: original, 2: Alias, 3: Custom, 4: Premium, 5: External + * @example 3 */ - XML?: string; + Type: number; + /** @default null */ + SignedKeyList: components["schemas"]["SignedKeyListInput"] | null; }; - PatchNewsInput: { + RenameUnverifiedAddressInput: { + /** @example me */ + Local: string; /** - * @description Proton Company announcements - * @default null + * @description either custom domain or a protonmail domain + * @example funoccupied.com */ - Announcements: boolean | null; + 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: { /** - * @deprecated - * @description Proton Product announcements - * @default null + * @description Optional, if empty string - use default + * @example hi */ - Features: boolean | null; + DisplayName: string; /** - * @description Proton newsletter - * @default null + * @description Optional, if empty string - use default + * @example signature */ - Newsletter: boolean | null; + Signature: string; + }; + /** Signed Key List */ + KTKeyList: { /** - * @description Proton beta announcements - * @default null + * @description Starting Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 125 */ - Beta: boolean | null; + MinEpochID?: number | null; /** - * @description Proton for Business newsletter - * @default null + * @description Ending Epoch ID for SKL. Can be null, if the epoch is not yet released + * @example 241 */ - Business: boolean | null; + MaxEpochID?: number | null; /** - * @description Proton offers and promotions - * @default null + * @description If epoch is not yet released this will be a future epoch ID + * @example 265 */ - Offers: boolean | null; + ExpectedMinEpochID?: number | null; /** - * @description Proton new email notifications - * @default 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}] */ - NewEmailNotif: boolean | null; + Data?: string | null; /** - * @description Proton welcome emails - * @default 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 */ - Onboarding: boolean | null; + ObsolescenceToken?: string | null; /** - * @description Proton user surveys - * @default null + * @description Armored OpenPGP signature for the data. If null, proof contains an obsolescenceToken + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - UserSurveys: boolean | null; + Signature?: string | null; /** - * @description Proton Mail and Calendar new features - * @default null + * @description Identifier of the revision version + * @example 42 */ - InboxNews: boolean | null; + Revision: number; + }; + 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)[]; + }; + UnprocessableResponse: { + Error: string; + /** ProtonErrorResponseCode */ + Code: number; + }; + /** @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; /** - * @description Proton VPN new features - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - VpnNews: boolean | null; + Code: 1000; + }; + 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; + }; + }; + AddNewUserKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + /** @example 1 */ + Primary: Record; + }; + SuccessfulResponse: { /** - * @description Proton Drive new features - * @default null + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - DriveNews: boolean | null; + Code: 1000; + }; + PutDomainFlagsInput: { + /** @default null */ + AllowedForMail: Record | null; + /** @default null */ + AllowedForSSO: Record | null; + }; + OffsetPagination: { + /** The page size */ + PageSize: number; /** - * @description Proton Pass new features - * @default null + * The page index using 0-based indexing + * @default 0 */ - PassNews: boolean | null; - /** - * @description Proton Wallet new features - * @default null - */ - WalletNews: boolean | null; + Page: number; }; - UpdateNewsInput: { + /** + * @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"]; /** - * @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 + * Format: timestamp + * @example 1687942995 */ - News?: number; - }; - UpdateHideSidePanelInput: { - /** @enum {integer} */ - HideSidePanel: 0 | 1; + CreateTime: number; }; - /** Theme */ - Theme: { + 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"]; /** - * @description Which theme mode to use (auto, dark, light) - * @example 1 + * Format: timestamp + * @example 1556136548 */ - Mode: number; + LastActiveTime: number; /** - * @description What theme to use in light mode - * @example 1 + * Format: timestamp + * @example 1446095611 */ - LightTheme: number; + CheckTime: number; /** - * @description What theme to use in dark mode - * @example 1 + * Format: timestamp + * @example 1554807818 */ - DarkTheme: number; + 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; /** - * @description Which font face to use - * @example 1 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - FontFace: number; + Code: 1000; + }; + /** + * @description
See values descriptions
ValueDescription
1True
0False
+ * @enum {integer} + */ + BoolInt: 1 | 0; + CreateDomainInput: { + /** @example funoccupied.com */ + Name: string; /** - * @description Which font size to use - * @example 1 + * @description True if this domain is intended for Mail usage + * @default true */ - FontSize: number; + AllowedForMail: boolean; /** - * @description Bitmap corresponding to which features are enabled and disabled - * @example 1 + * @description True if this domain is intended for SSO usage + * @default false */ - Features: number; + AllowedForSSO: boolean; }; - /** SessionAccountRecoveryInput */ - SessionAccountRecoveryInput: { + UpdateCatchAllAddressInput: { /** - * @description Possible values:
- 0: disable
- 1: enable - * @example 1 - * @enum {integer} + * @description or null to unset + * @example */ - SessionAccountRecovery: 0 | 1; - }; - /** AIAssistantFlagsInput */ - AIAssistantFlagsInput: { - AIAssistantFlags: components['schemas']['AIAssistantFlags']; + AddressID?: string | null; + /** @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"]; }; - /** ProductDisabledInput */ - ProductDisabledInput: { - /** @description Possible values:
- 1: Mail
- 2: VPN
- 3: Calendar
- 4: Drive
- 5: Pass
- 6: Wallet */ - Product: number; - Disabled: number; + ChildOrganizationDto: { + /** @description The ID of the organization */ + ID: components["schemas"]["Id"]; + /** @description The name of the organization */ + Name: 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; + GetChildOrganizationsResponse: { + /** @description List of child organizations */ + Organizations: components["schemas"]["ChildOrganizationDto"][]; + /** @description Total number of child organizations */ + Total: number; /** - * @description State of the SCIM integration: 0 for disabled, 1 for enabled - * @example 1 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - State?: number; + Code: 1000; }; - SendInvitationsInput: { - /** @default [] */ - Recipients: string[]; + /** @description An armored PGP Signature */ + PGPSignature: string; + AcceptGroupOwnerInviteRequest: { + TokenKeyPacket: string; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; }; - 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; + /** + * @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; }; - UploadAttachment: { + 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; + }; + /** + * @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"]; /** - * @description Token return from create ticket api - * @example 4w350m3h4x0r + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - Token: string; - /** @description The body of attachment */ - Body: string; - Product: components['schemas']['Product']; + Code: 1000; }; - CancelPlanReport: { + 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: number; + GroupVisibility?: components["schemas"]["GroupVisibility"]; + /** @default 3 */ + MemberVisibility: number; + /** @default */ + Description: string; + }; + EditGroupMemberRequest: { + 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"][]; + Total: number; /** - * @description The reason for cancellation - * @example other + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - 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; + Code: 1000; + }; + GroupMembersResponse: { + Members: components["schemas"]["GroupMemberOutput"][]; + Total: number; /** - * @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass - * @example 2 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - ClientType?: number; - Tags?: string[]; + Code: 1000; }; - Stream: { - /** @default null */ - Users: components['schemas']['EventCollectionOutput']; - /** @default null */ - Addresses: components['schemas']['EventCollectionOutput']; - /** @default null */ - 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']; + /** 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} */ - readonly Code: 1000; + Code: 1000; }; - /** @enum {integer} */ - BoolInt: 1 | 0; - FeedbackVO: { - FeedbackType: string; - Score: number; - Feedback?: string | null; + ForwardingKeys: { + PrivateKey?: components["schemas"]["PGPPrivateKey"] | null; + ActivationToken?: components["schemas"]["PGPMessage"] | null; }; - NotificationRequest: { - FullScreenImageSupport?: string | null; - FullScreenImageWidth?: number | null; - FullScreenImageHeight?: number | null; - SupportedFullScreenImageFormats: string[]; - Null: boolean; + 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; }; - 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; + InternalGroupMembershipsResponse: { + Memberships: components["schemas"]["InternalGroupMembership"][]; + Total: number; /** - * @description possible values: - * * - 0 => no desktop/email notifications - * * - 1 => notifications, folders only - * @default null - * @enum {integer|null} + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - Notify: 1 | 0 | null; + Code: 1000; }; - LabelIDs: { - LabelIDs: components['schemas']['LabelID'][]; + InviteGroupOwnerRequest: { + GroupMemberID: components["schemas"]["Id"]; + EncryptionAddress: string; + TokenKeyPacket: string; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; }; - IdpResponseVO: { - SAMLResponse: string; - }; - CreateCredentiallessUserInput: { + UpdateGroupRequest: { + /** @default null */ + Name: string | null; /** - * @description Optional field, frontend fingerprints + * 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 */ - 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; - }; + Email: string | null; + /** @default null */ + Permissions: components["schemas"]["GroupPermissions"] | null; + /** @default null */ + Flags: number | null; + /** @default null */ + GroupVisibility: components["schemas"]["GroupVisibility"] | null; + /** @default null */ + MemberVisibility: number | null; + /** @default null */ + Description: string | null; }; - /** Signed Key List */ - KTKeyList: { + AddressKeyInput2: Record; + UpdateFlagsInput: { + /** @example 1 */ + Flags: number; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + AddressKeyToken: { /** - * @description Starting Epoch ID for SKL. Can be null, if the epoch is not yet released - * @example 125 + * @description Encrypted Address key ID to replace the token + * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== */ - MinEpochID?: number | null; + AddressKeyID: string; /** - * @description Ending Epoch ID for SKL. Can be null, if the epoch is not yet released - * @example 241 + * @description Base-64 encoded key packet + * @example slCpH6qWMKGQ7d...R4eLU2+2BZvK0UeG/QY2 */ - MaxEpochID?: number | null; + KeyPacket: string; /** - * @description If epoch is not yet released this will be a future epoch ID - * @example 265 + * @description Token signature produced with the primary user key + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - ExpectedMinEpochID?: number | null; + Signature: string; + }; + 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 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 Required if not logged in + * @example user_name */ - Data?: string | null; + Username: string; /** - * @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 Reset token + * @example A194YN2F9R */ - ObsolescenceToken?: string | null; + Token: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrimaryKey: string; /** - * @description Armored OpenPGP signature for the data. If null, proof contains an obsolescenceToken - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + * @description RANDOMLY generated client-side + * @example */ - Signature?: string | null; + 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; + /** @description In the signed key list there is only the new keys */ + SignedKeyList?: components["schemas"]["SignedKeyListInput"]; + }[]; + /** @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 Identifier of the revision version - * @example 42 + * @description ID of the parent organization + * @default null */ - Revision?: number; - }; - /** Signed Key List */ - KTAddressListTransformer: { + ParentID: number | null; /** - * @description JSON-encoded content of the SAL - * @example [{"Email": "test@example.com","Flags": 1}] + * @description Token key packet from the parent organization, encrypted to the user key + * @default null */ - Data?: string; + ParentOrgTokenKeyPacket: components["schemas"]["BinaryString"] | null; /** - * @description The armored signature over the JSON-serialized data with the primary user key - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + * @description Signature of the token key packet made by the parent organization key + * @default null */ - Signature?: string; + ParentOrgSignature: components["schemas"]["PGPSignature"] | null; }; - 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

Either 1=PROTON or 2=MANAGED (default)

See values descriptions
ValueDescription
1Proton
2Managed
3External
4CredentialLess
+ * @enum {integer} + */ + UserType: 1 | 2 | 3 | 4; + MagicLinkInvitationInput: { /** - * @description 1: Proton (full), 2: Managed, 3: External - * @example 0 + * @description Invitation data containing address and expected KT revision + * @example {"Address":"member@internal-domain.com", "Revision":2} */ - Type?: number; - /** @example 1654615966 */ - CreateTime?: number; + Data: Record; + Signature?: components["schemas"]["PGPSignature"] | null; /** - * Format: int64 - * @description Max space (in bytes) - * @example 10737418240 + * @description The email to send an invitation to + * @example some.user@example.com */ - MaxSpace?: number; + Email: string; + /** @description Whether the member should remain private after creation or be unprivatized */ + PrivateIntent: boolean; + }; + AuthInfoInput: Record; + CreateMemberInput: { + /** @example Jason */ + Name: string; /** - * Format: int64 - * @description Max upload space (in bytes) - * @example 26214400 + * @description Use only if Type is 2=MANAGED + * @example 0 */ - MaxUpload?: number; + Private: number; + /** @example 1073741824 */ + MaxSpace: number; + /** @example 0 */ + MaxVPN: number; + /** @description Either 1=PROTON or 2=MANAGED (default) */ + Type: components["schemas"]["UserType"]; /** - * Format: int64 - * @description Used space (in bytes) - * @example 70376905 + * @description Use only if type is 1=PROTON + * @example user_name */ - UsedSpace?: number; - ProductUsedSpace?: components['schemas']['UserUsage']; - /** @description 1 when the user's member has an AI seat, 0 otherwise */ - NumAI?: number; - /** @example 2 */ - Role?: number; - /** @example 1 */ - Private?: number; + Username: string; + /** @description Invitation object if created using magic link */ + Invitation?: components["schemas"]["MagicLinkInvitationInput"] | null; + Auth: components["schemas"]["AuthInfoInput"]; /** - * @example 1 + * @default 0 * @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; + MaxAI: 0 | 1; /** - * @description Subscribed (bitmap): `1`: User has a mail subscription, `4`: User has a VPN subscription - * @example 5 + * @default 0 + * @enum {integer} */ - Subscribed?: number; + MaxLumo: 0 | 1; /** - * @description Activated services (bitmap): - * * `1`: User has the mail product activated, - * * `4`: User has the VPN activated - * @example 5 + * @description True if the user has a temporary password, false otherwise + * @default false */ - 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; - }; + TemporaryPassword: 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; + CreateMemberInvitationInput: { /** - * @description Deprecated! Please compute the fingerprint from the key - * @example c93f767df53b0ca8395cfde90483475164ec6353 + * Format: email + * @example ein@stein.com */ - Fingerprint?: string; - /** @example 1 */ - Primary?: number; + Email: string; + /** @example 100 */ + MaxSpace: number; /** - * @description Inactive keys (0) are kept for reactivation only, they are not trusted, and should not be unlocked - * @example 1 + * @default 0 + * @enum {integer} */ - Active?: number; + MaxAI: 0 | 1; /** - * @description Base64-encoded secret, made up of 32 random bytes - * @example 1H8EGg3J1...Qwk243hf + * @default 0 + * @enum {integer} */ - RecoverySecret?: string; - /** @example -----BEGIN PGP SIGNATURE-----... */ - RecoverySecretSignature?: string; + MaxLumo: 0 | 1; }; - AddressUser: { - /** - * @description Encrypted address ID - * @example qmhrlFY24BhSHiFplF0B7G_cMVLi1sokaWIhfNaee6dRtdIZPYnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg== - */ - ID?: string; + 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"][]; + /** @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 Encrypted domain ID - * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== + * @description The invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2, "Admin":true} */ - DomainID?: string | null; - /** @example jason@protonmail.dev */ - Email?: string; + InvitationData: string; + /** @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"]; + }; + 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: { /** - * @description 0 or 1 - * @example 1 + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== */ - 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; + 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"]; + /** @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; + /** @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"][]; /** - * @description 1: Original, 2: Alias, 3: Custom, 4: Premium, 5: External - * @example 1 + * @description If the data requires them to become admin, the tokens to access the org key + * @default null */ - Type?: number; + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; + }; + CreateScimTenantInput: { + /** @description The password for the SCIM tenant, used for the integration with the IdP */ + Password: string; + }; + OrganizationLogo: { /** - * @deprecated - * @description Replaced by "Priority" - * @example 1 + * @description The base64 encrypted logo + * @example iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyPH+/x8ABZMCtpUrn90AAAAASUVORK5CYII= */ - Order?: number; + 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 Ordered list, lowest first. Can start with a number > 1. + * @description State of the SCIM integration: 0 for disabled, 1 for enabled * @example 1 */ - Priority?: number; + State: number; + }; + UpdateOrganizationKeyBackupInput: { /** - * @description Can be empty but not null - * @example D L'u, P.D. 定超 + * @description organization private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ - DisplayName?: string; + PrivateKey: string; /** - * @description Can be empty but not null - * @example hi there + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== */ - Signature?: string; + KeySalt: string; /** - * @deprecated - * @description 0 or 1 - * @example 1 + * Format: base64 + * @description Optional, for inline re-authentication + * @example */ - HasKeys?: number; + ClientEphemeral: string; /** - * @description True if the address is a catch-all - * @example false + * Format: base64 + * @description Optional, for inline re-authentication + * @example */ - CatchAll?: Record; + ClientProof: string; /** - * @description True if the domain's record point to Proton servers - * @example true + * Format: hex + * @description Optional, for inline re-authentication + * @example */ - ProtonMX?: Record; - SignedKeyList?: components['schemas']['KTKeyList']; - Keys?: components['schemas']['AddressKey'][]; + SRPSession: string; /** - * @description Bitflags representing noencrypt/nosign - * @example 48 + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code */ - Flags?: number; - }; - /** LinkResponse */ - SwitchAddressesOrganizationPermissionsTransformer: { - AddressID?: string; - Response?: { - /** @example 13043 */ - Code?: number; - /** @example Address does not exist */ - Error?: string; - Details?: Record; + 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[]; }; }; - 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']['PGPMessage2'] | null; - ActivationAddressID?: components['schemas']['Id'] | null; - MemberID?: components['schemas']['Id'] | null; + UpdateOrganizationNameInput: { /** - * @description DeviceToken of the created device - * @example wfih0367aa7dc0359bf5c42d15a93e6c + * @description organization name, maximum 40 characters + * @example E-Corp */ - DeviceToken?: string | null; + Name: string; }; - DomainTransformer: { - /** @example BKiAUbkGnUPiy2c37zjon_g== */ - ID?: string; - /** @example protonvpn.ch */ - DomainName?: string; + UpdateOrganizationEmailInput: { /** - * @description 1 is Proton, 2 is user-assigned Proton subdomain, 3 is custom domain - * @example 3 + * Format: email + * @description organization email, can be null + * @example contact@e-corp.com */ - Type?: number; + Email: string; + }; + UpdateOrganizationTwoFactorGracePeriodInput: { /** - * @description 0 is default, 1 is active (verified), 2 is warn (dns issue) - * @example 1 + * @description number of seconds before 2FA enforced + * @example 86400 */ - State?: number; - /** @example 1556136548 */ - LastActiveTime?: number; - /** @example 1446095611 */ - CheckTime?: number; - /** @example 1554807818 */ - WarnTime?: number; - /** @example protonmail-verification=c701a28e2bdd3358c6dda71a3008b806e41950b0 */ - VerifyCode?: string; + 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: { /** - * @description 0 is default, 1 is has code but wrong, 2 is good - * @example 2 + * Format: base64 + * @description Optional, for inline re-authentication + * @example */ - VerifyState?: number; + ClientEphemeral: string; /** - * @description 0 is default, 1 is set but no us, 2 has us but priority is wrong, 3 is good - * @example 3 + * Format: base64 + * @description Optional, for inline re-authentication + * @example */ - MxState?: number; + ClientProof: string; /** - * @description 0 is default, 1 and 2 means detected a record but wrong, 3 is good - * @example 3 + * Format: hex + * @description Optional, for inline re-authentication + * @example */ - SpfState?: number; + SRPSession: string; /** - * @description 0 is default, 1 and 2 means detected record but wrong, 3 is good, 4 is good and relaxed policy - * @example 3 + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code */ - DmarcState?: number; - DKIM?: { + 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 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[]; }; - }; - /** GroupMemberResponse */ - GroupMemberResponse: { - ID: components['schemas']['Id']; - Type: components['schemas']['GroupMemberType2']; - State: components['schemas']['GroupMemberState']; - CreateTime: number; - GroupID: components['schemas']['Id']; - AddressID?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions2']; - }; - /** GroupResponse */ - GroupResponse: { - ID: components['schemas']['Id']; - Name: string; - Address: unknown[]; - Permissions: components['schemas']['GroupPermissions2']; - CreateTime: number; - Flags: components['schemas']['GroupFlags2']; - Description?: string | null; - }; - MemberInfo: Record; - UpdateMemberRoleInput: { - Role: number; - /** @default null */ - OrganizationKeyInvitation: components['schemas']['OrganizationKeyInvitationDto'] | null; - /** @default null */ - OrganizationKeyActivation: components['schemas']['OrganizationKeyActivationDto'] | null; - }; - VPNAuthenticationCertificateAdminTransformer: { /** - * @description Certificate serial number - * @example 6561979746 + * @description organization private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ - SerialNumber?: string; + PrivateKey: string; /** - * @description Blob content of the certificate - * @example -----BEGIN CERTIFICATE-----... + * @description backup private key encrypted with backup password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ - Certificate?: string; + BackupPrivateKey: string; /** - * @description Fingerprint of the client public key - * @example bHZDBSYbd27GFd + * Format: base64 + * @description random 16 bytes + * @example cmFuZGJhc2U2NHN0cmluZw== */ - ClientKeyFingerprint?: string; + 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 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 The input or default mode - * @example 1505758141 + * @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----- */ - ExpirationTime?: number; + Token: components["schemas"]["PGPMessage"] | null; /** - * @description The input or default mode - * @example session + * @description Signature of the token made by the user key of the current user + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - 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; - }[]; - }; - GetMemberUnprivatizationOutput: { - /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ - State: number; + Signature: components["schemas"]["PGPSignature"] | null; /** - * @description Invitation data - * @example {"Address":"member@internal-domain.com", "Revision":2} + * @description Invite all other private admins to the new key + * @default null */ - InvitationData?: string | null; - /** @description InvitationData signed with org key */ - InvitationSignature?: components['schemas']['PGPSignature2'] | 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']['PGPSignature2'] | null; - /** @description Organization public key */ - OrgPublicKey?: components['schemas']['PGPPublicKey2'] | 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: { + AdminInvitations: components["schemas"]["ReplaceOrganizationKeyInvitationDto"][] | null; /** - * @description Encrypted user ID - * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== + * @description Activate new key for all other non-private admins + * @default null */ - UserID: string; + AdminActivations: components["schemas"]["ReplaceOrganizationKeyActivationDto"][] | null; /** - * @description Unix timestamp of when the log happened. - * @example 1683644736 + * @description For primary org key rotation: updated ParentOrgTokenKeyPacket and ParentOrgTokenSignature for each linked non-primary organization + * @default null */ - Time: number; - Status: components['schemas']['AuthLogStatus']; + LinkedOrganizations: components["schemas"]["ReplaceOrganizationKeyLinkedOrgDto"][] | null; /** - * @description Various values. See AuthLogEvent constants. - * @example 23 + * Format: base64 + * @description For non-primary org key rotation: new ParentOrgTokenKeyPacket encrypted to the parent org key + * @default null + * @example */ - Event: number; + ParentOrgTokenKeyPacket: components["schemas"]["BinaryString"] | null; /** - * @description Localized description (name) of the log event. - * @example Sign in success (attempt) + * @description For non-primary org key rotation: new ParentOrgTokenSignature signed with the parent org key + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - 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; + ParentOrgTokenSignature: components["schemas"]["PGPSignature"] | null; + }; + ActivateOrganizationKeyInput: { /** - * @description ID of protection applied. - * Can be missing. Only present if user has High Security enabled. - * See AuthLogProtection enum for possible values. - * @example 1 + * @description organization private key encrypted with mailbox password hash + * @default null + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ - Protection?: components['schemas']['AuthLogProtection'] | null; + PrivateKey: string | null; /** - * @description Localized description of protection applied. - * Can be missing. Only present if user has High Security enabled. - * @example Anti-bot verification + * Format: base64 + * @description For passwordless key, the key packet needed to unlock the key, encrypted to the user key + * @default null + * @example TG9yZW0gaXBzdW0... */ - ProtectionDesc?: string | null; + 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; }; - Session: { - /** @example cc0a3ec21c3af3461c9c310bf3f568795fdf6dc5 */ - UID?: string; - /** @example Web */ - ClientID?: string; - /** @example 1527262849 */ - CreateTime?: number; - /** @example IhcUWoRxdY3S-6pfk2L1oSTeZx5kvpeqcxuii8h1ic1nYnSJa11LP8DABcgsRJCwXXDjxwPFSxEGJrlrvMWFpQ== */ - MemberID?: string; - /** @example 0 */ - Revocable?: number; + 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"]; }; - GetOrganizationKeysOutput: { + 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: { + /** @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 Organization private key encrypted with mailbox password hash - * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + * @description Activate key for other active private admins + * @default null */ - PrivateKey?: string | null; + AdminInvitations: components["schemas"]["MigrateOrganizationKeyInvitationDto"][] | 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----- + * @description Activate new key for all other non-private admins + * @default null */ - LegacyPrivateKey?: string | null; + AdminActivations: components["schemas"]["MigrateOrganizationKeyActivationDto"][] | null; + }; + UpdateOrgKeyFingerprintSignatureInput: { + /** @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: { /** - * @deprecated - * @description Organization public key - * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- + * @description The name of the password policy. This serves as identifier. + * @example AtLeastOneSpecialCharacter */ - PublicKey?: string | null; + PolicyName: string; + /** @description The state of the password policy. Disabled policies are not returned. */ + State: components["schemas"]["PasswordPolicyState"]; /** - * @description Token (key + data packets) to access the passwordless organization key for this user - * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- + * @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} */ - Token?: string | null; + Parameters: unknown[] | null; + }; + UpdateOrganizationSettingsRequest: { /** - * @description Signature of the token secret - * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + * @description Whether to show organization name in sidebar or not + * @default null + * @example true */ - Signature?: string | null; + ShowName: boolean | null; /** - * @description Address email of the admin that signed the token (if not the user key of the member themself) - * @example someadmin@myorg.com + * @description Whether to show the Scribe writing assistant or not + * @default null + * @example true */ - SignatureAddress?: string | null; + ShowScribeWritingAssistant: boolean | null; /** - * Format: encrypted string - * @description The address ID of the address that was invited to the organization key + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null + * @example true */ - EncryptionAddressID?: string | null; + VideoConferencingEnabled: boolean | null; /** - * @description Signature of the SHA256 fingerprint of the organization key - * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + * @description Whether the Meet video conferencing feature is enabled or not + * @default null + * @example true */ - FingerprintSignature?: string | null; + MeetVideoConferencingEnabled: boolean | null; /** - * @description The email address that signed the SHA256 fingerprint of the organization key - * @example someadmin@myorg.com + * @description The ID of the organization's logo + * @default null */ - FingerprintSignatureAddress?: string | null; + LogoID: 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 + * @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" + * ] */ - AccessToOrgKey?: components['schemas']['MemberOrgKeyStatus'] | null; - /** @description Whether the organization has passwordless keys or not */ - Passwordless: boolean; + 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; }; - GetOrganizationIdentityOutput: { + Sso: { /** - * @description Organization public key - * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- + * @description IdP URL. Optional for eduGAIN SSO configurations + * @example https://account.buck.proton.black */ - PublicKey: string; + SSOURL: string; /** - * @description Signature of the SHA256 fingerprint of the organization key - * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + * @description SSOEntityID URL + * @example https://account.buck.proton.black */ - FingerprintSignature: string; + SSOEntityID: string; /** - * @description The email address that signed the SHA256 fingerprint of the organization key - * @example someadmin@myorg.com + * @description Blob content of the certificate. Optional for eduGAIN SSO configurations + * @example -----BEGIN CERTIFICATE-----... */ - FingerprintSignatureAddress: string; - }; - OrganizationSettings: { + Certificate: string; /** - * @description Whether to show organization name in sidebar or not - * @default false - * @example true + * @description The encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ - ShowName: boolean; + DomainID: string; + SCIMOauthClientID?: number | null; /** - * @description Whether to show the Scribe writing assistant or not - * @default true - * @example true + * @description Issuer ID (our side) + * @example https://sso.proton.me/sp + */ + IssuerID: string; + /** + * @description Reply (ACS) URL + * @example https://sso.proton.me/auth/saml */ - ShowScribeWritingAssistant: boolean; + CallbackURL: string; /** - * @description Whether the video conferencing feature is enabled or not + * @description Allowed domain name + * @example example.com + */ + AllowedDomain: string; + /** + * @description Whether this SSO configuration 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; + 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; }; - 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'][]; - }; + SsoXml: { /** - * @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 + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ - News?: number; - /** @example en_US */ - Locale?: string; + DomainID: string; /** - * @description 0 => Disabled, 1 => Basic, 2 => Advanced - * @example 2 + * @description Base64 encoded XML blob + * @example */ - LogAuth?: number; - /** @example रिवार में हुआ। ज檷\n Cartoon Law Services\n 1 DisneyWorld Lane\n Orlando, FL, 12345\n VAT */ - InvoiceText?: string; + XML: string; + }; + SsoUrl: { /** - * @description 0 => Comfortable, 1 => Compact - * @example 0 + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ - Density?: number; - Theme?: components['schemas']['Theme2']; - /** @example 1 */ - ThemeType?: number; + DomainID: string; /** - * @description 0 => default, 1 => monday, 6 => saturday, 7 => sunday - * @example 1 + * @description full URL to SAML metadata + * @example */ - WeekStart?: number; + MetadataURL: string; + }; + PatchNewsInput: { /** - * @description 0 => default, 1 => DD_MM_YYYY, 2 => MM_DD_YYYY, 3 => YYYY_MM_DD - * @example 1 + * @description Proton Company announcements + * @default null */ - DateFormat?: number; + Announcements: boolean | null; /** - * @description 0 => default, 1 => 24h, 2 => 12h - * @example 1 + * @deprecated + * @description Proton Product announcements + * @default null */ - TimeFormat?: number; + Features: boolean | null; /** - * @description 0 => Has not been welcomed, 1 => Has been welcomed - * @example 1 + * @description Proton newsletter + * @default null */ - Welcome?: number; + Newsletter: boolean | null; /** - * @deprecated - * @description (Use `Welcome`) 0 => Has not been welcomed, 1 => Has been welcomed - * @example 1 + * @description Proton beta announcements + * @default null */ - WelcomeFlag?: number; + Beta: boolean | null; /** - * @description 0 => Regular access, 1 => Beta access - * @example 1 + * @description Proton for Business newsletter + * @default null */ - EarlyAccess?: number; - Flags?: { - /** @description 1 or 0 */ - Welcomed?: number; - /** @description 1 or 0 */ - InAppPromosHidden?: 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; - }; + Business: boolean | null; /** - * @description 0 or 1, 1 means device recovery enabled - * @example 1 + * @description Proton offers and promotions + * @default null */ - DeviceRecovery?: number; + Offers: boolean | null; /** - * @description 0 or 1, 1 means sending telemetry enabled - * @example 1 + * @description Proton new email notifications + * @default null */ - Telemetry?: number; + NewEmailNotif: boolean | null; /** - * @description 0 or 1, 1 means sending crash reports enabled - * @example 1 + * @description Proton welcome emails + * @default null */ - CrashReports?: number; + Onboarding: boolean | null; /** - * @description 0 or 1, 1 means hiding the side panel - * @example 1 + * @description Proton user surveys + * @default null */ - 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; - }; + UserSurveys: boolean | null; /** - * @description 0 or 1, 1 means session account recovery enabled - * @example 1 - * @enum {integer} + * @description Proton Mail and Calendar new features + * @default null */ - SessionAccountRecovery?: 0 | 1; + InboxNews: boolean | null; /** - * @description 0: unset, 1: off, 2: server-only, 3: client-only - * @example 1 - * @enum {integer} + * @description Proton VPN new features + * @default null + */ + VpnNews: boolean | null; + /** + * @description Proton Drive new features + * @default null */ - AIAssistantFlags?: 0 | 1 | 2 | 3; + DriveNews: boolean | null; /** - * @description Bitmap informing which client the user has logged in to. See ClientFlagBit - * @example 1 + * @description Proton Pass new features + * @default null + */ + PassNews: boolean | null; + /** + * @description Proton Wallet new features + * @default null + */ + WalletNews: boolean | null; + /** + * @description Proton Lumo new features + * @default null + */ + LumoNews: boolean | null; + /** + * @description Proton Meet new features + * @default null */ - UsedClientFlags?: number; + MeetNews: boolean | null; + /** + * @description In app notifications + * @default null + */ + InAppNotifications: boolean | null; + Summary: unknown[]; }; - Fido2RegisteredKey: { - /** @example fido2-u2f */ - AttestationFormat?: string; - CredentialID?: Record[]; - /** @example My security key */ - Name?: string; + 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; }; - ScheduleSupportCallOutput: { - /** @example https://calendly.com/proton-schedule */ - CalendlyLink: string; + UpdateHideSidePanelInput: { + /** @enum {integer} */ + HideSidePanel: 0 | 1; }; - AccountRecoveryAttempt: { + /** Theme */ + Theme: { /** - * @description 0 => None, 1 => Grace, 2 => Cancelled, 3 => Insecure, 4 => Expired + * @description Which theme mode to use (auto, dark, light) * @example 1 */ - State?: number; - /** @example 1686834569 */ - StartTime?: number; - /** @example 1687000169 */ - EndTime?: number; + Mode: number; /** - * @description 0 => None, 1 => Cancelled, 2 => Authentication + * @description What theme to use in light mode * @example 1 */ - Reason?: number; - /** - * @description The session ID that triggered the process - * @example qmi2ptbz4sefeahddjxghsxtu2orlgyf - */ - UID?: string; - }; - Plan: { - /** @example */ - ID?: string; + LightTheme: number; /** - * @description bits, 1 = primary plan, 0 = sub-plan (add-on) - * @example [ - * 0, - * 1 - * ] + * @description What theme to use in dark mode + * @example 1 */ - Type?: number; + DarkTheme: number; /** - * @description bits, 1 = plan available for subscription + * @description Which font face to use * @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; + FontFace: number; /** - * @description bits, 1 = mail, 4 = VPN + * @description Which font size to use * @example 1 */ - Services?: number; + FontSize: number; /** - * @description bits, 1 = catch-all addresses + * @description Bitmap corresponding to which features are enabled and disabled * @example 1 */ - Features?: number; + Features: number; }; - FeatureTransformer: { - /** @example promo */ - Code?: string; + /** SessionAccountRecoveryInput */ + SessionAccountRecoveryInput: { /** - * @example enumeration - * @enum {string} + * @description Possible values:
- 0: disable
- 1: enable + * @example 1 + * @enum {integer} */ - 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; + SessionAccountRecovery: 0 | 1; }; - 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; + /** + * @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: { + /** @description Possible values:
- 0: Unset
- 1: Off
- 2: Server-Only
- 3: Client-Only */ + AIAssistantFlags: components["schemas"]["AIAssistantFlags"]; }; - 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; + /** + * @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"]; }; - ReferralStatus: { - /** @example 2 */ - RewardMonths: number; - /** @example 6 */ - RewardMonthsLimit: number; - /** @example 10 */ - EmailsAvailable: number; + UpdateMemberLumoEntitlementInput: { + /** @enum {integer} */ + MaxLumo: 0 | 1; }; - GetUserInvitationsOutput: { - UserInvitations?: components['schemas']['GetUserInvitationOutput'][]; + /** ProductDisabledInput */ + ProductDisabledInput: { + /** @description Possible values:
- 1: Mail
- 2: VPN
- 3: Calendar
- 4: Drive
- 5: Pass
- 6: Wallet */ + Product: number; + Disabled: 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; }; GetUserInvitationOutput: { /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ - ID?: string; + ID: string; /** @example owner@family.org */ - InviterEmail?: string; + InviterEmail: string; /** @example 1000000000 */ - MaxSpace?: number; + MaxSpace: number; /** @example My Organization */ - OrganizationName?: string; + OrganizationName: string; /** @example family2022 | passfamily2024 */ - OrganizationPlanName?: string; - Validation?: components['schemas']['AcceptInvitationValidation']; + OrganizationPlanName: string; + Validation: components["schemas"]["AcceptInvitationValidation"]; }; - EventInfo: { - Code?: components['schemas']['ResponseCodeSuccess']; + UserInvitationResponse: { + UserInvitation: components["schemas"]["GetUserInvitationOutput"]; /** - * Format: byte - * @example ACXDmTaBub14w== + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + DismissInput: { + /** @default [] */ + 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"]; + /** @default null */ + PublicKey: components["schemas"]["PGPPublicKey"] | null; + /** @default null */ + PingNotificationStatus: components["schemas"]["PingNotificationStatus"] | null; + /** @default 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 + * @example 4w350m3h4x0r */ - EventID?: string; + Token: string; + /** @description The body of attachment */ + Body: string; + /** @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass */ + Product: components["schemas"]["Product"]; + }; + CancelPlanReport: { /** - * @description Bitmask to know what to refresh
`0`: Nothing
`1`: MAIL
`2`: CONTACTS
`255`: Everything - * @example 0 + * @description The reason for cancellation + * @example other */ - Refresh?: number; + 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` if there is more to pull + * @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass + * @example 2 + */ + 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"]; + /** @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 */ + 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"]; + EventOrder: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FeedbackVO: { + FeedbackType: string; + Score: number; + Feedback?: string | null; + }; + /** + * @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: expended and show sub-folders + * @default null + */ + Expanded: components["schemas"]["Expanded"] | null; + /** + * @description possible values:
0: no desktop/email notifications
1: notifications, folders only + * @default null + */ + Notify: components["schemas"]["Notify"] | null; + }; + /** + * @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; + }; + LabelResponse: { + Label: components["schemas"]["Label"]; + /** + * ProtonResponseCode + * @example 1000 * @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']['Filter']; - }[]; - 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; - }[]; + Code: 1000; + }; + ListEligibleTrialsResponse: { + TrialPlans: unknown[]; + CreditCardRequiredPlans: unknown[]; /** - * Format: int64 - * @description Used space (in bytes) - * @example 70376905 + * ProtonResponseCode + * @example 1000 + * @enum {integer} */ - 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']['CalendarAugmentedTransformer']; - }[]; - CalendarMembers?: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example 1 */ - Action?: number; - Member?: components['schemas']['MemberTransformer']; - }[]; - 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; + Code: 1000; + }; + BillingAddressDto: { /** - * @description Possible values:
- 0: offer - * @example 0 + * @description The country code. + * @example NL */ - 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; + CountryCode: string; /** - * @description v3 only - * @example 1 + * @description The state. + * @example Noord-Holland */ - Display?: number; + State?: string | null; /** - * @description v3 only - * @example 0 + * @description The zip code. + * @example 1234AA */ - Exclusive?: number; - }; - AuthInput: { - /** - * @description Token received from POST /auth/saml during SSO sign-in flow - * @default null - * @example - */ - SSOResponseToken: string | null; + ZipCode?: string | null; /** + * @description The VAT identifier. * @default null - * @example einstein + * @example AA-1234 */ - Username: string | null; + VatId: string | null; + }; + RegisterReferralInput: { + /** @example 12 */ + Cycle: number; + /** @example bundle2022 */ + Plan: string; + ReferralIdentifier: string; + /** @default null */ + ReferralID: string | null; /** - * Format: base64 * @default null - * @example + * @example payment-token */ - ClientEphemeral: string | null; + PaymentToken: string | null; + /** @default null */ + BillingAddress: components["schemas"]["BillingAddressDto"] | null; /** - * Format: base64 - * @default null - * @example + * @default [] + * @example [ + * "CODE1", + * "CODE2" + * ] */ - ClientProof: string | null; + Codes: string[] | null; /** - * @description Client-specific secret only necessary to access the admin panel * @default null - * @example demopass + * @example USD */ - ClientSecret: string | null; + 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; /** - * Format: hex - * @default null - * @example + * @deprecated + * @description Deprecated! Please compute the fingerprint from the key + * @example c93f767df53b0ca8395cfde90483475164ec6353 */ - SRPSession: string | null; + Fingerprint: string; + /** @example 1 */ + Primary: number; /** - * @description defaults to 0 if not present, transforms cookies into persistent cookies - * @default null + * @description Inactive keys (0) are kept for reactivation only, they are not trusted, and should not be unlocked * @example 1 */ - PersistentCookies: number | null; - /** - * @default null - * @example 123456 or recovery code - */ - TwoFactorCode: string | null; + Active: number; /** - * @description Either this or the TwoFactorCode - * @default null + * @description Base64-encoded secret, made up of 32 random bytes + * @example 1H8EGg3J1...Qwk243hf */ - 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[]; - }; + RecoverySecret: string; + /** @example -----BEGIN PGP SIGNATURE-----... */ + RecoverySecretSignature: string; + }; + 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 optional field, frontend fingerprints - * @default null + * @description 1: Proton (full), 2: Managed, 3: External, 4: CredentialLess + * @example 0 */ - 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; - }; + Type: number; + /** @example 1654615966 */ + CreateTime: number; /** - * @deprecated - * @description optional field used together with Android fingerprinting - * @default null - * @example + * Format: int64 + * @description Max space (in bytes) + * @example 10737418240 */ - 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: { + MaxSpace: number; /** - * @description 4 is the current version, older versions are not accepted - * @example 4 + * Format: int64 + * @description Max upload space (in bytes) + * @example 26214400 */ - Version?: number; - /** @example */ - ModulusID?: string; - /** @example */ - Salt?: string; - /** @example */ - Verifier?: 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; - }; - /** @description An encrypted ID */ - Id2: string; - /** @description Base64 encoded binary data */ - BinaryString: string; - ResetAuthDevicesUserKeyDto: { - ID: components['schemas']['EncryptedId2']; - PrivateKey: components['schemas']['PGPPrivateKey']; - }; - /** @enum {integer} */ - GroupMemberType: 0 | 1; - /** @description An armored PGP Signature */ - PGPSignature: string; - /** @description An armored PGP Private Key */ - PGPPrivateKey: string; - /** @description An armored PGP Message */ - PGPMessage: string; - GroupProxyInstance: { - PgpVersion: number; - GroupAddressKeyFingerprint: string; - GroupMemberAddressKeyFingerprint: string; - ProxyParam: string; - }; - /** @enum {integer} */ - GroupPermissions: 0 | 1 | 2 | 3; - /** @enum {integer} */ - GroupFlags: 0; - /** @enum {integer} */ - GroupMemberPermissions: 0 | 1 | 2 | 3; - ExternalGroupMembership: { - ID: components['schemas']['Id']; - /** Format: date-time */ - CreateTime: string; - State: components['schemas']['GroupMemberState']; - Type: components['schemas']['GroupMemberType2']; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions2']; - /** 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']['GroupMemberType2']; - AddressID?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions2']; - }; - InternalGroupMembership: { - ID: components['schemas']['Id']; - /** Format: date-time */ - CreateTime: string; - State: components['schemas']['GroupMemberState']; - Type: components['schemas']['GroupMemberType2']; - AddressId?: components['schemas']['Id'] | null; - Email?: string | null; - Permissions: components['schemas']['GroupMemberPermissions2']; - /** Format: date-time */ - JoinTime?: string | null; - TokenKeyPacket?: components['schemas']['BinaryString2'] | null; - TokenSignaturePacket?: components['schemas']['BinaryString2'] | null; - AddressSignaturePacket?: components['schemas']['BinaryString2'] | null; - Group: components['schemas']['GroupMembershipGroup']; - ForwardingKeys: components['schemas']['ForwardingKeys']; - GroupID: components['schemas']['Id']; - }; - AddressKeyToken: { + MaxUpload: number; /** - * @description Encrypted Address key ID to replace the token - * @example G1MbEt3Ep5P_E...Wz8WbHVAOl_6h== + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 */ - AddressKeyID?: string; + 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; /** - * @description Base-64 encoded key packet - * @example slCpH6qWMKGQ7d...R4eLU2+2BZvK0UeG/QY2 + * @example 1 + * @enum {integer} */ - KeyPacket?: string; + ToMigrate: 0 | 1; /** - * @description Token signature produced with the primary user key - * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- + * @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 */ - Signature?: string; - }; - /** @enum {integer} */ - UserType: 1 | 2 | 3 | 4; - MagicLinkInvitationInput: { + MnemonicStatus: number; /** - * @description Invitation data containing address and expected KT revision - * @example {"Address":"member@internal-domain.com", "Revision":2} + * @description Subscribed (bitmap): `1`: User has a mail subscription, `4`: User has a VPN subscription + * @example 5 */ - Data?: Record; - Signature?: components['schemas']['PGPSignature'] | null; + Subscribed: number; /** - * @description The email to send an invitation to - * @example some.user@example.com + * @description Activated services (bitmap): + * `1`: User has the mail product activated, + * `4`: User has the VPN activated + * @example 5 */ - Email: string; - /** @description Whether the member should remain private after creation or be unprivatized */ - PrivateIntent: boolean; - }; - /** @enum {integer} */ - MemberPermission: 1; - /** @enum {integer} */ - MemberPermissionAction: 0 | 1; - UserKeyInput: { - PrivateKey: components['schemas']['PGPPrivateKey2']; - OrgPrivateKey: components['schemas']['PGPPrivateKey2']; - /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ - OrgToken?: string; - }; - UnprivatizeMemberUserKeyDto: { - OrgPrivateKey: components['schemas']['PGPPrivateKey']; - OrgToken: components['schemas']['PGPMessage']; - }; - UnprivatizeMemberAddressKeyDto: { - AddressKeyID: components['schemas']['Id2']; - OrgTokenKeyPacket: components['schemas']['BinaryString']; - OrgSignature: components['schemas']['PGPSignature']; - }; - ReplaceOrganizationKeyInvitationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString2']; - Signature: components['schemas']['PGPSignature2']; - SignatureAddressID: components['schemas']['Id']; - EncryptionAddressID: components['schemas']['Id']; - }; - ReplaceOrganizationKeyActivationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString2']; - Signature: components['schemas']['PGPSignature2']; - }; - MigrateOrganizationKeyInvitationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString2']; - Signature: components['schemas']['PGPSignature2']; - }; - MigrateOrganizationKeyActivationDto: { - MemberID: components['schemas']['Id']; - TokenKeyPacket: components['schemas']['BinaryString2']; - Signature: components['schemas']['PGPSignature2']; + 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; + /** @description Whether the user has at least one bring-your-own-email address */ + "has-a-byoe-address"?: boolean; + }; }; - /** @enum {integer} */ - AIAssistantFlags: 0 | 1 | 2 | 3; - /** @enum {integer} */ - Environment: 6 | 7 | 16 | 4; - /** @description An armored PGP Public Key */ - PGPPublicKey: string; - /** @enum {integer} */ - PingNotificationStatus: 0 | 1; - /** @enum {integer} */ - PushNotificationStatus: 0 | 1; - /** @enum {integer} */ - Product: 1 | 2 | 3 | 4 | 5 | 6 | 7; - 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; - }; - /** @enum {integer} */ - DelinquentState: 0 | 1 | 2 | 3 | 4; /** AddressKey */ AddressKey: { /** * @description Encrypted AddressKey ID * @example G1MbEt3...Ol_6h== */ - ID?: string; + ID: string; /** * @description Latest version is 3 * @example 3 */ - Version?: number; + Version: number; /** * @deprecated * @description Deprecated! Do not rely on public keys returned from the API! * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.* */ - PublicKey?: string; + PublicKey: string; /** * @description This parameter is missing ONLY in the key reset call * @example -----BEGIN PGP PRIVATE KEY BLOCK----- @@ -8232,12 +8203,12 @@ export interface components { * @description Deprecated! Do not rely on fingerprints returned from the API! * @example c93f767df53b0ca8395cfde90483475164ec6353 */ - Fingerprint?: string; + Fingerprint: string; /** * @deprecated * @description Deprecated! Do not rely on fingerprints returned from the API! */ - Fingerprints?: string[]; + Fingerprints: string[]; /** * @deprecated * @description Deprecated! @@ -8250,13 +8221,13 @@ export interface components { * @description 0 or 1. There is only one primary key per address * @example 1 */ - Primary?: number; + Primary: number; /** * @description 0 or 1. * All active keys should decrypt successfully and all inactive keys should not be decrypted. * @example 1 */ - Active?: number; + Active: number; /** * @description Flags (bitmap): * * `1`: Can use key to verify signatures; @@ -8265,1941 +8236,4279 @@ export interface components { * * `8`: Do not expect signed email from this key; * @example 3 */ - Flags?: number; + Flags: number; /** * @description If not null, it represents a valid associated Address Forwarding instance * @example fWIio823...j45sL== */ - AddressForwardingID?: string; + AddressForwardingID: string; /** * @description If not null, it represents a valid associated Group Member instance * @example fWIio823...j45sL== */ - GroupMemberID?: string; + GroupMemberID: string; }; - /** @enum {integer} */ - AuthDeviceState: 0 | 1 | 2 | 3 | 4 | 5; - TranslatedStringInterface: Record; - /** @description An armored PGP Message */ - PGPMessage2: string; - /** @enum {integer} */ - GroupMemberType2: 0 | 1; - /** @enum {integer} */ - GroupMemberState: 0 | 1 | 2 | 3 | 4; - /** @enum {integer} */ - GroupMemberPermissions2: 0 | 1 | 2 | 3; - /** @enum {integer} */ - GroupPermissions2: 0 | 1 | 2 | 3; - /** @enum {integer} */ - GroupFlags2: 0; - OrganizationKeyInvitationDto: { - TokenKeyPacket: components['schemas']['BinaryString2']; - /** @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']['BinaryString2']; - /** @description Signature of the token key packet by the user key of the member */ - Signature: string; - }; - /** @description An armored PGP Signature */ - PGPSignature2: string; - /** @description An armored PGP Public Key */ - PGPPublicKey2: string; - /** @enum {string} */ - AuthLogStatus: 'success' | 'attempt' | 'failure'; - /** @enum {integer} */ - AuthLogProtection: 1 | 2 | 3 | 4 | 5; - /** @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== */ + AddressUser: { + /** + * @description Encrypted address ID + * @example qmhrlFY24BhSHiFplF0B7G_cMVLi1sokaWIhfNaee6dRtdIZPYnqgI4-MpAb8h3JhOOykKv8ZsuTH8X_SrUZSg== + */ ID: string; /** - * @description This value is UserID + MessageID.
It gives the order in which the messages were created in our database - * @example 456 + * @description Encrypted domain ID + * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== */ - Order: number; - /** @example Wk30GtU7aIj8Gu6yWkSc3SacA== */ - ConversationID: string; - /** @example new subject */ - Subject: string; - /** @example 1 */ - Unread: number; + DomainID?: string | null; + /** @example jason@protonmail.dev */ + Email: string; /** - * @deprecated + * @description 0 or 1 * @example 1 */ - Type: string; + Send: number; /** - * @deprecated - * @example me@protonmail.com + * @description 0 or 1 + * @example 1 */ - SenderAddress: string; + Receive: number; /** - * @deprecated - * @example Me + * @description Bitflag of: 1 - ReceiveAll, 2 - SendAll, 4 - AutoResponder, 8 - ReceiveOrg, 16 - SendOrg + * @example 7 */ - 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; + Permissions: number; /** - * @deprecated + * @description 2 if the address is invalid, 1 if the address is internal or has been verified, otherwise 0 * @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; + ConfirmationState: 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 + * @description 0: Disabled, 1:Enabled, 2:Deleting + * @example 1 */ - Flags: number; - AttachmentInfo?: components['schemas']['GroupedAttachmentsCount']; - AttachmentsMetadata?: components['schemas']['Metadata'][]; + Status: number; /** - * @deprecated - * @description Deprecated, check Sender.* properties + * @description 1: Original, 2: Alias, 3: Custom, 4: Premium, 5: External * @example 1 */ - SenderImage: number; - /** @description Indicates if the client has to display the text saying that the message has been reminded */ - DisplaySnoozedReminder: boolean; + Type: number; /** * @deprecated - * @description Deprecated, check Sender.* properties + * @description Replaced by "Priority" * @example 1 */ - IsProton: number; + Order: number; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example default + * @description Ordered list, lowest first. Can start with a number > 1. + * @example 1 */ - BimiSelector?: string | null; - }; - Conversation: { + Priority: number; /** - * @description The ID of the conversation - * @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== + * @description Can be empty but not null + * @example D L'u, P.D. 定超 */ - ID?: string; + DisplayName: string; /** - * @description The order is the sum of the conversationID and corresponding userID - * @example 675 + * @description Can be empty but not null + * @example hi there */ - Order?: number; + Signature: string; /** - * @description The subject of the conversation - * @example Testing + * @deprecated + * @description 0 or 1 + * @example 1 */ - Subject?: string; - /** @description The list of senders */ - Senders?: components['schemas']['Sender2'][]; - /** @description The list of recipients */ - Recipients?: components['schemas']['Recipient2'][]; + HasKeys: number; /** - * @description The number of messages in the conversation. - * @example 5 + * @description True if the address is a catch-all + * @example false */ - NumMessages?: number; + CatchAll: Record; /** - * @description The number of unread messages in the conversation. - * @example 0 + * @description True if the domain's record point to Proton servers + * @example true */ - NumUnread?: number; + ProtonMX: Record; + SignedKeyList: components["schemas"]["KTKeyList"]; + Keys: components["schemas"]["AddressKey"][]; /** - * @description The number of attachments of the messages in the conversation, excluding inline attachments - * @example 0 + * @description Bitflags representing noencrypt/nosign + * @example 48 */ - NumAttachments?: number; + Flags: number; + }; + /** LinkResponse */ + SwitchAddressesOrganizationPermissionsTransformer: { + AddressID: string; + Response: { + /** @example 13043 */ + Code?: number; + /** @example Address does not exist */ + Error?: string; + Details?: Record; + }; + }; + /** + * @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; + /** @description The translated client name used for login */ + LocalizedClientName: string; + /** @description The device platform */ + Platform?: string | null; /** - * @description The lowest expiration time of the messages in the conversations. - * * An expiration time of 0 means never. - * @example 0 + * Format: date-time + * @description Time the device was created */ - ExpirationTime?: number; + CreateTime: string; /** - * @description The sum of the sizes of all the messages in the conversation, expressed in bytes - * @example 3555 + * Format: date-time + * @description Time the device was activated */ - 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; + ActivateTime?: string | null; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 + * Format: date-time + * @description Time the device was rejected */ - DisplaySenderImage?: Record; + RejectTime?: string | null; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example 1 + * Format: date-time + * @description Time the device was last used (approximately to the hour) */ - IsProton?: Record; + 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; /** - * @deprecated - * @description Deprecated, check Sender.* properties - * @example default + * @description DeviceToken of the created device + * @example wfih0367aa7dc0359bf5c42d15a93e6c */ - BimiSelector?: string | null; - }; - AttachmentsMetadata: { - AttachmentInfo?: components['schemas']['GroupedAttachmentsCount2']; - AttachmentsMetadata?: components['schemas']['Metadata2'][]; + DeviceToken?: string | null; }; - /** Importer */ - ImporterTransformer: { - /** @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== */ - ID?: string; - /** @example test@protonmail.dev */ - Account?: string; - Product?: string[]; + AuthInput2: { /** - * @description 0: IMAP, 1: Google - * @example 1 + * @description Token received from POST /auth/saml during SSO sign-in flow + * @default null + * @example */ - Provider?: number; + SSOResponseToken: string | null; /** - * @description nullable, present only with token flow - * @example ziWi-ZOb28XR4sCGFCEpqQbd1...lEp-fhjBbUPDMHGU699fw== + * @default null + * @example einstein */ - TokenID?: string; + Username: string | null; /** - * @description Modify time of the importer - * @example 12345678 + * Format: base64 + * @default null + * @example */ - ModifyTime?: number; + ClientEphemeral: string | null; /** - * @description nullable, present only for IMAP flow - * @example imap.mail.ru + * Format: base64 + * @default null + * @example */ - ImapHost?: string; + ClientProof: string | null; /** - * @description nullable, present only for IMAP flow - * @example 993 + * @description Client-specific secret only necessary to access the admin panel + * @default null + * @example demopass */ - ImapPort?: number; + ClientSecret: string | null; /** - * @description nullable, present only for IMAP flow - * @example PLAIN + * Format: hex + * @default null + * @example */ - Sasl?: string; + SRPSession: string | null; /** - * @description nullable, present only for IMAP flow - 1 if certificate is not verified - * @example 0 + * @description defaults to 0 if not present, transforms cookies into persistent cookies + * @default null + * @example 1 */ - 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: { + PersistentCookies: number | null; /** - * @description Encrypted ID - * @example a29olIjFv0rnXxBhSMw== + * @default null + * @example 123456 or recovery code */ - ID?: string; - /** @example ProtonMail Features */ - Name?: string; - /** @example proton-legacy-139892c2-f691-4118-8c29-061196013e04 */ - UID?: string; - /** @example 1434 */ - Size?: number; + TwoFactorCode: string | null; /** - * Format: timestamp - * @example 1503815366 + * @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 */ - CreateTime?: number; + 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; /** - * Format: timestamp - * @example 1503815366 + * @deprecated + * @description optional field used together with Android fingerprinting + * @default null + * @example */ - 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'][]; + Salt: string | null; }; - ContactEmail: { + Fido2RegisteredKey: { + /** @example fido2-u2f */ + AttestationFormat: string; + CredentialID: Record[]; + /** @example My security key */ + Name: string; /** - * @description ContactList.ContactID - * @example aefew4323jFv0BhSMw== + * @example 1 + * @enum {unknown} */ - ID?: string; - /** @example test1 */ - Name?: string; - /** @example features@protonmail.black */ - Email?: string; - /** @description List of email types */ - Type?: string[]; + Flags: unknown; + }; + DomainOutput2: Record; + /** GroupResponse */ + GroupResponse: { + ID: components["schemas"]["Id"]; + Name: string; + Address: unknown[]; + Permissions: components["schemas"]["GroupPermissions"]; + CreateTime: number; + Flags: number; + GroupVisibility: components["schemas"]["GroupVisibility"]; + MemberVisibility: number; + Description?: string | null; + }; + MemberInfo: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID: string; + /** @example 2 */ + Role: number; /** - * @description 0 if contact contains custom sending preferences or keys, 1 otherwise + * @description Invited (2), Member (1) or Disabled (0) * @example 1 */ - Defaults?: number; + 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 */ - 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; + Self: number; /** - * @description Tells whether this is an official Proton address * @example 1 + * @enum {integer} */ - IsProton?: number; - }; - Filter: { - /** @example Ik65N-aChBuWFdo1JpmHJB4iWetfzjVLNILERQqbYFBZc5crnxOabXKuIMKhiwBNwiuogItetAUvkFTwJFJPQg== */ - ID?: string; - /** @example Updated */ - Name?: string; - /** @example 1 */ - Status?: number; + ToMigrate: 1 | 2; + /** + * @example 1 + * @enum {integer} + */ + BrokenSKL: 1 | 2; /** @example 1 */ - Priority?: number; - /** @example require ["fileinto"]; if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { fileinto "mylabel"; } else { keep; } */ - Sieve?: string; - Tree?: { - If?: { - Headers?: string[]; - Keys?: string[]; - Match?: { - /** @example Is */ - Type?: string; - }; - Format?: { - /** @example UnicodeCaseMap */ - Type?: string; - }; - /** @example Address */ - Type?: string; - AddressPart?: { - /** @example Domain */ - Type?: string; - }; - }; - Then?: { - /** @example Fileinto */ - Type?: string; - /** @example mylabel */ - Name?: string; - }[]; - /** @example If */ - Type?: string; - }[]; - /** @example 2 */ - Version?: number; + 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; }; - IncomingDefault: Record; - IncomingDefaultResponse: { - /** ID */ - ID?: string; - Location?: number; - Type?: number; - Time?: number; - Email?: string | null; + 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"]; }; - 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; + UpdateMemberRoleInput: { + Role: number; + /** @default null */ + OrganizationKeyInvitation: components["schemas"]["OrganizationKeyInvitationDto"] | null; + /** @default null */ + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; }; - 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; - }; + GetMemberUnprivatizationOutput: { + /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ + State: 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 + * @description Invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2} */ - AutoSaveContacts: number; + 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; + }; + /** @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. + */ + AuthLogResponse: { /** - * @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 Encrypted user ID + * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== */ - AutoWildcardSearch: number; + UserID: string; /** - * @description Possible values: - * - 0: normal - * - 1: maximized - * @default 0 + * @description Unix timestamp of when the log happened. + * @example 1683644736 */ - ComposerMode: number; + Time: number; + /** @description Status of the event. See AuthLogStatus enum for values. */ + Status: components["schemas"]["AuthLogStatus"]; /** - * @description Possible values: - * - 0: read first - * - 1: unread first - * @default 0 + * @description Various values. See AuthLogEvent constants. + * @example 23 */ - MessageButtons: number; + Event: 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 + * @description Localized description (name) of the log event. + * @example Sign in success (attempt) */ - ShowImages: number; + 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 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 ID of protection applied. + * Can be missing. Only present if user has High Security enabled. + * See AuthLogProtection enum for possible values. */ - ShowMoved: number; + Protection: components["schemas"]["AuthLogProtection"]; /** - * @description delay in days before messages put in trash and spam are permanantly deleted - * - * - null: implicitly disabled - * - 0: explicitly disabled - * @default null + * @description Localized description of protection applied. + * Can be missing. Only present if user has High Security enabled. + * @example Anti-bot verification */ - AutoDeleteSpamAndTrashDays: number | null; + ProtectionDesc?: 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

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 Possible values: - * - 0: Client should show the `ALL_MAIL` label - * - 1: Client should show the `ALMOST_ALL_MAIL` label - * @default 0 + * @description Organization private key encrypted with mailbox password hash + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ - AlmostAllMail: number; + PrivateKey?: string | null; /** - * @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 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----- */ - NextMessageOnMove: 0 | 1 | 2 | null; + LegacyPrivateKey?: string | null; /** - * @description Possible values: - * - 0: enable conversation mode - * - 1: no conversation grouping - * @default 0 + * @deprecated + * @description Organization public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- */ - ViewMode: number; + PublicKey?: string | null; /** - * @description Possible values: - * - 0: column - * - 1: row - * @default 0 + * @description Token (key + data packets) to access the passwordless organization key for this user + * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ - ViewLayout: number; + Token?: string | 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 Signature of the token secret + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - SwipeLeft: number; + Signature?: string | null; /** - * @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 Address email of the admin that signed the token (if not the user key of the member themself) + * @example someadmin@myorg.com */ - SwipeRight: number; + SignatureAddress?: string | null; /** - * @deprecated - * @example 0 + * Format: encrypted string + * @description The address ID of the address that was invited to the organization key */ - AlsoArchive?: number; + EncryptionAddressID?: string | null; /** - * @deprecated - * @default 0 + * @description Signature of the SHA256 fingerprint of the organization key + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - Hotkeys: number; + FingerprintSignature?: string | null; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description The email address that signed the SHA256 fingerprint of the organization key + * @example someadmin@myorg.com */ - Shortcuts: number; + 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 */ + AccessToOrgKey: components["schemas"]["MemberOrgKeyStatus"]; + /** @description Whether the organization has passwordless keys or not */ + Passwordless: boolean; /** - * @description Possible values: - * - 0: Disabled - * - 1: Enabled - * - 2: Enabled and Locked - * @default 0 + * @description Token for accessing via the parent organization key + * @example -----BEGIN PGP MESSAGE-----*-----END PGP MESSAGE----- */ - PMSignature: number; + ParentOrgToken?: string | null; /** - * @description Possible values: - * - 0: Disabled - * - 1: Enabled - * @default 0 + * @description Signature of the parent organization token + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - PMSignatureReferralLink: number; + ParentOrgTokenSignature?: string | null; + }; + GetOrganizationIdentityOutput: { /** - * @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 Organization public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----*-----BEGIN PGP PUBLIC KEY BLOCK----- */ - ImageProxy: number; - /** @example 50 */ - NumMessagePerPage?: number; + PublicKey: string; /** - * @description Default mime type of drafts. Implemented by the client. - * Possible values: - * - 'text/html' - * - 'text/plain' - * @example text/html + * @description Signature of the SHA256 fingerprint of the organization key + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- */ - DraftMIMEType?: string; + FingerprintSignature: string; /** - * @description Preferred mime type of received messages. Implemented by the backend. - * Possible values: - * - 'text/html' - * - 'text/plain' - * @example text/html + * @description The email address that signed the SHA256 fingerprint of the organization key + * @example someadmin@myorg.com */ - ReceiveMIMEType?: string; - /** @example text/html */ - ShowMIMEType?: string; + FingerprintSignatureAddress: string; + }; + OrganizationSettingsInputOutput: { /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 - */ - EnableFolderColor: number; - /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Whether to show organization name in sidebar or not + * @default null + * @example true */ - InheritParentFolderColor: number; + ShowName: boolean | null; /** - * @description Possible values: - * - 0: disabled - * - 1: enabled - * @default 0 + * @description Whether to show the Scribe writing assistant or not + * @default null + * @example true */ - SubmissionAccess: number; + ShowScribeWritingAssistant: boolean | null; /** - * @deprecated - * @default 0 + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null + * @example true */ - TLS: number; + VideoConferencingEnabled: boolean | null; /** - * @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 Whether the Meet video conferencing feature is enabled or not + * @default null + * @example true */ - RightToLeft: number; + MeetVideoConferencingEnabled: boolean | null; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @description The ID of the organization's logo + * @default null */ - AttachPublicKey: number; + LogoID: string | null; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 0 + * @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" + * ] */ - Sign: number; + AllowedProducts: unknown[] | null; /** - * @description Default PGP scheme to use when sending externally. Implemented by the client. - * Possible values: - * - 8: PGP Inline - * - 16: PGP Mime - * @default 16 + * @description List of PasswordPolicies. Only the PasswordPolicies passed are updated. Absent PasswordPolicies remain in their current state. + * @default null */ - PGPScheme: number; + PasswordPolicies: components["schemas"]["OrganizationPasswordPolicyInputOutput"][] | null; /** - * @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 Organization policy settings + * @default null */ - PromptPin: number; + OrganizationPolicy: { + /** + * @description 1 for business plans only organization settings override logAuth and highSecurity of user settings, 0 otherwise + * @example 1 + */ + Enforced?: number; + } | null; /** - * @deprecated - * @default 0 + * @description For business plans only, organization settings logAuth override the one in user settings. + * @default null + * @example [ + * 0, + * 1, + * 2 + * ] */ - Autocrypt: number; + LogAuth: number | null; /** - * @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 For business plans only, organization settings HighSecurity override the one in user settings. + * @default null + * @example [ + * 0, + * 1, + * 2 + * ] */ - StickyLabels: number; + HighSecurity: number | null; /** - * @description Possible values: - * - 0: disable - * - 1: enable - * @default 1 + * @description Whether Mail Category view is enabled or not for members + * @default false */ - ConfirmLink: number; + MailCategoryViewEnabled: boolean; /** - * @description Possible values between 0 and 30 - * @default 10 + * @description Whether Personal Access Token creation is disabled for members + * @default false */ - DelaySendSeconds: number; - /** @default 0 */ - KT: number; + PersonalAccessTokenCreationDisabled: boolean; + }; + SsoTransformer: Record; + Info: { + /** @example https://sso.proton.me/sp */ + EntityID: string; + /** @example https://sso.proton.me/auth/saml */ + CallbackURL: string; + }; + /** Theme */ + Theme2: Record; + 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 Possible values between 10 and 26 - * @default null + * @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 */ - FontSize: number | null; - /** @default null */ - FontFace: string; + News: number; + /** @example en_US */ + Locale: 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 + * @description 0 => Disabled, 1 => Basic, 2 => Advanced + * @example 2 */ - SpamAction: number | null; + LogAuth: number; + /** @example रिवार में हुआ। ज檷\n Cartoon Law Services\n 1 DisneyWorld Lane\n Orlando, FL, 12345\n VAT */ + InvoiceText: string; /** - * @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 0 => Comfortable, 1 => Compact + * @example 0 */ - 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[]; - }; - }; + Density: number; + Theme: components["schemas"]["Theme2"]; + /** @example 1 */ + ThemeType: number; /** - * @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} + * @description 0 => default, 1 => monday, 6 => saturday, 7 => sunday + * @example 1 */ - HideEmbeddedImages: 1 | 0; + WeekStart: number; /** - * @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 0 => default, 1 => DD_MM_YYYY, 2 => MM_DD_YYYY, 3 => YYYY_MM_DD + * @example 1 */ - HideRemoteImages: 1 | 0; + DateFormat: 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 + * @description 0 => default, 1 => 24h, 2 => 12h * @example 1 - * @enum {integer} */ - HideSenderImages?: 1 | 0; + TimeFormat: number; /** - * @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 + * @description 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 */ - RemoveImageMetadata?: Record; - }; - /** User settings for VPN product */ - VPNSettings: { + Welcome: number; /** - * @description OpenVPN / IKEv2 username - * @example 9rXSJiW7xf59U/OqUTjHRJy/ + * @deprecated + * @description (Use `Welcome`) 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 */ - Name?: string; + WelcomeFlag: number; /** - * @description OpenVPN / IKEv2 password - * @example sHwX8ye/ipCFfj5K0xuZYTlD + * @description 0 => Regular access, 1 => Beta access + * @example 1 */ - 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; + EarlyAccess: number; + Flags: { + /** @description 1 or 0 */ + 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 */ + Link?: string; + /** + * @description true if the user is eligible to the referral program + * @example true + */ + Eligible?: boolean; + }; /** - * @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 + * @description 0 or 1, 1 means device recovery enabled + * @example 1 */ - PlanName?: string | null; + DeviceRecovery: number; /** - * @description Title of the plan (PlanName) (for display only, the title of a - * plan can change over time, be translated, etc.) - * @example VPN Plus + * @description 0 or 1, 1 means sending telemetry enabled + * @example 1 */ - PlanTitle?: string | null; + Telemetry: number; /** - * @description Maximum number of connections/devices the user plan allows - * @example 10 + * @description 0 or 1, 1 means sending crash reports enabled + * @example 1 */ - MaxConnect?: number; + CrashReports: number; /** - * @description Maximum server tier level the user can access - * @example 2 + * @description 0 or 1, 1 means hiding the side panel + * @example 1 */ - MaxTier?: number | null; - Groups?: string[]; + 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 + * @example 1 + */ + Eligible?: number; + /** + * @description 1 => user has High Security enabled, 0 => disabled + * @example 1 + */ + Value?: number; + }; /** - * @description `true` if the user needs to allocate connection - * (to the sub-user via the VPN settings panel for instance) - * @example false + * @description 0 or 1, 1 means session account recovery enabled + * @example 1 + * @enum {integer} */ - NeedConnectionAllocation?: boolean; + SessionAccountRecovery: 0 | 1; /** - * @description `true` if the organization opted-in for telemetry) - * @example false + * @description 0: unset, 1: off, 2: server-only, 3: client-only + * @example 1 + * @enum {integer} */ - BusinessEvents?: boolean; + AIAssistantFlags: 0 | 1 | 2 | 3; /** - * @description `true` if the current user plan allow to use the browser extension - * @example false + * @deprecated + * @description Deprecated in favour of "UsedClients". First 64 bit of bitmap informing which client the user has logged in to. + * @example 1 */ - BrowserExtension?: boolean; + UsedClientFlags: number; /** - * @description A plan that the current user can buy/upgrade to in order to be able to use the browser extension - * @example vpnpro2023 + * @description List of clients the user has logged in to. + * @example [WebAccount, WebMail, iOSDrive] */ - 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; + UsedClients: Record[]; }; - /** 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; + ScheduleSupportCallOutput: { + /** @example https://calendly.com/proton-schedule */ + CalendlyLink: 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; + GetPasswordPolicyOutput: Record; + OutgoingDelegatedAccessOutput: Record; + AccountRecoveryAttempt: { /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH + * @description 0 => None, 1 => Grace, 2 => Cancelled, 3 => Insecure, 4 => Expired + * @example 1 + * @enum {integer} */ - EntryCountry?: string; + State: number; + /** @example 1686834569 */ + StartTime: number; + /** @example 1687000169 */ + EndTime: number; /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH + * @description 0 => None, 1 => Cancelled, 2 => Authentication + * @example 1 */ - ExitCountry?: string; + Reason: number; /** - * Format: alpha-2 - * @description alpha-2 country code - * @example CH + * @deprecated + * @description The session ID that triggered the process + * @example qmi2ptbz4sefeahddjxghsxtu2orlgyf */ - HostCountry?: string | null; + UID: string; /** - * @description Domain name - * @example es-05.protonvpn.com + * @description Is the current session the one that triggered the process + * @example true */ - Domain?: string; + 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: { /** - * @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 + * @description Certificate serial number + * @example 6561979746 */ - Features?: number; + SerialNumber: string; /** - * @description `1` if at least one physical server server is up and running and usable, `0` otherwise - * @example 1 - * @enum {integer} + * @description Blob content of the certificate + * @example -----BEGIN CERTIFICATE-----... */ - Status?: 0 | 1; + Certificate: string; /** - * @deprecated - * @description Use City or Name instead for geographic information - * @example null + * @description Fingerprint of the client public key + * @example bHZDBSYbd27GFd */ - Region?: number | null; + ClientKeyFingerprint: string; /** - * @description Optional city - * @example Stockholm + * @description The input or default mode + * @example 1505758141 */ - City?: number | null; - Servers?: components['schemas']['VPNServerTransformerInterface'][]; + ExpirationTime: number; /** - * @description Describe in a spiritual way how much the logical server is loaded - * @example 0 + * @description The input or default mode + * @example session */ - Load?: number; - /** @description The coordinate of the datacenter */ - Location?: { - /** - * @description Latitude - * @example 39.4667 - */ - Lat?: Record; - /** - * @description Longitude - * @example -0.3667 - */ - Long?: Record; - }; + 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; /** - * @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 + * @example enumeration + * @enum {string} */ - Score?: Record; + 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; }; - CalendarAugmentedTransformer: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; - /** @example Organizational Calendar */ - Name?: string; - /** @example This text describes the calendar */ - Description?: string; + 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; + }; + PushTransformer: { + /** @example 1H8EGg3J1QpSDL6K8hGsTvwm...hrHx6nnGQ== */ + PushID: string; /** - * @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 + * @description Any objectID from the event feed (*WARNING*: the object can be on another page) + * @example KPlISx5MiML3Xc...3MAOfsds-tfNw== */ - Flags?: number; + ObjectID: string; /** - * @description normal calendar: `0`, subscribed calendar: `1` - * @example 0 - * @enum {integer} + * @description Type of the ObjectID + * @example Messages */ - Type?: 0 | 1; - Members?: components['schemas']['MemberTransformer'][]; + Type: string; }; - MemberTransformer: { - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - ID?: string; + Sender: { + /** @example foo@protonmail.dev */ + Address: string; + /** @example Joe */ + Name: string; /** - * @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 + * @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} */ - Permissions?: number; - /** @example andy@pm.me */ - Email?: string; - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - AddressID?: string; - /** @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== */ - CalendarID?: string; - /** @example Organizational Calendar */ - Name?: string; - /** @example This text describes the calendar */ - Description?: string; - /** @example #8989AC */ - Color?: string; - /** @example 1 */ - Display?: number; + IsProton: 0 | 1; /** - * @description Priority describing the order of the member, 1 is highest + * @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} */ - Priority?: number; + DisplaySenderImage: 0 | 1; + /** @description Optional, BIMI selector header, set if present on message or if domain has BIMI */ + BimiSelector?: string | null; /** - * @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
+ * @description Whether the mail came through simple login * @example 1 + * @enum {integer} */ - Flags?: number; + IsSimpleLogin: 0 | 1; }; - 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; + 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; }; }; - /** CalendarUserSettings */ - UserSettingsTransformer2: { + /** @enum {string} */ + Disposition: "attachment" | "inline"; + Metadata: { + ID: components["schemas"]["Id"]; + Name?: string | null; + Size: number; + MIMEType: string; + Disposition: components["schemas"]["Disposition"]; + }; + MessageInfo: { + /** @example KPlISx5MiML3XcSYPrREF-Jw9AFa2kk60Lqw7FVWAGvJUsT_zzWKFI-adgMIhFhPaAukDm9fw3MAOfsds-tfNw== */ + ID: string; /** - * @description `0`: 7 Days, `1`: 5 Days - * @example 0 - * @enum {integer} + * @description This value is UserID + MessageID.
It gives the order in which the messages were created in our database + * @example 456 */ - WeekLength?: 0 | 1; + Order: number; + /** @example Wk30GtU7aIj8Gu6yWkSc3SacA== */ + ConversationID: string; + /** @example new subject */ + Subject: string; + /** @example 1 */ + Unread: number; /** - * @description `0`: Off, `1`: On + * @deprecated * @example 1 - * @enum {integer} */ - DisplayWeekNumber?: 0 | 1; + Type: string; /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} + * @deprecated + * @example me@protonmail.com */ - AutoDetectPrimaryTimezone?: 0 | 1; - /** @example Antarctica/Macquarie */ - PrimaryTimezone?: string; + SenderAddress: string; /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} + * @deprecated + * @example Me */ - DisplaySecondaryTimezone?: 0 | 1; + 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; /** - * @description Can be null if DisplaySecondaryTimezone is 0 - * @example null + * @deprecated + * @example 1 */ - SecondaryTimezone?: string; + 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 `0`: DAILY, `1`: WEEKLY, `2`: MONTHLY, `3`: YEARLY, `4`: PLANNING - * @example 1 - * @enum {integer} + * @description The number of attachments in the message, excluding inline attachments + * @example 2 */ - ViewPreference?: 0 | 1 | 2 | 3 | 4; + NumAttachments: number; /** - * @description Can be null, if the calendar type is `subscription`, instead of `normal`, it cannot be set as the default calendar - * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS== + * @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 */ - DefaultCalendarID?: string; + Flags: number; + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; + /** @description null */ + AttachmentsMetadata: components["schemas"]["Metadata"][]; /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 */ - ShowCancelled?: 0 | 1; + SenderImage: number; + /** @description Indicates if the client has to display the text saying that the message has been reminded */ + DisplaySnoozedReminder: boolean; /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 */ - ShowDeclined?: 0 | 1; + IsProton: number; /** - * @description `0`: Off, `1`: On - * @example 0 - * @enum {integer} + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default */ - AutoImportInvite?: 0 | 1; - /** @description Bitmap of whom to share busy-schedule with:
- 1 (2^0): To users in the same organization */ - ShareBusySchedule?: number; + BimiSelector?: string | null; }; - WalletOutput: { - ID: components['schemas']['Id']; + Sender2: Record; + Recipient2: Record; + Conversation: { /** - * @description 1 if the wallet has a passphrase - * @example 0 + * @description The ID of the conversation + * @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== */ - HasPassphrase: number; + ID: string; /** - * @description 0 if the wallet is created with Proton Wallet - * @example 0 + * @description The order is the sum of the conversationID and corresponding userID + * @example 675 */ - IsImported: number; + Order: number; /** - * Format: base64 - * @description Encrypted wallet mnemonic with the WalletKey, in base64 format - * @example + * @description The subject of the conversation + * @example Testing */ - Mnemonic?: components['schemas']['BinaryString2'] | null; + Subject: string; + /** @description The list of senders */ + Senders: components["schemas"]["Sender2"][]; + /** @description The list of recipients */ + Recipients: components["schemas"]["Recipient2"][]; /** - * @description Unique identifier of the mnemonic, using the first 4 bytes of the master public key hash - * @example 912914fb + * @description The number of messages in the conversation. + * @example 5 */ - Fingerprint?: string | null; - Name: components['schemas']['BinaryString2']; + NumMessages: number; /** - * @description Order of priority - * @example 1 + * @description The number of unread messages in the conversation. + * @example 0 */ - Priority: number; + NumUnread: number; /** - * Format: base64 - * @description Encrypted wallet public key with the WalletKey, in base64 format, only if on-chain watch-only - * @example + * @description The number of attachments of the messages in the conversation, excluding inline attachments + * @example 0 */ - PublicKey?: components['schemas']['BinaryString2'] | null; - Status: components['schemas']['WalletStatus']; - Type: components['schemas']['WalletType']; + NumAttachments: number; /** - * @description Set to 1 if wallet key needs to be rotated + * @description The lowest expiration time of the messages in the conversations.An expiration time of 0 means never. * @example 0 */ - MigrationRequired: number; + ExpirationTime: number; /** - * @description Set to 1 if mnemonic is encrypted with user key too - * @example 0 + * @description The sum of the sizes of all the messages in the conversation, expressed in bytes + * @example 3555 */ - Legacy: number; - }; - WalletAccountOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; + 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 Preferred fiat currency - * @example CHF + * @description The ID of the category + * @example 24 */ - FiatCurrency: string; - DerivationPath: components['schemas']['DerivationPath']; - Label: components['schemas']['BinaryString2']; - /** @description The index number that wallet last used to create address */ - LastUsedIndex: number; + CategoryID: string; + /** @description Indicates if the client has to display the text saying that the conversation has been reminded */ + DisplaySnoozedReminder: boolean; /** - * @description Size of Bitcoin address pool - * @example 10 + * @description Whether the conversation is expiring due to retention rule + * @example false */ - PoolSize: number; + ExpiringByRetention: boolean; /** - * @description Order of priority + * @deprecated + * @description Deprecated, check Sender.* properties * @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; + DisplaySenderImage: Record; /** - * @description Detached signature of the bitcoin address - * @default null - * @example -----BEGIN PGP SIGNATURE-----... + * @deprecated + * @description Deprecated, check Sender.* properties + * @example 1 */ - BitcoinAddressSignature: components['schemas']['PGPSignature2'] | null; + IsProton: Record; /** - * @description Index of the bitcoin address - * @default null - * @example 1 + * @deprecated + * @description Deprecated, check Sender.* properties + * @example default */ - BitcoinAddressIndex: number | null; + BimiSelector?: string | null; }; - WalletKeyOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - UserKeyID: components['schemas']['Id']; + AttachmentsMetadata: { + AttachmentInfo: components["schemas"]["GroupedAttachmentsCount"]; + AttachmentsMetadata: components["schemas"]["Metadata"][]; + }; + ContactEmail: { /** - * @description Encrypted AES-GCM 256 key used to encrypt the mnemonic or public key, as armored PGP - * @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- + * @description ContactList.ContactID + * @example aefew4323jFv0BhSMw== */ - WalletKey?: string; + ID: string; + /** @example test1 */ + Name: string; + /** @example features@protonmail.black */ + Email: string; + /** @description List of email types */ + Type: 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----- + * @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 */ - WalletKeySignature?: string; + IsProton: number; }; - WalletSettingsOutput: { - WalletID: components['schemas']['Id']; + ContactData: { /** - * @description Hide accounts, only used for on-chain wallet - * @example 0 + * @description Possible values: + *
- 0: clear text + *
- 1: encrypted + *
- 2: signed + *
- 3: encrypted and signed + * @example 2 + * @enum {integer} */ - HideAccounts: number; + Type: 0 | 1 | 2 | 3; /** - * @description Invoice default description, only used for lightning wallet - * @example Lightning payment from John Doe. + * @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 */ - InvoiceDefaultDescription?: string | null; + Data: string; /** - * @description Invoice expiration time, only used for lightning wallet - * @example 3600 + * @description PGP signature of the data + * @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - InvoiceExpirationTime: number; + Signature: string; + }; + Contact: { /** - * @description Max fee for automatic channel opening with Proton Lightning node, expressed in SATS, only used for lightning wallet - * @example 5000 + * @description Encrypted ID + * @example a29olIjFv0rnXxBhSMw== */ - MaxChannelOpeningFee: number; + ID: string; + /** @example ProtonMail Features */ + Name: string; + /** @example proton-legacy-139892c2-f691-4118-8c29-061196013e04 */ + UID: string; + /** @example 1434 */ + Size: number; /** - * @description User should see wallet recovery phrase without 2FA - * @example false + * Format: timestamp + * @example 1503815366 */ - ShowWalletRecovery: boolean; + 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"][]; }; - WalletTransactionOutput: { - ID: components['schemas']['Id']; - WalletID: components['schemas']['Id']; - WalletAccountID: components['schemas']['Id']; - TransactionID: components['schemas']['PGPMessage2']; + Tree: { + List?: string[]; + /** @example Require */ + Type?: string; + }[]; + FilterOutput: { + ID: components["schemas"]["Id"]; + Name: string; + /** @example 1 */ + Status: number; + /** @example 3 */ + Priority: number; /** - * @description Unix timestamp of when the transaction got created in Proton Wallet or confirmed in blockchain for incoming ones - * @example 1707287982 + * @example require ["fileinto"]; + * + * if address :DOMAIN :is ["From", "Delivered-To"] "protonmail.ch" { + * fileinto "mylabel"; + * } else + * keep; + * } */ - 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; - Type: components['schemas']['TransactionType']; - /** @default null */ - HashedTransactionID: components['schemas']['BinaryString2'] | null; - /** @default null */ - Label: components['schemas']['BinaryString2'] | null; - /** @default null */ - ExchangeRate: components['schemas']['ExchangeRateOutput'] | null; - /** @default null */ - Sender: components['schemas']['PGPMessage2'] | null; - /** @default null */ - ToList: components['schemas']['PGPMessage2'] | null; - /** @default null */ - Subject: components['schemas']['PGPMessage2'] | null; - /** @default null */ - Body: components['schemas']['PGPMessage2'] | null; + Sieve: string; + Tree: components["schemas"]["Tree"]; + /** @example 1 */ + Version: number; }; - WalletUserSettingsOutput: { + 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 Accept terms and conditions + * @description v3 only * @example 1 */ - AcceptTermsAndConditions: number; + Display: number; /** - * @description Preferred Bitcoin unit - * @example BTC + * @description v3 only + * @example 0 */ - BitcoinUnit: string; + 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 */ + 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 Preferred fiat currency - * @example CHF + * @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 */ - FiatCurrency: string; + AutoSaveContacts: number; /** - * @description Hide empty used addresses - * @example 1 + * @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 */ - HideEmptyUsedAddresses: number; + AutoWildcardSearch: number; /** - * @description Ask for 2FA verification when an amount threshold is reached - * @example 1000 + * @description Possible values: + * - 0: normal + * - 1: maximized + * @default 0 */ - TwoFactorAmountThreshold?: number | null; + ComposerMode: number; /** - * @description Receive inviter notification - * @example 1 + * @description Possible values: + * - 0: read first + * - 1: unread first + * @default 0 */ - ReceiveInviterNotification: number; + MessageButtons: number; /** - * @description Receive email integration notification - * @example 1 + * @description Possible values: + * - 0: don't auto load + * - 1: auto-load remote content + * - 2: auto-load embedded images + * - 3: auto-load both + * @default 2 */ - ReceiveEmailIntegrationNotification: number; + ShowImages: number; /** - * @description Receive transaction notification - * @example 1 + * @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 */ - ReceiveTransactionNotification: number; + ShowMoved: number; /** - * @description User has already created a wallet once - * @example 1 + * @description delay in days before messages put in trash and spam are permanantly deleted + * + * - null: implicitly disabled + * - 0: explicitly disabled + * @default null */ - WalletCreated: number; + 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 + */ + 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; + /** + * @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
ValueDescription
1InternalEncrypted
2ExternalUnencrypted
3ExternalEncrypted
+ * @enum {integer} + */ + AddressForwardingType: 1 | 2 | 3; + /** + * @description
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; + }; + /** + * @description
See values descriptions
ValueDescription
2V2
+ * @enum {integer} + */ + SieveVersion: 2; + /** AddressForwardingFilter */ + AddressForwardingFilter: { + Tree: components["schemas"]["Tree"]; + Sieve: string; + Version: components["schemas"]["SieveVersion"]; + }; + /** 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; + }; + 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; + }; + }; + DriveShareRefreshCoreEventService: { + DriveShareRefresh: { + /** @enum {integer} */ + Action?: 2; + }; + }; + 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; + }; + /** + * @description

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

See values descriptions
ValueDescription
0Normal
1Subscription
+ * @enum {integer} + */ + CalendarType: 0 | 1; + CalendarOwner: { + /** + * @description owner's email + * @example owner@pm.me + */ + Email: string; + }; + 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
ValueDescription
0None
1Zoom
2Meet
+ * @enum {integer} + */ + AutoAddConferenceLinkProvider: 0 | 1 | 2; + /** + * @description

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

See values descriptions
ValueDescription
1True
0False
+ * @enum {integer} + */ + 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"]; + }; + /** 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; + 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[]; + /** + * @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; + /** @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; + }[]; + }; + }; + }; + 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; + }; + }; + }; + 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: { + /** + * @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 Either the user belong to an organization being on a business plan + * @example false + */ + IsBusiness: boolean; + /** + * @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; + /** @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 An arbitrary string that uniquely identifies the circuit + * @example l8vWAXHBQmv0u7OVtPbcqMa4iwQaBqowINSQjPrxAr-Da8fVPKUkUcqAq30_BCxj1X0nW70HQRmAa-rIvzmKUA== + */ + ID: string; + /** + * @description IP client calls (client opens a tunnel between user network and this IP) + * @example 95.215.61.163 + */ + EntryIP: string; + /** + * @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 + */ + 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 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 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?: number[]; + }; + 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?: number[]; + }; + 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?: number[]; + }; + 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?: number[]; + }; + 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?: number[]; + }; + 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?: number[]; + }; + }; + }; + VPNLogical: { + /** + * @description An arbitrary string that uniquely identifies the given logical server + * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== + */ + ID: string; + /** + * @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; + /** + * 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 + */ + EntryCountry: string; + /** + * 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 + */ + ExitCountry: string; + /** + * 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 + */ + 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 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; + /** @description Encrypted wallet name with the WalletKey, in base64 format */ + 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; + /** + * @description Set to 1 if wallet is imported from hardware wallet + * @example 0 + */ + 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 Preferred fiat currency + * @example CHF + */ + 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 Size of Bitcoin address pool + * @example 10 + */ + PoolSize: number; + /** + * @description Order of priority + * @example 1 + */ + 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 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; + }; + /** + * @description
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; + }; + 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 Tell the client that it is allowed to show the review page + * @example 0 + */ + AllowReview: 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; + /** + * Format: date-time + * @description Timestamp about when user saw the review page on client + * @example null + */ + ReviewTime?: string | null; + /** + * @description User has already created a wallet once + * @example 1 + */ + 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"]; + /** + * 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"]; + }[]; + 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; + }[]; + /** + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 + */ + 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; + /** + * @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; + }; + }; + 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; + }; + }; + }; + }; + 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; + }; + }; + }; + }; + }; + }; + "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}-events-latest": { + parameters: { + query?: never; + 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"]["LatestEventResponse"]; + }; + }; + }; + }; + "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; }; - DriveShareRefreshCoreEventService: { - DriveShareRefresh?: { - /** @enum {integer} */ - Action?: 2; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: components["schemas"]["AddressUser"][]; + }; + }; }; }; - /** @description An encrypted ID */ - EncryptedId2: string; - GroupMembershipGroup: { - ID: components['schemas']['Id']; - Name: string; - Address: string; + }; + "post_core-{_version}-members-{enc_id}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; }; - /** @description Base64 encoded binary data */ - BinaryString2: string; - ForwardingKeys: { - PrivateKey?: components['schemas']['PGPPrivateKey2'] | null; - ActivationToken?: components['schemas']['PGPMessage2'] | null; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; }; - /** @description An armored PGP Private Key */ - PGPPrivateKey2: string; - 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"]; + 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"]; + }; + }; }; - 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; + }; + "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; }; - 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?: 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 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; + }; + "post_core-{_version}-addresses": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; }; - 'text/calendar'?: { - /** @example 1 */ - attachment?: number; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; }; }; - Metadata: { - ID: components['schemas']['Id3']; - Name?: string | null; - Size: number; - MIMEType: string; - Disposition?: components['schemas']['Disposition'] | null; + 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"]; + }; + }; }; - 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; + }; + "post_core-{_version}-members-addresses-available": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; }; - 'text/calendar'?: { - /** @example 1 */ - attachment?: number; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; }; }; - Metadata2: { - ID: components['schemas']['Id4']; - Name?: string | null; - Size: number; - MIMEType: string; - Disposition?: components['schemas']['Disposition2'] | null; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: 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; + }; + "put_core-{_version}-addresses-order": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - /** @enum {integer} */ - AddressForwardingType: 1 | 2 | 3; - /** @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"]["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"]; + }; + }; + }; }; - /** AddressForwardingFilter */ - AddressForwardingFilter: { - Tree: components['schemas']['Tree']; - Sieve: string; - Version: components['schemas']['SieveVersion']; + }; + "post_core-{_version}-addresses-setup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; }; - 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; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAddressInput"]; + }; }; - /** @enum {integer} */ - WalletStatus: 0 | 1; - /** @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; - /** @enum {integer} */ - ScriptType: 1 | 2 | 3 | 4; - /** - * @description BTC address - * @example 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa - */ - BitcoinAddress: string; - /** @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; + 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[]; + }; + }; + }; + }; }; - /** @enum {integer} */ - EventAction: 0 | 1 | 2 | 3; - /** @description An encrypted ID */ - Id3: string; - /** @enum {string} */ - Disposition: 'attachment' | 'inline'; - /** @description An encrypted ID */ - Id4: string; - /** @enum {string} */ - Disposition2: 'attachment' | 'inline'; - Tree: { - List?: string[]; - /** @example Require */ - Type?: string; - }[]; - /** @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; + "get_core-{_version}-addresses-canonical": { + parameters: { + query?: { + /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ + Emails?: string[]; }; - content: { - 'application/json': components['schemas']['ProtonSuccess']; + header?: never; + path: { + _version: string; }; + cookie?: never; }; - /** @description General Error */ - ProtonErrorResponse: { - headers: { - [name: string]: unknown; + 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; }; - content: { - 'application/json': components['schemas']['ProtonError']; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"]; + }; + }; }; }; }; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - 'get_core-{_version}-addresses-allowAddressDeletion': { + "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?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAddressInput"]; + }; + }; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'put_core-{_version}-keys-address-active': { + "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': { - /** - * @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; - }; - }; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10209,23 +12518,36 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SignedKeyList?: components['schemas']['KTKeyList'] | null; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-keys': { + "get_core-{_version}-domains-{enc_id}-addresses": { parameters: { query?: { - Email?: string; - Fingerprint?: string; + /** + * @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; }; @@ -10237,75 +12559,113 @@ 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; + "application/json": { + Addresses?: (components["schemas"]["AddressUser"] & { /** - * @description 0: Internal, 1: WKD, 2: KOO + * @description whether this is the catch-all address for this domain * @example 0 - * @enum {integer} */ - Source?: 0 | 1 | 2; + 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; }[]; - SignedKeyList?: components['schemas']['KTKeyList']; - /** @example [] */ - Warnings?: string[]; - /** - * @description Tells whether this is an official Proton address, optional field - * @example 1 - */ - IsProton?: number; + }; + }; + }; + /** @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': { + "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': { - /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ - AddressID?: string; - /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ - PrivateKey?: string; - /** @example 1 */ - Primary?: number; - SignedKeyList?: { - /** @example JSON.stringify([{""Fingerprint"": ""c93f767df53b0ca8395cfde90483475164ec6353"",""Primary"": 0,""Flags"": 1}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10315,65 +12675,30 @@ 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': { + "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': { - /** @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?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10383,63 +12708,30 @@ 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": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-group': { + "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': { - /** @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; - }; - }; + "application/json": components["schemas"]["AddressListInput"]; }; }; responses: { @@ -10449,104 +12741,30 @@ 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"]; }; }; }; }; }; - 'post_core-{_version}-keys-setup': { + "put_core-{_version}-addresses-{enc_id}-type": { 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': { - /** @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?: { - /** @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; - /** @example -----BEGIN PGP SIGNATURE-----.* */ - Signature?: string; - SignedKeyList?: { - /** @example JSON.stringify([{"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }[]; - /** @description include to enable 1 password mode, otherwise 2 password mode */ - Auth?: { - /** @example 4 */ - Version?: number; - /** @example */ - ModulusID?: string; - /** @example */ - Salt?: string; - /** @example */ - Verifier?: string; - }; - AddressList?: components['schemas']['KTAddressListTransformer']; - /** - * @description base64 encoded AES-GCM encrypted secret using the DeviceSecret as key - * @example dzOtLW5psxgB8oNc8On...oFRykab4EW1ka3GtQPF9x - */ - EncryptedSecret?: string; - }; + "application/json": components["schemas"]["ChangeAddressTypeInput"]; }; }; responses: { @@ -10556,39 +12774,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}-rename-internal": { parameters: { query?: never; header?: never; path: { /** - * @description the key id - * @example ACXDmTaBub14w== + * @description the encrypted id + * @example lKJl...ejjlk== */ enc_id: string; _version: string; @@ -10597,13 +12797,15 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - 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; - }; + "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; + }[]; }; }; }; @@ -10614,21 +12816,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}-rename-external": { parameters: { query?: never; header?: never; path: { /** - * @description the key id - * @example ACXDmTaBub14w== + * @description the encrypted id + * @example lKJle...jjlk== */ enc_id: string; _version: string; @@ -10637,14 +12839,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - 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; - }; - }; + "application/json": components["schemas"]["RenameUnverifiedAddressInput"]; }; }; responses: { @@ -10654,93 +12849,31 @@ 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_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': { - /** @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 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; - }; - /** - * @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 Required only when the session is SSO, base64 encoded AES-GCM encrypted secret using the DeviceSecret as key - * @example - */ - EncryptedSecret?: string; - }; + "application/json": components["schemas"]["UpdateEncryptionSignatureFlagsInput"]; }; }; responses: { @@ -10750,83 +12883,50 @@ 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}-members-addresses-permissions-organization-switch": { 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: { _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressIdsInput"]; + }; + }; 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; - }; - content?: never; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["SwitchAddressesOrganizationPermissionsTransformer"][]; + }; + }; }; }; }; - 'get_core-{_version}-members-{enc_id}-addresses': { + "post_core-{_version}-members-{memberId}-saml": { 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== - */ - memberid: string; _version: string; - enc_id: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -10838,29 +12938,24 @@ 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': { + "delete_core-{_version}-members-{memberId}-saml": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateAddressInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -10868,28 +12963,21 @@ 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': { + "delete_core-{_version}-members-{memberId}-devices-{deviceId}": { parameters: { - query?: { - PageSize?: components['schemas']['OffsetPagination']['PageSize'] & unknown; - Page?: components['schemas']['OffsetPagination']['Page'] & unknown; - }; + query?: never; header?: never; path: { _version: string; + memberId: components["schemas"]["Id"]; + deviceId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -10901,29 +12989,24 @@ 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"]; }; }; }; }; }; - 'post_core-{_version}-addresses': { + "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: { @@ -10931,44 +13014,45 @@ 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': { + "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?: { - 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"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + }; + }; }; }; }; - 'put_core-{_version}-addresses-order': { + "get_core-{_version}-members-devices-pending": { parameters: { query?: never; header?: never; @@ -10977,11 +13061,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['ReorderAddressesInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -10989,14 +13069,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; }; }; }; }; }; - 'post_core-{_version}-addresses-setup': { + "post_core-{_version}-auth-refresh": { parameters: { query?: never; header?: never; @@ -11007,7 +13088,20 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateAddressInput']; + "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: { @@ -11017,57 +13111,76 @@ 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"]; + /** @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"]; + }; + }; }; }; - 'get_core-{_version}-addresses-canonical': { + "put_core-{_version}-members-{memberId}-devices-{deviceId}-reject": { 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; + /** + * @description the device id + * @example ACXDmTaBub14w== + */ + deviceId: components["schemas"]["Id"]; _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; @@ -11079,38 +13192,33 @@ 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"]; }; }; }; }; }; - 'get_core-{_version}-addresses-{enc_id}': { + "post_core-{_version}-members-{memberId}-devices-reset": { parameters: { query?: never; header?: never; path: { /** - * @description the encrypted address id - * @example lKJlejjlk== + * @description the member id + * @example ACXDmTaBub14w== */ - enc_id: string; + memberid: string; _version: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["ResetAuthDevicesInput"]; + }; + }; responses: { /** @description Success */ 200: { @@ -11118,31 +13226,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Address?: components['schemas']['AddressUser']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-{_version}-addresses-{enc_id}': { + "post_core-{_version}-auth": { 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": components["schemas"]["AuthInput2"]; }; }; responses: { @@ -11152,220 +13254,184 @@ 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 + */ + 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; + }; }; }; }; }; }; - 'delete_core-{_version}-addresses-{enc_id}': { + "delete_core-{_version}-auth": { 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: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Domain does not exist */ - Error?: string; - Details?: string[]; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-domains-{enc_id}-claimedAddresses': { + "get_core-{_version}-auth-sso-{token}": { parameters: { query?: { - /** - * @description the page index using 0-based indexing - * @example 0 - */ - Page?: string; - /** - * @description the page size, maximum 150 - * @example 150 - */ - PageSize?: number; + FinalRedirectBaseUrl?: string | null; }; header?: never; path: { /** - * @description the encrypted domain id - * @example lKJle...jjlk== + * @description Token received as SSOChallengeToken from POST /auth/info + * @example a5fd396fcbb */ - DomainId: string; + token: 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: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Domain does not exist */ - Error?: string; - Details?: string[]; - }; - }; + content?: never; }; }; }; - 'put_core-{_version}-addresses-{enc_id}-enable': { + "post_core-{_version}-organization-communities": { 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']; + "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}-disable': { + "post_core-{_version}-members-{enc_id}-keys": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted address id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; + enc_id: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['AddressListInput']; + "application/json": components["schemas"]["CreateMemberKeysInput"]; }; }; responses: { @@ -11375,30 +13441,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}-delete': { + "post_core-{_version}-keys-user": { 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"]["AddNewUserKeyInput"]; }; }; responses: { @@ -11408,32 +13488,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}-type': { + "delete_core-{_version}-organization-{organization_id}": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; + organization_id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['ChangeAddressTypeInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -11441,106 +13515,72 @@ 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': { + "post_core-{_version}-settings-highsecurity-summary-email": { 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; - }[]; - }; - }; - }; + 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}-addresses-{enc_id}-rename-external': { + "delete_core-{_version}-settings-highsecurity-summary-email": { 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']; - }; - }; + 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}-addresses-{enc_addressId}-encryption': { + "put_core-{_version}-domains-{enc_id}-flags": { parameters: { query?: never; header?: never; path: { - /** - * @description the address id - * @example ACXDmTaBub14w== - */ - addressid: string; _version: string; - enc_addressId: string; + enc_id: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateEncryptionSignatureFlagsInput']; + "application/json": components["schemas"]["PutDomainFlagsInput"]; }; }; responses: { @@ -11550,55 +13590,54 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; }; }; }; }; }; - 'put_core-{_version}-members-addresses-permissions-organization-switch': { + "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; }; 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"]["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: { @@ -11606,20 +13645,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; }; @@ -11631,20 +13672,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domains?: string[]; }; }; }; }; }; - 'delete_core-{_version}-members-{memberId}-saml': { + "get_core-{_version}-domains-premium": { parameters: { query?: never; header?: never; path: { _version: string; - memberId: components['schemas']['Id']; }; cookie?: never; }; @@ -11656,25 +13697,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-optin": { parameters: { query?: never; header?: never; path: { - /** - * @description the member id - * @example ACXDmTaBub14w== - */ - memberid: string; _version: string; - id: components['schemas']['Id']; }; cookie?: never; }; @@ -11686,19 +13722,27 @@ 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; }; }; }; }; }; - 'get_core-{_version}-members-devices-pending': { + "get_core-{_version}-domains-{enc_id}": { parameters: { - query?: never; + query?: { + Refresh?: components["schemas"]["BoolInt"]; + }; header?: never; path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; _version: string; }; cookie?: never; @@ -11711,31 +13755,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - AuthDevices?: components['schemas']['AuthDeviceOutput'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; }; }; }; }; }; - 'put_core-{_version}-members-{memberId}-devices-{deviceId}-reject': { + "delete_core-{_version}-domains-{enc_id}": { parameters: { query?: never; 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; }; @@ -11747,31 +13785,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-{memberId}-devices-reset': { + "put_core-{_version}-domains-{enc_id}-catchall": { 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']; + "application/json": components["schemas"]["UpdateCatchAllAddressInput"]; }; }; responses: { @@ -11781,62 +13828,40 @@ 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': { + "get_core-{_version}-organizations-subsidiaries": { parameters: { - query?: never; + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateMemberKeysInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [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": components["schemas"]["GetChildOrganizationsResponse"]; }; }; }; }; - 'get_core-{_version}-organizations-scim': { + "get_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -11855,7 +13880,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-organizations-scim': { + "put_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -11866,7 +13891,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateScimTenantInput']; + "application/json": components["schemas"]["UpdateScimTenantInput"]; }; }; responses: { @@ -11878,7 +13903,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-organizations-scim': { + "post_core-{_version}-organizations-scim": { parameters: { query?: never; header?: never; @@ -11889,7 +13914,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateScimTenantInput']; + "application/json": components["schemas"]["CreateScimTenantInput"]; }; }; responses: { @@ -11901,7 +13926,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-domains': { + "get_core-{_version}-members-organizations": { parameters: { query?: never; header?: never; @@ -11912,57 +13937,55 @@ export interface operations { }; requestBody?: never; 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': { + "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; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateDomainInput']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Domain?: components['schemas']['DomainTransformer']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-domains-available': { + "delete_core-{_version}-groups-external-{jwt}": { parameters: { - query?: { - Type?: string | null; + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; }; header?: never; path: { _version: string; + jwt: string; }; cookie?: never; }; @@ -11971,43 +13994,40 @@ 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-premium': { + "post_core-{_version}-groups-owners-accept-{enc_id}": { parameters: { query?: never; header?: never; path: { _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']; - Domains?: string[]; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-domains-optin': { + "post_core-{_version}-groups-members": { parameters: { query?: never; header?: never; @@ -12016,63 +14036,53 @@ export interface operations { }; 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']; - /** @example proton.me */ - Domain?: string; - }; + "application/json": components["schemas"]["GroupMemberResponse"]; }; }; }; }; - 'get_core-{_version}-domains-{enc_id}': { + "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?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AddGroupOwnerRequest"]; + }; + }; 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}': { + "get_core-{_version}-groups": { parameters: { query?: never; header?: never; path: { - /** - * @description the encrypted id - * @example lKJlejjlk== - */ - enc_id: string; _version: string; }; cookie?: never; @@ -12085,30 +14095,27 @@ 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"][]; }; }; }; }; }; - 'put_core-{_version}-domains-{enc_id}-catchall': { + "post_core-{_version}-groups": { 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']; + "application/json": components["schemas"]["CreateGroupRequest"]; }; }; responses: { @@ -12118,23 +14125,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; }; }; }; }; }; - 'put_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: { - /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ - jwt: string; _version: string; + jwt: string; }; cookie?: never; }; @@ -12143,30 +14153,30 @@ 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"]; }; }; }; }; - 'delete_core-{_version}-groups-external-{jwt}': { + "put_core-{_version}-groups-{enc_id}": { parameters: { - query?: { - GroupID?: components['schemas']['Id'] | null; - }; + query?: never; header?: never; path: { - /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ - jwt: string; _version: string; + enc_id: components["schemas"]["EncryptedId"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateGroupRequest"]; + }; + }; responses: { /** @description Success */ 200: { @@ -12174,27 +14184,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; }; }; }; }; }; - 'post_core-{_version}-groups-members': { + "delete_core-{_version}-groups-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; + enc_id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['AddGroupMemberRequest']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -12202,20 +14210,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - GroupMember?: components['schemas']['GroupMemberResponse']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_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; }; @@ -12227,81 +14235,73 @@ 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': { + "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']['CreateGroupRequest']; + "application/json": components["schemas"]["EditGroupMemberRequest"]; }; }; 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"]["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; }; @@ -12310,23 +14310,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; }; @@ -12335,53 +14333,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; @@ -12389,23 +14377,22 @@ export interface operations { /** @description Success */ 200: { headers: { + "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; }; @@ -12417,16 +14404,21 @@ export interface operations { [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; @@ -12434,21 +14426,22 @@ export interface operations { /** @description Success */ 200: { headers: { + "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; }; @@ -12460,20 +14453,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; }; @@ -12482,18 +14475,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; @@ -12504,7 +14495,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example notification@email */ Email?: string; /** @@ -12519,15 +14510,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess']; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-invites-unused': { + "post_core-{_version}-invites-unused": { parameters: { query?: never; header?: never; @@ -12546,7 +14538,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-invites-check': { + "post_core-{_version}-invites-check": { parameters: { query?: never; header?: never; @@ -12565,7 +14557,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-keys-all': { + "get_core-{_version}-keys-all": { parameters: { query: { /** @@ -12577,7 +14569,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: { @@ -12593,7 +14585,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Success code * @example 1000 @@ -12601,13 +14593,18 @@ export interface operations { 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. First key is primary. */ + /** @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 @@ -12620,17 +14617,22 @@ 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?: { - /** @description Public key list for the catch-all address with metadata. First key is primary. */ + /** @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 @@ -12643,7 +14645,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?: { @@ -12654,6 +14656,11 @@ export interface operations { * @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 @@ -12688,7 +14695,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 @@ -12699,7 +14706,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-keys-signedkeylists': { + "get_core-{_version}-keys-signedkeylists": { parameters: { query?: { /** @deprecated */ @@ -12709,9 +14716,9 @@ export interface operations { * @deprecated * @description It will return all SKLs where a revision change happened after the specified epoch ID */ - AfterEpochID?: Record; + AfterEpochID?: number; /** @description It will return all SKLs where a revision change happened after the specified revision */ - AfterRevision?: Record; + AfterRevision?: number; }; header?: never; path: { @@ -12727,15 +14734,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; @@ -12746,7 +14753,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddressKeyInput3']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -12756,22 +14763,22 @@ 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 */ Email?: string | null; Identifier?: string | null; /** @description The returned SKL will be for the specified revision, if it exists */ - Revision?: Record; + Revision?: number; }; header?: never; path: { @@ -12787,15 +14794,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; @@ -12812,8 +14819,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; KeySalts?: { /** @example */ ID?: string; @@ -12825,7 +14832,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-keys-address-{enc_id}': { + "put_core-{_version}-keys-address-{enc_id}": { parameters: { query?: never; header?: never; @@ -12841,7 +14848,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddressKeyInput']; + "application/json": components["schemas"]["AddressKeyInput2"]; }; }; responses: { @@ -12851,14 +14858,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; @@ -12870,35 +14877,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: { @@ -12908,30 +14887,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: { @@ -12941,14 +14915,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; @@ -12964,7 +14938,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateFlagsInput']; + "application/json": components["schemas"]["UpdateFlagsInput"]; }; }; responses: { @@ -12974,14 +14948,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; @@ -12992,7 +14966,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceAddressTokensInput']; + "application/json": components["schemas"]["ReplaceAddressTokensInput"]; }; }; responses: { @@ -13002,8 +14976,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -13016,7 +14990,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-keys-user-{enc_id}': { + "put_core-{_version}-keys-user-{enc_id}": { parameters: { query?: never; header?: never; @@ -13032,7 +15006,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReactivateUserKeyInput']; + "application/json": components["schemas"]["ReactivateUserKeyInput"]; }; }; responses: { @@ -13042,14 +15016,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; @@ -13071,8 +15045,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -13085,7 +15059,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-keys-private-upgrade': { + "post_core-{_version}-keys-private-upgrade": { parameters: { query?: never; header?: never; @@ -13096,7 +15070,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ KeySalt?: string; Keys?: { @@ -13122,7 +15096,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----- */ @@ -13154,47 +15128,25 @@ 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; - path: { - _version: string; - }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': { - /** @example 5Pz8W...basdk== */ - ID?: 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?: { - /** @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; - }; - }; - }; + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateKeyInput"]; }; }; responses: { @@ -13204,14 +15156,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; @@ -13223,7 +15175,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LegacyKeyInput']; + "application/json": components["schemas"]["LegacyKeyInput"]; }; }; responses: { @@ -13233,14 +15185,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; @@ -13252,7 +15204,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['LegacyKeyInput']; + "application/json": components["schemas"]["LegacyKeyInput"]; }; }; responses: { @@ -13262,14 +15214,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-user': { + "post_core-{_version}-keys-reset": { parameters: { query?: never; header?: never; @@ -13280,7 +15232,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AddNewUserKeyInput']; + "application/json": components["schemas"]["ResetUserKeyInput"]; }; }; responses: { @@ -13290,97 +15242,62 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ - KeyID?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-keys-reset': { + "put_core-{_version}-organizations-link-{organization_id}": { parameters: { query?: never; header?: never; path: { _version: string; + organization_id: number; }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** - * @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----- */ - PrivateKey?: 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; - /** @description In the signed key list there is only the new keys */ - SignedKeyList?: { - /** @example JSON.stringify([{"Fingerprint": "de90483475164ec6353c93f767df53b0ca8395cf","SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Primary": 1,"Flags": 3}]) */ - Data?: string; - /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ - Signature?: string; - }; - }[]; - /** @description Include to enable 1 password mode, otherwise 2 password mode */ - Auth?: { - /** @example 4 */ - Version?: number; - /** @example */ - ModulusID?: string; - /** @example */ - Salt?: string; - /** @example */ - Verifier?: string; - }; - AddressList?: components['schemas']['KTAddressListTransformer']; - }; + "application/json": components["schemas"]["LinkOrganizationInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "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': { + "get_core-{_version}-members": { parameters: { query?: never; header?: never; @@ -13397,15 +15314,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; @@ -13416,7 +15333,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMemberInput']; + "application/json": components["schemas"]["CreateMemberInput"]; }; }; responses: { @@ -13426,15 +15343,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; @@ -13445,7 +15362,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['CreateMemberInvitationInput']; + "application/json": components["schemas"]["CreateMemberInvitationInput"]; }; }; responses: { @@ -13455,15 +15372,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; @@ -13480,7 +15397,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberInvitationInput']; + "application/json": components["schemas"]["UpdateMemberInvitationInput"]; }; }; responses: { @@ -13490,15 +15407,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; @@ -13521,14 +15438,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; @@ -13551,14 +15468,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; @@ -13575,7 +15492,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 9900000000 */ MaxSpace?: number; }; @@ -13588,14 +15505,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; @@ -13612,7 +15529,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example Jason */ Name?: string; }; @@ -13625,14 +15542,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; @@ -13649,7 +15566,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberRoleInput']; + "application/json": components["schemas"]["UpdateMemberRoleInput"]; }; }; responses: { @@ -13659,66 +15576,38 @@ 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': { + "put_core-{_version}-members-{memberId}-ai": { parameters: { - query?: { - /** - * @description the page index using 0-based indexing - * @example 0 - */ - Page?: string; - /** - * @description the page size, maximum 150 - * @example 150 - */ - PageSize?: string; - }; + query?: never; header?: never; path: { - /** - * @description the member id - * @example ACXDmTaBub14w== - */ - memberid: string; _version: string; - enc_id: string; + memberId: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberAIEntitlementInput"]; + }; + }; responses: { - /** @description Success */ - 200: { + default: { 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']['VPNAuthenticationCertificateAdminTransformer'][]; - AuthenticationCertificates?: components['schemas']['VPNAuthenticationCertificateAdminTransformer'][]; - }; - }; + content?: never; }; }; }; - 'put_core-{_version}-members-{enc_id}-vpn': { + "put_core-{_version}-members-{enc_id}-privatize": { parameters: { query?: never; header?: never; @@ -13733,14 +15622,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example 2 */ - MaxVPN?: number; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -13748,49 +15630,31 @@ 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': { + "get_core-{_version}-members-search": { 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; + 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; }; - }; - }; - '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; }; @@ -13802,14 +15666,17 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "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; @@ -13826,15 +15693,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; @@ -13851,14 +15718,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; @@ -13869,7 +15736,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AcceptMemberUnprivatizationInput']; + "application/json": components["schemas"]["AcceptMemberUnprivatizationInput"]; }; }; responses: { @@ -13881,7 +15748,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-me-unprivatize': { + "delete_core-{_version}-members-me-unprivatize": { parameters: { query?: never; header?: never; @@ -13900,13 +15767,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; }; @@ -13920,19 +15787,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: { @@ -13944,13 +15811,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; }; @@ -13964,7 +15831,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-members-{enc_id}': { + "get_core-{_version}-members-{enc_id}": { parameters: { query?: { /** @@ -13993,15 +15860,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; @@ -14024,14 +15891,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; @@ -14054,7 +15921,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** * @description Last login time (unix timestamp) * @example 1654615966 @@ -14080,7 +15947,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-members-{enc_id}-authlog': { + "get_core-{_version}-members-{enc_id}-authlog": { parameters: { query?: { /** @@ -14114,15 +15981,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; @@ -14142,7 +16009,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-{enc_id}-require2fa': { + "delete_core-{_version}-members-{enc_id}-require2fa": { parameters: { query?: never; header?: never; @@ -14162,7 +16029,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; @@ -14185,14 +16052,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; @@ -14215,14 +16082,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; @@ -14233,7 +16100,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['MemberManagePermissionsDto']; + "application/json": components["schemas"]["MemberManagePermissionsDto"]; }; }; responses: { @@ -14243,14 +16110,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; @@ -14267,7 +16134,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateMemberKeysInput']; + "application/json": components["schemas"]["UpdateMemberKeysInput"]; }; }; responses: { @@ -14277,8 +16144,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; @@ -14324,9 +16191,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; @@ -14343,7 +16220,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { AddressKeys?: { /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ ID?: string; @@ -14358,7 +16235,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----- */ @@ -14375,14 +16252,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; @@ -14399,10 +16276,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----- */ @@ -14419,14 +16296,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; @@ -14437,122 +16314,15 @@ export interface operations { */ 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; + enc_id: components["schemas"]["Id"]; }; 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; + "application/json": components["schemas"]["UnprivatizeMemberInput"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -14560,38 +16330,14 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-members-{enc_id}-sessions': { + "post_core-{_version}-members-{enc_id}-auth": { parameters: { query?: never; header?: never; @@ -14603,7 +16349,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 @@ -14652,8 +16398,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 */ @@ -14673,7 +16419,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-members-{enc_id}-sessions': { + "delete_core-{_version}-members-{enc_id}-sessions": { parameters: { query?: never; header?: never; @@ -14691,21 +16437,45 @@ 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}': { + "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; - enc_id: string; - uid: string; }; cookie?: never; }; @@ -14717,14 +16487,50 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "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': { + "get_core-{_version}-organizations": { parameters: { query?: never; header?: never; @@ -14741,8 +16547,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; @@ -14831,13 +16637,91 @@ export interface operations { * @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; }; }; }; }; }; }; - 'get_core-{_version}-organizations-keys': { + "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; + }; + }; + }; + }; + "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"]; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-settings-logauth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-organizations-keys": { parameters: { query?: never; header?: never; @@ -14854,12 +16738,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; @@ -14870,7 +16754,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; }; }; responses: { @@ -14880,8 +16764,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 @@ -14890,9 +16774,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; @@ -14903,7 +16797,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ReplaceOrganizationKeysInput']; + "application/json": components["schemas"]["ReplaceOrganizationKeysInput"]; }; }; responses: { @@ -14913,8 +16807,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 @@ -14923,9 +16817,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; @@ -14942,8 +16846,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 */ @@ -14953,7 +16857,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-organizations-keys-backup': { + "put_core-{_version}-organizations-keys-backup": { parameters: { query?: never; header?: never; @@ -14964,7 +16868,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; }; }; responses: { @@ -14974,8 +16878,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 @@ -14986,7 +16890,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-organizations-keys-backup': { + "post_core-{_version}-organizations-keys-backup": { parameters: { query?: never; header?: never; @@ -14997,7 +16901,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationKeyBackupInput']; + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; }; }; responses: { @@ -15007,8 +16911,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 @@ -15019,7 +16923,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-organizations-name': { + "put_core-{_version}-organizations-name": { parameters: { query?: never; header?: never; @@ -15030,7 +16934,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationNameInput']; + "application/json": components["schemas"]["UpdateOrganizationNameInput"]; }; }; responses: { @@ -15040,8 +16944,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; @@ -15087,13 +16991,35 @@ export interface operations { UsedVPN?: number; /** @example 1 */ HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + 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; @@ -15104,7 +17030,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationEmailInput']; + "application/json": components["schemas"]["UpdateOrganizationEmailInput"]; }; }; responses: { @@ -15114,8 +17040,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; @@ -15161,13 +17087,25 @@ export interface operations { UsedVPN?: number; /** @example 1 */ HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + 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; @@ -15178,7 +17116,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrganizationTwoFactorGracePeriodInput']; + "application/json": components["schemas"]["UpdateOrganizationTwoFactorGracePeriodInput"]; }; }; responses: { @@ -15188,8 +17126,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; @@ -15235,13 +17173,25 @@ export interface operations { UsedVPN?: number; /** @example 1 */ HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + 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; @@ -15252,7 +17202,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 @@ -15268,8 +17218,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; @@ -15315,13 +17265,25 @@ export interface operations { UsedVPN?: number; /** @example 1 */ HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + 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; @@ -15338,8 +17300,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; @@ -15385,13 +17347,25 @@ export interface operations { UsedVPN?: number; /** @example 1 */ HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + 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; @@ -15402,33 +17376,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: { @@ -15436,14 +17386,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; @@ -15459,80 +17409,25 @@ export interface operations { 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: { + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { + "x-pm-code": string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess'] & - components['schemas']['GetOrganizationIdentityOutput']; + "application/json": components["schemas"]["UnprocessableResponse"]; }; }; }; }; - 'put_core-{_version}-organizations-keys-signature': { + "post_core-{_version}-organizations-keys-migrate": { parameters: { query?: never; header?: never; @@ -15543,42 +17438,38 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateOrgKeyFingerprintSignatureInput']; + "application/json": components["schemas"]["MigrateOrganizationKeysInput"]; }; }; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - }; - }; - 'get_core-{_version}-organizations-logo-{enc_id}': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; - enc_id: components['schemas']['Id']; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Binary data of the image */ - 200: { + /** @description Error */ + 422: { headers: { [name: string]: unknown; }; content: { - 'application/octet-stream': string; + "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; @@ -15595,13 +17486,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonSuccess'] & - components['schemas']['OrganizationSettings']; + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["GetOrganizationIdentityOutput"]; }; }; }; }; - 'put_core-{_version}-organizations-settings': { + "put_core-{_version}-organizations-keys-signature": { parameters: { query?: never; header?: never; @@ -15610,20 +17500,21 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "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; @@ -15632,11 +17523,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['OrganizationLogo']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -15644,12 +17531,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; @@ -15658,7 +17545,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationSettingsRequest"]; + }; + }; responses: { /** @description Success */ 200: { @@ -15666,23 +17557,23 @@ 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 */ Dark?: string | null; /** @example 1 */ - ForceWebMessaging?: Record; + ForceWebMessaging?: number | null; /** @example a9mT4hlKgS_h66JKxe-MC5pp */ Token: string; }; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; host?: string; }; path: { @@ -15701,18 +17592,18 @@ export interface operations { }; }; }; - 'get_core-{_version}-resources-captcha': { + "get_core-{_version}-resources-captcha": { parameters: { query: { /** @example 1 */ Dark?: string | null; /** @example 1 */ - ForceWebMessaging?: Record; + ForceWebMessaging?: number | null; /** @example a9mT4hlKgS_h66JKxe-MC5pp */ Token: string; }; header?: { - 'x-pm-nonce'?: string | null; + "x-pm-nonce"?: string | null; host?: string; }; path: { @@ -15731,14 +17622,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; @@ -15756,7 +17647,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-saml-setup-fields': { + "post_core-{_version}-saml-setup-fields": { parameters: { query?: never; header?: never; @@ -15765,7 +17656,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["Sso"]; + }; + }; responses: { /** @description Success */ 200: { @@ -15773,14 +17668,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; @@ -15791,7 +17686,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SsoXml']; + "application/json": components["schemas"]["SsoXml"]; }; }; responses: { @@ -15801,14 +17696,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; @@ -15817,7 +17712,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SsoUrl"]; + }; + }; responses: { /** @description Success */ 200: { @@ -15825,15 +17724,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; @@ -15850,21 +17749,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; }; @@ -15876,21 +17775,51 @@ 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": { + 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}-fields': { + "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; }; @@ -15902,21 +17831,42 @@ 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': { + "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; - enc_id: components['schemas']['Id']; }; cookie?: never; }; @@ -15928,20 +17878,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - SSO?: components['schemas']['SsoTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-saml-sp-info': { + "get_core-{_version}-saml-edugain-info-{domainName}": { parameters: { query?: never; header?: never; path: { _version: string; + domainName: string; }; cookie?: never; }; @@ -15953,12 +17903,36 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Info']; + "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': { + "get_core-{_version}-settings": { parameters: { query?: never; header?: never; @@ -15975,15 +17949,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; @@ -15994,7 +17968,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16048,9 +18022,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 @@ -16061,7 +18035,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-password-upgrade': { + "put_core-{_version}-settings-password-upgrade": { parameters: { query?: never; header?: never; @@ -16072,7 +18046,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { Auth?: { /** @example 4 */ Version?: number; @@ -16093,15 +18067,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; @@ -16112,7 +18086,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16158,9 +18132,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 @@ -16171,7 +18145,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-email-verify': { + "post_core-{_version}-settings-email-verify": { parameters: { query?: never; header?: never; @@ -16182,7 +18156,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ Token?: string; }; @@ -16195,15 +18169,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; @@ -16214,7 +18188,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example 1 * @enum {integer} @@ -16230,15 +18204,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; @@ -16249,7 +18223,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16298,9 +18272,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 @@ -16311,7 +18285,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-phone': { + "put_core-{_version}-settings-phone": { parameters: { query?: never; header?: never; @@ -16322,7 +18296,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16368,9 +18342,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 @@ -16381,7 +18355,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-phone-verify': { + "post_core-{_version}-settings-phone-verify": { parameters: { query?: never; header?: never; @@ -16392,7 +18366,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ Token?: string; }; @@ -16405,15 +18379,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; @@ -16424,7 +18398,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @example 1 * @enum {integer} @@ -16440,15 +18414,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; @@ -16459,7 +18433,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16508,9 +18482,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 @@ -16521,7 +18495,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-locale': { + "put_core-{_version}-settings-locale": { parameters: { query?: never; header?: never; @@ -16532,7 +18506,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example en_US */ Locale?: string; }; @@ -16545,15 +18519,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; @@ -16564,7 +18538,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0 = off, 1 = on, 2 = on with IP logging * @example 0 @@ -16580,15 +18554,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; @@ -16599,7 +18573,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ DeviceRecovery?: number; }; @@ -16612,15 +18586,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; @@ -16631,7 +18605,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateNewsInput']; + "application/json": components["schemas"]["UpdateNewsInput"]; }; }; responses: { @@ -16641,15 +18615,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; @@ -16660,7 +18634,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['PatchNewsInput']; + "application/json": components["schemas"]["PatchNewsInput"]; }; }; responses: { @@ -16670,15 +18644,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; @@ -16695,8 +18669,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. @@ -16709,7 +18683,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-news-external': { + "put_core-{_version}-settings-news-external": { parameters: { query?: never; header?: never; @@ -16720,7 +18694,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateNewsInput']; + "application/json": components["schemas"]["UpdateNewsInput"]; }; }; responses: { @@ -16730,8 +18704,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. @@ -16744,7 +18718,7 @@ export interface operations { }; }; }; - 'patch_core-{_version}-settings-news-external': { + "patch_core-{_version}-settings-news-external": { parameters: { query?: never; header?: never; @@ -16755,7 +18729,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['PatchNewsInput']; + "application/json": components["schemas"]["PatchNewsInput"]; }; }; responses: { @@ -16765,8 +18739,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. @@ -16779,7 +18753,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-density': { + "put_core-{_version}-settings-density": { parameters: { query?: never; header?: never; @@ -16790,7 +18764,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0:comfortable, 1:compact * @example 0 @@ -16806,15 +18780,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; @@ -16825,7 +18799,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Maximum 5 lines * @example Mickey Mouse, Esq. @@ -16842,15 +18816,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; @@ -16861,7 +18835,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16905,9 +18879,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 @@ -16924,7 +18898,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 10041 */ Code?: number; /** @example Two Factor confirmation failed */ @@ -16935,7 +18909,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa-totp': { + "put_core-{_version}-settings-2fa-totp": { parameters: { query?: never; header?: never; @@ -16946,7 +18920,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -16990,9 +18964,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 @@ -17003,7 +18977,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-totp': { + "post_core-{_version}-settings-2fa-totp": { parameters: { query?: never; header?: never; @@ -17014,7 +18988,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17048,8 +19022,6 @@ export interface operations { /** @description CredentialID used */ CredentialID?: Record[]; }; - /** @example JBSWY3DPEHPK3PXP */ - TOTPSharedSecret?: string; /** @example 203941 */ TOTPConfirmation?: string; }; @@ -17062,9 +19034,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 @@ -17076,12 +19048,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 */ @@ -17092,7 +19064,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa': { + "put_core-{_version}-settings-2fa": { parameters: { query?: never; header?: never; @@ -17103,7 +19075,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17147,9 +19119,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 @@ -17160,7 +19132,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa': { + "post_core-{_version}-settings-2fa": { parameters: { query?: never; header?: never; @@ -17171,7 +19143,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17205,8 +19177,6 @@ export interface operations { /** @description CredentialID used */ CredentialID?: Record[]; }; - /** @example JBSWY3DPEHPK3PXP */ - TOTPSharedSecret?: string; /** @example 203941 */ TOTPConfirmation?: string; }; @@ -17219,9 +19189,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 @@ -17233,12 +19203,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 */ @@ -17249,7 +19219,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; @@ -17260,7 +19253,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @@ -17278,8 +19271,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -17289,7 +19282,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 */ @@ -17300,14 +19293,14 @@ export interface operations { }; }; }; - 'get_core-{_version}-settings-2fa-register': { + "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?: Record; + CrossPlatform?: boolean; }; header?: never; path: { @@ -17323,9 +19316,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 @@ -17338,7 +19331,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-register': { + "post_core-{_version}-settings-2fa-register": { parameters: { query?: never; header?: never; @@ -17349,7 +19342,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 @@ -17418,9 +19411,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 @@ -17431,7 +19424,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-settings-2fa-{credentialID}-remove': { + "post_core-{_version}-settings-2fa-{credentialID}-remove": { parameters: { query?: never; header?: never; @@ -17443,7 +19436,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description Optional, for inline re-authentication * @example @@ -17487,9 +19480,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 @@ -17500,7 +19493,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-2fa-{credentialID}-rename': { + "put_core-{_version}-settings-2fa-{credentialID}-rename": { parameters: { query?: never; header?: never; @@ -17512,7 +19505,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description User defined name for the credential. * @example My FIDO2 key @@ -17528,15 +19521,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; @@ -17547,7 +19540,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['UpdateHideSidePanelInput']; + "application/json": components["schemas"]["UpdateHideSidePanelInput"]; }; }; responses: { @@ -17557,15 +19550,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; @@ -17576,7 +19569,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description Length <= 40 */ Username?: string; }; @@ -17589,14 +19582,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; @@ -17607,7 +19600,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['Theme']; + "application/json": components["schemas"]["Theme"]; }; }; responses: { @@ -17617,15 +19610,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; @@ -17636,7 +19629,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example 1 */ ThemeType?: number; }; @@ -17649,15 +19642,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; @@ -17668,7 +19661,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description `0`: Locale default, `1`: Monday, `6`: Saturday, `7`: Sunday * @example 1 @@ -17684,15 +19677,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; @@ -17703,7 +19696,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 @@ -17719,15 +19712,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; @@ -17738,7 +19731,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Locale default, 1: 24H, 2: 12H * @example 1 @@ -17754,15 +19747,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; @@ -17779,8 +19772,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -17790,7 +19783,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example Unknown client */ @@ -17801,7 +19794,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-earlyaccess': { + "put_core-{_version}-settings-earlyaccess": { parameters: { query?: never; header?: never; @@ -17812,7 +19805,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description 0: Disabled, 1: Enabled * @example 1 @@ -17828,8 +19821,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -17839,7 +19832,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example Invalid client */ @@ -17850,7 +19843,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-settings-flags': { + "put_core-{_version}-settings-flags": { parameters: { query?: never; header?: never; @@ -17861,12 +19854,22 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "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; + /** + * @description 0: Disabled, 1: Enabled - disables easy-device-migration (as of now QR code sign-in) + * @example 0 + */ + EdmOptOut?: number; }; }; }; @@ -17877,14 +19880,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; @@ -17895,7 +19898,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ Telemetry?: number; }; @@ -17908,15 +19911,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; @@ -17927,7 +19930,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @description possible values:
- 0: disable
- 1: enable */ CrashReports?: number; }; @@ -17940,15 +19943,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; @@ -17965,8 +19968,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -17976,7 +19979,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -17986,7 +19989,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-settings-highsecurity': { + "delete_core-{_version}-settings-highsecurity": { parameters: { query?: never; header?: never; @@ -18003,14 +20006,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; @@ -18027,8 +20030,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18038,7 +20041,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @default 2011 */ Code: number; /** @default You do not have an active subscription */ @@ -18048,7 +20051,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-settings-breachalerts': { + "delete_core-{_version}-settings-breachalerts": { parameters: { query?: never; header?: never; @@ -18065,14 +20068,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; @@ -18083,7 +20086,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SessionAccountRecoveryInput']; + "application/json": components["schemas"]["SessionAccountRecoveryInput"]; }; }; responses: { @@ -18093,15 +20096,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; @@ -18112,7 +20115,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['AIAssistantFlagsInput']; + "application/json": components["schemas"]["AIAssistantFlagsInput"]; }; }; responses: { @@ -18122,15 +20125,41 @@ 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}-support-schedulecall': { + "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; @@ -18147,12 +20176,69 @@ 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": { + 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': { + "put_core-{_version}-settings-product-disabled": { parameters: { query?: never; header?: never; @@ -18163,7 +20249,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['ProductDisabledInput']; + "application/json": components["schemas"]["ProductDisabledInput"]; }; }; responses: { @@ -18175,7 +20261,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-delete': { + "get_core-{_version}-users-delete": { parameters: { query?: never; header?: never; @@ -18192,14 +20278,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; @@ -18210,7 +20296,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18273,8 +20359,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 @@ -18285,7 +20371,7 @@ export interface operations { }; }; }; - 'delete_core-{_version}-users-delete': { + "delete_core-{_version}-users-delete": { parameters: { query?: never; header?: never; @@ -18296,7 +20382,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * Format: base64 * @description Optional, for inline re-authentication @@ -18359,8 +20445,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 @@ -18371,7 +20457,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-reset': { + "get_core-{_version}-users-reset": { parameters: { query: { /** @@ -18394,23 +20480,122 @@ 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 + */ + 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}-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"]; /** - * @description internal or external - * @example internal + * @example 1 + * @enum {integer} */ - 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[]; + 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': { + "get_core-{_version}-users": { parameters: { - query?: never; + query?: { + Locale?: boolean; + }; header?: never; path: { _version: string; @@ -18425,15 +20610,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[]; }; @@ -18441,7 +20635,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users': { + "post_core-{_version}-users": { parameters: { query?: never; header?: never; @@ -18452,7 +20646,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -18499,13 +20693,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 @@ -18523,16 +20717,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 @@ -18544,7 +20738,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users-external': { + "post_core-{_version}-users-external": { parameters: { query?: never; header?: never; @@ -18555,7 +20749,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example user_name */ Username?: string; /** @example proton.me */ @@ -18602,13 +20796,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 @@ -18626,16 +20820,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 @@ -18647,7 +20841,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-users-check': { + "put_core-{_version}-users-check": { parameters: { query?: never; header?: never; @@ -18658,7 +20852,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** * @description in case of an invite must be selector:token * @example @@ -18684,21 +20878,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @description will only appear with successful coupon token checks */ - Plans?: (components['schemas']['Plan'] & { - /** @example 2 */ - MaxTier?: number; - /** @example 1 */ - Quantity?: number; - })[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-users-availableExternal': { + "get_core-{_version}-users-availableExternal": { parameters: { query?: { /** @@ -18712,7 +20899,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; @@ -18727,8 +20914,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18738,7 +20925,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -18751,7 +20938,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-available': { + "get_core-{_version}-users-available": { parameters: { query?: { /** @@ -18763,7 +20950,7 @@ export interface operations { * @description Set to 1 if username is the full email address, otherwise 0 (default) * @example 1 */ - ParseDomain?: Record; + ParseDomain?: number; }; header?: never; path: { @@ -18779,8 +20966,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -18790,7 +20977,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 12106 */ Code?: number; /** @example Username already used */ @@ -18803,7 +20990,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-available-{username}': { + "get_core-{_version}-users-available-{username}": { parameters: { query?: never; header?: never; @@ -18823,7 +21010,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-direct': { + "get_core-{_version}-users-direct": { parameters: { query?: { /** @@ -18846,8 +21033,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 @@ -18859,7 +21046,7 @@ export interface operations { }; }; }; - 'post_core-{_version}-users-code': { + "post_core-{_version}-users-code": { parameters: { query?: never; header?: never; @@ -18870,13 +21057,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 @@ -18904,14 +21091,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; @@ -18928,14 +21115,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; @@ -18946,13 +21133,18 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "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; }; }; }; @@ -18963,8 +21155,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -18972,7 +21164,7 @@ export interface operations { }; }; }; - 'put_core-{_version}-users-password': { + "put_core-{_version}-users-password": { parameters: { query?: never; header?: never; @@ -18983,7 +21175,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example */ ClientEphemeral?: string; /** @example */ @@ -19011,6 +21203,11 @@ export interface operations { /** @description CredentialID used */ CredentialID?: Record[]; }; + /** + * @description Token to use when re-authenticating a SSO user + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + SsoReauthToken?: Record; }; }; }; @@ -19021,8 +21218,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; /** @example */ ServerProof?: string; }; @@ -19030,11 +21227,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; @@ -19052,7 +21249,7 @@ export interface operations { }; }; }; - 'get_core-{_version}-users-disable-{jwt}': { + "get_core-{_version}-users-disable-{jwt}": { parameters: { query?: never; header?: never; @@ -19071,53 +21268,21 @@ 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-{_version}-users-invitations-{id}": { 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; - }; + query?: never; header?: never; - path?: never; + path: { + _version: string; + id: components["schemas"]["Id"]; + }; cookie?: never; }; requestBody?: never; @@ -19125,56 +21290,62 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example 76 */ - Total?: number; - Features?: components['schemas']['FeatureTransformer'][]; + "application/json": components["schemas"]["UserInvitationResponse"]; + }; + }; + /** @description Invitation not found */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: unknown; }; }; }; }; }; - 'post_core-v4-features': { + "get_core-{_version}-users-invitations": { 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; + 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: { @@ -19182,66 +21353,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'put_core-v4-features-{id}': { + "post_core-{_version}-users-invitations-{enc_id}-accept": { parameters: { query?: never; header?: never; path: { - /** @example 123 */ - id: number; + _version: string; + enc_id: 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; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -19249,97 +21378,232 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Feature?: components['schemas']['FeatureTransformer']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; - /** @description Feature not found */ - 400: { + /** @description Validation failed */ + 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2001 */ + "application/json": { + /** @example 2011 */ Code?: number; - /** @example higher is not one of the possible options among [low, medium, high]. */ - Error?: string; + Details?: { + Validation?: components["schemas"]["GetUserInvitationOutput"]; + }; }; }; }; - /** @description Not allowed */ - 403: { + }; + }; + "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': { - /** @example 2026 */ - Code?: number; - /** @example You're not allowed to modify the value of this feature */ - Error?: string; + "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"]; }; }; }; - /** @description Feature not found */ - 404: { + }; + }; + "post_core-{_version}-nps-dismiss": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DismissInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { headers: { + "x-pm-code": 1000; [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}': { + "post_core-{_version}-nps-submit": { parameters: { query?: never; header?: never; path: { - /** @example 123 */ - ID: string; - featureID: string; + _version: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmissionInput"]; + }; + }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; - /** @description Feature not found */ - 404: { + }; + }; + "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': { - /** @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; @@ -19356,9 +21620,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"]; }; }; }; @@ -19368,7 +21632,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19378,7 +21642,7 @@ export interface operations { }; }; }; - 'put_core-v4-features-{code}-value': { + "put_core-v4-features-{code}-value": { parameters: { query?: never; header?: never; @@ -19390,7 +21654,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** @example true */ Value?: Record; }; @@ -19403,9 +21667,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"]; }; }; }; @@ -19415,7 +21679,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]. */ @@ -19429,7 +21693,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 */ @@ -19443,7 +21707,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19453,7 +21717,7 @@ export interface operations { }; }; }; - 'delete_core-v4-features-{code}-value': { + "delete_core-v4-features-{code}-value": { parameters: { query?: never; header?: never; @@ -19470,9 +21734,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"]; }; }; }; @@ -19482,7 +21746,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 */ @@ -19496,7 +21760,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2501 */ Code?: number; /** @example Feature not found */ @@ -19506,23 +21770,41 @@ export interface operations { }; }; }; - 'put_core-v4-features-{code}-user-value': { + "post_core-{_version}-auth-info": { parameters: { query?: never; header?: never; path: { - /** @example blackFriday */ - code: string; + _version: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** @example true */ - Value?: Record; - UserIDs?: number[]; - UserNames?: string[]; + "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; }; }; }; @@ -19533,209 +21815,147 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { /** - * @description Number of touched users - * @example 2 + * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow + * @example a5fd396fcbb */ - Count?: number; + 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 */ + /** @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 */ + "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 Feature not found */ + /** @description Unprocessable Entity */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2501 */ - Code?: number; - /** @example Feature not found */ - Error?: string; - }; - }; - }; - }; - }; - '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'][]; + "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; }; }; }; }; }; - 'delete_core-{_version}-pushes-{enc_id}': { + "post_core-{_version}-auth-saml": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: 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']; - Pushes?: string[]; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-referrals': { + "post_core-{_version}-auth-jwt": { parameters: { - query?: { - /** @description Skip the given number of results */ - Offset?: number; - /** @description The number of results to return, maximum 100 */ - Limit?: number; - }; + query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: 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: { @@ -19743,16 +21963,33 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Referrals?: components['schemas']['ReferralOutput'][]; - Total?: number; - Code?: components['schemas']['ResponseCodeSuccess']; + "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}-referrals': { + "post_core-{_version}-auth-2fa": { parameters: { query?: never; header?: never; @@ -19763,7 +22000,25 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['SendInvitationsInput']; + "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: { @@ -19773,15 +22028,20 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Referrals?: components['schemas']['ReferralOutput'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @deprecated + * @example full + */ + Scope?: string; + Scopes?: string[]; }; }; }; }; }; - 'get_core-{_version}-referrals-status': { + "get_core-{_version}-auth-modulus": { parameters: { query?: never; header?: never; @@ -19798,48 +22058,48 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Referrals?: components['schemas']['ReferralStatus'][]; - 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== */ + ModulusID?: string; }; }; }; }; }; - 'get_core-{_version}-referrals-identifiers-{identifier}': { + "get_core-{_version}-auth-scopes": { parameters: { query?: never; header?: never; path: { - /** @example KPlISx5MiML3XcSYPrREF */ - identifier: string; _version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The identifier exists */ + /** @description Success */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': unknown; - }; - }; - /** @description The identifier does not exist */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': unknown; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @deprecated + * @example 217017207043915776 + */ + Scope?: string; + Scopes?: string[]; + }; }; }; }; }; - 'post_core-{_version}-devices': { + "post_core-{_version}-auth-cookies": { parameters: { query?: never; header?: never; @@ -19850,7 +22110,19 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['RegisterDeviceInput']; + "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: { @@ -19860,14 +22132,23 @@ 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 */ + 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; }; }; }; }; }; - 'delete_core-{_version}-devices': { + "post_core-{_version}-auth-credentialless": { parameters: { query?: never; header?: never; @@ -19876,19 +22157,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example 4b3403665fea6... */ - DeviceToken?: string; - /** - * Format: hex - * @example e35a8e0015b6ab79c80045881602b1e0560f59ba - */ - UID?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -19896,23 +22165,36 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "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}-betas-{client_id}': { + "get_core-{_version}-settings-mnemonic": { parameters: { query?: never; header?: never; path: { - /** - * @description the client ID - * @example iOSVPN - */ - client_id: string; _version: string; }; cookie?: never; @@ -19925,84 +22207,79 @@ export interface operations { [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; - }; + "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}-betas-{client_id}': { + "put_core-{_version}-settings-mnemonic": { 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; - }; + "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[]; }; }; }; }; - }; - '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: { @@ -20010,14 +22287,19 @@ 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 + */ + ServerProof?: string; }; }; }; }; }; - 'get_core-{_version}-betas': { + "get_core-{_version}-settings-mnemonic-reset": { parameters: { query?: never; header?: never; @@ -20034,24 +22316,22 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + /** @example 1H8EGg3J1Qwk243hf== */ + Salt?: string; }[]; }; }; }; }; }; - 'delete_core-{_version}-betas': { + "post_core-{_version}-settings-mnemonic-reset": { parameters: { query?: never; header?: never; @@ -20060,7 +22340,32 @@ export interface operations { }; cookie?: never; }; - requestBody?: 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: { @@ -20068,33 +22373,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Scopes?: string[]; }; }; }; }; }; - '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': { + "post_core-{_version}-settings-mnemonic-disable": { parameters: { query?: never; header?: never; @@ -20103,26 +22390,45 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; + 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[]; + }; }; - content?: never; - }; - }; - }; - 'get_core-{_version}-load': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -20130,14 +22436,19 @@ 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 + */ + ServerProof?: string; }; }; }; }; }; - 'post_core-{_version}-load': { + "put_core-{_version}-settings-mnemonic-reactivate": { parameters: { query?: never; header?: never; @@ -20146,7 +22457,22 @@ export interface operations { }; cookie?: never; }; - requestBody?: 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: { @@ -20154,30 +22480,26 @@ 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}-pushes": { parameters: { query?: { /** - * @description the page index using 0-based indexing + * @description Page index using 0-based indexing * @example 0 */ Page?: string; /** - * @description the page size, maximum 150 - * @example 150 + * @description Page size (max 250) + * @example 100 */ 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: { @@ -20193,19 +22515,28 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Logs?: components['schemas']['AuthLogResponse'][]; - /** @example 1 */ - Total?: number; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'delete_core-{_version}-logs-auth': { + "get_core-{_version}-pushes-active": { parameters: { - query?: never; + 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; @@ -20220,22 +22551,27 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'get_core-{_version}-metrics': { + "get_core-{_version}-pushes-active-session": { parameters: { query?: { - /** @example signup */ - Category?: string; - /** @example click */ - Action?: string; - /** @example coupon */ - Label?: string; + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; }; header?: never; path: { @@ -20251,42 +22587,25 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; }; }; }; }; }; - 'post_core-{_version}-metrics': { + "delete_core-{_version}-pushes-{enc_id}": { parameters: { query?: never; header?: never; path: { _version: string; + enc_id: 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; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20294,14 +22613,15 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: string[]; }; }; }; }; }; - 'post_core-{_version}-settings-recovery-secret': { + "post_core-{_version}-devices": { parameters: { query?: never; header?: never; @@ -20312,41 +22632,9 @@ export interface operations { }; 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; + "application/json": components["schemas"]["RegisterDeviceInput"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -20354,30 +22642,32 @@ 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}': { + "delete_core-{_version}-devices": { 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; + "application/json": { + /** @example 4b3403665fea6... */ + DeviceToken?: string; + /** + * Format: hex + * @example e35a8e0015b6ab79c80045881602b1e0560f59ba + */ + UID?: string; }; }; }; @@ -20388,136 +22678,28 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-bug': { + "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?: { - 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; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20525,25 +22707,43 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "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; + }; }; }; }; }; }; - 'post_core-{_version}-reports-bug-attachments': { + "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': components['schemas']['UploadAttachment']; + "application/json": { + /** @example john@example.com */ + Email?: string; + }; }; }; responses: { @@ -20553,24 +22753,34 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "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}-reports-bug-{ticketId}': { + "delete_core-{_version}-betas-{client_id}": { parameters: { - query?: { - RequesterID?: Record; - CreatedAt?: string; - BrandID?: Record; - }; + query?: never; header?: never; path: { + /** + * @description the client ID + * @example iOSVPN + */ + client_id: string; _version: string; - ticketId: Record; }; cookie?: never; }; @@ -20582,28 +22792,14 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reports-abuse': { + "get_core-{_version}-betas": { parameters: { query?: never; header?: never; @@ -20612,26 +22808,7 @@ export interface operations { }; 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; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20639,14 +22816,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "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; + }[]; }; }; }; }; }; - 'post_core-{_version}-reports-crash': { + "delete_core-{_version}-betas": { parameters: { query?: never; header?: never; @@ -20655,52 +22842,7 @@ export interface operations { }; 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; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20708,79 +22850,86 @@ 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}': { + "get_core-{_version}-load": { parameters: { query?: never; header?: never; path: { _version: string; - id: string; - type: string; }; cookie?: never; }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'post_core-{_version}-reports-sentry-api-{id}-{type}': { + "post_core-{_version}-load": { parameters: { query?: never; header?: never; path: { _version: string; - id: string; - type: string; }; cookie?: never; }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'post_core-{_version}-reports-phishing': { + "get_core-{_version}-logs-auth": { 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?: 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?: { - content: { - 'application/json': { - /** @example I6hgx3Ol-d3HYa3E394T_ACXDmTaBub14w== */ - MessageID?: string; - /** - * @description text/html or text/plain - * @example text/html - */ - MIMEType?: string; - /** @example */ - Body?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20788,14 +22937,17 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Logs?: components["schemas"]["AuthLogResponse"][]; + /** @example 1 */ + Total?: number; }; }; }; }; }; - 'post_core-{_version}-reports-spam': { + "delete_core-{_version}-logs-auth": { parameters: { query?: never; header?: never; @@ -20806,28 +22958,36 @@ export interface operations { }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'post_core-{_version}-reports-cancel-plan': { + "get_core-{_version}-metrics": { parameters: { - query?: never; + query?: { + /** @example signup */ + Category?: string; + /** @example click */ + Action?: string; + /** @example coupon */ + Label?: string; + }; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CancelPlanReport']; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -20835,30 +22995,42 @@ 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}': { + "post_core-{_version}-metrics": { 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; + 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: { @@ -20866,21 +23038,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @example 1 - * @enum {integer} - */ - ToMigrate?: 0 | 1; - /** @description NB: PrivateKey is null in keys */ - Addresses?: components['schemas']['AddressUser'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reset': { + "post_core-{_version}-settings-recovery-secret": { parameters: { query?: never; header?: never; @@ -20891,19 +23056,14 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - /** @example derp */ - Username?: string; - /** - * @description if Phone is not present - * @example derp@gmail.com - */ - Email?: string; + "application/json": { /** - * @description if Email is not present - * @example +1234567890 + * @description Base64-encoded secret, decodes to 32 bytes + * @example 1H8EGg3J1...Qwk243hf */ - Phone?: string; + RecoverySecret?: string; + /** @example -----BEGIN PGP SIGNATURE... */ + Signature?: string; }; }; }; @@ -20914,50 +23074,54 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; - /** @description Error */ - 422: { + }; + }; + "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': { - /** @example 19305 */ - Code?: number; - /** @example Username and recovery email mismatch */ - Error?: string; - Details?: string[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-reset-username': { + "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': { - /** - * @description if Phone is not present - * @example derp@gmail.com - */ - Email?: string; - /** - * @description if Email is not present - * @example +1234567890 - */ - Phone?: string; + "application/json": { + fields?: Record; + context?: Record; + legalConsentOptions?: Record; }; }; }; @@ -20968,14 +23132,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': { + "post_core-{_version}-reports-bug": { parameters: { query?: never; header?: never; @@ -20984,55 +23148,135 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; + 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; }; - content?: never; - }; - }; - }; - 'get_core-{_version}-system-version': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; }; - cookie?: never; }; - requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { 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: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; }; - content?: never; }; }; }; - 'get_core-{_version}-tests-error': { + "post_core-{_version}-reports-bug-attachments": { parameters: { query?: never; header?: never; @@ -21041,104 +23285,97 @@ export interface operations { }; 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; + requestBody?: { + content: { + "application/json": components["schemas"]["UploadAttachment"]; }; - cookie?: never; }; - requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; }; }; - 'get_core-{_version}-tests-memoryLeak': { + "delete_core-{_version}-reports-bug-{ticketId}": { parameters: { - query?: never; + query?: { + RequesterID?: number; + CreatedAt?: string; + BrandID?: number | null; + }; header?: never; path: { _version: string; + ticketId: number; }; cookie?: never; }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - }; - }; - 'get_core-{_version}-tests-logger': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; - cookie?: never; - }; - requestBody?: never; - responses: { - default: { + /** @description Error */ + 422: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Ticket does not exist */ + Error?: string; + }; + }; }; }; }; - 'get_core-{_version}-tests-logger-observability': { + "post_core-{_version}-reports-abuse": { parameters: { - query?: { - Level?: Record; - }; + query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; + 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; }; - content?: never; - }; - }; - }; - 'get_core-{_version}-tests-ping': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Success */ 200: { @@ -21146,33 +23383,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': { - 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': { + "post_core-{_version}-reports-crash": { parameters: { query?: never; header?: never; @@ -21181,29 +23399,52 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; - responses: { - default: { - headers: { - [name: string]: unknown; + 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; + }; }; - 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: { @@ -21211,48 +23452,58 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-users-invitations': { + "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: { - /** @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': { + "post_core-{_version}-reports-phishing": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; - requestBody?: 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: { @@ -21260,24 +23511,27 @@ 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}-reports-cancel-plan": { parameters: { query?: never; header?: never; path: { _version: string; - enc_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CancelPlanReport"]; + }; + }; responses: { /** @description Success */ 200: { @@ -21285,29 +23539,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}-reset": { parameters: { query?: never; header?: never; @@ -21318,12 +23557,19 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { + /** @example derp */ + Username?: string; /** - * @description Email address - * @example einstein@pm.me + * @description if Phone is not present + * @example derp@gmail.com */ Email?: string; + /** + * @description if Email is not present + * @example +1234567890 + */ + Phone?: string; }; }; }; @@ -21334,8 +23580,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -21345,18 +23591,18 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['ProtonError'] & { - /** - * @description Email address failed validation - * @default 2050 - */ - Code: unknown; + "application/json": { + /** @example 19305 */ + Code?: number; + /** @example Username and recovery email mismatch */ + Error?: string; + Details?: string[]; }; }; }; }; }; - 'post_core-{_version}-validate-phone': { + "post_core-{_version}-reset-username": { parameters: { query?: never; header?: never; @@ -21367,10 +23613,15 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { + "application/json": { /** - * @description Phone number - * @example +37012345678 + * @description if Phone is not present + * @example derp@gmail.com + */ + Email?: string; + /** + * @description if Email is not present + * @example +1234567890 */ Phone?: string; }; @@ -21383,35 +23634,19 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-verification-ownership-{token}': { + "get_core-{_version}-system-config": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21425,13 +23660,12 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-{token}': { + "get_core-{_version}-system-version": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21445,13 +23679,12 @@ export interface operations { }; }; }; - 'get_core-{_version}-verification-ownership-email-{token}': { + "get_core-{_version}-tests-exception": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21465,13 +23698,12 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-email-{token}': { + "get_core-{_version}-tests-error": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21485,13 +23717,12 @@ export interface operations { }; }; }; - 'get_core-{_version}-verification-ownership-sms-{token}': { + "get_core-{_version}-tests-notice": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21505,13 +23736,12 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-sms-{token}': { + "get_core-{_version}-tests-user-deprecation": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; }; cookie?: never; }; @@ -21525,14 +23755,12 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-{token}-{code}': { + "get_core-{_version}-tests-memoryLeak": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; - code: string; }; cookie?: never; }; @@ -21546,14 +23774,12 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-email-{token}-{code}': { + "get_core-{_version}-tests-logger": { parameters: { query?: never; header?: never; path: { _version: string; - token: string; - code: string; }; cookie?: never; }; @@ -21567,14 +23793,14 @@ export interface operations { }; }; }; - 'post_core-{_version}-verification-ownership-sms-{token}-{code}': { + "get_core-{_version}-tests-logger-observability": { parameters: { - query?: never; + query?: { + Level?: number; + }; header?: never; path: { _version: string; - token: string; - code: string; }; cookie?: never; }; @@ -21588,12 +23814,12 @@ export interface operations { }; }; }; - 'get_core-v6-events-{id}': { + "get_core-{_version}-tests-ping": { parameters: { query?: never; header?: never; path: { - id: components['schemas']['Id']; + _version: string; }; cookie?: never; }; @@ -21605,12 +23831,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Stream']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; }; }; }; }; - 'get_core-{_version}-events-latest': { + "get_core-{_version}-tests-version": { parameters: { query?: never; header?: never; @@ -21621,59 +23849,42 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example ACXDmTaBub14w== */ - EventID?: string; - }; - }; + content?: never; }; }; }; - 'get_core-{_version}-events-{id}': { + "get_core-{_version}-tests-stream": { parameters: { - query?: { - MessageCounts?: components['schemas']['BoolInt']; - ConversationCounts?: components['schemas']['BoolInt']; - NoMetaData?: unknown[]; - }; + query?: never; header?: never; path: { _version: string; - id: components['schemas']['Id']; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { headers: { [name: string]: unknown; }; - content: { - 'application/json': components['schemas']['EventInfo']; - }; + content?: never; }; }; }; - 'get_core-v4-events-{id}': { + "get_core-{_version}-update": { parameters: { query?: { - MessageCounts?: Record; - ConversationCounts?: Record; - }; - header?: { - 'x-pm-appversion'?: string; + /** @example 24m */ + cycle?: string; }; + header?: never; path: { - id: components['schemas']['Id']; + _version: string; }; cookie?: never; }; @@ -21685,12 +23896,14 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['EventInfo']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; }; }; }; }; - 'post_core-{_version}-feedback': { + "post_core-{_version}-validate-email": { parameters: { query?: never; header?: never; @@ -21701,7 +23914,13 @@ export interface operations { }; requestBody?: { content: { - 'application/json': components['schemas']['FeedbackVO']; + "application/json": { + /** + * @description Email address + * @example einstein@pm.me + */ + Email?: string; + }; }; }; responses: { @@ -21711,14 +23930,29 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "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; }; }; }; }; }; - 'get_core-{_version}-checklist-get-started': { + "post_core-{_version}-validate-phone": { parameters: { query?: never; header?: never; @@ -21727,7 +23961,17 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + /** + * @description Phone number + * @example +37012345678 + */ + Phone?: string; + }; + }; + }; responses: { /** @description Success */ 200: { @@ -21735,217 +23979,250 @@ export interface operations { [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; + "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}-checklist-paying-user': { + "get_core-{_version}-verification-ownership-{token}": { parameters: { query?: never; header?: never; path: { _version: string; + token: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { 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; - }; - }; + content?: never; }; }; }; - 'post_core-{_version}-checklist-get-started-seen-completed-list': { + "post_core-{_version}-verification-ownership-{token}": { parameters: { query?: never; header?: never; path: { _version: string; + token: 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}-checklist-paying-user-hide': { + "get_core-{_version}-verification-ownership-email-{token}": { parameters: { query?: never; header?: never; path: { _version: string; + token: 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}-checklist-paying-user-seen-completed-list': { + "post_core-{_version}-verification-ownership-email-{token}": { parameters: { query?: never; header?: never; path: { _version: string; + token: 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}-checklist-get-started-init': { + "get_core-{_version}-verification-ownership-sms-{token}": { parameters: { query?: never; header?: never; path: { _version: string; + token: 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}-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}-checklist-paying-user-init': { + "post_core-{_version}-verification-ownership-{token}-{code}": { parameters: { query?: never; header?: never; path: { _version: string; + token: string; + code: 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}-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; }; }; }; - 'put_core-{_version}-checklist-check-item': { + "post_core-{_version}-verification-ownership-sms-{token}-{code}": { parameters: { query?: never; header?: never; path: { _version: string; + token: string; + code: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example MobileApp */ - Item?: string; + 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': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["Stream"]; }; }; }; }; - 'put_core-{_version}-checklist-update-display': { + "get_core-{_version}-events-{id}": { parameters: { - query?: never; + query?: { + MessageCounts?: components["schemas"]["BoolInt"]; + ConversationCounts?: components["schemas"]["BoolInt"]; + NoMetaData?: unknown[]; + OnlyInInboxForCategoriesCounts?: components["schemas"]["BoolInt"]; + }; header?: never; path: { _version: string; + id: components["schemas"]["Id"]; }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': { - /** @example Hidden */ - Display?: string; - }; - }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -21953,35 +24230,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["EventInfo"]; }; }; }; }; - 'post_core-{_version}-verify-send': { + "get_core-v4-events-{id}": { parameters: { - query?: never; - header?: never; + query?: { + MessageCounts?: boolean; + ConversationCounts?: boolean; + }; + header?: { + "x-pm-appversion"?: string; + }; path: { - _version: string; + id: components["schemas"]["Id"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** - * @example external_email - * @enum {string} - */ - Type?: 'external_email, recovery_email'; - /** @example me@example.com */ - Destination?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -21989,14 +24257,12 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["EventInfo"]; }; }; }; }; - 'post_core-{_version}-verify-validate': { + "post_core-{_version}-feedback": { parameters: { query?: never; header?: never; @@ -22007,10 +24273,7 @@ export interface operations { }; requestBody?: { content: { - 'application/json': { - /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ - JWT?: string; - }; + "application/json": components["schemas"]["FeedbackVO"]; }; }; responses: { @@ -22020,55 +24283,38 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Previous confirmation state - * @example 1 - */ - PreviousState?: number; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'delete_core-{_version}-verify-validate': { + "post_core-{_version}-checklist-seen-completed-list-{checklistType}": { parameters: { query?: never; header?: never; path: { _version: string; + checklistType: components["schemas"]["UserChecklistType"]; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ - JWT?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Previous confirmation state - * @example 1 - */ - PreviousState?: number; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-verify-email': { + "post_core-{_version}-checklist-get-started-init": { parameters: { query?: never; header?: never; @@ -22082,22 +24328,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Previous confirmation state - * @example 1 - */ - PreviousState?: number; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-verify-phone': { + "post_core-{_version}-checklist-paying-user-init": { parameters: { query?: never; header?: never; @@ -22111,22 +24351,16 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @description Previous confirmation state - * @example 1 - */ - PreviousState?: number; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-verify-reauth-email': { + "put_core-{_version}-checklist-check-item": { parameters: { query?: never; header?: never; @@ -22135,50 +24369,25 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckItemInput"]; + }; + }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [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; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'post_core-{_version}-verify-reauth-phone': { + "put_core-{_version}-checklist-update-display": { parameters: { query?: never; header?: never; @@ -22187,70 +24396,46 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateDisplayInput"]; + }; + }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [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; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-notifications': { + "post_core-{_version}-verify-send": { 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']; - }; + query?: never; header?: never; path: { _version: string; }; cookie?: never; }; - requestBody?: 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: { @@ -22258,32 +24443,28 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Notifications?: components['schemas']['NotificationVersionTransformer'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'patch_core-v4-labels-{enc_id}': { + "post_core-{_version}-verify-validate": { 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']; + "application/json": { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + JWT?: string; + }; }; }; responses: { @@ -22293,38 +24474,33 @@ export interface operations { [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 (float given) */ - Error?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; }; }; }; }; }; - 'post_core-v4-labels-by-ids': { + "delete_core-{_version}-verify-validate": { parameters: { query?: never; header?: never; - path?: never; + path: { + _version: string; + }; cookie?: never; }; requestBody?: { content: { - 'application/json': components['schemas']['LabelIDs']; + "application/json": { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + JWT?: string; + }; }; }; responses: { @@ -22334,25 +24510,21 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Labels?: { - [key: string]: components['schemas']['Label']; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; }; }; }; }; }; - 'get_core-{_version}-labels': { + "post_core-{_version}-verify-email": { parameters: { - query?: { - /** - * @description 1 => Message Labels, 2 => Contact Groups, 3 => Message Folders, 4 => Message System Folders - * @example 3 - */ - Type?: number; - }; + query?: never; header?: never; path: { _version: string; @@ -22367,15 +24539,19 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Labels?: components['schemas']['Label'][]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Previous confirmation state + * @example 1 + */ + PreviousState?: number; }; }; }; }; }; - 'post_core-{_version}-labels': { + "post_core-{_version}-verify-phone": { parameters: { query?: never; header?: never; @@ -22384,55 +24560,36 @@ export interface operations { }; 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; + 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: { @@ -22440,9 +24597,8 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Label?: components['schemas']['Label']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; @@ -22452,45 +24608,31 @@ export interface operations { [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 */ + "application/json": { + /** @example 12087 */ Code?: number; - /** @example A label or folder with this name already exists */ + /** @example Invalid or already used token */ Error?: string; }; }; }; - /** @description Invalid name */ + /** @description Error */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2011 */ + "application/json": { + /** @example 9001 */ Code?: number; - /** @example Invalid name */ + /** @example Human verification required */ Error?: string; }; }; }; }; }; - 'delete_core-{_version}-labels': { + "post_core-{_version}-verify-reauth-phone": { parameters: { query?: never; header?: never; @@ -22499,13 +24641,7 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': { - LabelIDs?: string[]; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -22513,72 +24649,54 @@ export interface operations { [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; - }; - }; - }; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; - /** @description Bad Request */ + /** @description Error */ 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; - }; - }; + "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}-labels-available': { + "get_core-{_version}-notifications": { 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; + 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: { @@ -22594,80 +24712,93 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Notifications?: components["schemas"]["NotificationVersionTransformer"][]; }; }; }; - /** @description Error */ - 400: { + }; + }; + "get_core-{_version}-connection-information": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { headers: { + "x-pm-code": 1000; [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2011 */ - Code?: number; - /** @example Maximum 3 levels in the folder hierarchy */ - Error?: string; - }; + "application/json": components["schemas"]["ConnectionInformationResponse"]; }; }; - /** @description Name already exists */ - 409: { + /** @description Connection information cannot be provided. */ + 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @example 2500 */ - Code?: number; - /** @example A label or folder with this name already exists */ + "application/json": { + /** + * @example 2900 + * @enum {integer} + */ + Code?: 2900 | 2051; + /** @example Connection information cannot be provided at this time. */ Error?: string; }; }; }; - /** @description Invalid name */ - 422: { + }; + }; + "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': { - /** @example 2011 */ - Code?: number; - /** @example Invalid name */ - Error?: string; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: { + [key: string]: components["schemas"]["Label2"]; + }; }; }; }; }; }; - 'put_core-{_version}-labels-order': { + "post_core-v5-labels-by-ids": { parameters: { query?: never; header?: never; - path: { - _version: string; - }; + path?: never; 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; - }; + "application/json": components["schemas"]["LabelIDs"]; }; }; responses: { @@ -22677,20 +24808,26 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: components["schemas"]["Label2"][]; }; }; }; }; }; - 'put_core-{_version}-labels-order-tree-{startLabelId}': { + "get_core-{_version}-labels": { parameters: { - query?: never; + query?: { + /** + * @description 1 => Message Labels, 2 => Contact Groups, 3 => Message Folders, 4 => Message System Folders + * @example 3 + */ + Type?: number; + }; header?: never; path: { _version: string; - startLabelId: (string & components['schemas']['Id']) | null; }; cookie?: never; }; @@ -22702,136 +24839,69 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Labels?: components["schemas"]["Label2"][]; }; }; }; }; }; - 'put_core-{_version}-labels-{id}': { + "post_core-{_version}-labels": { parameters: { - query?: { - /** - * @description the encrypted label id - * @example lKJlejjlk== - */ - enc_id?: string; - /** - * @description the label id - * @example 4 - */ - id?: string; - }; + query?: never; header?: never; path: { _version: string; - id: string; }; cookie?: never; }; 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) - * @example Stuff + * @description required, cannot be same as an existing label of this Type. Max length is 100 characters + * @example Red Label */ Name?: string; /** - * @description required - * @example #ff9 + * @description required, must match default colors + * @example #f66 */ Color?: string; /** - * @description optional + * @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 + * @description optional, 0 => no desktop/email notifications, 1 => notifications, folders only, default is 1 for folders * @example 0 */ Notify?: number; /** - * @description optional + * @description optional, 0 => collapse and hide sub-folders, 1 => expanded and show sub-folders * @example 0 */ Expanded?: number; /** - * @description optional + * @description optional, 0 => not sticky, 1 => stick to the page in the sidebar * @example 0 */ Sticky?: number; /** - * @description optional + * @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 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: { @@ -22839,92 +24909,57 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label2"]; }; }; }; - }; - }; - '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: { + /** @description Error */ + 400: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** @example 3 */ - NumMessages?: number; + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Maximum 3 levels in the folder hierarchy */ + Error?: string; }; }; }; - /** @description Bad Request */ - 400: { + /** @description Already exists */ + 409: { 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; - }; - }; + "application/json": { + /** @example 2500 */ + Code?: number; + /** @example A label or folder with this name already exists */ + Error?: string; + }; }; }; - /** @description Unprocessable Entity */ + /** @description Invalid name */ 422: { headers: { [name: string]: unknown; }; content: { - 'application/json': { - /** @default 2501 */ - Code: number; - /** @default Label does not exist */ - Error: string; + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Invalid name */ + Error?: string; }; }; }; }; }; - 'post_core-{_version}-auth-info': { + "delete_core-{_version}-labels": { parameters: { query?: never; header?: never; @@ -22935,25 +24970,8 @@ export interface operations { }; 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; + "application/json": { + LabelIDs?: string[]; }; }; }; @@ -22964,40 +24982,30 @@ 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": { + /** @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 */ @@ -23006,214 +25014,129 @@ export interface operations { [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; + "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; + }; }; }; }; - /** @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}': { + }; + }; + "get_core-{_version}-labels-available": { parameters: { - query?: { - FinalRedirectBaseUrl?: string | null; + 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: { - /** - * @description Token received as SSOChallengeToken from POST /auth/info - * @example a5fd396fcbb - */ - token: string; _version: string; }; cookie?: never; }; requestBody?: never; responses: { - default: { + /** @description Success */ + 200: { 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']; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; }; - }; - responses: { - default: { + /** @description Error */ + 400: { headers: { [name: string]: unknown; }; - content?: never; - }; - }; - }; - 'post_core-{_version}-auth': { - parameters: { - query?: never; - header?: never; - path: { - _version: string; + content: { + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Maximum 3 levels in the folder hierarchy */ + Error?: string; + }; + }; }; - cookie?: never; - }; - requestBody?: { - content: { - 'application/json': components['schemas']['AuthInput']; + /** @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; + }; + }; }; - }; - responses: { - /** @description Success */ - 200: { + /** @description Invalid name */ + 422: { 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'][]; - }; - }; + "application/json": { + /** @example 2011 */ + Code?: number; + /** @example Invalid name */ + Error?: string; }; }; }; }; }; - 'delete_core-{_version}-auth': { + "put_core-{_version}-labels-order": { 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": { + /** @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: { @@ -23221,35 +25144,24 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-auth-jwt': { + "put_core-{_version}-labels-order-tree-{startLabelId}": { parameters: { query?: never; header?: never; path: { _version: string; + startLabelId: string & (components["schemas"]["Id"] | null); }; 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; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -23257,61 +25169,72 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-auth-2fa': { + "put_core-{_version}-labels-{id}": { parameters: { - query?: never; + 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': { + "application/json": { /** - * @description either this or the FIDO2 object - * @example 123456 or recovery code + * @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 */ - 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[]; - }; + 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; }; }; }; @@ -23322,25 +25245,55 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @deprecated - * @example full - */ - Scope?: string; - Scopes?: string[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Label?: components["schemas"]["Label2"]; + }; + }; + }; + /** @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; }; }; }; }; }; - 'get_core-{_version}-auth-modulus': { + "delete_core-{_version}-labels-{enc_id}": { parameters: { - query?: never; + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + }; header?: never; path: { _version: string; + enc_id: string; }; cookie?: never; }; @@ -23352,23 +25305,26 @@ export interface operations { [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; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-auth-scopes': { + "put_core-{_version}-labels-{enc_labelID}-detach": { parameters: { - query?: never; + query?: { + /** + * @description the encrypted label id + * @example lKJlejjlk== + */ + enc_id?: string; + }; header?: never; path: { _version: string; + enc_labelID: string; }; cookie?: never; }; @@ -23380,166 +25336,125 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - /** - * @deprecated - * @example 217017207043915776 - */ - Scope?: string; - Scopes?: string[]; + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 3 */ + NumMessages?: number; }; }; }; - }; - }; - '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; - /** @example http://protonmail.ch */ - RedirectURI?: string; - /** - * @deprecated - * @description This parameter is deprecated and should be passed via 'x-pm-uid' header instead - * @example m3mxv75of7tuy4na4c3fzkskaqnu35xj - */ - UID?: string; + /** @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; + }; + }; }; }; - }; - responses: { - /** @description Success */ - 200: { + /** @description Unprocessable Entity */ + 422: { 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; + "application/json": { + /** @default 2501 */ + Code: number; + /** @default Label does not exist */ + Error: string; }; }; }; }; }; - 'post_core-{_version}-auth-cookies': { + "patch_core-v4-labels-{enc_id}": { parameters: { - query?: never; + query: { + /** + * @description the encrypted label id + * @example wSGAB7IOerWAaIItAfyAIbSWIaFSS + */ + enc_id: string; + }; header?: never; path: { - _version: string; + enc_id: string; }; cookie?: never; }; requestBody?: { content: { - 'application/json': { - /** @example token */ - ResponseType?: string; - /** @example refresh_token */ - GrantType?: string; - /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ - RefreshToken?: string; - /** @example http://protonmail.ch */ - RedirectURI?: string; - /** - * @description defaults to 0 if not present, creates persistent cookies - * @example 1 - */ - Persistent?: number; - }; + "application/json": components["schemas"]["PatchInput"]; }; }; responses: { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [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; - }; + "application/json": components["schemas"]["LabelResponse"]; }; }; }; }; - 'post_core-{_version}-auth-credentialless': { + "get_core-{_version}-referrals-identifiers-{identifier}": { parameters: { query?: never; header?: never; path: { + /** @example KPlISx5MiML3XcSYPrREF */ + identifier: string; _version: string; }; cookie?: never; }; - requestBody?: { - content: { - 'application/json': components['schemas']['CreateCredentiallessUserInput']; - }; - }; + requestBody?: never; responses: { - /** @description Success */ + /** @description The identifier exists */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CreateCredentiallessUserOutput']; + "application/json": unknown; + }; + }; + /** @description The identifier does not exist */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; }; }; }; }; - 'get_core-{_version}-settings-mnemonic': { + "get_core-{_version}-referrals-info": { parameters: { query?: never; header?: never; @@ -23550,28 +25465,15 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Success */ - 200: { + default: { 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; - }[]; - }; - }; + content?: never; }; }; }; - 'put_core-{_version}-settings-mnemonic': { + "get_core-{_version}-referrals-status": { parameters: { query?: never; header?: never; @@ -23580,55 +25482,7 @@ export interface operations { }; 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[]; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -23636,23 +25490,21 @@ 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": { + Referrals?: components["schemas"]["ReferralStatus"][]; + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'get_core-{_version}-settings-mnemonic-reset': { + "get_core-{_version}-trials-{referralIdentifier}": { parameters: { query?: never; header?: never; path: { + /** @example KZPS5MML */ + referralIdentifier: string; _version: string; }; cookie?: never; @@ -23662,59 +25514,30 @@ export interface operations { /** @description Success */ 200: { headers: { + "x-pm-code": 1000; [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; - }[]; - }; + "application/json": components["schemas"]["ListEligibleTrialsResponse"]; }; }; }; }; - 'post_core-{_version}-settings-mnemonic-reset': { + "get_core-{_version}-referrals": { parameters: { - query?: never; + 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?: { - 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; - }; - }; - }; - }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -23722,15 +25545,16 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - Code?: components['schemas']['ResponseCodeSuccess']; - Scopes?: string[]; + "application/json": { + Referrals?: components["schemas"]["ReferralOutput"][]; + Total?: number; + Code?: components["schemas"]["ResponseCodeSuccess"]; }; }; }; }; }; - 'post_core-{_version}-settings-mnemonic-disable': { + "post_core-{_version}-referrals": { parameters: { query?: never; header?: never; @@ -23741,41 +25565,7 @@ export interface operations { }; 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[]; - }; - }; + "application/json": components["schemas"]["SendInvitationsInput"]; }; }; responses: { @@ -23785,19 +25575,15 @@ 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"]; + Referrals?: components["schemas"]["ReferralOutput"][]; }; }; }; }; }; - 'put_core-{_version}-settings-mnemonic-reactivate': { + "post_core-{_version}-referrals-register": { parameters: { query?: never; header?: never; @@ -23808,35 +25594,54 @@ export interface operations { }; 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']; + "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': { - Code?: components['schemas']['ResponseCodeSuccess']; - }; + "application/json": components["schemas"]["SuccessfulResponse"]; }; }; }; }; - 'get_core-{_version}-images': { + "get_core-{_version}-images": { parameters: { query: { /** @@ -23846,15 +25651,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; @@ -23870,13 +25675,15 @@ export interface operations { /** @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; + /** + * @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; + "application/octet-stream": string; }; }; /** @description Return an empty image when we cannot proxy the remote image */ @@ -23892,7 +25699,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2000 */ Code?: number; /** @example The Url is required */ @@ -23906,7 +25713,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { + "application/json": { /** @example 2052 */ Code?: number; /** @example The Url is not valid URL */ @@ -23916,4 +25723,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/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index e9ea0d73..a6022bba 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -5,7 +5,7 @@ import { Logger, VERSION } from '@protontech/drive-sdk'; import type { Config } from '../config'; import { Credentials } from '../credentials'; import type { paths as AuthPaths } from './api-auth-types'; -import { SUPPORTED_REQUIREMENT_MASK_BY_SCOPE, processDriveRequirementHeaders } from './apiRequirements'; +import { processDriveRequirementHeaders, SUPPORTED_REQUIREMENT_MASK_BY_SCOPE } from './apiRequirements'; import { MessageEmitter } from './messageEmitter'; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/js/cli/src/api/auth.ts b/js/cli/src/api/auth.ts index c7d3aeb7..05c650c0 100644 --- a/js/cli/src/api/auth.ts +++ b/js/cli/src/api/auth.ts @@ -1,14 +1,6 @@ -import bcrypt from 'bcryptjs'; - import { Logger } from '@protontech/drive-sdk'; import { Credentials } from '../credentials'; -import { VERIFICATION_STATUS } from '../crypto/lib'; -import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; -import { uint8ArrayToBinaryString, binaryStringToUint8Array, mergeUint8Arrays } from '../crypto/lib/utils'; -import { generateProofs } from '../srp/lib/srp'; -import { expandHash } from '../srp/lib/passwords'; -import { BCRYPT_PREFIX, SRP_LEN, SRP_MODULUS_KEY } from '../srp/lib/constants'; import { AccountApi, AccountApiError } from './accountApi'; import { FORK_INITIAL_DELAY_MS, @@ -18,14 +10,17 @@ import { parseUserKeyPassword, } from './authWeb'; import { sleepMs } from './sleep'; +import { Srp } from './srp'; export class Auth { + private readonly srpModule: Srp; constructor( - private readonly cryptoApi: CryptoApiInterface, private readonly accountApi: AccountApi, private readonly credentials: Credentials, private readonly logger: Logger, - ) {} + ) { + this.srpModule = new Srp(accountApi); + } isLoggedIn(): boolean { return this.credentials.isLoggedIn(); @@ -50,38 +45,33 @@ export class Auth { this.logger.debug('Getting auth info'); const info = await this.accountApi.info(username); - const publicKey = await this.cryptoApi.importPublicKey({ armoredKey: SRP_MODULUS_KEY }); - const modulusResult = await this.cryptoApi.verifyCleartextMessage({ - armoredCleartextMessage: info.Modulus || '', - verificationKeys: publicKey, - }); - - if (modulusResult.verificationStatus !== VERIFICATION_STATUS.SIGNED_AND_VALID) { - throw new Error('Failed to verify auth response'); + if (!info.Version || !info.Modulus || !info.SRPSession || !info.ServerEphemeral || !info.Salt) { + throw new Error('Missing required auth info fields'); } this.logger.debug('Generating proofs'); - const results = await this.generateAuthProofs({ - loginPassword: password, - modulusData: modulusResult.data, - serverEphemeral: info.ServerEphemeral || '', - salt: info.Salt || '', - }); + const { clientEphemeral, clientProof, expectedServerProof } = await this.srpModule.getSrp( + info.Version, + info.Modulus, + info.ServerEphemeral, + info.Salt, + password + ); this.logger.debug('Authenticating'); const authResponse = await this.accountApi.auth({ Username: username, - SRPSession: info.SRPSession || '', + SRPSession: info.SRPSession, PersistentCookies: 1, Payload: {}, - ClientProof: results.clientProof, - ClientEphemeral: results.clientEphemeral, + ClientProof: clientProof, + ClientEphemeral: clientEphemeral, }); if (!authResponse.ServerProof) { throw new Error('Missing ServerProof'); } - if (authResponse.ServerProof !== results.expectedServerProof) { + if (authResponse.ServerProof !== expectedServerProof) { throw new Error('Server proof verification failed'); } if (!authResponse.UID || !authResponse.AccessToken) { @@ -106,45 +96,6 @@ export class Auth { }; } - private async generateAuthProofs(params: { - loginPassword: string; - modulusData: string; - serverEphemeral: string; - salt: string; - }): Promise<{ - clientEphemeral: string; - clientProof: string; - expectedServerProof: string; - }> { - const modulusArray = Uint8Array.fromBase64(params.modulusData) as Uint8Array; - const serverEphemeralArray = Uint8Array.fromBase64(params.serverEphemeral) as Uint8Array; - const saltBytes = Uint8Array.fromBase64(params.salt); - const saltBinary = binaryStringToUint8Array(`${uint8ArrayToBinaryString(saltBytes)}proton`); - const unexpandedHash = await bcrypt.hash( - params.loginPassword, - BCRYPT_PREFIX + bcrypt.encodeBase64(saltBinary, 16), - ); - const hashedPasswordArray = await expandHash( - this.cryptoApi, - mergeUint8Arrays([binaryStringToUint8Array(unexpandedHash), modulusArray]), - ); - - const { clientEphemeral, clientProof, expectedServerProof } = await generateProofs(this.cryptoApi, { - byteLength: SRP_LEN, - modulusArray, - hashedPasswordArray, - serverEphemeralArray, - }); - - const results = { - clientEphemeral: clientEphemeral.toBase64(), - clientProof: clientProof.toBase64(), - expectedServerProof: expectedServerProof.toBase64(), - }; - - return results; - } - private async getUserKeyPassword(loginPassword: string): Promise { const salts = await this.accountApi.salts(); const userKeySalt = salts.KeySalts?.at(0)?.KeySalt; @@ -152,12 +103,7 @@ export class Auth { throw new Error('Missing KeySalt'); } - const keyPassword = ( - await bcrypt.hash( - loginPassword, - BCRYPT_PREFIX + bcrypt.encodeBase64(Uint8Array.fromBase64(userKeySalt), 16), - ) - ).slice(29); + const keyPassword = await this.srpModule.computeKeyPassword(loginPassword, userKeySalt); return keyPassword; } diff --git a/js/cli/src/api/httpClient.ts b/js/cli/src/api/httpClient.ts index 47ceec3b..8caea5a0 100644 --- a/js/cli/src/api/httpClient.ts +++ b/js/cli/src/api/httpClient.ts @@ -1,4 +1,4 @@ -import { ProtonDriveHTTPClientJsonRequest, ProtonDriveHTTPClientBlobRequest } from '@protontech/drive-sdk'; +import { ProtonDriveHTTPClientBlobRequest, ProtonDriveHTTPClientJsonRequest } from '@protontech/drive-sdk'; import { ApiClient } from './apiClient'; diff --git a/js/cli/src/api/index.ts b/js/cli/src/api/index.ts index e338d890..63a7a4c8 100644 --- a/js/cli/src/api/index.ts +++ b/js/cli/src/api/index.ts @@ -2,7 +2,6 @@ import { Logger } from '@protontech/drive-sdk'; import type { Config } from '../config'; import type { Credentials } from '../credentials'; -import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; import { AccountApi } from './accountApi'; import { Addresses } from './addresses'; import { ApiClient } from './apiClient'; @@ -14,12 +13,12 @@ export type { Addresses } from './addresses'; export type { Auth } from './auth'; export type { Srp } from './srp'; -export async function initApi(config: Config, cryptoApi: CryptoApiInterface, credentials: Credentials, logger: Logger) { +export async function initApi(config: Config, credentials: Credentials, logger: Logger) { const apiClient = new ApiClient(config, credentials, logger); const accountApi = new AccountApi(apiClient); - const addresses = new Addresses(cryptoApi, accountApi, credentials, logger); - const auth = new Auth(cryptoApi, accountApi, credentials, logger); - const srp = new Srp(cryptoApi, accountApi); + const addresses = new Addresses(accountApi, credentials, logger); + const auth = new Auth(accountApi, credentials, logger); + const srp = new Srp(accountApi); const httpClient = new HTTPClient(apiClient); await auth.loadSession(); diff --git a/js/cli/src/api/srp.ts b/js/cli/src/api/srp.ts index 90a674a9..36b33ebe 100644 --- a/js/cli/src/api/srp.ts +++ b/js/cli/src/api/srp.ts @@ -1,11 +1,9 @@ -import { type ApiInterface as CryptoApiInterface } from '../crypto/lib/worker/api'; -import { getSrp, getRandomSrpVerifier } from '../srp/lib/srp'; -import { computeKeyPassword } from '../srp/lib/keys'; +import { computeKeyPassword, generateKeySalt, getRandomSrpVerifier, getSrp } from '@protontech/crypto/srp'; + import { AccountApi } from './accountApi'; export class Srp { constructor( - private readonly cryptoApi: CryptoApiInterface, private readonly accountApi: AccountApi, ) {} @@ -21,7 +19,6 @@ export class Srp { clientEphemeral: string; }> { return getSrp( - this.cryptoApi, { Version: version, Modulus: modulus, @@ -39,7 +36,6 @@ export class Srp { } const { version, salt, verifier } = await getRandomSrpVerifier( - this.cryptoApi, { Modulus: result.Modulus, }, @@ -56,4 +52,8 @@ export class Srp { async computeKeyPassword(password: string, salt: string) { return await computeKeyPassword(password, salt); } + + generateKeySalt(): string { + return generateKeySalt(); + } } diff --git a/js/cli/src/cache/encryptedCache.ts b/js/cli/src/cache/encryptedCache.ts index b0c92fd9..1ba426df 100644 --- a/js/cli/src/cache/encryptedCache.ts +++ b/js/cli/src/cache/encryptedCache.ts @@ -1,6 +1,6 @@ import { Logger } from '@protontech/drive-sdk'; -import { encryptCacheValue, decryptCacheValue } from './crypto'; +import { decryptCacheValue, encryptCacheValue } from './crypto'; import { SQLiteEntititesCache } from './sqliteCache'; /** diff --git a/js/cli/src/cache/index.ts b/js/cli/src/cache/index.ts index 5dd04d9b..f9152e1d 100644 --- a/js/cli/src/cache/index.ts +++ b/js/cli/src/cache/index.ts @@ -1,9 +1,9 @@ import type { Logger, ProtonDriveCache } from '@protontech/drive-sdk'; -import { Credentials } from '../credentials'; import { Config } from '../config'; -import { SQLiteEntititesCache } from './sqliteCache'; +import { Credentials } from '../credentials'; import { EncryptedSQLiteEntitiesCache } from './encryptedCache'; +import { SQLiteEntititesCache } from './sqliteCache'; export function createEntitiesCache( config: Config, diff --git a/js/cli/src/cache/sqliteCache.ts b/js/cli/src/cache/sqliteCache.ts index 914205ab..022d0966 100644 --- a/js/cli/src/cache/sqliteCache.ts +++ b/js/cli/src/cache/sqliteCache.ts @@ -1,7 +1,7 @@ -import { Database } from 'bun:sqlite'; import path from 'node:path'; +import { Database } from 'bun:sqlite'; -import type { ProtonDriveCache, EntityResult } from '@protontech/drive-sdk'; +import type { EntityResult, ProtonDriveCache } from '@protontech/drive-sdk'; export class SQLiteEntititesCache implements ProtonDriveCache { private db: Database; diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 736519fa..cb6ebfee 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -3,7 +3,6 @@ import { Command } from './interface'; import { startRepl } from './repl'; import { runSingleInvocation } from './run'; -export type { Command, ActionArgs } from './interface'; export { formatAuthor, formatDate, @@ -13,9 +12,10 @@ export { printIterable, printObject, } from './formatters'; -export { getClaimedSize, getName, findName, getNode, getNodeUid } from './node'; -export { Path, Paths, PathType } from './paths'; +export type { ActionArgs, Command } from './interface'; +export { findName, getClaimedSize, getName, getNode, getNodeUid } from './node'; export { openBrowserUrl } from './openBrowserUrl'; +export { Path, Paths, PathType } from './paths'; export { readPasswordLine } from './readPasswordLine'; export { applyDefaultCliOptions } from './registryCore'; export type { CliSession } from './run'; diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 9f2da8ab..567e88da 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,8 +1,8 @@ import { ParseArgsConfig } from 'util'; -import { ProtonDriveClient, Logger } from '@protontech/drive-sdk'; -import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; +import { Logger, ProtonDriveClient } from '@protontech/drive-sdk'; import { Diagnostic } from '@protontech/drive-sdk/diagnostic'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { Auth } from '../api'; import { Paths } from './paths'; diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index 745b5769..890cd9b8 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -1,4 +1,4 @@ -import { MaybeNode, NodeEntity, DegradedNode } from '@protontech/drive-sdk'; +import { DegradedNode, MaybeNode, NodeEntity } from '@protontech/drive-sdk'; export function getNodeUid(maybeNode: MaybeNode): string { return maybeNode.ok ? maybeNode.value.uid : maybeNode.error.uid; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 1466af4f..e85c1249 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { ProtonDriveClient, MaybeNode, MaybeMissingNode, ValidationError } from '@protontech/drive-sdk'; +import { MaybeMissingNode, MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePublicLinkClient'; diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index c715567c..fffab24a 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -5,9 +5,9 @@ import { InitConfig } from '../config'; import { init } from '../init'; import { CommandError } from './errors'; import { Command } from './interface'; -import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; import { question } from './readline'; import { runCommand } from './run'; +import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; export async function startRepl(commands: Command[], initOptions: InitConfig): Promise { const session = await init(initOptions); diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 739d93d3..f032a40a 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -2,8 +2,8 @@ import { parseArgs } from 'util'; import { ProtonDriveError } from '@protontech/drive-sdk'; -import { init } from '../init'; import { InitConfig } from '../config'; +import { init } from '../init'; import { AuthRequiredError } from './errors'; import { formatReadableJson } from './formatters'; import { Command } from './interface'; diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts index 48c8647c..61c68846 100644 --- a/js/cli/src/commands/auth/commandAuthLogin.ts +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -1,5 +1,5 @@ import { Auth } from '../../api'; -import { openBrowserUrl, type ActionArgs, type Command } from '../../cli'; +import { type ActionArgs, type Command, openBrowserUrl } from '../../cli'; export class CommandAuthLogin implements Command { group = 'auth'; diff --git a/js/cli/src/commands/auth/commandAuthLogout.ts b/js/cli/src/commands/auth/commandAuthLogout.ts index 1a360197..6ba4ba83 100644 --- a/js/cli/src/commands/auth/commandAuthLogout.ts +++ b/js/cli/src/commands/auth/commandAuthLogout.ts @@ -1,4 +1,4 @@ -import { type Command, type ActionArgs } from '../../cli'; +import { type ActionArgs, type Command } from '../../cli'; export class CommandAuthLogout implements Command { group = 'auth'; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index aff65b6a..b75f7dae 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -1,8 +1,8 @@ import { ParseArgsConfig } from 'node:util'; -import { ProtonDriveClient, MaybeNode, ValidationError } from '@protontech/drive-sdk'; +import { MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; -import { printIterable, type Command, type ActionArgs, PathType, getNodeUid, getName, findName } from '../../cli'; +import { type ActionArgs, type Command, findName, getName, getNodeUid, PathType, printIterable } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts index 1df754d3..984e2a7b 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts @@ -1,4 +1,4 @@ -import { printObject, type Command, type ActionArgs, PathType } from '../../cli'; +import { type ActionArgs, type Command, PathType, printObject } from '../../cli'; export class CommandFileSystemCreateFolder implements Command { group = 'filesystem'; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts index 5f03b5d1..981fb05b 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts @@ -1,4 +1,4 @@ -import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 11461751..38b1b6f9 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -2,16 +2,16 @@ import { mkdir, readdir, rm, stat, unlink } from 'node:fs/promises'; import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; -import { FileDownloader, MaybeNode, ValidationError, type ProtonDriveClient } from '@protontech/drive-sdk'; +import { FileDownloader, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, getClaimedSize, PathType } from '../../cli'; +import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; import { ConflictChoice, ConflictTargetKind, TransferConflictResolver, } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; -import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; import { DownloadQueue, type QueueItemDirectory, type QueueItemFile } from './transferQueue'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts index 0d184ceb..0d149f5d 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts @@ -1,4 +1,4 @@ -import { type Command, type ActionArgs } from '../../cli'; +import { type ActionArgs, type Command } from '../../cli'; export class CommandFileSystemEmptyTrash implements Command { group = 'filesystem'; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts index bbad5feb..409d27cc 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts @@ -1,4 +1,4 @@ -import { printObject, type Command, type ActionArgs } from '../../cli'; +import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandFileSystemInfo implements Command { group = 'filesystem'; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts index 3f7136fe..53259efd 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemList.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -4,6 +4,8 @@ import { Device, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { + type ActionArgs, + type Command, formatAuthor, formatDate, formatMemberRole, @@ -14,8 +16,6 @@ import { Paths, PathType, printIterable, - type ActionArgs, - type Command, } from '../../cli'; export class CommandFileSystemList implements Command { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index 196abe9d..50723458 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -1,6 +1,6 @@ -import { ProtonDriveClient, MaybeNode } from '@protontech/drive-sdk'; +import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; -import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRename.ts b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts index ea2e01f1..b14b0956 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRename.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts @@ -1,4 +1,4 @@ -import { printObject, type Command, type ActionArgs } from '../../cli'; +import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandFileSystemRename implements Command { group = 'filesystem'; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts index 12bc8d70..b2e13edc 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts @@ -1,4 +1,4 @@ -import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts index 9d0a92a6..631417ab 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts @@ -1,4 +1,4 @@ -import { printIterable, type Command, type ActionArgs, PathType, findName } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.Photos]; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index ab8403db..334fe7f6 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -3,13 +3,14 @@ import { createReadStream } from 'node:fs'; import { ParseArgsConfig } from 'node:util'; import { - NodeWithSameNameExistsValidationError, - ValidationError, MaybeNode, + NodeWithSameNameExistsValidationError, type ProtonDriveClient, + ValidationError, } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, PathType } from '../../cli'; +import { generateThumbnails } from './generateThumbnails'; import { ConflictChoice, ConflictTargetKind, @@ -17,11 +18,10 @@ import { } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; import { - UploadQueue, type QueueItemDirectory, type QueueItemFile, + UploadQueue, } from './transferQueue'; -import { generateThumbnails } from './generateThumbnails'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; diff --git a/js/cli/src/commands/fileSystem/transferConflictResolver.ts b/js/cli/src/commands/fileSystem/transferConflictResolver.ts index ea3f715d..ccbce0b9 100644 --- a/js/cli/src/commands/fileSystem/transferConflictResolver.ts +++ b/js/cli/src/commands/fileSystem/transferConflictResolver.ts @@ -1,4 +1,4 @@ -import { ValidationError, Logger } from '@protontech/drive-sdk'; +import { Logger, ValidationError } from '@protontech/drive-sdk'; import { question } from '../../cli/readline'; diff --git a/js/cli/src/commands/fileSystem/transferQueue.test.ts b/js/cli/src/commands/fileSystem/transferQueue.test.ts index ad81230d..19429388 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.test.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.test.ts @@ -1,7 +1,7 @@ import { Dirent } from 'node:fs'; import path from 'node:path'; -import { ValidationError, NodeType, ProtonDriveClient, MaybeNode, MemberRole } from '@protontech/drive-sdk'; +import { MaybeNode, MemberRole, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; jest.mock('../../cli', () => jest.requireActual('../../cli/node')); @@ -11,9 +11,9 @@ jest.mock('node:fs/promises', () => ({ lstat: jest.fn(), })); -import { readdir, lstat } from 'node:fs/promises'; +import { lstat, readdir } from 'node:fs/promises'; -import { MAX_CONCURRENT_ITEMS, UploadQueue, DownloadQueue, QueueItem } from './transferQueue'; +import { DownloadQueue, MAX_CONCURRENT_ITEMS, QueueItem, UploadQueue } from './transferQueue'; const readdirMock = readdir as jest.MockedFunction; const lstatMock = lstat as jest.MockedFunction; diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts index 7f429942..8ab2de71 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -1,8 +1,8 @@ import type { Stats } from 'node:fs'; -import { readdir, lstat } from 'node:fs/promises'; +import { lstat, readdir } from 'node:fs/promises'; import path from 'node:path'; -import { ValidationError, MaybeNode, NodeType, ProtonDriveClient, Logger } from '@protontech/drive-sdk'; +import { Logger, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getName, getNode } from '../../cli'; diff --git a/js/cli/src/commands/registry.ts b/js/cli/src/commands/registry.ts index 23be545b..2f341698 100644 --- a/js/cli/src/commands/registry.ts +++ b/js/cli/src/commands/registry.ts @@ -1,5 +1,4 @@ import { applyDefaultCliOptions } from '../cli'; - import { CommandAuthLogin } from './auth/commandAuthLogin'; import { CommandAuthLogout } from './auth/commandAuthLogout'; import { CommandFileSystemCopy } from './fileSystem/commandFileSystemCopy'; diff --git a/js/cli/src/credentials/credentials.ts b/js/cli/src/credentials/credentials.ts index a4189233..d99eea33 100644 --- a/js/cli/src/credentials/credentials.ts +++ b/js/cli/src/credentials/credentials.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { Logger } from '@protontech/drive-sdk'; -import type { SessionInfo, CredentialsStore } from './interface'; +import type { CredentialsStore, SessionInfo } from './interface'; export class Credentials { private cachePassword?: string; diff --git a/js/cli/src/credentials/fileCredentialsStore.ts b/js/cli/src/credentials/fileCredentialsStore.ts index fa6037cc..d32a78f2 100644 --- a/js/cli/src/credentials/fileCredentialsStore.ts +++ b/js/cli/src/credentials/fileCredentialsStore.ts @@ -1,5 +1,5 @@ -import path from 'node:path'; import { unlink } from 'node:fs/promises'; +import path from 'node:path'; import { Logger } from '@protontech/drive-sdk'; diff --git a/js/cli/src/credentials/index.ts b/js/cli/src/credentials/index.ts index c6c21d70..df6acb60 100644 --- a/js/cli/src/credentials/index.ts +++ b/js/cli/src/credentials/index.ts @@ -2,8 +2,8 @@ import { Logger } from '@protontech/drive-sdk'; import type { Config } from '../config'; import { Credentials } from './credentials'; -import type { CredentialsStore } from './interface'; import { PlaintextFileSessionStore } from './fileCredentialsStore'; +import type { CredentialsStore } from './interface'; import { SecretsSessionStore } from './secretCredentialsStore'; export type { Credentials } from './credentials'; diff --git a/js/cli/src/credentials/secretCredentialsStore.ts b/js/cli/src/credentials/secretCredentialsStore.ts index b69081b9..502cbbe5 100644 --- a/js/cli/src/credentials/secretCredentialsStore.ts +++ b/js/cli/src/credentials/secretCredentialsStore.ts @@ -12,14 +12,12 @@ export class SecretsSessionStore implements CredentialsStore { async load(): Promise { this.logger.debug(`Loading session ${SECRET_NAME} from secrets`); const raw = - // @ts-expect-error: Bun.secrets is not typed. (await Bun.secrets.get({ service: SECRET_SERVICE, name: SECRET_NAME })) as string | null; return parseStoredSnapshot(raw); } async save(snapshot: Credentials): Promise { this.logger.debug(`Saving session ${SECRET_NAME} to secrets`); - // @ts-expect-error: Bun.secrets is not typed. await Bun.secrets.set({ service: SECRET_SERVICE, name: SECRET_NAME, @@ -29,7 +27,6 @@ export class SecretsSessionStore implements CredentialsStore { async remove(): Promise { this.logger.debug(`Removing session ${SECRET_NAME} from secrets`); - // @ts-expect-error: Bun.secrets is not typed. await Bun.secrets.delete({ service: SECRET_SERVICE, name: SECRET_NAME }); } } diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index f6c5b067..9f1d513d 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -1,31 +1,39 @@ +import '@protontech/drive-sdk/polyfill'; + +import { CryptoProxy } from '@protontech/crypto'; +import { Api as CryptoApi } from '@protontech/crypto/proxy/endpoint/api.ts'; import { - ProtonDriveClient, - MemoryCache, CachedCryptoMaterial, - OpenPGPCryptoWithCryptoProxy, - OpenPGPCryptoProxy, FeatureFlags, Logger, + MemoryCache, + OpenPGPCryptoWithCryptoProxy, + ProtonDriveClient, } from '@protontech/drive-sdk'; -import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { initDiagnostic } from '@protontech/drive-sdk/diagnostic'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { initApi } from './api'; import { createEntitiesCache } from './cache'; import { Paths } from './cli'; import { getConfig, InitConfig } from './config'; import { initCredentials } from './credentials'; -import { Api as CryptoApi } from './crypto/lib/worker/api'; import { initTelemetry } from './telemetry'; +function initOpenPGPCryptoModule() { + CryptoApi.init({}); + CryptoProxy.setEndpoint(new CryptoApi(), endpoint => endpoint.clearKeyStore()); + return new OpenPGPCryptoWithCryptoProxy(CryptoProxy) +} + export async function init(configOptions: InitConfig) { const config = getConfig(configOptions); const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); const logger = telemetry.getLogger('cli'); - const cryptoApi = initCrypto(); + const openPGPCryptoModule = initOpenPGPCryptoModule(); const credentials = initCredentials(config, logger); - const { auth, addresses, srp, httpClient } = await initApi(config, cryptoApi, credentials, logger); + const { auth, addresses, srp, httpClient } = await initApi(config, credentials, logger); const entitiesCache = createEntitiesCache(config, credentials, logger); const sdkDependencies = { @@ -37,7 +45,7 @@ export async function init(configOptions: InitConfig) { entitiesCache, cryptoCache: new MemoryCache(), telemetry, - openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoApi as OpenPGPCryptoProxy), + openPGPCryptoModule, account: addresses, srpModule: srp, latestEventIdProvider: new NoLatestEventIdProvider(), @@ -60,11 +68,6 @@ export async function init(configOptions: InitConfig) { }; } -function initCrypto() { - CryptoApi.init(); - return new CryptoApi(); -} - class NoLatestEventIdProvider { async getLatestEventId(): Promise { return null; diff --git a/js/cli/src/tests/polyfill.ts b/js/cli/src/tests/polyfill.ts new file mode 100644 index 00000000..0eaed4d0 --- /dev/null +++ b/js/cli/src/tests/polyfill.ts @@ -0,0 +1 @@ +import '@protontech/drive-sdk/polyfill'; \ No newline at end of file diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json index ed05e30c..775e81c8 100644 --- a/js/cli/tsconfig.json +++ b/js/cli/tsconfig.json @@ -6,7 +6,7 @@ "forceConsistentCasingInFileNames": true, "incremental": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "paths": { "@protontech/drive-sdk": ["../sdk/src/index.ts"], "@protontech/drive-sdk/*": ["../sdk/src/*"] @@ -20,6 +20,8 @@ "sourceMap": true, "outDir": "dist", "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "types": [ "bun", "jest" 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 c7ff4bb16bc710fce9d3adace0749cfc804e6ad4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 13:39:12 +0200 Subject: [PATCH 607/732] 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 a3bf9ede4d8c5fed08924c98e29b4ae63a438bcc Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 08:22:24 +0200 Subject: [PATCH 608/732] 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 3baaa108019d802033091a4758d59e35d6f91fa7 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 09:46:05 +0200 Subject: [PATCH 609/732] Persist crypto cache --- .../src/cache/driveCryptoCacheAdapter.test.ts | 190 +++++++++++++++++ js/cli/src/cache/driveCryptoCacheAdapter.ts | 198 ++++++++++++++++++ js/cli/src/cache/encryptedCache.ts | 10 +- js/cli/src/cache/index.ts | 49 ++++- js/cli/src/cache/sqliteCache.ts | 6 +- js/cli/src/init.ts | 17 +- 6 files changed, 442 insertions(+), 28 deletions(-) create mode 100644 js/cli/src/cache/driveCryptoCacheAdapter.test.ts create mode 100644 js/cli/src/cache/driveCryptoCacheAdapter.ts diff --git a/js/cli/src/cache/driveCryptoCacheAdapter.test.ts b/js/cli/src/cache/driveCryptoCacheAdapter.test.ts new file mode 100644 index 00000000..9671e8eb --- /dev/null +++ b/js/cli/src/cache/driveCryptoCacheAdapter.test.ts @@ -0,0 +1,190 @@ +import '@protontech/drive-sdk/polyfill'; + +import { CryptoProxy, type SessionKey } from '@protontech/crypto'; +import { Api as CryptoApi } from '@protontech/crypto/proxy/endpoint/api.ts'; +import { type CachedCryptoMaterial, MemoryCache } from '@protontech/drive-sdk'; + +import { DriveCryptoCacheAdapter } from './driveCryptoCacheAdapter'; + +type CachedPrivateKey = NonNullable['key']; + +describe('DriveCryptoCacheAdapter', () => { + let cryptoApi: CryptoApi; + let inner: MemoryCache; + let adapter: DriveCryptoCacheAdapter; + + beforeAll(() => { + CryptoApi.init({}); + cryptoApi = new CryptoApi(); + CryptoProxy.setEndpoint(cryptoApi, async (endpoint) => endpoint.clearKeyStore()); + }); + + afterAll(async () => { + await CryptoProxy.releaseEndpoint(); + }); + + beforeEach(() => { + inner = new MemoryCache(); + adapter = new DriveCryptoCacheAdapter(inner); + }); + + it('round-trips empty material', async () => { + const value: CachedCryptoMaterial = {}; + await adapter.setEntity('k', value); + const out = await adapter.getEntity('k'); + expect(out).toEqual({}); + }); + + it('round-trips nodeKeys with optional contentKeyPacketSessionKey and hashKey', async () => { + const key = await generatePrivateKey(); + const passphraseSessionKey = await generateSessionKey(key); + const contentKeyPacketSessionKey = await generateSessionKey(key); + const hashKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i)); + + const value: CachedCryptoMaterial = { + nodeKeys: { + passphrase: 'node-pass', + key, + passphraseSessionKey, + contentKeyPacketSessionKey, + hashKey, + }, + }; + + await adapter.setEntity('node', value); + const out = await adapter.getEntity('node'); + + expect(out.nodeKeys?.passphrase).toBe('node-pass'); + await expectSamePrivateKeys(value.nodeKeys!.key, out.nodeKeys!.key); + expectSameSessionKeys(passphraseSessionKey, out.nodeKeys!.passphraseSessionKey); + expectSameSessionKeys(contentKeyPacketSessionKey, out.nodeKeys!.contentKeyPacketSessionKey!); + expect(Buffer.from(out.nodeKeys!.hashKey!).equals(Buffer.from(hashKey))).toBe(true); + }); + + it('round-trips shareKey and publicShareKey', async () => { + const sharePrivate = await generatePrivateKey(); + const publicPrivate = await generatePrivateKey(); + const passphraseSessionKey = await generateSessionKey(sharePrivate); + + const value: CachedCryptoMaterial = { + shareKey: { + key: sharePrivate, + passphraseSessionKey, + }, + publicShareKey: { + key: publicPrivate, + }, + }; + + await adapter.setEntity('share', value); + const out = await adapter.getEntity('share'); + + await expectSamePrivateKeys(sharePrivate, out.shareKey!.key); + expectSameSessionKeys(passphraseSessionKey, out.shareKey!.passphraseSessionKey); + await expectSamePrivateKeys(publicPrivate, out.publicShareKey!.key); + }); + + it('passes tags to the inner cache', async () => { + await adapter.setEntity('tagged', {}, ['parent:x']); + await expect(inner.getEntity('tagged')).resolves.toBeTruthy(); + const results = await Array.fromAsync(inner.iterateEntitiesByTag('parent:x')); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ key: 'tagged', ok: true }); + }); + + it('delegates clear and removeEntities', async () => { + await adapter.setEntity('a', {}); + await adapter.clear(); + await expect(inner.getEntity('a')).rejects.toThrow('Entity not found'); + + await adapter.setEntity('b', {}); + await adapter.removeEntities(['b']); + await expect(inner.getEntity('b')).rejects.toThrow('Entity not found'); + }); + + it('iterateEntities forwards inner errors without deserializing', async () => { + const results = await Array.fromAsync(adapter.iterateEntities(['missing'])); + expect(results).toEqual([ + { + key: 'missing', + ok: false, + error: 'Error: Entity not found', + }, + ]); + }); + + it('iterateEntities yields deserialized values', async () => { + const key = await generatePrivateKey(); + const sk = await generateSessionKey(key); + await adapter.setEntity('one', { + nodeKeys: { + passphrase: 'p', + key, + passphraseSessionKey: sk, + }, + }); + + const results = await Array.fromAsync(adapter.iterateEntities(['one'])); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ key: 'one', ok: true }); + const ok = results[0] as { ok: true; value: CachedCryptoMaterial }; + await expectSamePrivateKeys(key, ok.value.nodeKeys!.key); + expectSameSessionKeys(sk, ok.value.nodeKeys!.passphraseSessionKey); + }); + + it('iterateEntities maps deserialization failures to ok: false', async () => { + await inner.setEntity('bad', 'not-valid-json'); + const results = await Array.fromAsync(adapter.iterateEntities(['bad'])); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ key: 'bad', ok: false }); + }); + + it('iterateEntitiesByTag yields deserialized entries', async () => { + const key = await generatePrivateKey(); + await adapter.setEntity('tagged-node', { + nodeKeys: { + passphrase: '', + key, + passphraseSessionKey: await generateSessionKey(key), + }, + }, ['t:1']); + + const results = await Array.fromAsync(adapter.iterateEntitiesByTag('t:1')); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ key: 'tagged-node', ok: true }); + }); + + it('throws when cache version is unsupported', async () => { + await inner.setEntity('old', '{"v":0}'); + await expect(adapter.getEntity('old')).rejects.toThrow('Unsupported crypto cache version'); + }); +}); + +async function generatePrivateKey() { + return CryptoProxy.generateKey({ + userIDs: [{ name: 'DriveCryptoCacheAdapter test' }], + type: 'ecc', + curve: 'ed25519Legacy', + }); +} + +async function generateSessionKey(recipientPrivateKey: Awaited>) { + const publicKey = await CryptoProxy.importPublicKey({ + binaryKey: await CryptoProxy.exportPublicKey({ key: recipientPrivateKey, format: 'binary' }), + }); + return CryptoProxy.generateSessionKey({ recipientKeys: publicKey }); +} + +async function expectSamePrivateKeys(a: CachedPrivateKey, b: CachedPrivateKey) { + const [ea, eb] = await Promise.all([ + CryptoProxy.exportPrivateKey({ privateKey: a, passphrase: null }), + CryptoProxy.exportPrivateKey({ privateKey: b, passphrase: null }), + ]); + expect(ea).toBe(eb); +} + +function expectSameSessionKeys(a: SessionKey, b: SessionKey) { + expect(Buffer.from(a.data).equals(Buffer.from(b.data))).toBe(true); + expect(a.algorithm).toBe(b.algorithm); + expect(a.aeadAlgorithm).toBe(b.aeadAlgorithm); +} diff --git a/js/cli/src/cache/driveCryptoCacheAdapter.ts b/js/cli/src/cache/driveCryptoCacheAdapter.ts new file mode 100644 index 00000000..3aba434d --- /dev/null +++ b/js/cli/src/cache/driveCryptoCacheAdapter.ts @@ -0,0 +1,198 @@ +import { CryptoProxy, type SessionKey } from '@protontech/crypto'; +import type { CachedCryptoMaterial, EntityResult, ProtonDriveCache } from '@protontech/drive-sdk'; + +const VERSION = 1; + +type SerializedCachedCryptoMaterial = { + v: typeof VERSION; + nodeKeys?: { + passphrase: string; + armoredPrivateKey: string; + passphraseSessionKey: SerializedSessionKey; + contentKeyPacketSessionKey?: SerializedSessionKey; + hashKeyBase64?: string; + }; + shareKey?: { + armoredPrivateKey: string; + passphraseSessionKey: SerializedSessionKey; + }; + publicShareKey?: { + armoredPrivateKey: string; + }; +}; + +type SerializedSessionKey = { + dataBase64: string; + algorithm: number; + aeadAlgorithm?: number; +}; + +/** + * Adapter converting a CachedCryptoMaterial to string and back by serialising + * crypto objects to JSON (armored keys, base64 session key material). Values + * are not encrypted because the underlying cache should handle the encryption + * of the whole value. + */ +export class DriveCryptoCacheAdapter implements ProtonDriveCache { + constructor(private readonly cache: ProtonDriveCache) {} + + async setEntity(key: string, value: CachedCryptoMaterial, tags?: string[]): Promise { + await this.cache.setEntity(key, await serializeCachedCryptoMaterial(value), tags); + } + + async getEntity(key: string): Promise { + return deserializeCachedCryptoMaterial(await this.cache.getEntity(key)); + } + + async *iterateEntities(keys: string[]): AsyncGenerator> { + for await (const result of this.cache.iterateEntities(keys)) { + yield await this.convertResult(result); + } + } + + async *iterateEntitiesByTag(tag: string): AsyncGenerator> { + for await (const result of this.cache.iterateEntitiesByTag(tag)) { + yield await this.convertResult(result); + } + } + + private async convertResult(result: EntityResult): Promise> { + if (!result.ok) { + return result; + } + try { + return { + key: result.key, + ok: true, + value: await deserializeCachedCryptoMaterial(result.value), + }; + } catch (error: unknown) { + return { + key: result.key, + ok: false, + error: error instanceof Error ? error.message : `${error}`, + }; + } + } + + async clear(): Promise { + return this.cache.clear(); + } + + async removeEntities(keys: string[]): Promise { + return this.cache.removeEntities(keys); + } +} + +async function serializeCachedCryptoMaterial(value: CachedCryptoMaterial): Promise { + const output: SerializedCachedCryptoMaterial = { v: VERSION }; + + if (value.nodeKeys) { + const nk = value.nodeKeys; + output.nodeKeys = { + passphrase: nk.passphrase, + armoredPrivateKey: await CryptoProxy.exportPrivateKey({ + privateKey: nk.key, + passphrase: null, + }), + passphraseSessionKey: serializeSessionKey(nk.passphraseSessionKey), + ...(nk.contentKeyPacketSessionKey + ? { contentKeyPacketSessionKey: serializeSessionKey(nk.contentKeyPacketSessionKey) } + : {}), + ...(nk.hashKey ? { hashKeyBase64: Buffer.from(nk.hashKey).toString('base64') } : {}), + }; + } + + if (value.shareKey) { + const sk = value.shareKey; + output.shareKey = { + armoredPrivateKey: await CryptoProxy.exportPrivateKey({ + privateKey: sk.key, + passphrase: null, + }), + passphraseSessionKey: serializeSessionKey(sk.passphraseSessionKey), + }; + } + + if (value.publicShareKey) { + const psk = value.publicShareKey; + output.publicShareKey = { + armoredPrivateKey: await CryptoProxy.exportPrivateKey({ + privateKey: psk.key, + passphrase: null, + }), + }; + } + + return JSON.stringify(output); +} + +async function deserializeCachedCryptoMaterial(json: string): Promise { + const output = JSON.parse(json) as SerializedCachedCryptoMaterial; + if (output.v !== VERSION) { + throw new Error(`Unsupported crypto cache version: ${output.v}`); + } + + const value: CachedCryptoMaterial = {}; + + if (output.nodeKeys) { + const nk = output.nodeKeys; + value.nodeKeys = { + passphrase: nk.passphrase, + key: await CryptoProxy.importPrivateKey({ + armoredKey: nk.armoredPrivateKey, + passphrase: null, + }), + passphraseSessionKey: deserializeSessionKey(nk.passphraseSessionKey), + ...(nk.contentKeyPacketSessionKey + ? { contentKeyPacketSessionKey: deserializeSessionKey(nk.contentKeyPacketSessionKey) } + : {}), + ...(nk.hashKeyBase64 ? { hashKey: new Uint8Array(Buffer.from(nk.hashKeyBase64, 'base64')) } : {}), + }; + } + + if (output.shareKey) { + const sk = output.shareKey; + value.shareKey = { + key: await CryptoProxy.importPrivateKey({ + armoredKey: sk.armoredPrivateKey, + passphrase: null, + }), + passphraseSessionKey: deserializeSessionKey(sk.passphraseSessionKey), + }; + } + + if (output.publicShareKey) { + const psk = output.publicShareKey; + value.publicShareKey = { + key: await CryptoProxy.importPrivateKey({ + armoredKey: psk.armoredPrivateKey, + passphrase: null, + }), + }; + } + + return value; +} + +function serializeSessionKey(sk: SessionKey): SerializedSessionKey { + const output: SerializedSessionKey = { + dataBase64: Buffer.from(sk.data).toString('base64'), + algorithm: sk.algorithm as unknown as number, + }; + if (sk.aeadAlgorithm !== undefined) { + output.aeadAlgorithm = sk.aeadAlgorithm as unknown as number; + } + return output; +} + +function deserializeSessionKey(json: SerializedSessionKey): SessionKey { + const output: SessionKey = { + data: new Uint8Array(Buffer.from(json.dataBase64, 'base64')), + algorithm: json.algorithm as unknown as SessionKey['algorithm'], + }; + if (json.aeadAlgorithm !== undefined) { + output.aeadAlgorithm = json.aeadAlgorithm as unknown as NonNullable; + } + return output; +} diff --git a/js/cli/src/cache/encryptedCache.ts b/js/cli/src/cache/encryptedCache.ts index 1ba426df..57b7abde 100644 --- a/js/cli/src/cache/encryptedCache.ts +++ b/js/cli/src/cache/encryptedCache.ts @@ -1,21 +1,21 @@ import { Logger } from '@protontech/drive-sdk'; import { decryptCacheValue, encryptCacheValue } from './crypto'; -import { SQLiteEntititesCache } from './sqliteCache'; +import { SQLiteCache } from './sqliteCache'; /** - * SQLite entity cache that encrypts values at rest (HKDF-SHA256 + AES-256-GCM), + * SQLite cache that encrypts values at rest (HKDF-SHA256 + AES-256-GCM), * matching `Proton.Sdk.Caching.EncryptedCacheRepository` in the C# SDK. */ -export class EncryptedSQLiteEntitiesCache extends SQLiteEntititesCache { +export class EncryptedSQLiteCache extends SQLiteCache { private encryptionKeyMaterialPromise?: Promise; constructor( - cacheDir: string, + cacheFile: string, private readonly getEncryptionPassword: () => Promise, private readonly logger: Logger, ) { - super(cacheDir); + super(cacheFile); } override async setEntity(key: string, data: string, tags?: string[]) { diff --git a/js/cli/src/cache/index.ts b/js/cli/src/cache/index.ts index f9152e1d..ccfdd72a 100644 --- a/js/cli/src/cache/index.ts +++ b/js/cli/src/cache/index.ts @@ -1,20 +1,55 @@ -import type { Logger, ProtonDriveCache } from '@protontech/drive-sdk'; +import path from 'node:path'; + +import type { CachedCryptoMaterial, Logger, ProtonDriveCache } from '@protontech/drive-sdk'; import { Config } from '../config'; import { Credentials } from '../credentials'; -import { EncryptedSQLiteEntitiesCache } from './encryptedCache'; -import { SQLiteEntititesCache } from './sqliteCache'; +import { DriveCryptoCacheAdapter } from './driveCryptoCacheAdapter'; +import { EncryptedSQLiteCache } from './encryptedCache'; +import { SQLiteCache } from './sqliteCache'; + +const CRYPTO_CACHE_FILE = 'cache-crypto.sqlite'; +const ENTITIES_CACHE_FILE = 'cache-entities.sqlite'; + +export function createCaches( + config: Config, + credentials: Credentials, + logger: Logger, +): { + entitiesCache: ProtonDriveCache; + cryptoCache: ProtonDriveCache; +} { + return { + cryptoCache: createCryptoCache(logger, config, credentials), + entitiesCache: createEntitiesCache(logger, config, credentials), + }; +} -export function createEntitiesCache( +function createCryptoCache( + logger: Logger, config: Config, credentials: Credentials, +): ProtonDriveCache { + const cache = createCache(logger, config, credentials, CRYPTO_CACHE_FILE); + return new DriveCryptoCacheAdapter(cache); +} + +function createEntitiesCache(logger: Logger, config: Config, credentials: Credentials): ProtonDriveCache { + return createCache(logger, config, credentials, ENTITIES_CACHE_FILE); +} + +function createCache( logger: Logger, + config: Config, + credentials: Credentials, + cacheFileName: string, ): ProtonDriveCache { + const cacheFile = path.join(config.cacheDir, cacheFileName); if (config.unsafeCache) { - return new SQLiteEntititesCache(config.cacheDir); + return new SQLiteCache(cacheFile); } - return new EncryptedSQLiteEntitiesCache( - config.cacheDir, + return new EncryptedSQLiteCache( + cacheFile, async () => { return credentials.getCachePassword(); }, diff --git a/js/cli/src/cache/sqliteCache.ts b/js/cli/src/cache/sqliteCache.ts index 022d0966..e7ff097f 100644 --- a/js/cli/src/cache/sqliteCache.ts +++ b/js/cli/src/cache/sqliteCache.ts @@ -1,13 +1,11 @@ -import path from 'node:path'; import { Database } from 'bun:sqlite'; import type { EntityResult, ProtonDriveCache } from '@protontech/drive-sdk'; -export class SQLiteEntititesCache implements ProtonDriveCache { +export class SQLiteCache implements ProtonDriveCache { private db: Database; - constructor(cacheDir: string) { - const cacheFile = path.join(cacheDir, 'cache-entities.sqlite'); + constructor(cacheFile: string) { this.db = new Database(cacheFile, { create: true }); this.db.run('CREATE TABLE IF NOT EXISTS entities (key TEXT PRIMARY KEY, value TEXT)'); this.db.run('CREATE TABLE IF NOT EXISTS entities_labels (label TEXT, key TEXT, UNIQUE (label, key))'); diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 9f1d513d..465b2f9f 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -2,19 +2,12 @@ import '@protontech/drive-sdk/polyfill'; import { CryptoProxy } from '@protontech/crypto'; import { Api as CryptoApi } from '@protontech/crypto/proxy/endpoint/api.ts'; -import { - CachedCryptoMaterial, - FeatureFlags, - Logger, - MemoryCache, - OpenPGPCryptoWithCryptoProxy, - ProtonDriveClient, -} from '@protontech/drive-sdk'; +import { FeatureFlags, Logger, OpenPGPCryptoWithCryptoProxy, ProtonDriveClient } from '@protontech/drive-sdk'; import { initDiagnostic } from '@protontech/drive-sdk/diagnostic'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { initApi } from './api'; -import { createEntitiesCache } from './cache'; +import { createCaches } from './cache'; import { Paths } from './cli'; import { getConfig, InitConfig } from './config'; import { initCredentials } from './credentials'; @@ -35,15 +28,15 @@ export async function init(configOptions: InitConfig) { const credentials = initCredentials(config, logger); const { auth, addresses, srp, httpClient } = await initApi(config, credentials, logger); - const entitiesCache = createEntitiesCache(config, credentials, logger); + const caches = createCaches(config, credentials, logger); const sdkDependencies = { config: { baseUrl: config.baseUrl, clientUid: config.clientUid, }, httpClient, - entitiesCache, - cryptoCache: new MemoryCache(), + entitiesCache: caches.entitiesCache, + cryptoCache: caches.cryptoCache, telemetry, openPGPCryptoModule, account: addresses, From 05320cde9c4c876670f6af446daf847762d3b4cd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 11:15:46 +0000 Subject: [PATCH 610/732] Add events subscriptions for CLI --- .gitignore | 2 + js/cli/src/cli/interface.ts | 2 + js/cli/src/cli/paths.ts | 40 ++++-- js/cli/src/cli/repl.ts | 38 ++--- js/cli/src/cli/run.ts | 10 +- js/cli/src/config.ts | 5 +- js/cli/src/events/index.ts | 5 + js/cli/src/events/interface.ts | 11 ++ js/cli/src/events/lock.test.ts | 43 ++++++ js/cli/src/events/lock.ts | 113 +++++++++++++++ js/cli/src/events/manager.test.ts | 116 +++++++++++++++ js/cli/src/events/manager.ts | 152 ++++++++++++++++++++ js/cli/src/events/providerMemory.test.ts | 27 ++++ js/cli/src/events/providerMemory.ts | 41 ++++++ js/cli/src/events/providerNoEvents.ts | 26 ++++ js/cli/src/events/providerPersisted.test.ts | 110 ++++++++++++++ js/cli/src/events/providerPersisted.ts | 71 +++++++++ js/cli/src/events/storage.test.ts | 85 +++++++++++ js/cli/src/events/storage.ts | 135 +++++++++++++++++ js/cli/src/init.ts | 30 ++-- js/cli/src/proton-drive.ts | 7 +- js/sdk/src/index.ts | 1 + js/sdk/src/internal/events/eventManager.ts | 1 + js/sdk/src/internal/events/interface.ts | 8 ++ 24 files changed, 1037 insertions(+), 42 deletions(-) create mode 100644 js/cli/src/events/index.ts create mode 100644 js/cli/src/events/interface.ts create mode 100644 js/cli/src/events/lock.test.ts create mode 100644 js/cli/src/events/lock.ts create mode 100644 js/cli/src/events/manager.test.ts create mode 100644 js/cli/src/events/manager.ts create mode 100644 js/cli/src/events/providerMemory.test.ts create mode 100644 js/cli/src/events/providerMemory.ts create mode 100644 js/cli/src/events/providerNoEvents.ts create mode 100644 js/cli/src/events/providerPersisted.test.ts create mode 100644 js/cli/src/events/providerPersisted.ts create mode 100644 js/cli/src/events/storage.test.ts create mode 100644 js/cli/src/events/storage.ts 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/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 567e88da..bdd9b9bd 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -5,6 +5,7 @@ import { Diagnostic } from '@protontech/drive-sdk/diagnostic'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { Auth } from '../api'; +import type { Manager } from '../events'; import { Paths } from './paths'; export interface Command { @@ -25,6 +26,7 @@ export interface ActionArgs { photosSdk: ProtonDrivePhotosClient; sdkDiagnostic: Diagnostic; paths: Paths; + eventsManager: Manager; args: string[]; options: { [name: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index e85c1249..027d4da9 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -4,6 +4,7 @@ import { MaybeMissingNode, MaybeNode, ProtonDriveClient, ValidationError } from import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePublicLinkClient'; +import type { Manager } from '../events/manager'; import { getName } from './node'; export enum PathType { @@ -29,6 +30,7 @@ export class Paths { private auth: { isLoggedIn(): boolean; }, + private readonly eventsManager: Manager, ) { this.sdk = sdk; this.photosSdk = photosSdk; @@ -87,7 +89,7 @@ export class Paths { } getPath(path: string, supportedTypes?: PathType[]): Path { - const p = new Path(this.sdk, this.photosSdk, path); + const p = new Path(this.sdk, this.photosSdk, path, this.eventsManager); if (supportedTypes && !supportedTypes.includes(p.type)) { throw new ValidationError(`Path "${path}" is not supported`); } @@ -119,6 +121,7 @@ export class Path { private driveSdk: ProtonDriveClient, private photosSdk: ProtonDrivePhotosClient, public fullPath: string, + private readonly eventsManager: Manager, ) { this.driveSdk = driveSdk; this.photosSdk = photosSdk; @@ -163,7 +166,7 @@ export class Path { } get parentPath() { - return new Path(this.driveSdk, this.photosSdk, path.dirname(this.fullPath)); + return new Path(this.driveSdk, this.photosSdk, path.dirname(this.fullPath), this.eventsManager); } get name() { @@ -171,6 +174,13 @@ export class Path { } get sdk(): ProtonDriveClient | ProtonDrivePhotosClient { + if (this.usePhotoSdk()) { + return this.photosSdk; + } + return this.driveSdk; + } + + private usePhotoSdk(): boolean { const photoPaths = [ PathType.Albums, PathType.Photos, @@ -178,20 +188,30 @@ export class Path { PathType.PhotosSharedWithMe, PathType.PhotosTrash, ]; - if (photoPaths.includes(this.type)) { - return this.photosSdk; - } - return this.driveSdk; + return photoPaths.includes(this.type); } async getNode(): Promise { + const node = await this.getNodeInternal(); + + const scopeId = node.ok ? node.value.treeEventScopeId : node.error.treeEventScopeId; + if (this.usePhotoSdk()) { + await this.eventsManager.subscribePhotosScope(scopeId); + } else { + await this.eventsManager.subscribeDriveScope(scopeId); + } + + return node; + } + + private async getNodeInternal(): Promise { if (this.type === PathType.MyFiles) { const rootNode = await this.driveSdk.getMyFilesRootFolder(); return this.getNodeByPath(rootNode, this.sectionPath); } if (this.type === PathType.SharedWithMe || this.type === PathType.PhotosSharedWithMe) { - const rootNodeName = await this.getSharedWithMeRootFolder(); - return this.getNodeByPath(rootNodeName, this.sectionPathWithoutRoot); + const rootNode = await this.getSharedWithMeRootFolder(); + return this.getNodeByPath(rootNode, this.sectionPathWithoutRoot); } if (this.type === PathType.Trash || this.type === PathType.PhotosTrash) { const parts = this.sectionPath.split(path.sep); @@ -201,8 +221,8 @@ export class Path { return this.getTrashedNode(parts[0]); } if (this.type === PathType.Devices) { - const rootNodeName = await this.getDevicesRootFolder(); - return this.getNodeByPath(rootNodeName, this.sectionPathWithoutRoot); + const rootNode = await this.getDevicesRootFolder(); + return this.getNodeByPath(rootNode, this.sectionPathWithoutRoot); } if (this.type === PathType.Photos) { return this.getPhotoNodeByPath(this.sectionPath); diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index fffab24a..9dc39417 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -12,26 +12,30 @@ import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; export async function startRepl(commands: Command[], initOptions: InitConfig): Promise { const session = await init(initOptions); - while (true) { - const line = await question('proton-drive> '); - const trimmed = line.trim(); - if (trimmed === '') { - continue; - } - if (trimmed === 'exit' || trimmed === 'quit') { - break; - } - try { - const parts = splitQuotedLine(trimmed); - const syntheticArgv = ['', '', ...parts]; - await runCommand(commands, syntheticArgv, initOptions, session); - } catch (error: unknown) { - if (isRecoverableReplError(error)) { - console.error(error); + try { + while (true) { + const line = await question('proton-drive> '); + const trimmed = line.trim(); + if (trimmed === '') { continue; } - throw error; + if (trimmed === 'exit' || trimmed === 'quit') { + break; + } + try { + const parts = splitQuotedLine(trimmed); + const syntheticArgv = ['', '', ...parts]; + await runCommand(commands, syntheticArgv, initOptions, session); + } catch (error: unknown) { + if (isRecoverableReplError(error)) { + console.error(error); + continue; + } + throw error; + } } + } finally { + await session.dispose(); } } diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index f032a40a..4539a388 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -44,9 +44,16 @@ export async function runCommand( } const debug = initOptions.debug === undefined ? !options.json : initOptions.debug; + const oneOffSession = !session; session = session ?? await init({ ...initOptions, debug }); verifyAuthentication(command, session); - await dispatchAction(command, session, args, options, debug); + try { + await dispatchAction(command, session, args, options, debug); + } finally { + if (oneOffSession) { + await session.dispose(); + } + } } function parseCliInvocation(commands: Command[], argv: string[]) { @@ -88,6 +95,7 @@ async function dispatchAction( photosSdk: session.photosSdk, sdkDiagnostic: session.sdkDiagnostic, paths: session.paths, + eventsManager: session.eventsManager, args, options, }); diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 3370e056..e2fba746 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -2,6 +2,7 @@ export interface InitConfig { debug?: boolean; appVersion: string; clientUid: string; + enablePersistedEvents?: boolean; } export interface Config { @@ -9,6 +10,7 @@ export interface Config { appVersion: string; baseUrl: string; cacheDir: string; + enablePersistedEvents: boolean; enableConsoleLog: boolean; unsafeSecrets: boolean; unsafeCache: boolean; @@ -24,7 +26,8 @@ export function getConfig(options: InitConfig): Config { appVersion: options.appVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), - enableConsoleLog: options.debug ?? false, + enablePersistedEvents: options.enablePersistedEvents || false, + enableConsoleLog: options.debug || false, unsafeSecrets, unsafeCache: unsafeSecrets, }; diff --git a/js/cli/src/events/index.ts b/js/cli/src/events/index.ts new file mode 100644 index 00000000..9634f3cb --- /dev/null +++ b/js/cli/src/events/index.ts @@ -0,0 +1,5 @@ +export type { EventsProvider } from './interface'; +export { Manager } from './manager'; +export { MemoryEventsProvider } from './providerMemory'; +export { NoEventsProvider } from './providerNoEvents'; +export { PersistedEventsProvider } from './providerPersisted'; diff --git a/js/cli/src/events/interface.ts b/js/cli/src/events/interface.ts new file mode 100644 index 00000000..63c45fbb --- /dev/null +++ b/js/cli/src/events/interface.ts @@ -0,0 +1,11 @@ +import type { LatestEventIdProvider } from '@protontech/drive-sdk'; + +export type EventsContext = 'drive' | 'photos'; + +export interface EventsProvider extends LatestEventIdProvider { + canListenForEvents(): boolean; + getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[]; + setLatestEventId(context: EventsContext, treeEventScopeId: string, eventId: string): Promise; + removeScope(context: EventsContext, treeEventScopeId: string): Promise; + dispose(): Promise; +} diff --git a/js/cli/src/events/lock.test.ts b/js/cli/src/events/lock.test.ts new file mode 100644 index 00000000..09565f03 --- /dev/null +++ b/js/cli/src/events/lock.test.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; + +import { EVENTS_LOCK_FILE, releaseEventsLock, tryAcquireEventsLock } from './lock'; + +async function tempDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-events-lock')); +} + +function eventsLockFile(dir: string): string { + return path.join(dir, EVENTS_LOCK_FILE); +} + +describe('lock', () => { + const logger = getMockLogger(); + + it('acquires when free', async () => { + const dir = await tempDir(); + expect(await tryAcquireEventsLock(dir)).toBe(true); + }); + + it('returns false when live holder', async () => { + const dir = await tempDir(); + expect(await tryAcquireEventsLock(dir)).toBe(true); + expect(await tryAcquireEventsLock(dir)).toBe(false); + }); + + it('replaces stale pid', async () => { + const dir = await tempDir(); + await fs.writeFile(eventsLockFile(dir), JSON.stringify({ pid: 999_999_999 }), 'utf8'); + expect(await tryAcquireEventsLock(dir)).toBe(true); + }); + + it('release removes own lock', async () => { + const dir = await tempDir(); + await tryAcquireEventsLock(dir); + await releaseEventsLock(logger, dir); + await expect(fs.readFile(eventsLockFile(dir))).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/js/cli/src/events/lock.ts b/js/cli/src/events/lock.ts new file mode 100644 index 00000000..014bf77b --- /dev/null +++ b/js/cli/src/events/lock.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { Logger } from '@protontech/drive-sdk'; + +export const EVENTS_LOCK_FILE = 'events.lock'; + +type EventsLockPayload = { + pid: number; +}; + +/** + * Try to acquire the events lock. If another process holds the lock, return false. + * If there is a lock file, but the process is not alive, remove it and try again. + * + * Call `releaseEventsLock` to release the lock at the end of the process. + */ +export async function tryAcquireEventsLock(cacheDir: string): Promise { + const lockPath = eventsLockPath(cacheDir); + await fs.mkdir(cacheDir, { recursive: true }); + + try { + const raw = await fs.readFile(lockPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + const holderPid = + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as { pid?: unknown }).pid === 'number' + ? (parsed as { pid: number }).pid + : NaN; + + if (Number.isInteger(holderPid) && holderPid > 0 && isProcessAlive(holderPid)) { + return false; + } + await fs.unlink(lockPath).catch(() => {}); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + // ENOENT = no such file or directory + if (err.code !== 'ENOENT') { + throw error; + } + } + + const payload: EventsLockPayload = { pid: process.pid }; + try { + await fs.writeFile(lockPath, JSON.stringify(payload), { + flag: 'wx', // Fail if file already exists. + encoding: 'utf8', + }); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + // EEXIST = file already exists + if (err.code === 'EEXIST') { + return false; + } + throw error; + } + return true; +} + +function eventsLockPath(cacheDir: string): string { + return path.join(cacheDir, EVENTS_LOCK_FILE); +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + // 0 is a signal to check if the process is alive. + process.kill(pid, 0); + return true; + } catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + // ESRCH = no such process + if (err.code === 'ESRCH') { + return false; + } + // EPERM = operation not permitted (e.g. process is running for our perspective) + if (err.code === 'EPERM') { + return true; + } + return false; + } +} + +/** + * Releases the events lock if the current process is the holder. + */ +export async function releaseEventsLock(logger: Logger, cacheDir: string): Promise { + const lockPath = eventsLockPath(cacheDir); + let raw: string; + try { + raw = await fs.readFile(lockPath, 'utf8'); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + // ENOENT = no such file or directory + if (err.code === 'ENOENT') { + return; + } + throw error; + } + + try { + const parsed = JSON.parse(raw) as { pid?: number }; + if (parsed.pid === process.pid) { + await fs.unlink(lockPath); + } + } catch (error: unknown) { + // Ignore malformed lock file. + logger.warn(`Failed to release events lock: ${error}`); + } +} diff --git a/js/cli/src/events/manager.test.ts b/js/cli/src/events/manager.test.ts new file mode 100644 index 00000000..5ca7f122 --- /dev/null +++ b/js/cli/src/events/manager.test.ts @@ -0,0 +1,116 @@ +import { type DriveEvent, DriveEventType, type DriveListener } from '@protontech/drive-sdk'; +import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; + +import { Manager } from './manager'; +import { MemoryEventsProvider } from './providerMemory'; +import { NoEventsProvider } from './providerNoEvents'; + +describe('Manager', () => { + const logger = getMockLogger(); + + const driveDispose = jest.fn(); + let driveListener: DriveListener | undefined; + const subscribeToDriveEvents = jest.fn(async (l: DriveListener) => { + driveListener = l; + return { dispose: driveDispose, getLatestEventId: jest.fn().mockResolvedValue('drive1') }; + }); + + const treeDispose = jest.fn(); + let treeListener: DriveListener | undefined; + const subscribeToTreeEvents = jest.fn(async (scope: string, l: DriveListener) => { + treeListener = l; + return { dispose: treeDispose, getLatestEventId: jest.fn().mockResolvedValue(`${scope}1`) }; + }); + + const photosTreeDispose = jest.fn(); + const photosSubscribeToTreeEvents = jest.fn(async (scope: string) => { + return { dispose: photosTreeDispose, getLatestEventId: jest.fn().mockResolvedValue(`photos-${scope}`) }; + }); + + const driveSdk = { + subscribeToDriveEvents, + subscribeToTreeEvents, + }; + + const photosSdk = { + subscribeToDriveEvents: jest.fn(async () => ({ dispose: jest.fn(), getLatestEventId: jest.fn() })), + subscribeToTreeEvents: photosSubscribeToTreeEvents, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('create does not subscribe when no provider is used', async () => { + await Manager.create(logger, driveSdk, photosSdk, new NoEventsProvider()); + + expect(subscribeToDriveEvents).not.toHaveBeenCalled(); + expect(subscribeToTreeEvents).not.toHaveBeenCalled(); + expect(photosSubscribeToTreeEvents).not.toHaveBeenCalled(); + }); + + it('create subscribes to drive and volume events', async () => { + const provider = new MemoryEventsProvider(); + await provider.setLatestEventId('drive', 'vol', 'seed'); + + await Manager.create(logger, driveSdk, photosSdk, provider); + + expect(subscribeToTreeEvents).toHaveBeenCalledWith('vol', expect.any(Function)); + expect(subscribeToDriveEvents).toHaveBeenCalledWith(expect.any(Function)); + + await treeListener!(nodeCreated('vol', 'e1')); + expect(await provider.getLatestEventId('vol')).toBe('e1'); + + await driveListener!(nodeCreated('core', 'e2')); + expect(await provider.getLatestEventId('core')).toBe('e2'); + }); + + it('TreeRemove removes scope and disposes subscription', async () => { + const provider = new MemoryEventsProvider(); + await provider.setLatestEventId('drive', 'vol', 'seed'); + + await Manager.create(logger, driveSdk, photosSdk, provider); + await treeListener!(nodeCreated('vol', 'e0')); + await treeListener!({ + type: DriveEventType.TreeRemove, + treeEventScopeId: 'vol', + eventId: 'none', + }); + + expect(await provider.getLatestEventId('vol')).toBeNull(); + expect(treeDispose).toHaveBeenCalled(); + }); + + it('subscribeDriveScope adds new scope', async () => { + const provider = new MemoryEventsProvider(); + await provider.setLatestEventId('drive', 'vol', 'seed'); + + const m = await Manager.create(logger, driveSdk, photosSdk, provider); + await m.subscribeDriveScope('extra'); + + expect(subscribeToTreeEvents).toHaveBeenCalledWith('extra', expect.any(Function)); + expect(provider.getInitialSubscriptionScopeIds()).toEqual([ + { context: 'drive', treeEventScopeIds: ['vol', 'extra'] }, + ]); + }); + + it('subscribePhotosScope uses photos SDK', async () => { + const provider = new MemoryEventsProvider(); + const m = await Manager.create(logger, driveSdk, photosSdk, provider); + await m.subscribePhotosScope('photoVol'); + + expect(photosSubscribeToTreeEvents).toHaveBeenCalledWith('photoVol', expect.any(Function)); + }); +}); + +function nodeCreated(treeEventScopeId: string, eventId: string): DriveEvent { + return { + type: DriveEventType.NodeCreated, + nodeUid: 'n', + parentNodeUid: 'p', + isTrashed: false, + isShared: false, + treeEventScopeId, + eventId, + }; +} diff --git a/js/cli/src/events/manager.ts b/js/cli/src/events/manager.ts new file mode 100644 index 00000000..85e75062 --- /dev/null +++ b/js/cli/src/events/manager.ts @@ -0,0 +1,152 @@ +import { + type DriveEvent, + DriveEventType, + type DriveListener, + type EventSubscription, + Logger, + type ProtonDriveClient, +} from '@protontech/drive-sdk'; + +import type { EventsContext, EventsProvider } from './interface'; + +type TreeSdk = Pick; + +export class Manager { + private coreSubscription: EventSubscription | null = null; + private readonly volumeSubscriptions = { + drive: new Map(), + photos: new Map(), + }; + + private processingEvents = new Set>(); + + private constructor( + private readonly logger: Logger, + private readonly driveSdk: TreeSdk, + private readonly photosSdk: TreeSdk, + private readonly provider: EventsProvider, + ) {} + + static async create( + logger: Logger, + driveSdk: TreeSdk, + photosSdk: TreeSdk, + provider: EventsProvider, + ): Promise { + const manager = new Manager(logger, driveSdk, photosSdk, provider); + if (provider.canListenForEvents()) { + await manager.subscribeCoreEvents(); + await manager.subscribeInitialScopesFromProvider(); + } + return manager; + } + + private async subscribeCoreEvents(): Promise { + if (this.coreSubscription) { + return; + } + this.coreSubscription = await this.driveSdk.subscribeToDriveEvents(this.createHandler('drive')); + } + + private async subscribeInitialScopesFromProvider(): Promise { + for (const { context, treeEventScopeIds } of this.provider.getInitialSubscriptionScopeIds()) { + for (const scopeId of treeEventScopeIds) { + if (context === 'drive') { + await this.subscribeDriveScope(scopeId); + } else { + await this.subscribePhotosScope(scopeId); + } + } + } + } + + async subscribeDriveScope(scopeId: string): Promise { + await this.subscribeVolumeForSdk('drive', scopeId, this.driveSdk); + } + + async subscribePhotosScope(scopeId: string): Promise { + await this.subscribeVolumeForSdk('photos', scopeId, this.photosSdk); + } + + private async subscribeVolumeForSdk(sdkContext: EventsContext, scopeId: string, sdk: TreeSdk): Promise { + if (!this.provider.canListenForEvents()) { + return; + } + if (this.volumeSubscriptions[sdkContext].has(scopeId)) { + return; + } + const subscription = await sdk.subscribeToTreeEvents(scopeId, this.createHandler(sdkContext)); + this.volumeSubscriptions[sdkContext].set(scopeId, subscription); + const latestEventId = subscription.getLatestEventId(); + if (latestEventId) { + this.logger.debug(`Subscribed to scope ${sdkContext}:${scopeId} with latest event ID ${latestEventId}`); + await this.provider.setLatestEventId(sdkContext, scopeId, latestEventId); + } + } + + private createHandler(sdkContext: EventsContext): DriveListener { + return async (event: DriveEvent) => { + const { promise, resolve } = Promise.withResolvers(); + this.processingEvents.add(promise); + + const scopeId = event.treeEventScopeId; + try { + if (event.type === DriveEventType.TreeRemove) { + const sub = this.volumeSubscriptions[sdkContext].get(scopeId); + if (sub) { + try { + sub.dispose(); + } catch (error: unknown) { + this.logger.warn(`Failed to dispose volume event subscription: ${error}`); + } + this.volumeSubscriptions[sdkContext].delete(scopeId); + } + await this.provider.removeScope(sdkContext, scopeId); + return; + } + + const eventId = event.eventId; + this.logger.debug(`Updating latest event ID for scope ${sdkContext}:${scopeId} to ${eventId}`); + await this.provider.setLatestEventId(sdkContext, scopeId, eventId); + } catch (error: unknown) { + this.logger.error(`Failed to handle event ${event.type} for scope ${scopeId}`, error); + throw error; + } finally { + this.processingEvents.delete(promise); + resolve(); + } + }; + } + + async dispose(): Promise { + this.logger.debug('Disposing events manager'); + + if (this.coreSubscription) { + try { + this.coreSubscription.dispose(); + } catch (error: unknown) { + this.logger.warn(`Failed to dispose core event subscription: ${error}`); + } + this.coreSubscription = null; + } + + const subscriptions = [...this.volumeSubscriptions.drive.values(), ...this.volumeSubscriptions.photos.values()]; + for (const sub of subscriptions) { + try { + sub.dispose(); + } catch (error: unknown) { + this.logger.warn(`Failed to dispose event subscription: ${error}`); + } + } + this.volumeSubscriptions.drive.clear(); + this.volumeSubscriptions.photos.clear(); + + try { + await Promise.all(this.processingEvents); + } catch (error: unknown) { + this.logger.warn(`Failed to wait for processing events: ${error}`); + } + + await this.provider.dispose(); + } +} diff --git a/js/cli/src/events/providerMemory.test.ts b/js/cli/src/events/providerMemory.test.ts new file mode 100644 index 00000000..88d6ef7c --- /dev/null +++ b/js/cli/src/events/providerMemory.test.ts @@ -0,0 +1,27 @@ +import { MemoryEventsProvider } from './providerMemory'; + +describe('MemoryEventsProvider', () => { + it('round-trips set and get per context', async () => { + const p = new MemoryEventsProvider(); + await p.setLatestEventId('drive', 'scopeId', 'eventId'); + expect(await p.getLatestEventId('scopeId')).toBe('eventId'); + expect(p.getInitialSubscriptionScopeIds()).toEqual([ + { context: 'drive', treeEventScopeIds: ['scopeId'] }, + ]); + + await p.removeScope('drive', 'scopeId'); + expect(await p.getLatestEventId('scopeId')).toBeNull(); + expect(p.getInitialSubscriptionScopeIds()).toEqual([]); + }); + + it('keeps drive and photos scopes separate', async () => { + const p = new MemoryEventsProvider(); + await p.setLatestEventId('drive', 'same', 'd1'); + await p.setLatestEventId('photos', 'same', 'p1'); + expect(await p.getLatestEventId('same')).toBe('d1'); + expect(p.getInitialSubscriptionScopeIds()).toEqual([ + { context: 'drive', treeEventScopeIds: ['same'] }, + { context: 'photos', treeEventScopeIds: ['same'] }, + ]); + }); +}); diff --git a/js/cli/src/events/providerMemory.ts b/js/cli/src/events/providerMemory.ts new file mode 100644 index 00000000..5ea45138 --- /dev/null +++ b/js/cli/src/events/providerMemory.ts @@ -0,0 +1,41 @@ +import type { EventsContext, EventsProvider } from './interface'; + +export class MemoryEventsProvider implements EventsProvider { + private readonly lastByContext: Record> = { + drive: new Map(), + photos: new Map(), + }; + + canListenForEvents(): boolean { + return true; + } + + getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[] { + const out: { context: EventsContext; treeEventScopeIds: string[] }[] = []; + for (const context of ['drive', 'photos'] as const) { + const ids = [...this.lastByContext[context].keys()]; + if (ids.length > 0) { + out.push({ context, treeEventScopeIds: ids }); + } + } + return out; + } + + async getLatestEventId(treeEventScopeId: string): Promise { + return ( + this.lastByContext.drive.get(treeEventScopeId) ?? + this.lastByContext.photos.get(treeEventScopeId) ?? + null + ); + } + + async setLatestEventId(context: EventsContext, treeEventScopeId: string, eventId: string): Promise { + this.lastByContext[context].set(treeEventScopeId, eventId); + } + + async removeScope(context: EventsContext, treeEventScopeId: string): Promise { + this.lastByContext[context].delete(treeEventScopeId); + } + + async dispose(): Promise {} +} diff --git a/js/cli/src/events/providerNoEvents.ts b/js/cli/src/events/providerNoEvents.ts new file mode 100644 index 00000000..7fd3e54f --- /dev/null +++ b/js/cli/src/events/providerNoEvents.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import type { EventsContext, EventsProvider } from './interface'; + +/** + * Never listens and never resumes from stored cursors. + */ +export class NoEventsProvider implements EventsProvider { + canListenForEvents(): boolean { + return false; + } + + getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[] { + return []; + } + + async getLatestEventId(treeEventScopeId: string): Promise { + return null; + } + + async setLatestEventId(context: EventsContext, treeEventScopeId: string, eventId: string): Promise {} + + async removeScope(context: EventsContext, treeEventScopeId: string): Promise {} + + async dispose(): Promise {} +} diff --git a/js/cli/src/events/providerPersisted.test.ts b/js/cli/src/events/providerPersisted.test.ts new file mode 100644 index 00000000..a4218d39 --- /dev/null +++ b/js/cli/src/events/providerPersisted.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; + +import { tryAcquireEventsLock } from './lock'; +import { PersistedEventsProvider } from './providerPersisted'; +import { EVENTS_FILE, EventsFileStore } from './storage'; + +async function tempDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-events-persisted-')); +} + +function eventsJsonFile(dir: string): string { + return path.join(dir, EVENTS_FILE); +} + +describe('PersistedEventsProvider', () => { + const logger = getMockLogger(); + + it('setLatestEventId flushes data to disk', async () => { + const dir = await tempDir(); + expect(await tryAcquireEventsLock(dir)).toBe(true); + const store = await EventsFileStore.load(dir); + // @ts-expect-error - private constructor + const p = new PersistedEventsProvider(logger, dir, store, true); + await p.setLatestEventId('drive', 'scope', 'id1'); + const disk = JSON.parse(await fs.readFile(eventsJsonFile(dir), 'utf8')); + expect(disk.drive.scope.lastEventId).toBe('id1'); + await p.dispose(); + }); + + it('removeScope flushes data to disk', async () => { + const dir = await tempDir(); + expect(await tryAcquireEventsLock(dir)).toBe(true); + const store = await EventsFileStore.load(dir); + // @ts-expect-error - private constructor + const p = new PersistedEventsProvider(logger, dir, store, true); + await p.setLatestEventId('drive', 'a', '1'); + await p.setLatestEventId('drive', 'b', '2'); + await p.removeScope('drive', 'a'); + const disk = JSON.parse(await fs.readFile(eventsJsonFile(dir), 'utf8')); + expect(disk.drive.a).toBeUndefined(); + expect(disk.drive.b.lastEventId).toBe('2'); + await p.dispose(); + }); +}); + +describe('PersistedEventsProvider.open', () => { + const logger = getMockLogger(); + + it('listens when lock acquired and persists', async () => { + const dir = await tempDir(); + const p = await PersistedEventsProvider.open(logger, dir); + expect(p.canListenForEvents()).toBe(true); + await p.setLatestEventId('photos', 'scope', 'id1'); + const disk = JSON.parse(await fs.readFile(eventsJsonFile(dir), 'utf8')); + expect(disk.photos.scope.lastEventId).toBe('id1'); + await p.dispose(); + }); + + it('does not listen when lock held', async () => { + const dir = await tempDir(); + const first = await PersistedEventsProvider.open(logger, dir); + expect(first.canListenForEvents()).toBe(true); + const second = await PersistedEventsProvider.open(logger, dir); + expect(second.canListenForEvents()).toBe(false); + expect(await second.getLatestEventId('scope')).toBeNull(); + expect(second.getInitialSubscriptionScopeIds()).toEqual([]); + await first.dispose(); + }); + + it('exposes scope ids from events.json for load-time subscribe', async () => { + const dir = await tempDir(); + await fs.writeFile( + eventsJsonFile(dir), + JSON.stringify({ + version: 1, + drive: { volA: { lastEventId: '1' }, volB: { lastEventId: '2' } }, + photos: {}, + }), + 'utf8', + ); + const p = await PersistedEventsProvider.open(logger, dir); + expect(p.getInitialSubscriptionScopeIds()).toEqual([ + { context: 'drive', treeEventScopeIds: expect.arrayContaining(['volA', 'volB']) }, + ]); + const driveIds = p.getInitialSubscriptionScopeIds()[0].treeEventScopeIds.sort(); + expect(driveIds).toEqual(['volA', 'volB']); + await p.dispose(); + }); + + it('migrates legacy scopes file for initial subscribe', async () => { + const dir = await tempDir(); + await fs.writeFile( + eventsJsonFile(dir), + JSON.stringify({ + version: 1, + scopes: { volA: { lastEventId: '1' } }, + }), + 'utf8', + ); + const p = await PersistedEventsProvider.open(logger, dir); + expect(p.getInitialSubscriptionScopeIds()).toEqual([ + { context: 'drive', treeEventScopeIds: ['volA'] }, + ]); + await p.dispose(); + }); +}); diff --git a/js/cli/src/events/providerPersisted.ts b/js/cli/src/events/providerPersisted.ts new file mode 100644 index 00000000..d85e0dc1 --- /dev/null +++ b/js/cli/src/events/providerPersisted.ts @@ -0,0 +1,71 @@ +import { Logger } from '@protontech/drive-sdk'; + +import type { EventsContext, EventsProvider } from './interface'; +import { releaseEventsLock, tryAcquireEventsLock } from './lock'; +import { EventsFileStore } from './storage'; + +/** + * Persisted event provider that stores the last event ID in `events.json`, + * gated by `events.lock`. Only one process can hold the lock at a time. + */ +export class PersistedEventsProvider implements EventsProvider { + private constructor( + private readonly logger: Logger, + private readonly cacheDir: string, + private readonly store: EventsFileStore, + private readonly holdsLock: boolean, + ) {} + + static async open(logger: Logger, cacheDir: string): Promise { + const lockingResult = await tryAcquireEventsLock(cacheDir); + if (!lockingResult) { + return new PersistedEventsProvider(logger, cacheDir, EventsFileStore.empty(cacheDir), false); + } + const store = await EventsFileStore.load(cacheDir); + return new PersistedEventsProvider(logger, cacheDir, store, true); + } + + canListenForEvents(): boolean { + return this.holdsLock; + } + + getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[] { + if (!this.holdsLock) { + return []; + } + return this.store.getInitialSubscriptionScopeIds(); + } + + async getLatestEventId(treeEventScopeId: string): Promise { + if (!this.holdsLock) { + return null; + } + return ( + this.store.getLatestEventId('drive', treeEventScopeId) ?? + this.store.getLatestEventId('photos', treeEventScopeId) ?? + null + ); + } + + async setLatestEventId(context: EventsContext, treeEventScopeId: string, eventId: string): Promise { + if (!this.holdsLock) { + return; + } + this.store.setLatestEventId(context, treeEventScopeId, eventId); + await this.store.flush(); + } + + async removeScope(context: EventsContext, treeEventScopeId: string): Promise { + if (!this.holdsLock) { + return; + } + this.store.removeScope(context, treeEventScopeId); + await this.store.flush(); + } + + async dispose(): Promise { + if (this.holdsLock) { + await releaseEventsLock(this.logger, this.cacheDir); + } + } +} diff --git a/js/cli/src/events/storage.test.ts b/js/cli/src/events/storage.test.ts new file mode 100644 index 00000000..a68f9d33 --- /dev/null +++ b/js/cli/src/events/storage.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { EVENTS_FILE, EventsFileStore } from './storage'; + +async function tempDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-events-storage-')); +} + +function eventsJsonFile(dir: string): string { + return path.join(dir, EVENTS_FILE); +} + +describe('EventsFileStore', () => { + it('yields empty document for invalid JSON on disk', async () => { + const dir = await tempDir(); + await fs.writeFile(eventsJsonFile(dir), '', 'utf8'); + const store = await EventsFileStore.load(dir); + expect(store.getScopeIds('drive')).toEqual([]); + expect(store.getScopeIds('photos')).toEqual([]); + }); + + it('reads v1 drive/photos from disk', async () => { + const dir = await tempDir(); + await fs.writeFile( + eventsJsonFile(dir), + JSON.stringify({ + version: 1, + drive: { a: { lastEventId: '1' } }, + photos: { b: { lastEventId: '2' } }, + }), + 'utf8', + ); + const store = await EventsFileStore.load(dir); + expect(store.getLatestEventId('drive', 'a')).toBe('1'); + expect(store.getLatestEventId('photos', 'b')).toBe('2'); + expect(store.getLatestEventId('drive', 'b')).toBeUndefined(); + }); + + it('migrates legacy scopes into drive', async () => { + const dir = await tempDir(); + await fs.writeFile( + eventsJsonFile(dir), + JSON.stringify({ + version: 1, + scopes: { a: { lastEventId: '1' } }, + }), + 'utf8', + ); + const store = await EventsFileStore.load(dir); + expect(store.getLatestEventId('drive', 'a')).toBe('1'); + expect(store.getScopeIds('photos')).toEqual([]); + }); + + it('getScopeIds returns persisted keys per context', () => { + const dir = 'unused-for-path'; + const store = EventsFileStore.empty(dir); + store.setLatestEventId('drive', 'a', '1'); + store.setLatestEventId('drive', 'b', '2'); + store.setLatestEventId('photos', 'c', '3'); + expect(store.getScopeIds('drive').sort()).toEqual(['a', 'b']); + expect(store.getScopeIds('photos')).toEqual(['c']); + }); + + it('writes to empty events.json with drive and photos', async () => { + const dir = await tempDir(); + const store = EventsFileStore.empty(dir); + store.setLatestEventId('drive', 'scopeId', 'eventId'); + await store.flush(); + const raw = JSON.parse(await fs.readFile(eventsJsonFile(dir), 'utf8')); + expect(raw.version).toBe(1); + expect(raw.drive.scopeId.lastEventId).toBe('eventId'); + expect(raw.photos).toEqual({}); + }); + + it('load reads flushed data', async () => { + const dir = await tempDir(); + const a = EventsFileStore.empty(dir); + a.setLatestEventId('photos', 'x', 'y'); + await a.flush(); + const b = await EventsFileStore.load(dir); + expect(b.getLatestEventId('photos', 'x')).toBe('y'); + }); +}); diff --git a/js/cli/src/events/storage.ts b/js/cli/src/events/storage.ts new file mode 100644 index 00000000..50dbe421 --- /dev/null +++ b/js/cli/src/events/storage.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { EventsContext } from './interface'; + +export const EVENTS_FILE = 'events.json'; + +type EventsScopeEntry = { + lastEventId: string; +}; + +type EventsScopes = { + // WARNING + // When we need to change the format, we need to add migration logic, or + // drop the old cache. At this moment, it just starts over with an empty + // document and skips events from the previous version. + version: 1; + drive: Record; + photos: Record; +}; + +export class EventsFileStore { + private constructor( + private readonly cacheDir: string, + private scopes: EventsScopes, + ) {} + + static empty(cacheDir: string): EventsFileStore { + return new EventsFileStore(cacheDir, emptyEventsDocument()); + } + + static async load(cacheDir: string): Promise { + try { + const content = await fs.readFile(eventsJsonPath(cacheDir), 'utf8'); + return new EventsFileStore(cacheDir, parseEventsDocument(content)); + } catch { + return new EventsFileStore(cacheDir, emptyEventsDocument()); + } + } + + getScopeIds(context: EventsContext): string[] { + return Object.keys(this.scopes[context]); + } + + getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[] { + const out: { context: EventsContext; treeEventScopeIds: string[] }[] = []; + for (const context of ['drive', 'photos'] as const) { + const ids = this.getScopeIds(context); + if (ids.length > 0) { + out.push({ context, treeEventScopeIds: ids }); + } + } + return out; + } + + getLatestEventId(context: EventsContext, scopeId: string): string | undefined { + return this.scopes[context][scopeId]?.lastEventId; + } + + setLatestEventId(context: EventsContext, scopeId: string, lastEventId: string): void { + this.scopes[context][scopeId] = { lastEventId }; + } + + removeScope(context: EventsContext, scopeId: string): void { + delete this.scopes[context][scopeId]; + } + + async flush(): Promise { + const filePath = eventsJsonPath(this.cacheDir); + const body = JSON.stringify(this.scopes, null, 2); + await fs.writeFile(filePath, body, 'utf8'); + } +} + +function eventsJsonPath(cacheDir: string): string { + return path.join(cacheDir, EVENTS_FILE); +} + +function parseScopeRecordMap(value: unknown): Record { + if (typeof value !== 'object' || value === null) { + return {}; + } + const out: Record = {}; + for (const [scopeId, entry] of Object.entries(value)) { + if ( + entry && + typeof entry === 'object' && + typeof (entry as { lastEventId?: unknown }).lastEventId === 'string' + ) { + out[scopeId] = { lastEventId: (entry as { lastEventId: string }).lastEventId }; + } + } + return out; +} + +function parseEventsDocument(content: string): EventsScopes { + try { + const raw = JSON.parse(content) as unknown; + if (typeof raw !== 'object' || raw === null || (raw as { version?: unknown }).version !== 1) { + return emptyEventsDocument(); + } + + const driveRaw = (raw as { drive?: unknown }).drive; + const photosRaw = (raw as { photos?: unknown }).photos; + const legacyScopes = (raw as { scopes?: unknown }).scopes; + + const hasNewShape = + (typeof driveRaw === 'object' && driveRaw !== null) || + (typeof photosRaw === 'object' && photosRaw !== null); + + if (hasNewShape) { + return { + version: 1, + drive: parseScopeRecordMap(driveRaw), + photos: parseScopeRecordMap(photosRaw), + }; + } + + if (typeof legacyScopes === 'object' && legacyScopes !== null) { + return { + version: 1, + drive: parseScopeRecordMap(legacyScopes), + photos: {}, + }; + } + + return emptyEventsDocument(); + } catch { + return emptyEventsDocument(); + } +} + +function emptyEventsDocument(): EventsScopes { + return { version: 1, drive: {}, photos: {} }; +} diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 465b2f9f..8563383d 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -11,14 +11,9 @@ import { createCaches } from './cache'; import { Paths } from './cli'; import { getConfig, InitConfig } from './config'; import { initCredentials } from './credentials'; +import { Manager, NoEventsProvider, PersistedEventsProvider } from './events'; import { initTelemetry } from './telemetry'; -function initOpenPGPCryptoModule() { - CryptoApi.init({}); - CryptoProxy.setEndpoint(new CryptoApi(), endpoint => endpoint.clearKeyStore()); - return new OpenPGPCryptoWithCryptoProxy(CryptoProxy) -} - export async function init(configOptions: InitConfig) { const config = getConfig(configOptions); const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); @@ -29,6 +24,10 @@ export async function init(configOptions: InitConfig) { const { auth, addresses, srp, httpClient } = await initApi(config, credentials, logger); const caches = createCaches(config, credentials, logger); + const eventsProvider = config.enablePersistedEvents + ? await PersistedEventsProvider.open(logger, config.cacheDir) + : new NoEventsProvider(); + const sdkDependencies = { config: { baseUrl: config.baseUrl, @@ -41,14 +40,16 @@ export async function init(configOptions: InitConfig) { openPGPCryptoModule, account: addresses, srpModule: srp, - latestEventIdProvider: new NoLatestEventIdProvider(), + latestEventIdProvider: eventsProvider, featureFlagProvider: await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), }; const sdk = new ProtonDriveClient(sdkDependencies); const photosSdk = new ProtonDrivePhotosClient(sdkDependencies); const sdkDiagnostic = initDiagnostic(sdkDependencies); - const paths = new Paths(sdk, photosSdk, auth); + const eventsManager = await Manager.create(logger, sdk, photosSdk, eventsProvider); + + const paths = new Paths(sdk, photosSdk, auth, eventsManager); return { logger: logger as Logger, @@ -58,13 +59,18 @@ export async function init(configOptions: InitConfig) { photosSdk, sdkDiagnostic, paths, + eventsManager, + eventsProvider, + dispose: async () => { + await eventsManager.dispose(); + }, }; } -class NoLatestEventIdProvider { - async getLatestEventId(): Promise { - return null; - } +function initOpenPGPCryptoModule() { + CryptoApi.init({}); + CryptoProxy.setEndpoint(new CryptoApi(), endpoint => endpoint.clearKeyStore()); + return new OpenPGPCryptoWithCryptoProxy(CryptoProxy) } class FeatureFlagProvider { diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 3d3ce7de..d27cd326 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -5,6 +5,11 @@ const CLIENT_UID = 'proton-drive-sdk-js-cli'; // Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). const APP_VERSION = 'external-drive-sdkclijs@1.0.0'; -await run(COMMANDS, { clientUid: CLIENT_UID, appVersion: APP_VERSION, debug: false }); +await run(COMMANDS, { + clientUid: CLIENT_UID, + appVersion: APP_VERSION, + debug: false, + enablePersistedEvents: true, +}); process.exit(0); 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 f2b8a158a6ddb4acf6272f6885c4dfe20ef0301d Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 11:50:19 +0000 Subject: [PATCH 611/732] Persist client UID --- .gitignore | 1 + js/cli/src/api/apiClient.ts | 3 +-- js/cli/src/clientUid.ts | 45 +++++++++++++++++++++++++++++++++++++ js/cli/src/config.ts | 6 ++--- js/cli/src/init.ts | 4 +++- js/cli/src/proton-drive.ts | 6 ++--- 6 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 js/cli/src/clientUid.ts 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 diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index a6022bba..ef33b9f5 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -126,9 +126,8 @@ export class ApiClient { headers.set('x-pm-appversion', this.config.appVersion); headers.set('x-pm-uid', uid); headers.set('Authorization', `Bearer ${accessToken}`); - options.headers = headers; - return this.authenticatedClient(request, options); + return this.authenticatedClient(request, { ...options, headers }); }; } diff --git a/js/cli/src/clientUid.ts b/js/cli/src/clientUid.ts new file mode 100644 index 00000000..138f5a41 --- /dev/null +++ b/js/cli/src/clientUid.ts @@ -0,0 +1,45 @@ +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Logger } from '@protontech/drive-sdk/interface/telemetry'; + +import type { Config } from './config'; + +const CLIENT_UID_FILE = 'clientUid.json'; + +interface ClientUidFile { + clientUid: string; +} + +/** + * Returns a persistent client UID for this machine and CLI variant (`prefix`) + * stored as plain JSON in cache directory. + */ +export async function getOrGenerateClientUid(config: Config, logger: Logger): Promise { + const path = join(config.cacheDir, CLIENT_UID_FILE); + const file = Bun.file(path); + + if (await file.exists()) { + try { + const data = JSON.parse(await file.text()) as ClientUidFile; + if (typeof data.clientUid === 'string' && data.clientUid.startsWith(expectedUidPrefix(config.clientUidPrefix))) { + logger.debug(`Using client UID: ${data.clientUid}`); + return data.clientUid; + } + } catch (error: unknown) { + logger.error(`Failed to load client UID`, error); + } + } + + const clientUid = `${config.clientUidPrefix}-${crypto.randomUUID()}`; + await mkdir(config.cacheDir, { recursive: true }); + const payload: ClientUidFile = { clientUid }; + await Bun.write(path, `${JSON.stringify(payload, null, 2)}\n`); + + logger.info(`Generated new client UID: ${clientUid}`); + return clientUid; +} + +function expectedUidPrefix(prefix: string): string { + return `${prefix}-`; +} diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index e2fba746..d82e6313 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -1,12 +1,12 @@ export interface InitConfig { debug?: boolean; appVersion: string; - clientUid: string; + clientUidPrefix: string; enablePersistedEvents?: boolean; } export interface Config { - clientUid: string; + clientUidPrefix: string; appVersion: string; baseUrl: string; cacheDir: string; @@ -22,7 +22,7 @@ export function getConfig(options: InitConfig): Config { ); return { - clientUid: options.clientUid, + clientUidPrefix: options.clientUidPrefix, appVersion: options.appVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 8563383d..b1e804c2 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -9,6 +9,7 @@ import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotos import { initApi } from './api'; import { createCaches } from './cache'; import { Paths } from './cli'; +import { getOrGenerateClientUid } from './clientUid'; import { getConfig, InitConfig } from './config'; import { initCredentials } from './credentials'; import { Manager, NoEventsProvider, PersistedEventsProvider } from './events'; @@ -23,6 +24,7 @@ export async function init(configOptions: InitConfig) { const credentials = initCredentials(config, logger); const { auth, addresses, srp, httpClient } = await initApi(config, credentials, logger); + const clientUid = await getOrGenerateClientUid(config, logger); const caches = createCaches(config, credentials, logger); const eventsProvider = config.enablePersistedEvents ? await PersistedEventsProvider.open(logger, config.cacheDir) @@ -31,7 +33,7 @@ export async function init(configOptions: InitConfig) { const sdkDependencies = { config: { baseUrl: config.baseUrl, - clientUid: config.clientUid, + clientUid, }, httpClient, entitiesCache: caches.entitiesCache, diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index d27cd326..a4b05bd0 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,12 +1,12 @@ import { run } from './cli'; import { COMMANDS } from './commands'; -const CLIENT_UID = 'proton-drive-sdk-js-cli'; +const CLIENT_UID_PREFIX = 'sdk-js-cli'; // Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). -const APP_VERSION = 'external-drive-sdkclijs@1.0.0'; +const APP_VERSION = 'external-drive-sdkclijs@0.0.1'; await run(COMMANDS, { - clientUid: CLIENT_UID, + clientUidPrefix: CLIENT_UID_PREFIX, appVersion: APP_VERSION, debug: false, enablePersistedEvents: true, From 429c71e6044603edd7c2439057fde681703a5927 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 28 Apr 2026 14:55:03 +0200 Subject: [PATCH 612/732] Add download verifier for CLI --- .../fileSystem/commandFileSystemDownload.ts | 28 +++++++++++++++++-- .../fileSystem/commandFileSystemUpload.ts | 19 ++----------- js/cli/src/commands/fileSystem/digest.ts | 10 +++++++ 3 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 js/cli/src/commands/fileSystem/digest.ts diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 38b1b6f9..1b3b887c 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -2,9 +2,10 @@ import { mkdir, readdir, rm, stat, unlink } from 'node:fs/promises'; import path from 'node:path'; import { ParseArgsConfig } from 'node:util'; -import { FileDownloader, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { FileDownloader, IntegrityError, type Logger, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, getClaimedSize, PathType } from '../../cli'; +import { getSha1 } from './digest'; import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; import { ConflictChoice, @@ -23,6 +24,7 @@ const FILE_DOWNLOAD_CONFLICT_STRATEGIES = [ ]; type DownloadContext = { + logger: Logger; sdk: ProtonDriveClient; json: boolean; progress?: TransferProgressInterface; @@ -99,6 +101,7 @@ export class CommandFileSystemDownload implements Command { }); const ctx: DownloadContext = { + logger, sdk, json, progress, @@ -189,8 +192,13 @@ export class CommandFileSystemDownload implements Command { } } + const expectedSha1 = + item.remoteNode.ok && item.remoteNode.value.activeRevision?.claimedDigests?.sha1Verified + ? item.remoteNode.value.activeRevision?.claimedDigests?.sha1 + : undefined; + const downloader = await ctx.sdk.getFileDownloader(item.remoteNode); - await this.downloadToPath(ctx, item, downloader, targetPath); + await this.downloadToPath(ctx, item, downloader, targetPath, expectedSha1); return; } } @@ -200,6 +208,7 @@ export class CommandFileSystemDownload implements Command { item: QueueItemFile<{ remoteNode: MaybeNode }>, downloader: FileDownloader, localPath: string, + expectedSha1: string | undefined, ): Promise { assertDownloadDestination(ctx.downloadRoot, localPath); @@ -228,9 +237,22 @@ export class CommandFileSystemDownload implements Command { try { await controller.completion(); await writer.end(); + + if (expectedSha1) { + const computedSha1 = await getSha1(localPath); + if (computedSha1 !== expectedSha1) { + ctx.logger.error( + `Integrity verification failed: computedSha1=${computedSha1} expectedSha1=${expectedSha1}`, + ); + throw new IntegrityError('Integrity verification failed', { + computedSha1, + expectedSha1, + }); + } + } } catch (error: unknown) { - await writer.end(error instanceof Error ? error : new Error('Unknown error', { cause: error })); await unlink(localPath).catch(() => {}); + await writer.end(error instanceof Error ? error : new Error('Unknown error', { cause: error })); throw error; } finally { progressTracker?.onFinished(); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 334fe7f6..746d9864 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; import { ParseArgsConfig } from 'node:util'; import { @@ -10,12 +8,9 @@ import { } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, PathType } from '../../cli'; +import { getSha1 } from './digest'; import { generateThumbnails } from './generateThumbnails'; -import { - ConflictChoice, - ConflictTargetKind, - TransferConflictResolver, -} from './transferConflictResolver'; +import { ConflictChoice, ConflictTargetKind, TransferConflictResolver } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; import { type QueueItemDirectory, @@ -171,7 +166,7 @@ export class CommandFileSystemUpload implements Command { } private async uploadFile(ctx: UploadContext, item: QueueItemFile<{ parentNode: MaybeNode }>): Promise { - const expectedSha1 = await this.getSha1(item.localPath); + const expectedSha1 = await getSha1(item.localPath); const file = Bun.file(item.localPath); const metadata = { mediaType: file.type, @@ -237,14 +232,6 @@ export class CommandFileSystemUpload implements Command { } } - private async getSha1(localPath: string): Promise { - const hash = createHash('sha1'); - for await (const chunk of createReadStream(localPath)) { - hash.update(chunk as Buffer); - } - return hash.digest('hex'); - } - private async trashConflictingNode(ctx: UploadContext, node: MaybeNode): Promise { for await (const result of ctx.sdk.trashNodes([node])) { if (!result.ok) { diff --git a/js/cli/src/commands/fileSystem/digest.ts b/js/cli/src/commands/fileSystem/digest.ts new file mode 100644 index 00000000..3199b57a --- /dev/null +++ b/js/cli/src/commands/fileSystem/digest.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; + +export async function getSha1(localPath: string): Promise { + const hash = createHash('sha1'); + for await (const chunk of createReadStream(localPath)) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} From efc6d454754253be206cb40a9790606bf1fe058e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 14:42:35 +0200 Subject: [PATCH 613/732] 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 f92ae440c8ae213c285b7a952dbbcdc34e93ad64 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 30 Apr 2026 15:00:29 +0200 Subject: [PATCH 614/732] 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 c1c4ada0ef43c40399cd720ed4876c3f8fd96f48 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 May 2026 16:27:27 +0200 Subject: [PATCH 615/732] 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 50accf8394a843eb0cde6fab22db6aa414b791a4 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 1 May 2026 14:53:07 +0000 Subject: [PATCH 616/732] 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 da73e9b49fbef532792dcd4a2750f1bc09c2e108 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 22 Apr 2026 11:13:51 +0200 Subject: [PATCH 617/732] 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 4b938e9e6806c7d90669a0660efd50c81d4ddd31 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 May 2026 08:24:59 +0200 Subject: [PATCH 618/732] Sanitize output in CLI --- js/cli/src/cli/formatters.ts | 11 +++++++++-- js/cli/src/cli/index.ts | 1 + js/cli/src/commands/auth/commandAuthLogin.ts | 4 ++-- .../src/commands/fileSystem/commandFileSystemCopy.ts | 6 +++--- .../commands/fileSystem/commandFileSystemDelete.ts | 4 ++-- .../src/commands/fileSystem/commandFileSystemList.ts | 7 ++++--- .../src/commands/fileSystem/commandFileSystemMove.ts | 4 ++-- .../commands/fileSystem/commandFileSystemRestore.ts | 4 ++-- .../src/commands/fileSystem/commandFileSystemTrash.ts | 4 ++-- .../commands/fileSystem/transferConflictResolver.ts | 3 ++- js/cli/src/commands/fileSystem/transferProgress.ts | 9 +++++---- 11 files changed, 34 insertions(+), 23 deletions(-) diff --git a/js/cli/src/cli/formatters.ts b/js/cli/src/cli/formatters.ts index d20c2a2a..19255565 100644 --- a/js/cli/src/cli/formatters.ts +++ b/js/cli/src/cli/formatters.ts @@ -1,10 +1,12 @@ +import { stripVTControlCharacters } from 'node:util'; + import { inspect } from 'util'; import { Author, MemberRole } from '@protontech/drive-sdk'; export function printObject(object: object | undefined, json: boolean) { if (json) { - console.log(JSON.stringify(object)); + console.log(sanitizeTerminalText(JSON.stringify(object))); } else { console.log(object ? formatReadableJson(object) : 'N/A'); } @@ -37,7 +39,8 @@ export async function printIterable( export function formatReadableJson(json: object) { // Prints the JSON in a readable format without the depth limit. - return inspect(json, { showHidden: false, depth: null, colors: true }); + // No styling: `colors` would add VT sequences that must not mix with unsanitized user strings. + return sanitizeTerminalText(inspect(json, { showHidden: false, depth: null, colors: false })); } export function formatAuthor(author: Author) { @@ -82,3 +85,7 @@ export function formatMemberRole(memberRole: MemberRole): string { return '👑'; } } + +export function sanitizeTerminalText(value: unknown): string { + return stripVTControlCharacters(String(value)); +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index cb6ebfee..dc28e240 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -11,6 +11,7 @@ export { formatSize, printIterable, printObject, + sanitizeTerminalText, } from './formatters'; export type { ActionArgs, Command } from './interface'; export { findName, getClaimedSize, getName, getNode, getNodeUid } from './node'; diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts index 61c68846..51e5a39e 100644 --- a/js/cli/src/commands/auth/commandAuthLogin.ts +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -1,5 +1,5 @@ import { Auth } from '../../api'; -import { type ActionArgs, type Command, openBrowserUrl } from '../../cli'; +import { type ActionArgs, type Command, openBrowserUrl, sanitizeTerminalText } from '../../cli'; export class CommandAuthLogin implements Command { group = 'auth'; @@ -14,7 +14,7 @@ export class CommandAuthLogin implements Command { return auth.authViaWeb((signInUrl) => { openBrowserUrl(signInUrl); console.log('Sign in in your browser (URL also printed if it did not open automatically):'); - console.log(signInUrl); + console.log(sanitizeTerminalText(signInUrl)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index b75f7dae..a791a433 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -2,7 +2,7 @@ import { ParseArgsConfig } from 'node:util'; import { MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, findName, getName, getNodeUid, PathType, printIterable } from '../../cli'; +import { type ActionArgs, type Command, findName, getName, getNodeUid, PathType, printIterable, sanitizeTerminalText } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; @@ -38,14 +38,14 @@ export class CommandFileSystemCopy implements Command { private async copyNode(sdk: ProtonDriveClient, sourceNode: MaybeNode, targetNode: MaybeNode, json: boolean, name: string) { await printIterable(sdk.copyNodes([{ uid: getNodeUid(sourceNode), name }], targetNode), json, (result) => { - console.log(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error}`)); }); } private async copyNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { await printIterable(sdk.copyNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); - console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts index 981fb05b..483fee7d 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts @@ -1,4 +1,4 @@ -import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable, sanitizeTerminalText } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; @@ -13,7 +13,7 @@ export class CommandFileSystemDelete implements Command { await printIterable(nodePaths[0].sdk.deleteNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts index 53259efd..927ec8cb 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemList.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -16,6 +16,7 @@ import { Paths, PathType, printIterable, + sanitizeTerminalText, } from '../../cli'; export class CommandFileSystemList implements Command { @@ -41,7 +42,7 @@ export class CommandFileSystemList implements Command { switch (path.type) { case PathType.Root: const rootPaths = paths.rootPaths.map((path) => ({ path })); - await printIterable(rootPaths, json, (item) => console.log(item.path)); + await printIterable(rootPaths, json, (item) => console.log(sanitizeTerminalText(item.path))); break; case PathType.MyFiles: await this.printChildren(sdk, paths, pathString, { json, nodeType }); @@ -134,10 +135,10 @@ export class CommandFileSystemList implements Command { const claimedSize = getClaimedSize(maybeNode); const size = claimedSize ? formatSize(claimedSize) : '-'; const name = getName(maybeNode); - console.log(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${name}`); + console.log(sanitizeTerminalText(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${name}`)); } private printDeviceHuman(device: Device): void { - console.log(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`); + console.log(sanitizeTerminalText(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`)); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index 50723458..a89679c3 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -1,6 +1,6 @@ import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable, sanitizeTerminalText } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices]; @@ -22,7 +22,7 @@ export class CommandFileSystemMove implements Command { private async moveNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { await printIterable(sdk.moveNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); - console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts index b2e13edc..5c4c3c27 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts @@ -1,4 +1,4 @@ -import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable, sanitizeTerminalText } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; @@ -13,7 +13,7 @@ export class CommandFileSystemRestore implements Command { await printIterable(nodePaths[0].sdk.restoreNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts index 631417ab..fae995e8 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts @@ -1,4 +1,4 @@ -import { type ActionArgs, type Command, findName, PathType, printIterable } from '../../cli'; +import { type ActionArgs, type Command, findName, PathType, printIterable, sanitizeTerminalText } from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.Photos]; @@ -13,7 +13,7 @@ export class CommandFileSystemTrash implements Command { await printIterable(nodePaths[0].sdk.trashNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/transferConflictResolver.ts b/js/cli/src/commands/fileSystem/transferConflictResolver.ts index ccbce0b9..15e8412a 100644 --- a/js/cli/src/commands/fileSystem/transferConflictResolver.ts +++ b/js/cli/src/commands/fileSystem/transferConflictResolver.ts @@ -1,5 +1,6 @@ import { Logger, ValidationError } from '@protontech/drive-sdk'; +import { sanitizeTerminalText } from '../../cli/formatters'; import { question } from '../../cli/readline'; export enum ConflictChoice { @@ -114,7 +115,7 @@ export class TransferConflictResolver { const choices = kind === ConflictTargetKind.File ? this.fileStrategyChoices : this.folderStrategyChoices; const hint = `Type a strategy (${choices.join(', ')}; or abbreviations), or [a]pply-to-all`; while (true) { - const line = await question(`Conflict on "${name}" (${kind}). ${hint}\n> `); + const line = await question(`Conflict on "${sanitizeTerminalText(name)}" (${kind}). ${hint}\n> `); const trimmed = line.trim().toLowerCase(); if (trimmed === 'a' || trimmed === 'apply' || trimmed === 'apply-to-all') { const applyAllLine = await question(`Default for all ${kind} conflicts:\n> `); diff --git a/js/cli/src/commands/fileSystem/transferProgress.ts b/js/cli/src/commands/fileSystem/transferProgress.ts index 3b110347..3ab561c4 100644 --- a/js/cli/src/commands/fileSystem/transferProgress.ts +++ b/js/cli/src/commands/fileSystem/transferProgress.ts @@ -1,4 +1,4 @@ -import { formatSize } from "src/cli/formatters"; +import { formatSize, sanitizeTerminalText } from 'src/cli/formatters'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -165,7 +165,8 @@ class TransferProgress implements TransferProgressInterface { ? [] : Array.from( this.active.values(), - (item) => `${frame} ${getProgressPercentage(item)} ${item.name} (${formatSize(item.size, true)})`, + (item) => + `${frame} ${getProgressPercentage(item)} ${sanitizeTerminalText(item.name)} (${formatSize(item.size, true)})`, ); if (lines.length === 0 && this.previousLineCount === 0) { @@ -188,9 +189,9 @@ class TransferProgress implements TransferProgressInterface { } } -function getProgressPercentage(item: { progress: number, size?: number }): string { +function getProgressPercentage(item: { progress: number; size?: number }): string { if (item.size === undefined) { return ''; } - return `${(item.progress / item.size * 100).toFixed(2)}%`; + return `${((item.progress / item.size) * 100).toFixed(2)}%`; } From ef482b9596e93449997c0dc3fc4239735b4ed655 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 May 2026 14:07:44 +0000 Subject: [PATCH 619/732] 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 9dd40ce4b02e22819b2dbfa0fb3ba46922c1432c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 4 May 2026 16:00:47 +0000 Subject: [PATCH 620/732] 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 94ffb887a273bb38fbc5d3d5d66b0e79c023ddf9 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 29 Apr 2026 14:12:02 +0200 Subject: [PATCH 621/732] CLI build jobs --- js/cli/package.json | 2 +- js/cli/scripts/build-cli.mjs | 77 ++++++++++++++++++++++++++++++++++-- js/cli/src/api/apiClient.ts | 1 + js/cli/src/cli/index.ts | 6 +++ js/cli/src/config.ts | 4 ++ js/cli/src/init.ts | 4 +- js/cli/src/proton-drive.ts | 32 ++++++++++----- 7 files changed, 112 insertions(+), 14 deletions(-) diff --git a/js/cli/package.json b/js/cli/package.json index a61e2c5e..dfd90045 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "main": "dist/main.js", "scripts": { - "build": "bun ./scripts/build-cli.mjs", + "build": "bun ./scripts/build-cli.mjs main", "build:internal": "bun ./scripts/build-cli.mjs internal", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/api/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/api/api-auth-types.ts", diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index b5ec77ca..985213c6 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -1,17 +1,30 @@ #!/usr/bin/env bun +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const GIT_ROOT = path.resolve(import.meta.dir, '../../..'); + const mode = process.argv[2] === 'internal' ? 'internal' : 'main'; +const target = process.argv[3] || 'bun'; +let outfileSuffix = ''; +if (target.startsWith('bun-')) { + outfileSuffix = '-' + target.slice(4); +} + const entry = mode === 'internal' ? 'src/internal-cli/proton-drive-internal.ts' : 'src/proton-drive.ts'; const outfile = mode === 'internal' ? 'release/proton-drive-internal' : 'release/proton-drive'; const args = [ 'build', - entry, - `--outfile=${outfile}`, '--compile', - '--target=bun', + `--target=${target}`, + // Slower compile, bigger bundle size, faster execution. + '--bytecode', + // Reduce bundle size (not much, the biggest part is the embedded Bun itself). '--minify', + // Include source maps for readable stack traces. '--sourcemap=inline', // Sharp requires a native module which we keep out of the bundle. // User must install sharp separately by installing the package @@ -19,6 +32,12 @@ const args = [ // load the user's package.json. '--external=sharp', '--compile-autoload-package-json', + '--define', + `APP_VERSION=${JSON.stringify(`${process.env.CLI_APP_VERSION_NAME || 'external-drive-sdkclijs'}@${getVersion('cli') || '0.0.0'}`)}`, + '--define', + `SDK_VERSION=${JSON.stringify(`js@${getVersion('js')}`)}`, + entry, + `--outfile=${outfile}${outfileSuffix}`, ]; const proc = Bun.spawn(['bun', ...args], { @@ -29,3 +48,55 @@ const code = await proc.exited; if (code !== 0) { process.exit(code); } + +/** + * Returns version from environment variable if set, + * otherwise from local git repository. + * Returns undefined if no version can be determined. + */ +function getVersion(tagPrefix) { + const envName = { cli: 'CLI_VERSION', js: 'JS_VERSION' }[tagPrefix]; + const envVersion = envName ? process.env[envName]?.trim() : undefined; + if (envVersion) { + return envVersion; + } + try { + const shortHash = getShortHash(); + const semver = semverFromLatestTag(tagPrefix); + return `${semver}+${shortHash}`; + } catch (error) { + console.warn(`Error getting version for ${tagPrefix}:`, error); + return undefined; + } +} + +/** + * Returns latest version (as x.y.z) from tags matching + * `{tagPrefix}/vx.y.z`, or `0.0.0` if none. + */ +function semverFromLatestTag(tagPrefix) { + const tags = git(['tag', '-l', `${tagPrefix}/v*`, '--sort=-v:refname']); + const tag = tags.split('\n').find(Boolean); + if (!tag) { + console.warn(`No tag found for ${tagPrefix}`); + return '0.0.0'; + } + const prefixSlashV = `${tagPrefix}/v`; + if (!tag.startsWith(prefixSlashV)) { + console.warn(`No tag found for ${tagPrefix}`); + return '0.0.0'; + } + return tag.slice(prefixSlashV.length); +} + +function getShortHash() { + return git(['rev-parse', '--short', 'HEAD']); +} + +function git(args) { + return execFileSync('git', args, { + encoding: 'utf8', + cwd: GIT_ROOT, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); +} diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index ef33b9f5..0bca9e28 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -34,6 +34,7 @@ export class ApiClient { const baseClientOptions = { headers: { 'x-pm-appversion': this.config.appVersion, + 'x-pm-drive-sdk-version': this.config.sdkVersion, }, timeout: DEFAULT_TIMEOUT_MS, }; diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index dc28e240..3e8148ab 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -22,6 +22,12 @@ export { applyDefaultCliOptions } from './registryCore'; export type { CliSession } from './run'; export async function run(commands: Command[], initOptions: InitConfig) { + if (Bun.argv[2] === '-v' || Bun.argv[2] === '--version') { + console.log(`Proton Drive CLI ${initOptions.appVersion}`); + console.log(`Proton Drive SDK ${initOptions.sdkVersion}`); + return; + } + if (shouldStartRepl(Bun.argv)) { await startRepl(commands, initOptions); } else { diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index d82e6313..84569d5b 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -1,13 +1,16 @@ export interface InitConfig { debug?: boolean; appVersion: string; + sdkVersion?: string; clientUidPrefix: string; enablePersistedEvents?: boolean; + flags?: Record; } export interface Config { clientUidPrefix: string; appVersion: string; + sdkVersion?: string; baseUrl: string; cacheDir: string; enablePersistedEvents: boolean; @@ -24,6 +27,7 @@ export function getConfig(options: InitConfig): Config { return { clientUidPrefix: options.clientUidPrefix, appVersion: options.appVersion, + sdkVersion: options.sdkVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), enablePersistedEvents: options.enablePersistedEvents || false, diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index b1e804c2..f8d2aadb 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -43,7 +43,9 @@ export async function init(configOptions: InitConfig) { account: addresses, srpModule: srp, latestEventIdProvider: eventsProvider, - featureFlagProvider: await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), + featureFlagProvider: configOptions.flags + ? new FeatureFlagProvider(configOptions.flags) + : await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), }; const sdk = new ProtonDriveClient(sdkDependencies); const photosSdk = new ProtonDrivePhotosClient(sdkDependencies); diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index a4b05bd0..5d29ec21 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,15 +1,29 @@ +import { FeatureFlags } from '@protontech/drive-sdk'; + import { run } from './cli'; import { COMMANDS } from './commands'; -const CLIENT_UID_PREFIX = 'sdk-js-cli'; // Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). -const APP_VERSION = 'external-drive-sdkclijs@0.0.1'; +declare const APP_VERSION: string; +declare const SDK_VERSION: string | undefined; -await run(COMMANDS, { - clientUidPrefix: CLIENT_UID_PREFIX, - appVersion: APP_VERSION, - debug: false, - enablePersistedEvents: true, -}); +const CLIENT_UID_PREFIX = 'sdk-js-cli'; -process.exit(0); +(async () => { + await run(COMMANDS, { + clientUidPrefix: CLIENT_UID_PREFIX, + appVersion: APP_VERSION, + sdkVersion: SDK_VERSION, + enablePersistedEvents: true, + debug: false, + // TODO: Configure flags via Unleash. + flags: { + [FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead]: true, + [FeatureFlags.DriveSmallFileUpload]: true, + }, + }); + process.exit(0); +})().catch((err) => { + console.error(err); + process.exit(1); +}); From 09902d7ba70d291953175690146db9888c4ab714 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 13:37:43 +0000 Subject: [PATCH 622/732] Fix starting without session --- js/cli/README.md | 1 + js/cli/src/cli/errors.ts | 21 ++++++ js/cli/src/cli/index.ts | 6 -- js/cli/src/cli/interface.ts | 1 + js/cli/src/cli/registryCore.ts | 75 ++++++++++++++++--- js/cli/src/cli/repl.ts | 25 +------ js/cli/src/cli/run.ts | 65 ++++------------ js/cli/src/commands/auth/commandAuthLogin.ts | 3 +- js/cli/src/commands/auth/commandAuthLogout.ts | 3 +- js/cli/src/config.ts | 11 ++- js/cli/src/events/interface.ts | 1 + js/cli/src/events/manager.ts | 18 ++++- js/cli/src/events/providerMemory.ts | 9 ++- js/cli/src/events/providerNoEvents.ts | 2 + js/cli/src/events/providerPersisted.ts | 4 + js/cli/src/events/storage.ts | 4 + js/cli/src/init.ts | 8 +- js/cli/src/proton-drive.ts | 1 - js/cli/tsconfig.json | 9 ++- 19 files changed, 162 insertions(+), 105 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index ebba583b..15551600 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -49,3 +49,4 @@ Get help: | Name | Description | Default Value | |--------------------------|-------------------------------------------------------------------|-----------------------------| | `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | +| `PROTON_DRIVE_LOG_LEVEL` | Log level to use for the logging. | DEBUG | diff --git a/js/cli/src/cli/errors.ts b/js/cli/src/cli/errors.ts index cc7968d7..ba040b62 100644 --- a/js/cli/src/cli/errors.ts +++ b/js/cli/src/cli/errors.ts @@ -1,3 +1,8 @@ +import { ProtonDriveError } from '@protontech/drive-sdk'; + +import { AccountApiError } from '../api/accountApi'; +import { ReplUnclosedQuoteError } from './splitQuotedLine'; + export class CommandError extends Error {} export class CommandNotFoundError extends CommandError {} @@ -9,3 +14,19 @@ export class AuthRequiredError extends CommandError { super('You need to login first'); } } + +export function isRecoverableReplError(error: unknown): boolean { + if ( + error instanceof ProtonDriveError || + error instanceof CommandError || + error instanceof AccountApiError || + error instanceof ReplUnclosedQuoteError + ) { + return true; + } + if (error instanceof TypeError) { + const code = (error as NodeJS.ErrnoException).code; + return typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS_'); + } + return false; +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 3e8148ab..dc28e240 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -22,12 +22,6 @@ export { applyDefaultCliOptions } from './registryCore'; export type { CliSession } from './run'; export async function run(commands: Command[], initOptions: InitConfig) { - if (Bun.argv[2] === '-v' || Bun.argv[2] === '--version') { - console.log(`Proton Drive CLI ${initOptions.appVersion}`); - console.log(`Proton Drive SDK ${initOptions.sdkVersion}`); - return; - } - if (shouldStartRepl(Bun.argv)) { await startRepl(commands, initOptions); } else { diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index bdd9b9bd..3f0ff517 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -27,6 +27,7 @@ export interface ActionArgs { sdkDiagnostic: Diagnostic; paths: Paths; eventsManager: Manager; + clearCaches: () => Promise; args: string[]; options: { [name: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/js/cli/src/cli/registryCore.ts b/js/cli/src/cli/registryCore.ts index e6639168..38e36299 100644 --- a/js/cli/src/cli/registryCore.ts +++ b/js/cli/src/cli/registryCore.ts @@ -1,5 +1,6 @@ -import { ParseArgsConfig } from 'util'; +import { parseArgs, ParseArgsConfig } from 'util'; +import { InitConfig } from '../config'; import { CommandNotFoundError, InvalidCommandArgumentsError } from './errors'; import { Command } from './interface'; @@ -15,14 +16,27 @@ export function applyDefaultCliOptions(commands: Command[]): Command[] { short: 'j', default: false, }; + command.options['verbose'] = { + type: 'boolean', + short: 'v', + default: false, + }; } return commands; } -export function getCommand(commands: Command[], groupName: string, commandName: string): Command { - if (groupName === 'help') { +export function getCommand( + commands: Command[], + groupName: string, + commandName: string, + initOptions: InitConfig, +): Command { + if (groupName === 'help' || commandName === '--help' || commandName === '-h') { return new CommandHelp(commands); } + if (groupName === 'version' || commandName === '--version' || commandName === '-v') { + return new CommandVersion(initOptions); + } if (groupName === 'fs') { groupName = 'filesystem'; @@ -33,9 +47,7 @@ export function getCommand(commands: Command[], groupName: string, commandName: return matches[0]; } - matches = commands.filter( - (command) => command.group.startsWith(groupName) && command.name.startsWith(commandName), - ); + matches = commands.filter((command) => command.group.startsWith(groupName) && command.name.startsWith(commandName)); if (matches.length === 1) { return matches[0]; } @@ -44,12 +56,37 @@ export function getCommand(commands: Command[], groupName: string, commandName: throw new CommandNotFoundError(`Command not found: ${groupName} ${commandName}`); } -export function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { - // Do not validate arguments when help is requested. - if (values['help']) { - return; +export function getCommandArguments( + command: Command, + argv: string[], +): { options: { [name: string]: unknown }; args: string[] } { + if (command instanceof CommandHelp || command instanceof CommandVersion) { + return { options: {}, args: [] }; } + try { + const { values: options, positionals } = parseArgs({ + args: argv, + options: command.options || {}, + strict: true, + allowPositionals: true, + }); + const args = positionals.slice(4); + validateCommandArguments(command, args, options); + return { options, args }; + } catch (error) { + if (error instanceof InvalidCommandArgumentsError) { + throw error; + } + if (error instanceof TypeError) { + printCommandUsage(command); + throw new InvalidCommandArgumentsError(error.message); + } + throw error; + } +} + +function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { if (command.args) { const hasVariableArgs = command.args.some((arg) => arg.endsWith('...')); const hasExpectedArgs = hasVariableArgs @@ -85,14 +122,28 @@ class CommandHelp implements Command { } } +class CommandVersion implements Command { + group = 'version'; + name = 'version'; + isAuthAction = true; + + constructor(private initOptions: InitConfig) {} + + async action() { + console.log(`Proton Drive CLI ${this.initOptions.appVersion}`); + console.log(`Proton Drive SDK ${this.initOptions.sdkVersion}`); + } +} + function printUsage(commands: Command[]) { console.log('Usage:'); commands.map((command) => { printCommandManual(command); }); console.log('General options:'); - console.log(' --help: Show help for a command'); - console.log(' -j, --json: Output in JSON format'); + console.log(' -h|--help: Show help for a command'); + console.log(' -j|--json: Output in JSON format'); + console.log(' -v|--verbose: Enable verbose output'); } export function printCommandUsage(command: Command) { diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index 9dc39417..fd8fd881 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -1,13 +1,10 @@ -import { ProtonDriveError } from '@protontech/drive-sdk'; - -import { AccountApiError } from '../api/accountApi'; import { InitConfig } from '../config'; import { init } from '../init'; -import { CommandError } from './errors'; +import { isRecoverableReplError } from './errors'; import { Command } from './interface'; import { question } from './readline'; import { runCommand } from './run'; -import { ReplUnclosedQuoteError, splitQuotedLine } from './splitQuotedLine'; +import { splitQuotedLine } from './splitQuotedLine'; export async function startRepl(commands: Command[], initOptions: InitConfig): Promise { const session = await init(initOptions); @@ -28,7 +25,7 @@ export async function startRepl(commands: Command[], initOptions: InitConfig): P await runCommand(commands, syntheticArgv, initOptions, session); } catch (error: unknown) { if (isRecoverableReplError(error)) { - console.error(error); + console.error(error instanceof Error ? error.message : String(error)); continue; } throw error; @@ -38,19 +35,3 @@ export async function startRepl(commands: Command[], initOptions: InitConfig): P await session.dispose(); } } - -function isRecoverableReplError(error: unknown): boolean { - if ( - error instanceof ProtonDriveError || - error instanceof CommandError || - error instanceof AccountApiError || - error instanceof ReplUnclosedQuoteError - ) { - return true; - } - if (error instanceof TypeError) { - const code = (error as NodeJS.ErrnoException).code; - return typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS_'); - } - return false; -} diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 4539a388..718c8ceb 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -1,13 +1,9 @@ -import { parseArgs } from 'util'; - -import { ProtonDriveError } from '@protontech/drive-sdk'; - import { InitConfig } from '../config'; import { init } from '../init'; -import { AuthRequiredError } from './errors'; +import { AuthRequiredError, isRecoverableReplError } from './errors'; import { formatReadableJson } from './formatters'; import { Command } from './interface'; -import { getCommand, printCommandUsage, validateCommandArguments } from './registryCore'; +import { getCommand, getCommandArguments, printCommandUsage } from './registryCore'; export type CliSession = Awaited>; @@ -20,8 +16,8 @@ export async function runSingleInvocation( try { await runCommand(commands, argv, initOptions, session); } catch (error: unknown) { - if (error instanceof ProtonDriveError) { - console.error(error.message); + if (isRecoverableReplError(error)) { + console.error(error instanceof Error ? error.message : String(error)); process.exit(1); return; } @@ -36,19 +32,22 @@ export async function runCommand( initOptions: InitConfig, session?: CliSession, ): Promise { - const { command, options, args } = parseCliInvocation(commands, argv); + const { command, options, args } = parseCliInvocation(commands, argv, initOptions); if (options['help']) { printCommandUsage(command); return; } - const debug = initOptions.debug === undefined ? !options.json : initOptions.debug; const oneOffSession = !session; - session = session ?? await init({ ...initOptions, debug }); + session = session ?? (await init({ ...initOptions, enableConsoleLog: !!options.verbose })); verifyAuthentication(command, session); try { - await dispatchAction(command, session, args, options, debug); + await command.action({ + ...session, + args, + options, + }); } finally { if (oneOffSession) { await session.dispose(); @@ -56,18 +55,11 @@ export async function runCommand( } } -function parseCliInvocation(commands: Command[], argv: string[]) { +function parseCliInvocation(commands: Command[], argv: string[], initOptions: InitConfig) { const groupName = argv[2]!; const commandName = argv[3]!; - const command = getCommand(commands, groupName, commandName); - const { values: options, positionals } = parseArgs({ - args: argv, - options: command.options || {}, - strict: true, - allowPositionals: true, - }); - const args = positionals.slice(4); - validateCommandArguments(command, args, options); + const command = getCommand(commands, groupName, commandName, initOptions); + const { options, args } = getCommandArguments(command, argv); return { command, options, args }; } @@ -77,35 +69,6 @@ function verifyAuthentication(command: Command, session: CliSession) { } } -async function dispatchAction( - command: Command, - session: CliSession, - args: string[], - options: { [name: string]: unknown }, - debug: boolean, -) { - if (debug) { - console.time('Command execution'); - } - try { - await command.action({ - logger: session.logger, - auth: session.auth, - sdk: session.sdk, - photosSdk: session.photosSdk, - sdkDiagnostic: session.sdkDiagnostic, - paths: session.paths, - eventsManager: session.eventsManager, - args, - options, - }); - } finally { - if (debug) { - console.timeEnd('Command execution'); - } - } -} - function reportFatalError(error: unknown) { console.error('==============================================='); console.trace(error); diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts index 51e5a39e..8cb63c04 100644 --- a/js/cli/src/commands/auth/commandAuthLogin.ts +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -6,8 +6,9 @@ export class CommandAuthLogin implements Command { name = 'login'; isAuthAction = true; - async action({ auth }: ActionArgs) { + async action({ auth, eventsManager }: ActionArgs) { await this.handleAuthViaWeb(auth); + await eventsManager.startSubscriptions(); } protected async handleAuthViaWeb(auth: Auth) { diff --git a/js/cli/src/commands/auth/commandAuthLogout.ts b/js/cli/src/commands/auth/commandAuthLogout.ts index 6ba4ba83..8bc07666 100644 --- a/js/cli/src/commands/auth/commandAuthLogout.ts +++ b/js/cli/src/commands/auth/commandAuthLogout.ts @@ -5,7 +5,8 @@ export class CommandAuthLogout implements Command { name = 'logout'; isAuthAction = true; - async action({ auth }: ActionArgs) { + async action({ auth, clearCaches }: ActionArgs) { await auth.logout(); + await clearCaches(); } } diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 84569d5b..1e972a92 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -1,5 +1,7 @@ +import { LogLevel } from '@protontech/drive-sdk/telemetry'; + export interface InitConfig { - debug?: boolean; + enableConsoleLog?: boolean; appVersion: string; sdkVersion?: string; clientUidPrefix: string; @@ -15,6 +17,7 @@ export interface Config { cacheDir: string; enablePersistedEvents: boolean; enableConsoleLog: boolean; + logLevel: LogLevel; unsafeSecrets: boolean; unsafeCache: boolean; } @@ -24,6 +27,9 @@ export function getConfig(options: InitConfig): Config { process.env.PROTON_DRIVE_UNSAFE_SECRETS?.toLowerCase() ?? '', ); + const logLevelOption = process.env.PROTON_DRIVE_LOG_LEVEL?.toUpperCase() ?? 'DEBUG'; + const logLevel = LogLevel[logLevelOption as keyof typeof LogLevel] ?? LogLevel.DEBUG; + return { clientUidPrefix: options.clientUidPrefix, appVersion: options.appVersion, @@ -31,7 +37,8 @@ export function getConfig(options: InitConfig): Config { baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), enablePersistedEvents: options.enablePersistedEvents || false, - enableConsoleLog: options.debug || false, + enableConsoleLog: options.enableConsoleLog || false, + logLevel, unsafeSecrets, unsafeCache: unsafeSecrets, }; diff --git a/js/cli/src/events/interface.ts b/js/cli/src/events/interface.ts index 63c45fbb..7e5c9f3c 100644 --- a/js/cli/src/events/interface.ts +++ b/js/cli/src/events/interface.ts @@ -7,5 +7,6 @@ export interface EventsProvider extends LatestEventIdProvider { getInitialSubscriptionScopeIds(): { context: EventsContext; treeEventScopeIds: string[] }[]; setLatestEventId(context: EventsContext, treeEventScopeId: string, eventId: string): Promise; removeScope(context: EventsContext, treeEventScopeId: string): Promise; + clear(): Promise; dispose(): Promise; } diff --git a/js/cli/src/events/manager.ts b/js/cli/src/events/manager.ts index 85e75062..58c9cfaf 100644 --- a/js/cli/src/events/manager.ts +++ b/js/cli/src/events/manager.ts @@ -32,15 +32,22 @@ export class Manager { driveSdk: TreeSdk, photosSdk: TreeSdk, provider: EventsProvider, + startSubscriptions: boolean = true, ): Promise { const manager = new Manager(logger, driveSdk, photosSdk, provider); - if (provider.canListenForEvents()) { - await manager.subscribeCoreEvents(); - await manager.subscribeInitialScopesFromProvider(); + if (startSubscriptions) { + await manager.startSubscriptions(); } return manager; } + async startSubscriptions() { + if (this.provider.canListenForEvents()) { + await this.subscribeCoreEvents(); + await this.subscribeInitialScopesFromProvider(); + } + } + private async subscribeCoreEvents(): Promise { if (this.coreSubscription) { return; @@ -118,6 +125,11 @@ export class Manager { }; } + async clear(): Promise { + await this.dispose(); + await this.provider.clear(); + } + async dispose(): Promise { this.logger.debug('Disposing events manager'); diff --git a/js/cli/src/events/providerMemory.ts b/js/cli/src/events/providerMemory.ts index 5ea45138..478d35fe 100644 --- a/js/cli/src/events/providerMemory.ts +++ b/js/cli/src/events/providerMemory.ts @@ -23,9 +23,7 @@ export class MemoryEventsProvider implements EventsProvider { async getLatestEventId(treeEventScopeId: string): Promise { return ( - this.lastByContext.drive.get(treeEventScopeId) ?? - this.lastByContext.photos.get(treeEventScopeId) ?? - null + this.lastByContext.drive.get(treeEventScopeId) ?? this.lastByContext.photos.get(treeEventScopeId) ?? null ); } @@ -37,5 +35,10 @@ export class MemoryEventsProvider implements EventsProvider { this.lastByContext[context].delete(treeEventScopeId); } + async clear(): Promise { + this.lastByContext.drive.clear(); + this.lastByContext.photos.clear(); + } + async dispose(): Promise {} } diff --git a/js/cli/src/events/providerNoEvents.ts b/js/cli/src/events/providerNoEvents.ts index 7fd3e54f..5aaf13cc 100644 --- a/js/cli/src/events/providerNoEvents.ts +++ b/js/cli/src/events/providerNoEvents.ts @@ -22,5 +22,7 @@ export class NoEventsProvider implements EventsProvider { async removeScope(context: EventsContext, treeEventScopeId: string): Promise {} + async clear(): Promise {} + async dispose(): Promise {} } diff --git a/js/cli/src/events/providerPersisted.ts b/js/cli/src/events/providerPersisted.ts index d85e0dc1..18fa2f01 100644 --- a/js/cli/src/events/providerPersisted.ts +++ b/js/cli/src/events/providerPersisted.ts @@ -63,6 +63,10 @@ export class PersistedEventsProvider implements EventsProvider { await this.store.flush(); } + async clear(): Promise { + await this.store.clear(); + } + async dispose(): Promise { if (this.holdsLock) { await releaseEventsLock(this.logger, this.cacheDir); diff --git a/js/cli/src/events/storage.ts b/js/cli/src/events/storage.ts index 50dbe421..0e06f4be 100644 --- a/js/cli/src/events/storage.ts +++ b/js/cli/src/events/storage.ts @@ -70,6 +70,10 @@ export class EventsFileStore { const body = JSON.stringify(this.scopes, null, 2); await fs.writeFile(filePath, body, 'utf8'); } + + async clear(): Promise { + await fs.unlink(eventsJsonPath(this.cacheDir)); + } } function eventsJsonPath(cacheDir: string): string { diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index f8d2aadb..0b3ecbbd 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -17,7 +17,7 @@ import { initTelemetry } from './telemetry'; export async function init(configOptions: InitConfig) { const config = getConfig(configOptions); - const telemetry = initTelemetry(config.cacheDir, config.enableConsoleLog); + const telemetry = initTelemetry(config); const logger = telemetry.getLogger('cli'); const openPGPCryptoModule = initOpenPGPCryptoModule(); @@ -51,7 +51,7 @@ export async function init(configOptions: InitConfig) { const photosSdk = new ProtonDrivePhotosClient(sdkDependencies); const sdkDiagnostic = initDiagnostic(sdkDependencies); - const eventsManager = await Manager.create(logger, sdk, photosSdk, eventsProvider); + const eventsManager = await Manager.create(logger, sdk, photosSdk, eventsProvider, auth.isLoggedIn()); const paths = new Paths(sdk, photosSdk, auth, eventsManager); @@ -68,6 +68,10 @@ export async function init(configOptions: InitConfig) { dispose: async () => { await eventsManager.dispose(); }, + clearCaches: async () => { + logger.debug('Clearing caches'); + await Promise.allSettled([eventsManager.clear(), caches.cryptoCache.clear(), caches.entitiesCache.clear()]); + }, }; } diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 5d29ec21..490f00d8 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -15,7 +15,6 @@ const CLIENT_UID_PREFIX = 'sdk-js-cli'; appVersion: APP_VERSION, sdkVersion: SDK_VERSION, enablePersistedEvents: true, - debug: false, // TODO: Configure flags via Unleash. flags: { [FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead]: true, diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json index 775e81c8..5bea5383 100644 --- a/js/cli/tsconfig.json +++ b/js/cli/tsconfig.json @@ -9,7 +9,14 @@ "moduleResolution": "bundler", "paths": { "@protontech/drive-sdk": ["../sdk/src/index.ts"], - "@protontech/drive-sdk/*": ["../sdk/src/*"] + "@protontech/drive-sdk/*": ["../sdk/src/*"], + // Use the patched version of crypto. + "@protontech/crypto": [ + "./node_modules/@protontech/crypto/src/index.ts" + ], + "@protontech/crypto/*": [ + "./node_modules/@protontech/crypto/src/*" + ] }, "noEmit": false, "noImplicitAny": true, From b9e1fab439faeefe2392ff8ce0f3402013b8cc69 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 15:15:33 +0200 Subject: [PATCH 623/732] Add sharing commands --- js/cli/src/commands/registry.ts | 16 ++++++ .../sharing/commandInvitationAccept.ts | 21 +++++++ .../commands/sharing/commandInvitationList.ts | 48 ++++++++++++++++ .../sharing/commandInvitationReject.ts | 21 +++++++ .../commands/sharing/commandSharingInvite.ts | 57 +++++++++++++++++++ .../commands/sharing/commandSharingRemove.ts | 47 +++++++++++++++ .../sharing/commandSharingRemoveUrl.ts | 18 ++++++ .../commands/sharing/commandSharingSetUrl.ts | 48 ++++++++++++++++ .../commands/sharing/commandSharingStatus.ts | 16 ++++++ js/cli/src/commands/sharing/invitations.ts | 26 +++++++++ 10 files changed, 318 insertions(+) create mode 100644 js/cli/src/commands/sharing/commandInvitationAccept.ts create mode 100644 js/cli/src/commands/sharing/commandInvitationList.ts create mode 100644 js/cli/src/commands/sharing/commandInvitationReject.ts create mode 100644 js/cli/src/commands/sharing/commandSharingInvite.ts create mode 100644 js/cli/src/commands/sharing/commandSharingRemove.ts create mode 100644 js/cli/src/commands/sharing/commandSharingRemoveUrl.ts create mode 100644 js/cli/src/commands/sharing/commandSharingSetUrl.ts create mode 100644 js/cli/src/commands/sharing/commandSharingStatus.ts create mode 100644 js/cli/src/commands/sharing/invitations.ts diff --git a/js/cli/src/commands/registry.ts b/js/cli/src/commands/registry.ts index 2f341698..cfbebf86 100644 --- a/js/cli/src/commands/registry.ts +++ b/js/cli/src/commands/registry.ts @@ -13,6 +13,14 @@ import { CommandFileSystemRename } from './fileSystem/commandFileSystemRename'; import { CommandFileSystemRestore } from './fileSystem/commandFileSystemRestore'; import { CommandFileSystemTrash } from './fileSystem/commandFileSystemTrash'; import { CommandFileSystemUpload } from './fileSystem/commandFileSystemUpload'; +import { CommandInvitationAccept } from './sharing/commandInvitationAccept'; +import { CommandInvitationList } from './sharing/commandInvitationList'; +import { CommandInvitationReject } from './sharing/commandInvitationReject'; +import { CommandSharingInvite } from './sharing/commandSharingInvite'; +import { CommandSharingRemove } from './sharing/commandSharingRemove'; +import { CommandSharingRemoveUrl } from './sharing/commandSharingRemoveUrl'; +import { CommandSharingSetUrl } from './sharing/commandSharingSetUrl'; +import { CommandSharingStatus } from './sharing/commandSharingStatus'; export const COMMANDS = applyDefaultCliOptions([ new CommandAuthLogin(), @@ -29,4 +37,12 @@ export const COMMANDS = applyDefaultCliOptions([ new CommandFileSystemRestore(), new CommandFileSystemDelete(), new CommandFileSystemEmptyTrash(), + new CommandSharingStatus(), + new CommandSharingInvite(), + new CommandSharingRemove(), + new CommandSharingSetUrl(), + new CommandSharingRemoveUrl(), + new CommandInvitationList(), + new CommandInvitationAccept(), + new CommandInvitationReject(), ]); diff --git a/js/cli/src/commands/sharing/commandInvitationAccept.ts b/js/cli/src/commands/sharing/commandInvitationAccept.ts new file mode 100644 index 00000000..16e238d6 --- /dev/null +++ b/js/cli/src/commands/sharing/commandInvitationAccept.ts @@ -0,0 +1,21 @@ +import { type ActionArgs, type Command } from '../../cli'; +import { parseInvitationUid } from './invitations'; + +export class CommandInvitationAccept implements Command { + group = 'invitation'; + name = 'accept'; + args = ['invitationUid']; + + async action({ sdk, photosSdk, args: [invitationUid], options: { json } }: ActionArgs) { + const { isForPhotos, uid } = parseInvitationUid(invitationUid); + if (isForPhotos) { + await photosSdk.acceptInvitation(uid); + } else { + await sdk.acceptInvitation(uid); + } + + if (!json) { + console.log(`✅ Invitation accepted`); + } + } +} diff --git a/js/cli/src/commands/sharing/commandInvitationList.ts b/js/cli/src/commands/sharing/commandInvitationList.ts new file mode 100644 index 00000000..6cdda16c --- /dev/null +++ b/js/cli/src/commands/sharing/commandInvitationList.ts @@ -0,0 +1,48 @@ +import { ProtonDriveClient, ProtonInvitationWithNode } from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; + +import { + type ActionArgs, + type Command, + formatAuthor, + formatDate, + formatMemberRole, + printIterable, + sanitizeTerminalText, +} from '../../cli'; +import { getInvitationUid } from './invitations'; + +export class CommandInvitationList implements Command { + group = 'invitation'; + name = 'list'; + + async action({ sdk, photosSdk, options: { json } }: ActionArgs) { + await this.printInvitations(sdk, 'drive', json); + await this.printInvitations(photosSdk, 'photos', json); + } + + private async printInvitations( + sdk: ProtonDriveClient | ProtonDrivePhotosClient, + context: 'drive' | 'photos', + json: boolean, + ): Promise { + await printIterable( + sdk.iterateInvitations(), + json, + (invitation) => this.printInvitationHuman(invitation, getInvitationUid(context, invitation.uid)), + (invitation) => ({ + ...invitation, + uid: getInvitationUid(context, invitation.uid), + }), + ); + } + + private printInvitationHuman(invitation: ProtonInvitationWithNode, uid: string): void { + const type = invitation.node.type === 'file' ? '📄' : '🗂️'; + const permissionFlag = formatMemberRole(invitation.role); + const author = formatAuthor(invitation.addedByEmail); + const created = formatDate(invitation.invitationTime, true); + const name = invitation.node.name.ok ? invitation.node.name.value : invitation.node.name.error.name; + console.log(sanitizeTerminalText(`${type}${permissionFlag} ${author} ${created} ${name} "${uid}"`)); + } +} diff --git a/js/cli/src/commands/sharing/commandInvitationReject.ts b/js/cli/src/commands/sharing/commandInvitationReject.ts new file mode 100644 index 00000000..6061ddfd --- /dev/null +++ b/js/cli/src/commands/sharing/commandInvitationReject.ts @@ -0,0 +1,21 @@ +import { type ActionArgs, type Command } from '../../cli'; +import { parseInvitationUid } from './invitations'; + +export class CommandInvitationReject implements Command { + group = 'invitation'; + name = 'reject'; + args = ['invitationUid']; + + async action({ sdk, photosSdk, args: [invitationUid], options: { json } }: ActionArgs) { + const { isForPhotos, uid } = parseInvitationUid(invitationUid); + if (isForPhotos) { + await photosSdk.rejectInvitation(uid); + } else { + await sdk.rejectInvitation(uid); + } + + if (!json) { + console.log(`✅ Invitation rejected`); + } + } +} diff --git a/js/cli/src/commands/sharing/commandSharingInvite.ts b/js/cli/src/commands/sharing/commandSharingInvite.ts new file mode 100644 index 00000000..b6956a9f --- /dev/null +++ b/js/cli/src/commands/sharing/commandSharingInvite.ts @@ -0,0 +1,57 @@ +import { ParseArgsConfig } from 'util'; + +import { MemberRole, ValidationError } from '@protontech/drive-sdk'; + +import { type ActionArgs, type Command, printObject } from '../../cli'; + +export class CommandSharingInvite implements Command { + group = 'sharing'; + name = 'invite'; + args = ['path']; + options: ParseArgsConfig['options'] = { + user: { + type: 'string', + short: 'u', + multiple: true, + default: [], + }, + role: { + type: 'string', + short: 'r', + default: 'viewer', + }, + message: { + type: 'string', + short: 'm', + default: '', + }, + 'include-node-name': { + type: 'boolean', + short: 'n', + default: false, + }, + }; + + async action({ + paths, + args: [pathString], + options: { user: userEmails, role, message, 'include-node-name': includeNodeName, json }, + }: ActionArgs) { + if (role !== MemberRole.Viewer && role !== MemberRole.Editor && role !== MemberRole.Admin) { + throw new ValidationError(`Invalid role: ${role}, must be one of: viewer, editor, admin`); + } + + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const sharingInfo = await nodePath.sdk.shareNode(node, { + users: userEmails.map((email: string) => ({ email, role })), + emailOptions: { + message: message || undefined, + includeNodeName, + }, + }); + + printObject(sharingInfo, json); + } +} diff --git a/js/cli/src/commands/sharing/commandSharingRemove.ts b/js/cli/src/commands/sharing/commandSharingRemove.ts new file mode 100644 index 00000000..57f8c8b7 --- /dev/null +++ b/js/cli/src/commands/sharing/commandSharingRemove.ts @@ -0,0 +1,47 @@ +import { ParseArgsConfig } from 'util'; + +import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; +import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; + +import { type ActionArgs, type Command, printObject } from '../../cli'; + +export class CommandSharingRemove implements Command { + group = 'sharing'; + name = 'remove'; + args = ['path']; + options: ParseArgsConfig['options'] = { + email: { + type: 'string', + short: 'e', + multiple: true, + default: [], + }, + everyone: { + type: 'boolean', + short: 'a', + default: false, + }, + }; + + async action({ paths, args: [pathString], options: { email: emails, everyone, json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const users = everyone ? await this.getAllMembers(nodePath.sdk, node) : emails; + const sharingInfo = await nodePath.sdk.unshareNode(node, { + users, + }); + + printObject(sharingInfo, json); + } + + private async getAllMembers(sdk: ProtonDriveClient | ProtonDrivePhotosClient, node: MaybeNode): Promise { + const sharingInfo = await sdk.getSharingInfo(node); + + return [ + ...sharingInfo?.members.map((member) => member.inviteeEmail) || [], + ...sharingInfo?.protonInvitations.map((invitation) => invitation.inviteeEmail) || [], + ...sharingInfo?.nonProtonInvitations.map((invitation) => invitation.inviteeEmail) || [], + ] + } +} diff --git a/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts b/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts new file mode 100644 index 00000000..9ff8adcd --- /dev/null +++ b/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts @@ -0,0 +1,18 @@ +import { type ActionArgs, type Command, printObject } from '../../cli'; + +export class CommandSharingRemoveUrl implements Command { + group = 'sharing'; + name = 'remove-url'; + args = ['path']; + + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const sharingInfo = await nodePath.sdk.unshareNode(node, { + publicLink: 'remove', + }); + + printObject(sharingInfo, json); + } +} diff --git a/js/cli/src/commands/sharing/commandSharingSetUrl.ts b/js/cli/src/commands/sharing/commandSharingSetUrl.ts new file mode 100644 index 00000000..da2d81f2 --- /dev/null +++ b/js/cli/src/commands/sharing/commandSharingSetUrl.ts @@ -0,0 +1,48 @@ +import { ParseArgsConfig } from 'util'; + +import { MemberRole, ValidationError } from '@protontech/drive-sdk'; + +import { type ActionArgs, type Command, printObject } from '../../cli'; + +export class CommandSharingSetUrl implements Command { + group = 'sharing'; + name = 'set-url'; + args = ['path']; + options: ParseArgsConfig['options'] = { + role: { + type: 'string', + default: 'viewer', + }, + password: { + type: 'string', + default: '', + }, + expiration: { + type: 'string', + default: '', + }, + }; + + async action({ paths, args: [pathString], options: { json, role, password, expiration } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + if (role !== MemberRole.Viewer && role !== MemberRole.Editor) { + throw new ValidationError(`Invalid role: ${role}, must be one of: viewer, editor`); + } + + if (expiration && isNaN(new Date(expiration).getTime())) { + throw new ValidationError(`Invalid expiration date: ${expiration}`); + } + + const sharingInfo = await nodePath.sdk.shareNode(node, { + publicLink: { + role, + customPassword: password || undefined, + expiration: expiration ? new Date(expiration) : undefined, + }, + }); + + printObject(sharingInfo, json); + } +} diff --git a/js/cli/src/commands/sharing/commandSharingStatus.ts b/js/cli/src/commands/sharing/commandSharingStatus.ts new file mode 100644 index 00000000..aa258f3a --- /dev/null +++ b/js/cli/src/commands/sharing/commandSharingStatus.ts @@ -0,0 +1,16 @@ +import { type ActionArgs, type Command, printObject } from '../../cli'; + +export class CommandSharingStatus implements Command { + group = 'sharing'; + name = 'status'; + args = ['path']; + + async action({ paths, args: [pathString], options: { json } }: ActionArgs) { + const nodePath = paths.getPath(pathString); + const node = await nodePath.getNode(); + + const sharingInfo = await nodePath.sdk.getSharingInfo(node); + + printObject(sharingInfo, json); + } +} diff --git a/js/cli/src/commands/sharing/invitations.ts b/js/cli/src/commands/sharing/invitations.ts new file mode 100644 index 00000000..f9eda97b --- /dev/null +++ b/js/cli/src/commands/sharing/invitations.ts @@ -0,0 +1,26 @@ +import { ValidationError } from "@protontech/drive-sdk"; + +type InvitationContext = 'drive' | 'photos'; + +export function getInvitationUid(context: InvitationContext, uid: string): string { + return `${context}~${uid}`; +} + +export function parseInvitationUid(input: string): { + isForPhotos: boolean; + uid: string; +} { + if (input.startsWith('drive~')) { + return { + isForPhotos: false, + uid: input.slice(5), + }; + } else if (input.startsWith('photos~')) { + return { + isForPhotos: true, + uid: input.slice(6), + }; + } else { + throw new ValidationError(`Invalid invitation UID: ${input}`); + } +} From 048920a80ad58b8d3092f1081ad74437ace46792 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 15:31:23 +0200 Subject: [PATCH 624/732] 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:11:00 +0200 Subject: [PATCH 625/732] Improve auth UX --- js/cli/src/api/auth.ts | 3 ++- js/cli/src/commands/auth/commandAuthLogin.ts | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/js/cli/src/api/auth.ts b/js/cli/src/api/auth.ts index 05c650c0..d9f5e555 100644 --- a/js/cli/src/api/auth.ts +++ b/js/cli/src/api/auth.ts @@ -138,7 +138,8 @@ export class Auth { } catch (error) { // The API returns 422 if the authentication is not yet ready. if (error instanceof AccountApiError && error.httpCode === 422) { - this.logger.debug('Authentication not yet ready'); + const debug = error.debug as { Error?: string }; + this.logger.debug(`Authentication not yet ready (${error.code}: ${debug.Error})`); await sleepMs(FORK_POLL_INTERVAL_MS, signal); continue; } diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts index 8cb63c04..28c6e6e8 100644 --- a/js/cli/src/commands/auth/commandAuthLogin.ts +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -6,16 +6,26 @@ export class CommandAuthLogin implements Command { name = 'login'; isAuthAction = true; - async action({ auth, eventsManager }: ActionArgs) { - await this.handleAuthViaWeb(auth); + async action({ auth, eventsManager, options: { json } }: ActionArgs) { + await this.handleAuthViaWeb(auth, json); + if (!json) { + console.log('Authentication successful'); + } await eventsManager.startSubscriptions(); } - protected async handleAuthViaWeb(auth: Auth) { + protected async handleAuthViaWeb(auth: Auth, json: boolean) { return auth.authViaWeb((signInUrl) => { openBrowserUrl(signInUrl); - console.log('Sign in in your browser (URL also printed if it did not open automatically):'); - console.log(sanitizeTerminalText(signInUrl)); + if (json) { + console.log(sanitizeTerminalText(JSON.stringify({ signInUrl }))); + } else { + console.log( + 'Sign in in your browser. Keep the terminal open. Waiting for authentication to complete...', + ); + console.log('Open following URL manually if browser did not open automatically:'); + console.log(sanitizeTerminalText(signInUrl)); + } }); } } From ceb652d418346a389ce543c5e5010a4cc5c729a6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 08:49:12 +0200 Subject: [PATCH 626/732] Use standard directories for caches, app data and logs --- js/cli/README.md | 85 +++++++++++++++++++++++++-------- js/cli/src/clientUid.ts | 4 +- js/cli/src/config.ts | 63 +++++++++++++++++++++++- js/cli/src/credentials/index.ts | 2 +- js/cli/src/init.ts | 13 ++++- 5 files changed, 141 insertions(+), 26 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index 15551600..2f1688f4 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -1,52 +1,97 @@ # Proton Drive CLI -The CLI provides dependencies for the SDK (such as HTTP client, logging, crypto module or account interface) and implements a simple interactive shell to run commands. The CLI is currently built using Bun and can be run on any platform that Bun supports. +Command-line interface for **Proton Drive**. Use it to script backups, uploads, downloads, and sharing workflows against your cloud Drive, with machine-readable output for automation. -## Installation +The CLI is built with [Bun](https://bun.sh) and uses the Drive SDK under the hood integrating all required dependencies (such as HTTP client, crypto library, caching, etc.). -First, you need to install Bun. +## Requirements -Then you need to install CLI dependencies. +- [Bun](https://bun.sh) (for development builds from this repository) +- A Proton account and browser access for sign-in +- **[sharp](https://www.npmjs.com/package/sharp/)** if you want thumbnails generated on image upload (see [Thumbnails](#thumbnails)) -From `js/cli`: +## Install and build (from source) + +From the `js/cli` directory: ```bash bun install +bun run build ``` -Then you can build the CLI. +This produces a standalone executable at **`release/proton-drive`**. Add that directory to your `PATH`, or invoke it with a full path. + +The build embeds Bun and minifies the bundle; `sharp` library is not bundled, see [Thumbnails](#thumbnails) for how to supply it to the binary. + +## Authentication + +Sign-in uses the browser (no password on the command line): ```bash -bun run build +./release/proton-drive auth login ``` -Executable is written to the **release** folder when you run the command above. +After a successful login, the CLI stores the session in the **OS secret store** (see [Where data is stored](#where-data-is-stored)). Log out with `auth logout`. -Note: The CLI requires the sharp package for generating thumbnails during upload. The library is not bundled with the CLI as it requires a native module depending on the platform. You need to install the sharp package separately when running the CLI outside of the CLI directory. Either install it globally (`bun install -g sharp`) and provide `NODE_PATH` environment variable to the CLI executable, or install it locally by running `bun install sharp` in the directory with the CLI executable. +## Usage -## Run +**Interactive shell** (no arguments): -Sign in via the browser (no username/password arguments needed): +```bash +./release/proton-drive +``` + +**One-shot commands** (good for scripts): ```bash -./proton-drive auth login +./release/proton-drive filesystem list /my-files +./release/proton-drive filesystem upload ./local-folder /my-files/parent +./release/proton-drive sharing status /my-files/shared-folder ``` -Start the interactive shell: +**Discover commands and their usage:** ```bash -./proton-drive +./release/proton-drive help +./release/proton-drive filesystem upload --help ``` -Get help: +**Version:** (to verify the CLI and SDK versions) ```bash -./proton-drive help +./release/proton-drive version ``` +**Automation-friendly output**: pass **`--json`** (`-j`) for structured results. + +**Verbose output**: Use **`--verbose`** (`-v`) for logs directly in the console. Log level is configured separately, see [Environment variables](#environment-variables)). + +## Thumbnails + +The CLI uses `sharp` library to generate thumbnails for images on upload for common image types (JPEG, PNG, GIF, BMP, TIFF, WebP). The library is not bundled with the CLI. To enable this feature, you need to install it with one of the following methods: + +1. Install `sharp` globally (`bun install -g sharp`) and provide `NODE_PATH=~/.bun/install/global/node_modules` environment variable to the CLI executable. +2. Install `sharp` locally (`bun install sharp`) and run the CLI from the directory with the `package.json` file. + +If `sharp` is missing, thumbnail generation fails with a clear error; you can skip thumbnail generation with **`--skip-thumbnails`** (**`-t`**) on `filesystem upload` when you do not need previews. + ## Environment variables -| Name | Description | Default Value | -|--------------------------|-------------------------------------------------------------------|-----------------------------| -| `PROTON_DRIVE_CACHE_DIR` | Directory where the SQLite and other caches, or logs, are stored. | Current working directory | -| `PROTON_DRIVE_LOG_LEVEL` | Log level to use for the logging. | DEBUG | +| Variable | Purpose | Default | +|----------|---------|---------| +| `PROTON_DRIVE_CACHE_DIR` | If set, **cache**, **app data**, and **logs** all use this single directory (portable installs, explicit data root). | Unset — OS-specific paths below | +| `PROTON_DRIVE_LOG_LEVEL` | Minimum log level written by the telemetry stack: `DEBUG`, `INFO`, `WARNING`, `ERROR`. | `DEBUG` | + +## Where data is stored + +Unless `PROTON_DRIVE_CACHE_DIR` is set: + +| Purpose | macOS | Windows | Linux / Unix | +|---------|--------|---------|----------------| +| **Cache** (`cache-crypto.sqlite`, `cache-entities.sqlite`) | `~/Library/Caches/proton-drive-cli` | `%LOCALAPPDATA%\proton-drive-cli\Cache` | `$XDG_CACHE_HOME/proton-drive-cli` or `~/.cache/proton-drive-cli` | +| **App data** (`clientUid.json`, `config.json`, `events.json`) | `~/Library/Application Support/proton-drive-cli` | `%LOCALAPPDATA%\proton-drive-cli\Data` | `$XDG_DATA_HOME/proton-drive-cli` or `~/.local/share/proton-drive-cli` | +| **Logs** (`proton-drive.log`) | `~/Library/Logs/proton-drive-cli` | `%LOCALAPPDATA%\proton-drive-cli\Logs` | `$XDG_STATE_HOME/proton-drive-cli` or `~/.local/state/proton-drive-cli` | + +**Credentials** are stored in keychain under service `ch.proton.drive/drive-sdk-cli`. + +To reset local state for troubleshooting, stop the CLI, then remove the relevant directories (or the single override directory). diff --git a/js/cli/src/clientUid.ts b/js/cli/src/clientUid.ts index 138f5a41..5e254c1f 100644 --- a/js/cli/src/clientUid.ts +++ b/js/cli/src/clientUid.ts @@ -16,7 +16,7 @@ interface ClientUidFile { * stored as plain JSON in cache directory. */ export async function getOrGenerateClientUid(config: Config, logger: Logger): Promise { - const path = join(config.cacheDir, CLIENT_UID_FILE); + const path = join(config.appDir, CLIENT_UID_FILE); const file = Bun.file(path); if (await file.exists()) { @@ -32,7 +32,7 @@ export async function getOrGenerateClientUid(config: Config, logger: Logger): Pr } const clientUid = `${config.clientUidPrefix}-${crypto.randomUUID()}`; - await mkdir(config.cacheDir, { recursive: true }); + await mkdir(config.appDir, { recursive: true }); const payload: ClientUidFile = { clientUid }; await Bun.write(path, `${JSON.stringify(payload, null, 2)}\n`); diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 1e972a92..17912488 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -1,5 +1,10 @@ +import { homedir } from 'node:os'; +import path from 'node:path'; + import { LogLevel } from '@protontech/drive-sdk/telemetry'; +const APP_DIR_NAME = 'proton-drive-cli'; + export interface InitConfig { enableConsoleLog?: boolean; appVersion: string; @@ -10,15 +15,32 @@ export interface InitConfig { } export interface Config { + /** Client UID is auto generated at the first run with a given prefix. */ clientUidPrefix: string; + /** Version of the CLI. */ appVersion: string; + /** Version of the SDK used by the CLI. */ sdkVersion?: string; + /** Base URL for the API. */ baseUrl: string; + + /** Cache folder for ephemeral files (cryptographic cache, etc.). */ cacheDir: string; + /** App data folder for persistent files (events, client UID, etc.). */ + appDir: string; + /** Folder for storing log files. */ + logDir: string; + + /** Whether to enable persisted events, stored in the app data folder. */ enablePersistedEvents: boolean; + /** Whether to enable printing logs to the console. */ enableConsoleLog: boolean; + /** Level of logging to both console and log file. */ logLevel: LogLevel; + + /** Only for testing: store secrets in the app data folder instead of keychain. */ unsafeSecrets: boolean; + /** Only for testing: store cryptographic cache unencrypted. */ unsafeCache: boolean; } @@ -30,12 +52,16 @@ export function getConfig(options: InitConfig): Config { const logLevelOption = process.env.PROTON_DRIVE_LOG_LEVEL?.toUpperCase() ?? 'DEBUG'; const logLevel = LogLevel[logLevelOption as keyof typeof LogLevel] ?? LogLevel.DEBUG; + const { cacheDir, appDir, logDir } = defaultDataDirs(); + return { clientUidPrefix: options.clientUidPrefix, appVersion: options.appVersion, sdkVersion: options.sdkVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', - cacheDir: process.env.PROTON_DRIVE_CACHE_DIR || process.cwd(), + cacheDir, + appDir, + logDir, enablePersistedEvents: options.enablePersistedEvents || false, enableConsoleLog: options.enableConsoleLog || false, logLevel, @@ -43,3 +69,38 @@ export function getConfig(options: InitConfig): Config { unsafeCache: unsafeSecrets, }; } + +function defaultDataDirs(): Pick { + const home = homedir(); + const override = process.env.PROTON_DRIVE_CACHE_DIR; + if (override) { + return { cacheDir: override, appDir: override, logDir: override }; + } + + if (process.platform === 'darwin') { + return { + cacheDir: path.join(home, 'Library', 'Caches', APP_DIR_NAME), + appDir: path.join(home, 'Library', 'Application Support', APP_DIR_NAME), + logDir: path.join(home, 'Library', 'Logs', APP_DIR_NAME), + }; + } + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const root = path.join(localAppData, APP_DIR_NAME); + return { + cacheDir: path.join(root, 'Cache'), + appDir: path.join(root, 'Data'), + logDir: path.join(root, 'Logs'), + }; + } + + const xdgCache = process.env.XDG_CACHE_HOME || path.join(home, '.cache'); + const xdgData = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'); + const xdgState = process.env.XDG_STATE_HOME || path.join(home, '.local', 'state'); + return { + cacheDir: path.join(xdgCache, APP_DIR_NAME), + appDir: path.join(xdgData, APP_DIR_NAME), + logDir: path.join(xdgState, APP_DIR_NAME), + }; +} diff --git a/js/cli/src/credentials/index.ts b/js/cli/src/credentials/index.ts index df6acb60..1488e3ee 100644 --- a/js/cli/src/credentials/index.ts +++ b/js/cli/src/credentials/index.ts @@ -15,7 +15,7 @@ export function initCredentials(config: Config, logger: Logger): Credentials { function createAuthSessionStore(config: Config, logger: Logger): CredentialsStore { if (config.unsafeSecrets) { - return new PlaintextFileSessionStore(config.cacheDir, logger); + return new PlaintextFileSessionStore(config.appDir, logger); } return new SecretsSessionStore(logger); } diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 0b3ecbbd..b66c91d4 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -1,5 +1,8 @@ import '@protontech/drive-sdk/polyfill'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; + import { CryptoProxy } from '@protontech/crypto'; import { Api as CryptoApi } from '@protontech/crypto/proxy/endpoint/api.ts'; import { FeatureFlags, Logger, OpenPGPCryptoWithCryptoProxy, ProtonDriveClient } from '@protontech/drive-sdk'; @@ -17,6 +20,12 @@ import { initTelemetry } from './telemetry'; export async function init(configOptions: InitConfig) { const config = getConfig(configOptions); + await Promise.all([ + mkdir(config.cacheDir, { recursive: true }), + mkdir(config.appDir, { recursive: true }), + mkdir(config.logDir, { recursive: true }), + ]); + const telemetry = initTelemetry(config); const logger = telemetry.getLogger('cli'); @@ -27,7 +36,7 @@ export async function init(configOptions: InitConfig) { const clientUid = await getOrGenerateClientUid(config, logger); const caches = createCaches(config, credentials, logger); const eventsProvider = config.enablePersistedEvents - ? await PersistedEventsProvider.open(logger, config.cacheDir) + ? await PersistedEventsProvider.open(logger, config.appDir) : new NoEventsProvider(); const sdkDependencies = { @@ -45,7 +54,7 @@ export async function init(configOptions: InitConfig) { latestEventIdProvider: eventsProvider, featureFlagProvider: configOptions.flags ? new FeatureFlagProvider(configOptions.flags) - : await FeatureFlagProvider.fromJsonFile(config.cacheDir + '/config.json'), + : await FeatureFlagProvider.fromJsonFile(path.join(config.appDir, 'config.json')), }; const sdk = new ProtonDriveClient(sdkDependencies); const photosSdk = new ProtonDrivePhotosClient(sdkDependencies); From 51250a903c129d93cdcbec6ec4f2c17f5dad06dc Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 07:07:54 +0000 Subject: [PATCH 627/732] 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 9278908a81dbb165efc6174f260218d99af4f1bf Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 12:10:50 +0000 Subject: [PATCH 628/732] 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 86c8fbd316cbd207acfdc13524225baf4ef02a7e Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 15:35:33 +0200 Subject: [PATCH 629/732] Remove verbose param from help for each command --- js/cli/src/cli/registryCore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/src/cli/registryCore.ts b/js/cli/src/cli/registryCore.ts index 38e36299..0ca97741 100644 --- a/js/cli/src/cli/registryCore.ts +++ b/js/cli/src/cli/registryCore.ts @@ -154,7 +154,7 @@ export function printCommandUsage(command: Command) { function printCommandManual(command: Command) { const args = (command.args || []).map((arg) => `<${arg}>`).join(' '); const options = Object.entries(command.options || {}) - .filter(([name]) => name !== 'help' && name !== 'json') + .filter(([name]) => name !== 'help' && name !== 'json' && name !== 'verbose') .map(([name, { type, short }]) => { const flag = short ? `-${short}|--${name}` : `--${name}`; return type === 'boolean' ? flag : `${flag} <${type}>`; From e9d8f190bb198a8f9360d5d9187c56c4b301a8d8 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 04:51:39 +0000 Subject: [PATCH 630/732] Drop leftover crypto dependencies --- js/cli/bun.lock | 14 +------------- js/cli/package.json | 2 -- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index bd7e18eb..71d7fca2 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -8,8 +8,6 @@ "@noble/hashes": "^1.8.0", "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", - "@protontech/pmcrypto": "^8.8.1", - "bcryptjs": "^2.4.3", "ky": "^1.14.3", "sharp": "^0.34.5", "ttag": "^1.8.7", @@ -482,8 +480,6 @@ "@protontech/global-types": ["@protontech/global-types@1.1.0", "", {}, "sha512-gMqOI4Vpr57wI5Th66D6okSDdNu93dvCc3tXnLQYN4BdZy/nc6mS/ih8yiLaw7L6anSDtoBEiQiadUNNVJVH6g=="], - "@protontech/pmcrypto": ["@protontech/pmcrypto@8.9.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "@openpgp/web-stream-tools": "~0.1.3", "jsmimeparser": "npm:@protontech/jsmimeparser@^3.0.2", "openpgp": "npm:@protontech/openpgp@~6.3.0" } }, "sha512-XTghHhZ9IR/VagaVYhUPyGNxhId0mKV3sw5+ggMmAYcJ7GIdflF36cc8c5fqfiNb0wCfZseGth+7M1bIJMDIcA=="], - "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "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" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], @@ -784,7 +780,7 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], - "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], @@ -1680,14 +1676,8 @@ "@protontech/crypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@protontech/crypto/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], - "@protontech/drive-sdk/@protontech/crypto": ["@protontech/crypto@2.0.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@openpgp/web-stream-tools": "~0.3.1", "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" } }, "sha512-PMvQi1L98eU9tCVNvoLIEq21Jwfs2a4LxJwoEVbEIhvZVVprCf4lRrwqce2HhPmyjQuQJDcSlqbma+o++G2LEw=="], - "@protontech/pmcrypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - - "@protontech/pmcrypto/openpgp": ["@protontech/openpgp@6.3.0", "", {}, "sha512-30dFyC2OUSlA8p5EYv+57pANFE9sZ71Koh24i69WrEmqGTsNIhPZmbCMhmLETdd6ZBMnXItYmumWY+CxKoYh+w=="], - "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -1824,8 +1814,6 @@ "@protontech/drive-sdk/@protontech/crypto/@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.3.1", "", { "peerDependencies": { "@types/node": ">=18.0.0", "typescript": ">=4.7" }, "optionalPeers": ["@types/node", "typescript"] }, "sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA=="], - "@protontech/drive-sdk/@protontech/crypto/bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], - "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/js/cli/package.json b/js/cli/package.json index dfd90045..481807ee 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -36,8 +36,6 @@ "@noble/hashes": "^1.8.0", "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", - "@protontech/pmcrypto": "^8.8.1", - "bcryptjs": "^2.4.3", "ky": "^1.14.3", "sharp": "^0.34.5", "ttag": "^1.8.7" From 24a1895fa85bbcffc75a4fe713744fdce6098406 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 14:14:30 +0200 Subject: [PATCH 631/732] Fix using correct client ID for authentication --- js/cli/src/api/auth.ts | 5 +++-- js/cli/src/api/authWeb.ts | 8 +++++--- js/cli/src/api/index.ts | 2 +- js/cli/src/config.ts | 3 +++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/js/cli/src/api/auth.ts b/js/cli/src/api/auth.ts index d9f5e555..fb1ad22d 100644 --- a/js/cli/src/api/auth.ts +++ b/js/cli/src/api/auth.ts @@ -15,6 +15,7 @@ import { Srp } from './srp'; export class Auth { private readonly srpModule: Srp; constructor( + private readonly authClientId: string, private readonly accountApi: AccountApi, private readonly credentials: Credentials, private readonly logger: Logger, @@ -55,7 +56,7 @@ export class Auth { info.Modulus, info.ServerEphemeral, info.Salt, - password + password, ); this.logger.debug('Authenticating'); @@ -118,7 +119,7 @@ export class Auth { this.logger.debug('Authenticating via web'); const forkResponse = await this.accountApi.sessionForksInit(); - const { encryptionKey, signInUrl } = generateSignInUrl(forkResponse.UserCode); + const { encryptionKey, signInUrl } = generateSignInUrl(this.authClientId, forkResponse.UserCode); await onSignInUrl(signInUrl); diff --git a/js/cli/src/api/authWeb.ts b/js/cli/src/api/authWeb.ts index 5602dbfd..d5550d38 100644 --- a/js/cli/src/api/authWeb.ts +++ b/js/cli/src/api/authWeb.ts @@ -1,7 +1,6 @@ import { createDecipheriv, randomBytes } from 'node:crypto'; const PROTON_ACCOUNT_URL = 'https://account.proton.me'; -const PROTON_SSO_CHILD_CLIENT_ID = 'external-drive'; const FORK_AAD = Buffer.from('fork', 'utf8'); const GCM_NONCE_LENGTH = 12; const GCM_TAG_LENGTH = 16; @@ -15,13 +14,16 @@ type ForkPayloadJson = { keyPassword?: string; }; -export function generateSignInUrl(userCode: string): { +export function generateSignInUrl( + authClientId: string, + userCode: string, +): { encryptionKey: Buffer; signInUrl: string; } { const encryptionKey = randomBytes(32); const base64EncodedKey = encryptionKey.toString('base64'); - const payload = `0:${userCode}:${base64EncodedKey}:${PROTON_SSO_CHILD_CLIENT_ID}`; + const payload = `0:${userCode}:${base64EncodedKey}:${authClientId}`; const signInUrl = `${PROTON_ACCOUNT_URL}/desktop/login?app=drive&pv=3#payload=${encodeURIComponent(payload)}`; return { diff --git a/js/cli/src/api/index.ts b/js/cli/src/api/index.ts index 63a7a4c8..fcafafc8 100644 --- a/js/cli/src/api/index.ts +++ b/js/cli/src/api/index.ts @@ -17,7 +17,7 @@ export async function initApi(config: Config, credentials: Credentials, logger: const apiClient = new ApiClient(config, credentials, logger); const accountApi = new AccountApi(apiClient); const addresses = new Addresses(accountApi, credentials, logger); - const auth = new Auth(accountApi, credentials, logger); + const auth = new Auth(config.authClientId, accountApi, credentials, logger); const srp = new Srp(accountApi); const httpClient = new HTTPClient(apiClient); diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 17912488..8b0559ea 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -19,6 +19,8 @@ export interface Config { clientUidPrefix: string; /** Version of the CLI. */ appVersion: string; + /** Client ID for the authentication. cli-drive for the official CLI, external-drive for 3p forks. */ + authClientId: string; /** Version of the SDK used by the CLI. */ sdkVersion?: string; /** Base URL for the API. */ @@ -57,6 +59,7 @@ export function getConfig(options: InitConfig): Config { return { clientUidPrefix: options.clientUidPrefix, appVersion: options.appVersion, + authClientId: options.appVersion.startsWith('cli-drive') ? 'cli-drive' : 'external-drive', sdkVersion: options.sdkVersion, baseUrl: process.env.PROTON_DRIVE_BASE_URL || 'drive-api.proton.me', cacheDir, From 674d97f855ae0b97615316ba50e032b72a00f1cd Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 14:18:17 +0200 Subject: [PATCH 632/732] Fix listing and processing invitations --- .../commands/sharing/commandInvitationList.ts | 31 ++++++++++--------- js/cli/src/commands/sharing/invitations.ts | 7 ++--- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/js/cli/src/commands/sharing/commandInvitationList.ts b/js/cli/src/commands/sharing/commandInvitationList.ts index 6cdda16c..122391bf 100644 --- a/js/cli/src/commands/sharing/commandInvitationList.ts +++ b/js/cli/src/commands/sharing/commandInvitationList.ts @@ -1,5 +1,4 @@ -import { ProtonDriveClient, ProtonInvitationWithNode } from '@protontech/drive-sdk'; -import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; +import { ProtonInvitationWithNode } from '@protontech/drive-sdk'; import { type ActionArgs, @@ -17,26 +16,30 @@ export class CommandInvitationList implements Command { name = 'list'; async action({ sdk, photosSdk, options: { json } }: ActionArgs) { - await this.printInvitations(sdk, 'drive', json); - await this.printInvitations(photosSdk, 'photos', json); - } - - private async printInvitations( - sdk: ProtonDriveClient | ProtonDrivePhotosClient, - context: 'drive' | 'photos', - json: boolean, - ): Promise { await printIterable( - sdk.iterateInvitations(), + this.iterateInvitations({ sdk, photosSdk }), json, - (invitation) => this.printInvitationHuman(invitation, getInvitationUid(context, invitation.uid)), - (invitation) => ({ + ({ invitation, context }) => + this.printInvitationHuman(invitation, getInvitationUid(context, invitation.uid)), + ({ invitation, context }) => ({ ...invitation, uid: getInvitationUid(context, invitation.uid), }), ); } + private async *iterateInvitations({ sdk, photosSdk }: Pick): AsyncIterable<{ + invitation: ProtonInvitationWithNode; + context: 'drive' | 'photos'; + }> { + for await (const invitation of sdk.iterateInvitations()) { + yield { invitation, context: 'drive' }; + } + for await (const invitation of photosSdk.iterateInvitations()) { + yield { invitation, context: 'photos' }; + } + } + private printInvitationHuman(invitation: ProtonInvitationWithNode, uid: string): void { const type = invitation.node.type === 'file' ? '📄' : '🗂️'; const permissionFlag = formatMemberRole(invitation.role); diff --git a/js/cli/src/commands/sharing/invitations.ts b/js/cli/src/commands/sharing/invitations.ts index f9eda97b..f57dd452 100644 --- a/js/cli/src/commands/sharing/invitations.ts +++ b/js/cli/src/commands/sharing/invitations.ts @@ -13,14 +13,13 @@ export function parseInvitationUid(input: string): { if (input.startsWith('drive~')) { return { isForPhotos: false, - uid: input.slice(5), + uid: input.slice('drive~'.length), }; } else if (input.startsWith('photos~')) { return { isForPhotos: true, - uid: input.slice(6), + uid: input.slice('photos~'.length), }; - } else { - throw new ValidationError(`Invalid invitation UID: ${input}`); } + throw new ValidationError(`Invalid invitation UID: ${input}`); } From 3ac30d792da054a425f103a9d07af51b7dab1556 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 12:46:59 +0200 Subject: [PATCH 633/732] Remove slash validation name after decryption --- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs | 5 +++-- js/cli/src/cli/node.ts | 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 +++--- 9 files changed, 16 insertions(+), 21 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/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index 890cd9b8..cf7209e0 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -40,8 +40,5 @@ export function getClaimedSize(maybeNode: MaybeNode): number | undefined { } function validateName(name: string): boolean { - if (name.length == 0 || name.includes('/')) { - return false; - } - return true; + return name.length > 0; } 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 e97fa9dea266f510f624cad0b7b7afe750dc1eb6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 15:28:13 +0200 Subject: [PATCH 634/732] 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 1a37b9f3053cefa52107aab3c5eeea2453ed3469 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 14:40:43 +0200 Subject: [PATCH 635/732] Fix awaiting for command completion --- js/cli/scripts/build-cli.mjs | 2 ++ js/cli/src/proton-drive.ts | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index 985213c6..08bb7ad0 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -22,6 +22,8 @@ const args = [ `--target=${target}`, // Slower compile, bigger bundle size, faster execution. '--bytecode', + // Use modern ESM format to allow await syntax in the entry file. + '--format=esm', // Reduce bundle size (not much, the biggest part is the embedded Bun itself). '--minify', // Include source maps for readable stack traces. diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 490f00d8..a04d18a8 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -9,7 +9,7 @@ declare const SDK_VERSION: string | undefined; const CLIENT_UID_PREFIX = 'sdk-js-cli'; -(async () => { +try { await run(COMMANDS, { clientUidPrefix: CLIENT_UID_PREFIX, appVersion: APP_VERSION, @@ -22,7 +22,7 @@ const CLIENT_UID_PREFIX = 'sdk-js-cli'; }, }); process.exit(0); -})().catch((err) => { - console.error(err); +} catch (error: unknown) { + console.error(error); process.exit(1); -}); +} From 425bf23e42883fd55e07849136f042deeb7746f7 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 7 May 2026 21:54:24 +0000 Subject: [PATCH 636/732] 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 6c38a5df29c4c867b6096510a404939002ea1ce3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 6 May 2026 16:45:42 +0200 Subject: [PATCH 637/732] 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 289d329e4d3638218bfc96cdd2ccb13a6560496b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 05:03:20 +0000 Subject: [PATCH 638/732] 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 eeb3732f96a2e891bbea2fbb6d8d6da232286ca4 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 09:51:57 +0000 Subject: [PATCH 639/732] 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 c753eca59d37552a833e9095b3a77ea76827f3f9 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 08:31:12 +0200 Subject: [PATCH 640/732] Add keychain requirement in the README --- js/cli/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index 2f1688f4..080fd7b9 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -8,7 +8,11 @@ The CLI is built with [Bun](https://bun.sh) and uses the Drive SDK under the hoo - [Bun](https://bun.sh) (for development builds from this repository) - A Proton account and browser access for sign-in -- **[sharp](https://www.npmjs.com/package/sharp/)** if you want thumbnails generated on image upload (see [Thumbnails](#thumbnails)) +- A secret store provided by your OS: + - Windows: Windows Credential Manager + - macOS: Keychain Services + - Linux: libsecret (e.g., GNOME Keyring, KWallet) +- [sharp](https://www.npmjs.com/package/sharp/) if you want thumbnails generated on image upload (see [Thumbnails](#thumbnails)) ## Install and build (from source) @@ -92,6 +96,6 @@ Unless `PROTON_DRIVE_CACHE_DIR` is set: | **App data** (`clientUid.json`, `config.json`, `events.json`) | `~/Library/Application Support/proton-drive-cli` | `%LOCALAPPDATA%\proton-drive-cli\Data` | `$XDG_DATA_HOME/proton-drive-cli` or `~/.local/share/proton-drive-cli` | | **Logs** (`proton-drive.log`) | `~/Library/Logs/proton-drive-cli` | `%LOCALAPPDATA%\proton-drive-cli\Logs` | `$XDG_STATE_HOME/proton-drive-cli` or `~/.local/state/proton-drive-cli` | -**Credentials** are stored in keychain under service `ch.proton.drive/drive-sdk-cli`. +**Credentials** are stored in the OS secret store (see [Requirements](#requirements)) under the service `ch.proton.drive/drive-sdk-cli`. To reset local state for troubleshooting, stop the CLI, then remove the relevant directories (or the single override directory). From ddf2b1bf68d3f22832e9432fdecff264ecf6d321 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 10:54:20 +0000 Subject: [PATCH 641/732] 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 b6aeeed63b398c9d47f52370907f31c7b1d695ee Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 5 May 2026 16:33:19 +0200 Subject: [PATCH 642/732] 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 19ae1db0cb09ca2b163ac8a69663e70a1c429138 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 12:14:52 +0000 Subject: [PATCH 643/732] 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 97e9f54bef338e32b84f04ccac3b11a18df21a24 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 11 May 2026 15:14:44 +0000 Subject: [PATCH 644/732] 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 535f44f6bf3adaee9f52808a9f6744cee383f178 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 May 2026 04:42:36 +0000 Subject: [PATCH 645/732] Enfroce POSIX remote paths --- js/cli/src/cli/paths.ts | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 027d4da9..bc8572dd 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -7,6 +7,9 @@ import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePu import type { Manager } from '../events/manager'; import { getName } from './node'; +// Virtual Drive paths are always POSIX (/my-files/...) regardless of the host OS. +const PATH = path.posix; + export enum PathType { Root = 'root', MyFiles = 'my-files', @@ -129,48 +132,48 @@ export class Path { } get type() { - if (this.fullPath === `${path.sep}`) { + if (this.fullPath === PATH.sep) { return PathType.Root; } - if (this.fullPath.startsWith(`${path.sep}my-files`)) { + if (this.fullPath.startsWith(`${PATH.sep}my-files`)) { return PathType.MyFiles; } - if (this.fullPath.startsWith(`${path.sep}devices`)) { + if (this.fullPath.startsWith(`${PATH.sep}devices`)) { return PathType.Devices; } - if (this.fullPath.startsWith(`${path.sep}shared-by-me`)) { + if (this.fullPath.startsWith(`${PATH.sep}shared-by-me`)) { return PathType.SharedByMe; } - if (this.fullPath.startsWith(`${path.sep}shared-with-me`)) { + if (this.fullPath.startsWith(`${PATH.sep}shared-with-me`)) { return PathType.SharedWithMe; } - if (this.fullPath.startsWith(`${path.sep}trash`)) { + if (this.fullPath.startsWith(`${PATH.sep}trash`)) { return PathType.Trash; } - if (this.fullPath.startsWith(`${path.sep}photos-shared-by-me`)) { + if (this.fullPath.startsWith(`${PATH.sep}photos-shared-by-me`)) { return PathType.PhotosSharedByMe; } - if (this.fullPath.startsWith(`${path.sep}photos-shared-with-me`)) { + if (this.fullPath.startsWith(`${PATH.sep}photos-shared-with-me`)) { return PathType.PhotosSharedWithMe; } - if (this.fullPath.startsWith(`${path.sep}photos-trash`)) { + if (this.fullPath.startsWith(`${PATH.sep}photos-trash`)) { return PathType.PhotosTrash; } - if (this.fullPath.startsWith(`${path.sep}photos`)) { + if (this.fullPath.startsWith(`${PATH.sep}photos`)) { return PathType.Photos; } - if (this.fullPath.startsWith(`${path.sep}albums`)) { + if (this.fullPath.startsWith(`${PATH.sep}albums`)) { return PathType.Albums; } throw new ValidationError(`Path "${this.fullPath}" not supported`); } get parentPath() { - return new Path(this.driveSdk, this.photosSdk, path.dirname(this.fullPath), this.eventsManager); + return new Path(this.driveSdk, this.photosSdk, PATH.dirname(this.fullPath), this.eventsManager); } get name() { - return path.basename(this.fullPath); + return PATH.basename(this.fullPath); } get sdk(): ProtonDriveClient | ProtonDrivePhotosClient { @@ -214,7 +217,7 @@ export class Path { return this.getNodeByPath(rootNode, this.sectionPathWithoutRoot); } if (this.type === PathType.Trash || this.type === PathType.PhotosTrash) { - const parts = this.sectionPath.split(path.sep); + const parts = this.sectionPath.split(PATH.sep); if (parts.length > 1) { throw new ValidationError('Browsing trashed folders is not supported'); } @@ -240,17 +243,17 @@ export class Path { private get sectionPath() { // /my-files/foo/bar/baz -> foo/bar/baz - return this.fullPath.split(path.sep).slice(2).join(path.sep); + return this.fullPath.split(PATH.sep).slice(2).join(PATH.sep); } private get sectionRootNodeName() { // /shared-with-me/foo/bar/baz -> foo - return this.sectionPath.split(path.sep)[0]; + return this.sectionPath.split(PATH.sep)[0]; } private get sectionPathWithoutRoot() { // /shared-with-me/foo/bar/baz -> bar/baz - return this.fullPath.split(path.sep).slice(3).join(path.sep); + return this.fullPath.split(PATH.sep).slice(3).join(PATH.sep); } private async getSharedWithMeRootFolder() { @@ -288,7 +291,7 @@ export class Path { private async getNodeByPath(parentNode: MaybeNode, pathString: string) { let node = parentNode; - const pathParts = pathString.split(path.sep); + const pathParts = pathString.split(PATH.sep); for (const part of pathParts) { if (part === '') { continue; @@ -375,7 +378,7 @@ export class PublicLinkPath { private async getNodeByPath(parentNode: MaybeNode, pathString: string) { let node = parentNode; - const pathParts = pathString.split(path.sep); + const pathParts = pathString.split(PATH.sep); for (const part of pathParts) { if (part === '') { continue; From 43511ff502a59aba977d99dce8c271581bb533e8 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 May 2026 06:11:52 +0000 Subject: [PATCH 646/732] 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 3a7d05f5492662e2c86f5e1136c809e18cc2d982 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 13 May 2026 13:52:27 +0200 Subject: [PATCH 647/732] 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: Thu, 14 May 2026 04:59:35 +0000 Subject: [PATCH 648/732] Improve help for CLI --- js/cli/src/cli/help.ts | 191 ++++++++++++++++++ js/cli/src/cli/index.ts | 2 +- js/cli/src/cli/interface.ts | 13 +- js/cli/src/cli/registryCore.ts | 38 +--- js/cli/src/cli/run.ts | 3 +- .../fileSystem/commandFileSystemCopy.ts | 27 ++- .../commandFileSystemCreateFolder.ts | 2 +- .../fileSystem/commandFileSystemDownload.ts | 13 +- .../fileSystem/commandFileSystemList.ts | 18 +- .../fileSystem/commandFileSystemMove.ts | 2 +- .../fileSystem/commandFileSystemUpload.ts | 21 +- .../commands/sharing/commandSharingInvite.ts | 11 +- .../commands/sharing/commandSharingRemove.ts | 16 +- .../commands/sharing/commandSharingSetUrl.ts | 10 +- 14 files changed, 290 insertions(+), 77 deletions(-) create mode 100644 js/cli/src/cli/help.ts diff --git a/js/cli/src/cli/help.ts b/js/cli/src/cli/help.ts new file mode 100644 index 00000000..0d5c6a1b --- /dev/null +++ b/js/cli/src/cli/help.ts @@ -0,0 +1,191 @@ +import { Command, Option } from "./interface"; + +const ROW_INDENT = ' '; +const HIDDEN_OPTIONS = new Set(['help', 'json', 'verbose']); + +const OPTION_COLUMN_WIDTH = 25; +const OPTION_DESCRIPTION_PAD = ROW_INDENT + ' '.repeat(OPTION_COLUMN_WIDTH); + +/** + * Example output: + * + * ``` + * Usage: + * ... + * filesystem upload [-c STRATEGY] [-f STRATEGY] [-d STRATEGY] [-t] localPath... parentPath + * ... + * + * General options: + * -h|--help: Show extended help for a command + * -j|--json: Output in JSON format + * -v|--verbose: Enable verbose output + * ``` + */ +export function printUsage(commands: Command[]) { + console.log('Usage:'); + for (const command of commands) { + printCommandSynopsisLine(command); + } + + console.log(''); + console.log('General options:'); + console.log(`${ROW_INDENT}-h|--help: Show extended help for a command`); + console.log(`${ROW_INDENT}-j|--json: Output in JSON format`); + console.log(`${ROW_INDENT}-v|--verbose: Enable verbose output`); +} + +/** + * Example output: + * + * ``` + * Usage: + * filesystem upload [-c STRATEGY] [-f STRATEGY] [-d STRATEGY] [-t] localPath... parentPath + * + * Options: + * -c, --conflict-strategy STRATEGY + * Conflict strategy applied to all files and folders + * Values: merge, keep-both, replace, skip. + * ... + * ``` + */ +export function printCommandUsage(command: Command) { + console.log('Usage:'); + printCommandSynopsisLine(command); + + const opts = visibleOptions(command); + if (opts.length) { + console.log(''); + console.log('Options:'); + for (const [name, opt] of opts) { + printOptionManualRow(name, opt); + } + } + + const hasPath = command.args?.find((arg) => arg.includes('path')); + if (hasPath) { + printPathsHelp(); + } +} + +function printPathsHelp() { + console.log(''); + console.log('Remote paths:'); + console.log(`${ROW_INDENT}Posix paths are always used, regardless of the host OS.`); + console.log(`${ROW_INDENT}Node names are used for paths if available. When name cannot be decrypted`); + console.log(`${ROW_INDENT}or conflicts with other node(s), node UIDs can be used instead.`); + console.log(``); + console.log(`${ROW_INDENT}Examples:`); + console.log(`${ROW_INDENT}- /my-files/folder/file.txt`); + console.log(`${ROW_INDENT}- /my-files/folder/[NODE-UID]/file.txt`); + console.log(`${ROW_INDENT}- /shared-with-me/[NODE-UID]/file.txt`); +} + +function printCommandSynopsisLine(command: Command) { + const body = buildCommandSynopsis(command); + console.log(`${ROW_INDENT}${command.group} ${command.name}${body ? ` ${body}` : ''}`); +} + +function buildCommandSynopsis(command: Command): string { + const positionals = command.args || []; + const optionParts = visibleOptions(command).map(([name, opt]) => buildCommandSynopsisOption(name, opt)); + return [...optionParts, ...positionals].filter(Boolean).join(' '); +} + +function buildCommandSynopsisOption(longName: string, opt: Option): string { + const optional = opt.default !== undefined; + + let token = opt.short ? `-${opt.short}` : `--${longName}`; + if (opt.type === 'string') { + const meta = getOptionVariableName(longName); + token += ` ${meta}`; + if (opt.multiple) { + token += '...'; + } + } + return optional ? `[${token}]` : token; +} + +function printOptionManualRow(longName: string, opt: Option) { + const synopsis = buildCommandSynopsisOptionColumn(longName, opt); + const description = buildCommandOptionDescription(opt); + const descriptionWidth = Math.max(24, 80 - OPTION_DESCRIPTION_PAD.length); + const descriptionLines = wrapText(description, descriptionWidth); + + if (synopsis.length > OPTION_COLUMN_WIDTH) { + console.log(`${ROW_INDENT}${synopsis}`); + for (const line of descriptionLines) { + console.log(`${OPTION_DESCRIPTION_PAD}${line}`); + } + return; + } + + console.log(`${ROW_INDENT}${synopsis.padEnd(OPTION_COLUMN_WIDTH)}${descriptionLines[0] ?? ''}`); + for (let i = 1; i < descriptionLines.length; i++) { + console.log(`${OPTION_DESCRIPTION_PAD}${descriptionLines[i]}`); + } +} + +function buildCommandSynopsisOptionColumn(longName: string, opt: Option): string { + let spec = opt.short ? `-${opt.short}, --${longName}` : `--${longName}`; + if (opt.type === 'string') { + const meta = getOptionVariableName(longName); + spec += ` ${meta}`; + if (opt.multiple) { + spec += '...'; + } + } + return spec; +} + +function buildCommandOptionDescription(opt: Option): string { + let text = opt.help?.trim() || ''; + if (opt.allowedValues?.length) { + const allowed = opt.allowedValues.join(', '); + text = text ? `${text} Values: ${allowed}.` : `Values: ${allowed}.`; + } + if (opt.multiple) { + text = text ? `${text} May be repeated.` : 'May be repeated.'; + } + if (opt.default !== undefined && opt.default !== '' && !Array.isArray(opt.default)) { + text = text ? `${text} (default: ${opt.default})` : `(default: ${opt.default})`; + } + return text; +} + +function visibleOptions(command: Command): [string, Option][] { + return Object.entries(command.options || {}).filter(([name]) => !HIDDEN_OPTIONS.has(name)) as [ + string, + Option, + ][]; +} + +/** Last segment of the long option name (e.g. conflict-strategy → STRATEGY). */ +function getOptionVariableName(longName: string): string { + const segments = longName.split('-').filter(Boolean); + const base = segments.length ? segments[segments.length - 1]! : longName; + return base.toUpperCase(); +} + +function wrapText(text: string, width: number): string[] { + if (!text) { + return ['']; + } + const words = text.trim().split(/\s+/); + const lines: string[] = []; + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length <= width) { + line = next; + } else { + if (line) { + lines.push(line); + } + line = word; + } + } + if (line) { + lines.push(line); + } + return lines.length ? lines : ['']; +} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index dc28e240..f1e4c8e2 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -13,7 +13,7 @@ export { printObject, sanitizeTerminalText, } from './formatters'; -export type { ActionArgs, Command } from './interface'; +export type { ActionArgs, Command, Options } from './interface'; export { findName, getClaimedSize, getName, getNode, getNodeUid } from './node'; export { openBrowserUrl } from './openBrowserUrl'; export { Path, Paths, PathType } from './paths'; diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 3f0ff517..2a6abf38 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -1,4 +1,4 @@ -import { ParseArgsConfig } from 'util'; +import { ParseArgsOptionDescriptor } from 'util'; import { Logger, ProtonDriveClient } from '@protontech/drive-sdk'; import { Diagnostic } from '@protontech/drive-sdk/diagnostic'; @@ -14,11 +14,20 @@ export interface Command { isAuthAction?: boolean; isPublicAction?: boolean; args?: string[]; - options?: ParseArgsConfig['options']; + options?: Options; action: (args: ActionArgs) => Promise; } +export interface Options { + [longOption: string]: Option; +} + +export type Option = ParseArgsOptionDescriptor & { + allowedValues?: string[]; + help?: string; +}; + export interface ActionArgs { logger: Logger; auth: Auth; diff --git a/js/cli/src/cli/registryCore.ts b/js/cli/src/cli/registryCore.ts index 0ca97741..a7d13a29 100644 --- a/js/cli/src/cli/registryCore.ts +++ b/js/cli/src/cli/registryCore.ts @@ -2,6 +2,7 @@ import { parseArgs, ParseArgsConfig } from 'util'; import { InitConfig } from '../config'; import { CommandNotFoundError, InvalidCommandArgumentsError } from './errors'; +import { printCommandUsage, printUsage } from './help'; import { Command } from './interface'; export function applyDefaultCliOptions(commands: Command[]): Command[] { @@ -9,6 +10,7 @@ export function applyDefaultCliOptions(commands: Command[]): Command[] { command.options = command.options || {}; command.options['help'] = { type: 'boolean', + short: 'h', default: false, }; command.options['json'] = { @@ -31,10 +33,10 @@ export function getCommand( commandName: string, initOptions: InitConfig, ): Command { - if (groupName === 'help' || commandName === '--help' || commandName === '-h') { + if (groupName === 'help' || groupName === '--help' || groupName === '-h') { return new CommandHelp(commands); } - if (groupName === 'version' || commandName === '--version' || commandName === '-v') { + if (groupName === 'version' || groupName === '--version' || groupName === '-v') { return new CommandVersion(initOptions); } @@ -87,6 +89,10 @@ export function getCommandArguments( } function validateCommandArguments(command: Command, args: string[], values: { [name: string]: unknown }) { + if (values['help']) { + return; + } + if (command.args) { const hasVariableArgs = command.args.some((arg) => arg.endsWith('...')); const hasExpectedArgs = hasVariableArgs @@ -134,31 +140,3 @@ class CommandVersion implements Command { console.log(`Proton Drive SDK ${this.initOptions.sdkVersion}`); } } - -function printUsage(commands: Command[]) { - console.log('Usage:'); - commands.map((command) => { - printCommandManual(command); - }); - console.log('General options:'); - console.log(' -h|--help: Show help for a command'); - console.log(' -j|--json: Output in JSON format'); - console.log(' -v|--verbose: Enable verbose output'); -} - -export function printCommandUsage(command: Command) { - console.log('Usage:'); - printCommandManual(command); -} - -function printCommandManual(command: Command) { - const args = (command.args || []).map((arg) => `<${arg}>`).join(' '); - const options = Object.entries(command.options || {}) - .filter(([name]) => name !== 'help' && name !== 'json' && name !== 'verbose') - .map(([name, { type, short }]) => { - const flag = short ? `-${short}|--${name}` : `--${name}`; - return type === 'boolean' ? flag : `${flag} <${type}>`; - }) - .join(' '); - console.log(` ${command.group} ${command.name} ${args} ${options}`); -} diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 718c8ceb..74f61944 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -2,8 +2,9 @@ import { InitConfig } from '../config'; import { init } from '../init'; import { AuthRequiredError, isRecoverableReplError } from './errors'; import { formatReadableJson } from './formatters'; +import { printCommandUsage } from './help'; import { Command } from './interface'; -import { getCommand, getCommandArguments, printCommandUsage } from './registryCore'; +import { getCommand, getCommandArguments } from './registryCore'; export type CliSession = Awaited>; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index a791a433..a42e3f88 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -1,20 +1,29 @@ -import { ParseArgsConfig } from 'node:util'; - import { MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, findName, getName, getNodeUid, PathType, printIterable, sanitizeTerminalText } from '../../cli'; +import { + type ActionArgs, + type Command, + findName, + getName, + getNodeUid, + Options, + PathType, + printIterable, + sanitizeTerminalText, +} from '../../cli'; const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; export class CommandFileSystemCopy implements Command { group = 'filesystem'; name = 'copy'; - args = ['sourcePath...', 'targetPath']; - options: ParseArgsConfig['options'] = { + args = ['sourcePath...', 'targetParentPath']; + options: Options = { name: { type: 'string', short: 'n', default: '', + help: 'Name of the copied node.', }, }; @@ -36,7 +45,13 @@ export class CommandFileSystemCopy implements Command { } } - private async copyNode(sdk: ProtonDriveClient, sourceNode: MaybeNode, targetNode: MaybeNode, json: boolean, name: string) { + private async copyNode( + sdk: ProtonDriveClient, + sourceNode: MaybeNode, + targetNode: MaybeNode, + json: boolean, + name: string, + ) { await printIterable(sdk.copyNodes([{ uid: getNodeUid(sourceNode), name }], targetNode), json, (result) => { console.log(sanitizeTerminalText(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error}`)); }); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts index 984e2a7b..c06ff0a1 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts @@ -3,7 +3,7 @@ import { type ActionArgs, type Command, PathType, printObject } from '../../cli' export class CommandFileSystemCreateFolder implements Command { group = 'filesystem'; name = 'create-folder'; - args = ['path', 'name']; + args = ['parentPath', 'name']; async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { const parent = await paths.getNode(pathString, [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 1b3b887c..ada830bb 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -1,10 +1,9 @@ import { mkdir, readdir, rm, stat, unlink } from 'node:fs/promises'; import path from 'node:path'; -import { ParseArgsConfig } from 'node:util'; import { FileDownloader, IntegrityError, type Logger, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, getClaimedSize, PathType } from '../../cli'; +import { type ActionArgs, type Command, getClaimedSize, Options, PathType } from '../../cli'; import { getSha1 } from './digest'; import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; import { @@ -36,22 +35,28 @@ type DownloadContext = { export class CommandFileSystemDownload implements Command { group = 'filesystem'; name = 'download'; - args = ['remotes...', 'localFolder']; - options: ParseArgsConfig['options'] = { + args = ['path...', 'localFolder']; + options: Options = { 'conflict-strategy': { type: 'string', short: 'c', default: '', + allowedValues: ['merge', 'keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to all files and folders.', }, 'file-conflict-strategy': { type: 'string', short: 'f', default: '', + allowedValues: ['keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to files.', }, 'folder-conflict-strategy': { type: 'string', short: 'd', default: '', + allowedValues: ['merge', 'keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to folders.', }, }; diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts index 927ec8cb..0f72c2c2 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemList.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -1,5 +1,3 @@ -import { ParseArgsConfig } from 'node:util'; - import { Device, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; @@ -13,6 +11,7 @@ import { getClaimedSize, getName, getNode, + Options, Paths, PathType, printIterable, @@ -23,11 +22,13 @@ export class CommandFileSystemList implements Command { group = 'filesystem'; name = 'list'; args = ['path']; - options: ParseArgsConfig['options'] = { + options: Options = { type: { type: 'string', short: 't', default: '', + allowedValues: Object.values(NodeType), + help: 'Type of the node to filter by.', }, }; @@ -105,7 +106,12 @@ export class CommandFileSystemList implements Command { await printIterable(sdk.iterateDevices(), options.json, (device) => this.printDeviceHuman(device)); } - private async printChildren(sdk: ProtonDriveClient, paths: Paths, pathString: string, options: { json: boolean; nodeType?: NodeType }) { + private async printChildren( + sdk: ProtonDriveClient, + paths: Paths, + pathString: string, + options: { json: boolean; nodeType?: NodeType }, + ) { const parentNode = await paths.getNode(pathString); const filterOptions = options.nodeType ? { type: options.nodeType } : undefined; const childrenIterator = sdk.iterateFolderChildren(parentNode, filterOptions); @@ -139,6 +145,8 @@ export class CommandFileSystemList implements Command { } private printDeviceHuman(device: Device): void { - console.log(sanitizeTerminalText(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`)); + console.log( + sanitizeTerminalText(`${device.type} ${device.name.ok ? device.name.value : device.name.error.name}`), + ); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index a89679c3..c4b14d69 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -7,7 +7,7 @@ const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices]; export class CommandFileSystemMove implements Command { group = 'filesystem'; name = 'move'; - args = ['sourcePath...', 'targetPath']; + args = ['sourcePath...', 'targetParentPath']; async action({ sdk, paths, args, options: { json } }: ActionArgs) { const sourcePathStrings = args.slice(0, -1); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 746d9864..6bc9fb2c 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -1,5 +1,3 @@ -import { ParseArgsConfig } from 'node:util'; - import { MaybeNode, NodeWithSameNameExistsValidationError, @@ -7,16 +5,12 @@ import { ValidationError, } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, PathType } from '../../cli'; +import { type ActionArgs, type Command, Options, PathType } from '../../cli'; import { getSha1 } from './digest'; import { generateThumbnails } from './generateThumbnails'; import { ConflictChoice, ConflictTargetKind, TransferConflictResolver } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; -import { - type QueueItemDirectory, - type QueueItemFile, - UploadQueue, -} from './transferQueue'; +import { type QueueItemDirectory, type QueueItemFile, UploadQueue } from './transferQueue'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; @@ -32,27 +26,34 @@ type UploadContext = { export class CommandFileSystemUpload implements Command { group = 'filesystem'; name = 'upload'; - args = ['sources...', 'parentPath']; - options: ParseArgsConfig['options'] = { + args = ['localPath...', 'parentPath']; + options: Options = { 'conflict-strategy': { type: 'string', short: 'c', default: '', + allowedValues: ['merge', 'keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to all files and folders.', }, 'file-conflict-strategy': { type: 'string', short: 'f', default: '', + allowedValues: ['merge', 'keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to files.', }, 'folder-conflict-strategy': { type: 'string', short: 'd', default: '', + allowedValues: ['merge', 'keep-both', 'replace', 'skip'], + help: 'Conflict strategy applied to folders.', }, 'skip-thumbnails': { type: 'boolean', short: 't', default: false, + help: 'Skip generating thumbnails.', }, }; diff --git a/js/cli/src/commands/sharing/commandSharingInvite.ts b/js/cli/src/commands/sharing/commandSharingInvite.ts index b6956a9f..a9514954 100644 --- a/js/cli/src/commands/sharing/commandSharingInvite.ts +++ b/js/cli/src/commands/sharing/commandSharingInvite.ts @@ -1,34 +1,37 @@ -import { ParseArgsConfig } from 'util'; - import { MemberRole, ValidationError } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, printObject } from '../../cli'; +import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingInvite implements Command { group = 'sharing'; name = 'invite'; args = ['path']; - options: ParseArgsConfig['options'] = { + options: Options = { user: { type: 'string', short: 'u', multiple: true, default: [], + help: 'Email addresses of the users to invite.', }, role: { type: 'string', short: 'r', default: 'viewer', + allowedValues: Object.values(MemberRole), + help: 'Role of the users to invite.', }, message: { type: 'string', short: 'm', default: '', + help: 'Message to be included in the invitation email.', }, 'include-node-name': { type: 'boolean', short: 'n', default: false, + help: 'Whether to include the node name in the invitation email.', }, }; diff --git a/js/cli/src/commands/sharing/commandSharingRemove.ts b/js/cli/src/commands/sharing/commandSharingRemove.ts index 57f8c8b7..0f8e1706 100644 --- a/js/cli/src/commands/sharing/commandSharingRemove.ts +++ b/js/cli/src/commands/sharing/commandSharingRemove.ts @@ -1,25 +1,25 @@ -import { ParseArgsConfig } from 'util'; - import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; -import { type ActionArgs, type Command, printObject } from '../../cli'; +import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingRemove implements Command { group = 'sharing'; name = 'remove'; args = ['path']; - options: ParseArgsConfig['options'] = { + options: Options = { email: { type: 'string', short: 'e', multiple: true, default: [], + help: 'Email addresses of the users to remove.', }, everyone: { type: 'boolean', short: 'a', default: false, + help: 'Remove access for everyone.', }, }; @@ -39,9 +39,9 @@ export class CommandSharingRemove implements Command { const sharingInfo = await sdk.getSharingInfo(node); return [ - ...sharingInfo?.members.map((member) => member.inviteeEmail) || [], - ...sharingInfo?.protonInvitations.map((invitation) => invitation.inviteeEmail) || [], - ...sharingInfo?.nonProtonInvitations.map((invitation) => invitation.inviteeEmail) || [], - ] + ...(sharingInfo?.members.map((member) => member.inviteeEmail) || []), + ...(sharingInfo?.protonInvitations.map((invitation) => invitation.inviteeEmail) || []), + ...(sharingInfo?.nonProtonInvitations.map((invitation) => invitation.inviteeEmail) || []), + ]; } } diff --git a/js/cli/src/commands/sharing/commandSharingSetUrl.ts b/js/cli/src/commands/sharing/commandSharingSetUrl.ts index da2d81f2..71c07567 100644 --- a/js/cli/src/commands/sharing/commandSharingSetUrl.ts +++ b/js/cli/src/commands/sharing/commandSharingSetUrl.ts @@ -1,25 +1,27 @@ -import { ParseArgsConfig } from 'util'; - import { MemberRole, ValidationError } from '@protontech/drive-sdk'; -import { type ActionArgs, type Command, printObject } from '../../cli'; +import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingSetUrl implements Command { group = 'sharing'; name = 'set-url'; args = ['path']; - options: ParseArgsConfig['options'] = { + options: Options = { role: { type: 'string', default: 'viewer', + allowedValues: Object.values(MemberRole), + help: 'Role of the users to invite.', }, password: { type: 'string', default: '', + help: 'Custom password for the shared link.', }, expiration: { type: 'string', default: '', + help: 'Expiration date of the shared link in ISO format (e.g. 2025-06-06).', }, }; From fd3303e219390340f82d593d5d0bc00f0d2b0669 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 12 May 2026 08:04:54 +0200 Subject: [PATCH 649/732] 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 52ba81cf9fe5c1ea83972bb976d6397818418717 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 05:07:50 +0000 Subject: [PATCH 650/732] 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 43ad416a136be7e48650a7067c93405ded5fc63c Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 08:23:38 +0200 Subject: [PATCH 651/732] 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 652/732] 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 c6a305e87082fa3b71269771a272b7e9131d3a39 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 08:33:47 +0200 Subject: [PATCH 653/732] 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 13695a7d9278f0ef2cf1ca5a03ad399b79ad9b65 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 14 May 2026 08:47:17 +0200 Subject: [PATCH 654/732] Fix SDK version for upgrade hints --- js/cli/src/api/apiClient.ts | 8 ++++---- js/cli/src/api/apiRequirements.test.ts | 2 +- js/cli/src/api/apiRequirements.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index 0bca9e28..85390911 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -1,6 +1,6 @@ import ky, { type AfterResponseHook, type KyInstance } from 'ky'; -import { Logger, VERSION } from '@protontech/drive-sdk'; +import { Logger } from '@protontech/drive-sdk'; import type { Config } from '../config'; import { Credentials } from '../credentials'; @@ -77,7 +77,7 @@ export class ApiClient { private createDriveApiRequirementsAfterResponseHook(): AfterResponseHook { return (_request, _options, response) => { processDriveRequirementHeaders(response.headers, { - clientSdkVersion: VERSION, + clientSdkVersion: this.config.sdkVersion ?? '', supportedRequirementMasksByScope: SUPPORTED_REQUIREMENT_MASK_BY_SCOPE, onUnsupportedFeature: (scope, requiredMask) => { const message = `Update needed: unsupported feature for ${scope}`; @@ -87,14 +87,14 @@ export class ApiClient { }); }, onRequiredUpdate: (requiredVersion) => { - const message = `Update required: required SDK version ${requiredVersion} or newer (currently using ${VERSION})`; + const message = `Update required: required SDK version ${requiredVersion} or newer (currently using ${this.config.sdkVersion ?? '0.0.1'})`; this.driveRequirementNoticeOnce.emitOnce(message, (msg) => { this.logger.warn(msg); process.stderr.write(msg + '\n'); }); }, onSuggestedUpdate: (suggestedVersion) => { - const message = `Update recommended: suggested SDK version ${suggestedVersion} or newer (currently using ${VERSION})`; + const message = `Update recommended: suggested SDK version ${suggestedVersion} or newer (currently using ${this.config.sdkVersion ?? '0.0.1'})`; this.driveRequirementNoticeOnce.emitOnce(message, (msg) => { this.logger.warn(msg); process.stderr.write(msg + '\n'); diff --git a/js/cli/src/api/apiRequirements.test.ts b/js/cli/src/api/apiRequirements.test.ts index 3312d3ad..39adace0 100644 --- a/js/cli/src/api/apiRequirements.test.ts +++ b/js/cli/src/api/apiRequirements.test.ts @@ -22,7 +22,7 @@ function generateHeaders(options?: { describe('processDriveRequirementHeaders', () => { const options = { - clientSdkVersion: '1.0.0', + clientSdkVersion: 'js@1.0.0-beta.1+abcdef01', supportedRequirementMasksByScope: { [RequirementScope.Drive]: TEST_FEATURE_MASK | TEST_FEATURE_MASK_2, [RequirementScope.Photos]: TEST_FEATURE_MASK, diff --git a/js/cli/src/api/apiRequirements.ts b/js/cli/src/api/apiRequirements.ts index e7096069..2806d604 100644 --- a/js/cli/src/api/apiRequirements.ts +++ b/js/cli/src/api/apiRequirements.ts @@ -138,7 +138,7 @@ function compareSemverLoose(a: string, b: string): number | null { function parseSemverParts(v: string): [number, number, number] | null { const trimmed = v.trim(); - const m = trimmed.match(/^(\d+)\.(\d+)\.(\d+)/); + const m = trimmed.match(/(\d+)\.(\d+)\.(\d+)/); if (!m) { return null; } From 479003a56a39da1bc25b1f106ca851a729b01b64 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 15 May 2026 09:50:13 +0000 Subject: [PATCH 655/732] 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 0a5dea3578e55ce9d247e55af62e3cd173280e21 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 15 May 2026 08:25:58 +0200 Subject: [PATCH 656/732] Support blob paths in interactive shell --- .../fileSystem/commandFileSystemDownload.ts | 7 ++- .../src/commands/fileSystem/localPath.test.ts | 62 +++++++++++++++++++ js/cli/src/commands/fileSystem/localPath.ts | 42 +++++++++++++ .../src/commands/fileSystem/transferQueue.ts | 24 +++---- 4 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 js/cli/src/commands/fileSystem/localPath.test.ts create mode 100644 js/cli/src/commands/fileSystem/localPath.ts diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index ada830bb..21256548 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -6,6 +6,7 @@ import { FileDownloader, IntegrityError, type Logger, MaybeNode, type ProtonDriv import { type ActionArgs, type Command, getClaimedSize, Options, PathType } from '../../cli'; import { getSha1 } from './digest'; import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; +import { resolveLocalPaths } from './localPath'; import { ConflictChoice, ConflictTargetKind, @@ -79,7 +80,11 @@ export class CommandFileSystemDownload implements Command { throw new ValidationError('At least one remote path and a local folder are required'); } - const downloadRoot = assertValidDownloadRoot(localFolder); + const resolvedLocalPaths = await resolveLocalPaths(localFolder); + if (resolvedLocalPaths.length !== 1) { + throw new ValidationError('Expected exactly one local path'); + } + const downloadRoot = assertValidDownloadRoot(resolvedLocalPaths[0]); await mkdir(downloadRoot, { recursive: true }); const progress = json ? undefined : createTransferProgress(); diff --git a/js/cli/src/commands/fileSystem/localPath.test.ts b/js/cli/src/commands/fileSystem/localPath.test.ts new file mode 100644 index 00000000..53dd7676 --- /dev/null +++ b/js/cli/src/commands/fileSystem/localPath.test.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { ValidationError } from '@protontech/drive-sdk'; + +import { resolveLocalPaths } from './localPath'; + +describe('localPath', () => { + describe('resolveLocalPaths', () => { + it('resolves a non-glob path to a single absolute path', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-local-path-')); + const file = path.join(dir, 'plain.txt'); + await fs.writeFile(file, 'x'); + + const result = await resolveLocalPaths(file); + expect(result).toEqual([path.resolve(file)]); + }); + + it('expands ~ to the user home directory', async () => { + const result = await resolveLocalPaths('~'); + expect(result).toEqual([path.resolve(os.homedir())]); + }); + + it('expands ~/ to a path under the home directory', async () => { + const result = await resolveLocalPaths('~/'); + expect(result).toEqual([path.resolve(path.join(os.homedir(), ''))]); + }); + + it('expands ~/segment under the home directory', async () => { + const result = await resolveLocalPaths('~/Library'); + expect(result).toEqual([path.resolve(path.join(os.homedir(), 'Library'))]); + }); + + it('expands ~\\segment under the home directory', async () => { + const result = await resolveLocalPaths('~\\Library'); + expect(result).toEqual([path.resolve(path.join(os.homedir(), 'Library'))]); + }); + + it('expands a glob to sorted absolute matches', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-local-path-glob-')); + await fs.writeFile(path.join(dir, 'b.txt'), 'b'); + await fs.writeFile(path.join(dir, 'a.txt'), 'a'); + + const pattern = path.join(dir, '*.txt'); + const result = await resolveLocalPaths(pattern); + + const expectedA = path.resolve(path.join(dir, 'a.txt')); + const expectedB = path.resolve(path.join(dir, 'b.txt')); + expect(result).toEqual([expectedA, expectedB]); + }); + + it('throws when a glob matches nothing', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'drive-cli-local-path-empty-glob-')); + const pattern = path.join(dir, 'no-such-*.txt'); + + await expect(resolveLocalPaths(pattern)).rejects.toThrow( + new ValidationError(`No paths matched: ${pattern}`), + ); + }); + }); +}); diff --git a/js/cli/src/commands/fileSystem/localPath.ts b/js/cli/src/commands/fileSystem/localPath.ts new file mode 100644 index 00000000..2ae1fa18 --- /dev/null +++ b/js/cli/src/commands/fileSystem/localPath.ts @@ -0,0 +1,42 @@ +import { glob } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +import { ValidationError } from '@protontech/drive-sdk'; + +const LOCAL_PATH_LOOKS_GLOBBED = /[*?\[{]/; + +/** + * Resolves a local path to a list of absolute paths. + * If the path is a glob, it is expanded to a list of paths. + * If the path is relative from home, it is resolved to an absolute path. + * @param localPath - The local path to resolve. + * @returns A list of absolute paths. + */ +export async function resolveLocalPaths(localPath: string): Promise { + const absolute = expandLeadingTilde(localPath); + const expanded = await expandLocalPathIfGlobbed(absolute); + return expanded.map((p) => path.resolve(p)); +} + +async function expandLocalPathIfGlobbed(localPath: string): Promise { + if (!LOCAL_PATH_LOOKS_GLOBBED.test(localPath)) { + return [localPath]; + } + const matches = await Array.fromAsync(glob(localPath)); + matches.sort((a, b) => a.localeCompare(b)); + if (matches.length === 0) { + throw new ValidationError(`No paths matched: ${localPath}`); + } + return matches; +} + +function expandLeadingTilde(localPath: string): string { + if (localPath === '~') { + return homedir(); + } + if (localPath.startsWith('~/') || localPath.startsWith('~\\')) { + return path.join(homedir(), localPath.slice(2)); + } + return localPath; +} diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts index 8ab2de71..fbd5e65d 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { Logger, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getName, getNode } from '../../cli'; +import { resolveLocalPaths } from './localPath'; export const MAX_CONCURRENT_ITEMS = 5; @@ -62,29 +63,30 @@ class TransferQueue { export class UploadQueue extends TransferQueue<{ parentNode: MaybeNode }> { async enqueueLocalPaths(localPaths: string[], parentNode: MaybeNode): Promise { for (const localPath of localPaths) { - await this.enqueueLocalPath(localPath, parentNode); + const expanded = await resolveLocalPaths(localPath); + for (const absolutePath of expanded) { + await this.enqueueLocalPath(absolutePath, parentNode); + } } } - async enqueueLocalDirectoryChildren(localPath: string, parentNode: MaybeNode): Promise { - const resolvedDir = path.resolve(localPath); - const parentStats = await lstat(resolvedDir); - assertLocalPathIsUploadable(resolvedDir, parentStats); + async enqueueLocalDirectoryChildren(absolutePath: string, parentNode: MaybeNode): Promise { + const parentStats = await lstat(absolutePath); + assertLocalPathIsUploadable(absolutePath, parentStats); if (!parentStats.isDirectory()) { - throw new ValidationError(`Not a directory: ${resolvedDir}`); + throw new ValidationError(`Not a directory: ${absolutePath}`); } - const entries = await readdir(resolvedDir, { withFileTypes: true }); + const entries = await readdir(absolutePath, { withFileTypes: true }); for (const ent of entries) { if (ent.name === '.' || ent.name === '..') { continue; } - const absolutePath = path.join(resolvedDir, ent.name); - await this.enqueueLocalPath(absolutePath, parentNode, parentStats.dev); + const childPath = path.join(absolutePath, ent.name); + await this.enqueueLocalPath(childPath, parentNode, parentStats.dev); } } - private async enqueueLocalPath(localPath: string, parentNode: MaybeNode, parentDevice?: number): Promise { - const absolutePath = path.resolve(localPath); + private async enqueueLocalPath(absolutePath: string, parentNode: MaybeNode, parentDevice?: number): Promise { const stats = await lstat(absolutePath); assertLocalPathIsUploadable(absolutePath, stats); if (parentDevice !== undefined && stats.dev !== parentDevice) { From 77d0e41abe0b449524a1829608d386c49304680e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 05:03:15 +0000 Subject: [PATCH 657/732] i18n(weekly-mr): Upgrade translations from crowdin (0a5dea35). --- 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 451c1bf702cef45491fa2ef3c21ee87d3d954496 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 13 May 2026 14:48:02 +0200 Subject: [PATCH 658/732] 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 4c91d44ecb10325e45f7fefc93a9e750b618d7fc Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 11:16:54 +0000 Subject: [PATCH 659/732] Add Sentry to CLI builds --- js/cli/bun.lock | 127 +++++++++++++++++++++++++++++++++++ js/cli/package.json | 1 + js/cli/scripts/build-cli.mjs | 2 + js/cli/src/cli/run.ts | 3 + js/cli/src/proton-drive.ts | 6 ++ 5 files changed, 139 insertions(+) diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 71d7fca2..13ed8cc4 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -8,6 +8,7 @@ "@noble/hashes": "^1.8.0", "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", + "@sentry/bun": "^10.53.1", "ky": "^1.14.3", "sharp": "^0.34.5", "ttag": "^1.8.7", @@ -348,6 +349,8 @@ "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@fastify/otel": ["@fastify/otel@0.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], + "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], @@ -474,6 +477,60 @@ "@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.1.3", "", { "peerDependencies": { "typescript": ">=4.2" }, "optionalPeers": ["typescript"] }, "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], + + "@opentelemetry/instrumentation-amqplib": ["@opentelemetry/instrumentation-amqplib@0.61.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q=="], + + "@opentelemetry/instrumentation-connect": ["@opentelemetry/instrumentation-connect@0.57.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.38" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg=="], + + "@opentelemetry/instrumentation-dataloader": ["@opentelemetry/instrumentation-dataloader@0.31.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g=="], + + "@opentelemetry/instrumentation-fs": ["@opentelemetry/instrumentation-fs@0.33.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA=="], + + "@opentelemetry/instrumentation-generic-pool": ["@opentelemetry/instrumentation-generic-pool@0.57.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow=="], + + "@opentelemetry/instrumentation-graphql": ["@opentelemetry/instrumentation-graphql@0.62.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg=="], + + "@opentelemetry/instrumentation-hapi": ["@opentelemetry/instrumentation-hapi@0.60.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w=="], + + "@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/instrumentation": "0.214.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg=="], + + "@opentelemetry/instrumentation-kafkajs": ["@opentelemetry/instrumentation-kafkajs@0.23.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.30.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ=="], + + "@opentelemetry/instrumentation-knex": ["@opentelemetry/instrumentation-knex@0.58.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw=="], + + "@opentelemetry/instrumentation-koa": ["@opentelemetry/instrumentation-koa@0.62.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.36.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA=="], + + "@opentelemetry/instrumentation-lru-memoizer": ["@opentelemetry/instrumentation-lru-memoizer@0.58.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q=="], + + "@opentelemetry/instrumentation-mongodb": ["@opentelemetry/instrumentation-mongodb@0.67.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ=="], + + "@opentelemetry/instrumentation-mongoose": ["@opentelemetry/instrumentation-mongoose@0.60.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg=="], + + "@opentelemetry/instrumentation-mysql": ["@opentelemetry/instrumentation-mysql@0.60.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/mysql": "2.15.27" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ=="], + + "@opentelemetry/instrumentation-mysql2": ["@opentelemetry/instrumentation-mysql2@0.60.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@opentelemetry/sql-common": "^0.41.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw=="], + + "@opentelemetry/instrumentation-pg": ["@opentelemetry/instrumentation-pg@0.66.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/sql-common": "^0.41.2", "@types/pg": "8.15.6", "@types/pg-pool": "2.0.7" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA=="], + + "@opentelemetry/instrumentation-tedious": ["@opentelemetry/instrumentation-tedious@0.33.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/tedious": "^4.0.14" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + + "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], + + "@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="], + "@protontech/crypto": ["@protontech/crypto@2.0.0", "", { "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" } }, "sha512-D3M023jLq/aMNCSr5p1KVlLV0grAeVXpUh8WyN2HITCIX83KLJo5BReNbVT1mXMvodPf/qYIxUoYXyaEW3pfOQ=="], "@protontech/drive-sdk": ["@protontech/drive-sdk@file:../sdk", { "dependencies": { "@noble/hashes": "^1.8.0", "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/jest": "^29.5.14", "@types/mocha": "^10.0.10", "@typescript-eslint/eslint-plugin": "^8.58.1", "@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.6.2", "ttag-cli": "^1.10.18", "typedoc": "^0.28.16", "typescript": "^5.9.3" }, "peerDependencies": { "@protontech/crypto": "^2.0.0" } }], @@ -548,6 +605,16 @@ "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.9", "", { "dependencies": { "@rushstack/terminal": "0.24.0", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ=="], + "@sentry/bun": ["@sentry/bun@10.53.1", "", { "dependencies": { "@sentry/core": "10.53.1", "@sentry/node": "10.53.1" } }, "sha512-UCRUtEc3qLaanmp+9rKQi58d+weBdovytqW6d8ria1fkmxfvnVCYQqVQ0c/FLrGggqjqizO4b4WnLnuy/y/CXw=="], + + "@sentry/core": ["@sentry/core@10.53.1", "", {}, "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA=="], + + "@sentry/node": ["@sentry/node@10.53.1", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.53.1", "@sentry/node-core": "10.53.1", "@sentry/opentelemetry": "10.53.1", "import-in-the-middle": "^3.0.0" } }, "sha512-rxHVil0tJAmz+keFcZCj1LaUdgdkK66E/l0gqh2p1209PNCGoD3lnClFr6vusy1aF3zF8O9JPtuMEJzXOKhs+w=="], + + "@sentry/node-core": ["@sentry/node-core@10.53.1", "", { "dependencies": { "@sentry/core": "10.53.1", "@sentry/opentelemetry": "10.53.1", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-iH7SMcM/7jPbN+t7+7ussQOiIqI4BMOGt4VYWlV71/z7k0pY+YPaSvlfVkNXfISiDzFAKv0ecCY3BmsLMu1xDQ=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.53.1", "", { "dependencies": { "@sentry/core": "10.53.1" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-Zok6UXla0mFOjd1YnVb1TZtQNOry9v93fXUqx8jmDaygwWM2BwvP8rBQabLz0/OZXo8+35oge+Vmw+QY5aesnA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], @@ -650,12 +717,18 @@ "@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="], + "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], "@types/parse5": ["@types/parse5@6.0.3", "", {}, "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g=="], + "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], + + "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], + "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -668,6 +741,8 @@ "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], @@ -726,6 +801,8 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1000,6 +1077,8 @@ "formidable": ["formidable@1.2.6", "", {}, "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], @@ -1070,6 +1149,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-in-the-middle": ["import-in-the-middle@3.0.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], @@ -1288,6 +1369,8 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], @@ -1364,6 +1447,12 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1380,6 +1469,14 @@ "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -1430,6 +1527,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], @@ -1618,6 +1717,8 @@ "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@2.1.2", "", {}, "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A=="], @@ -1648,6 +1749,10 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], + + "@fastify/otel/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -1674,6 +1779,10 @@ "@microsoft/tsdoc-config/ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], + "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], + "@protontech/crypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@protontech/drive-sdk/@protontech/crypto": ["@protontech/crypto@2.0.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@openpgp/web-stream-tools": "~0.3.1", "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" } }, "sha512-PMvQi1L98eU9tCVNvoLIEq21Jwfs2a4LxJwoEVbEIhvZVVprCf4lRrwqce2HhPmyjQuQJDcSlqbma+o++G2LEw=="], @@ -1732,6 +1841,8 @@ "http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], + "import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-config/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1796,6 +1907,12 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="], + + "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], + + "@fastify/otel/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -1810,6 +1927,10 @@ "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], + + "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], + "@protontech/drive-sdk/@protontech/crypto/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@protontech/drive-sdk/@protontech/crypto/@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.3.1", "", { "peerDependencies": { "@types/node": ">=18.0.0", "typescript": ">=4.7" }, "optionalPeers": ["@types/node", "typescript"] }, "sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA=="], @@ -1926,6 +2047,10 @@ "typedoc/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + + "@fastify/otel/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@jest/create-cache-key-function/@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], @@ -1936,6 +2061,8 @@ "@microsoft/api-extractor/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/js/cli/package.json b/js/cli/package.json index 481807ee..32ce357b 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -36,6 +36,7 @@ "@noble/hashes": "^1.8.0", "@protontech/crypto": "2.0.0", "@protontech/drive-sdk": "file:../sdk", + "@sentry/bun": "^10.53.1", "ky": "^1.14.3", "sharp": "^0.34.5", "ttag": "^1.8.7" diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index 08bb7ad0..6b818c11 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -38,6 +38,8 @@ const args = [ `APP_VERSION=${JSON.stringify(`${process.env.CLI_APP_VERSION_NAME || 'external-drive-sdkclijs'}@${getVersion('cli') || '0.0.0'}`)}`, '--define', `SDK_VERSION=${JSON.stringify(`js@${getVersion('js')}`)}`, + '--define', + `SENTRY_DSN=${JSON.stringify(process.env.SENTRY_DSN)}`, entry, `--outfile=${outfile}${outfileSuffix}`, ]; diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 74f61944..9d00f8a9 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -1,5 +1,6 @@ import { InitConfig } from '../config'; import { init } from '../init'; +import { captureException, flushSentry } from '../sentry'; import { AuthRequiredError, isRecoverableReplError } from './errors'; import { formatReadableJson } from './formatters'; import { printCommandUsage } from './help'; @@ -23,6 +24,8 @@ export async function runSingleInvocation( return; } reportFatalError(error); + captureException(error); + await flushSentry(); process.exit(1); } } diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index a04d18a8..85cc48d3 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -2,13 +2,17 @@ import { FeatureFlags } from '@protontech/drive-sdk'; import { run } from './cli'; import { COMMANDS } from './commands'; +import { captureException, flushSentry, initSentry } from './sentry'; // Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). declare const APP_VERSION: string; declare const SDK_VERSION: string | undefined; +declare const SENTRY_DSN: string | undefined; const CLIENT_UID_PREFIX = 'sdk-js-cli'; +initSentry({ dsn: SENTRY_DSN, appVersion: APP_VERSION, sdkVersion: SDK_VERSION }); + try { await run(COMMANDS, { clientUidPrefix: CLIENT_UID_PREFIX, @@ -24,5 +28,7 @@ try { process.exit(0); } catch (error: unknown) { console.error(error); + captureException(error); + await flushSentry(); process.exit(1); } From e7841891f394cf9a9f4d7fc90c3b2ddaf16f4067 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 14:28:46 +0200 Subject: [PATCH 660/732] 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 a8f8342694702cdba6fcd9eaa1c4f8a8ae08640a Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 18 May 2026 13:30:52 +0000 Subject: [PATCH 661/732] 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 4a341031e9f263b9f91b4cdfae4b48d20b5eb40b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 May 2026 05:31:08 +0000 Subject: [PATCH 662/732] 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 c970b96259049d2ae88952dc3a6beb0b3e664f24 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 19 May 2026 05:35:50 +0000 Subject: [PATCH 663/732] 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 135434cc9482e96dc5f7ebcf21bf2d303b43af25 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 20 May 2026 04:46:55 +0000 Subject: [PATCH 664/732] 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 b6c737ef70c7e7fa95df5a484b30b1f4d90c9d6f Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 21 May 2026 08:39:22 +0200 Subject: [PATCH 665/732] 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 dd6df5045a5a98d89730397548f378e26c1a1f61 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 11:22:45 +0200 Subject: [PATCH 666/732] 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 f56a7c20a06e3e17cae174034546a3c0fc34fc91 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 16:18:28 +0000 Subject: [PATCH 667/732] 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 59bb38b45a5c4482b82f766d1fb3d72f8c1ee7d9 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 22 May 2026 16:26:36 +0000 Subject: [PATCH 668/732] 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 f84f7d5471934f0b941d2c1cb0230e51fe21299e Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 25 May 2026 05:06:01 +0000 Subject: [PATCH 669/732] 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 27d441233fd7d4165ab9ddf03350bf0e5bf09e5e Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 12:32:47 +0000 Subject: [PATCH 670/732] 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 861148a876d9e7a9f76054a49e6980d7bf1f5525 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 12:34:07 +0000 Subject: [PATCH 671/732] Sanitize node names before download --- .../fileSystem/downloadPathValidation.test.ts | 79 ++++++++++++++++++- .../fileSystem/downloadPathValidation.ts | 33 +++++++- .../commands/fileSystem/transferQueue.test.ts | 15 ++++ .../src/commands/fileSystem/transferQueue.ts | 5 +- 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts b/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts index 3a1ccdd0..3c0ae072 100644 --- a/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts +++ b/js/cli/src/commands/fileSystem/downloadPathValidation.test.ts @@ -6,6 +6,7 @@ import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment, + sanitizePathSegmentForLocalFilesystem, } from './downloadPathValidation'; describe('downloadPathValidation', () => { @@ -35,9 +36,81 @@ describe('downloadPathValidation', () => { expect(() => assertValidPathSegment('.')).toThrow(new ValidationError('Invalid path segment: "."')); }); - it('rejects reserved Windows names', () => { - expect(() => assertValidPathSegment('CON')).toThrow(new ValidationError('Reserved path segment name: "CON"')); - expect(() => assertValidPathSegment('COM1')).toThrow(new ValidationError('Reserved path segment name: "COM1"')); + it('rejects reserved Windows names on win32', () => { + const prev = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(() => assertValidPathSegment('CON')).toThrow(new ValidationError('Reserved path segment name: "CON"')); + expect(() => assertValidPathSegment('COM1')).toThrow(new ValidationError('Reserved path segment name: "COM1"')); + } finally { + Object.defineProperty(process, 'platform', { value: prev }); + } + }); + + it('allows reserved Windows names on non-win32 platforms', () => { + if (process.platform === 'win32') { + return; + } + expect(() => assertValidPathSegment('CON')).not.toThrow(); + expect(() => assertValidPathSegment('COM1')).not.toThrow(); + }); + }); + + describe('sanitizePathSegmentForLocalFilesystem', () => { + it('replaces illegal characters with underscores', () => { + expect(sanitizePathSegmentForLocalFilesystem('bad:name')).toBe('bad_name'); + expect(sanitizePathSegmentForLocalFilesystem('a/b')).toBe('a_b'); + }); + + it('preserves segments that do not need sanitization', () => { + expect(sanitizePathSegmentForLocalFilesystem('a..b')).toBe('a..b'); + expect(sanitizePathSegmentForLocalFilesystem('a.b.c')).toBe('a.b.c'); + expect(sanitizePathSegmentForLocalFilesystem('.hidden')).toBe('.hidden'); + expect(sanitizePathSegmentForLocalFilesystem('report.pdf')).toBe('report.pdf'); + expect(sanitizePathSegmentForLocalFilesystem('folder name')).toBe('folder name'); + }); + + it('maps empty, dot, and double-dot segments', () => { + expect(sanitizePathSegmentForLocalFilesystem('')).toBe('_'); + expect(sanitizePathSegmentForLocalFilesystem('.')).toBe('_'); + expect(sanitizePathSegmentForLocalFilesystem('..')).toBe('__'); + }); + + it('prefixes reserved Windows device names on win32', () => { + const prev = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(sanitizePathSegmentForLocalFilesystem('CON')).toBe('_CON'); + expect(sanitizePathSegmentForLocalFilesystem('COM1')).toBe('_COM1'); + } finally { + Object.defineProperty(process, 'platform', { value: prev }); + } + }); + + it('does not prefix reserved Windows device names on non-win32 platforms', () => { + if (process.platform === 'win32') { + return; + } + expect(sanitizePathSegmentForLocalFilesystem('CON')).toBe('CON'); + expect(sanitizePathSegmentForLocalFilesystem('COM1')).toBe('COM1'); + }); + + it('produces a segment that passes assertValidPathSegment on current platform', () => { + const samples = ['bad:name', 'a/b', '', '.', '..', 'CON', 'COM1', 'ok.txt', ':::']; + for (const s of samples) { + expect(() => assertValidPathSegment(sanitizePathSegmentForLocalFilesystem(s))).not.toThrow(); + } + }); + + it('replaces trailing dots and spaces on Windows', () => { + const prev = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(sanitizePathSegmentForLocalFilesystem('file.')).toBe('file_'); + expect(sanitizePathSegmentForLocalFilesystem('file ')).toBe('file_'); + } finally { + Object.defineProperty(process, 'platform', { value: prev }); + } }); }); diff --git a/js/cli/src/commands/fileSystem/downloadPathValidation.ts b/js/cli/src/commands/fileSystem/downloadPathValidation.ts index 5d9e4887..bacbd901 100644 --- a/js/cli/src/commands/fileSystem/downloadPathValidation.ts +++ b/js/cli/src/commands/fileSystem/downloadPathValidation.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { ValidationError } from '@protontech/drive-sdk'; /** Characters invalid in a single path segment on common platforms. */ -const ILLEGAL_SEGMENT_CHARS = /[\x00-\x1f\x7f<>:"|?*\\/]/; +const ILLEGAL_SEGMENT_CHARS = /[\x00-\x1f\x7f<>:"|?*\\/]/g; /** Windows reserved device names (case-insensitive), with optional extension. */ const WIN_RESERVED_NAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; @@ -69,13 +69,38 @@ export function assertValidPathSegment(name: string): void { if (ILLEGAL_SEGMENT_CHARS.test(name)) { throw new ValidationError(`Invalid character in path segment: "${name}"`); } - if (WIN_RESERVED_NAME.test(name)) { - throw new ValidationError(`Reserved path segment name: "${name}"`); - } if (process.platform === 'win32') { + if (WIN_RESERVED_NAME.test(name)) { + throw new ValidationError(`Reserved path segment name: "${name}"`); + } const trailing = /[.\u0020]$/; if (trailing.test(name)) { throw new ValidationError(`Invalid path segment (trailing dot or space): "${name}"`); } } } + +/** + * Produces a single path segment safe for the current OS by replacing characters + * invalid on common platforms with `_`, adjusting `.` / `..`, Windows reserved + * names, and (on Windows) trailing dots/spaces. + */ +export function sanitizePathSegmentForLocalFilesystem(name: string): string { + let segment = name.replace(ILLEGAL_SEGMENT_CHARS, '_'); + if (segment.length === 0) { + return '_'; + } + if (segment === '.') { + return '_'; + } + if (segment === '..') { + return '__'; + } + if (process.platform === 'win32') { + if (WIN_RESERVED_NAME.test(segment)) { + segment = `_${segment}`; + } + segment = segment.replace(/[.\u0020]+$/gu, (m) => '_'.repeat(m.length)); + } + return segment; +} diff --git a/js/cli/src/commands/fileSystem/transferQueue.test.ts b/js/cli/src/commands/fileSystem/transferQueue.test.ts index 19429388..10e05fa8 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.test.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.test.ts @@ -331,6 +331,21 @@ describe('DownloadQueue', () => { expect(dirPath).toBe(path.join(path.resolve('/local/out'), 'remoteDir')); }); + it('enqueueRemotePaths sanitizes names that are not valid as local path segments', async () => { + const invalidNameFile = mockFileMaybe('bad/name.txt', 'uid'); + const sdk = { iterateFolderChildren: jest.fn() }; + const q = createQueue(sdk); + await q.enqueueRemotePaths(['/x'], '/local/out', async () => invalidNameFile); + expect(q['queue']).toEqual([ + { + kind: 'file', + remoteNode: invalidNameFile, + baseName: 'bad_name.txt', + localPath: path.join(path.resolve('/local/out'), 'bad_name.txt'), + }, + ]); + }); + it('enqueueRemoteNode rejects unsupported node types', async () => { const albumNode = { ok: true, diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts index fbd5e65d..cbef7d4a 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { Logger, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getName, getNode } from '../../cli'; +import { sanitizePathSegmentForLocalFilesystem } from './downloadPathValidation'; import { resolveLocalPaths } from './localPath'; export const MAX_CONCURRENT_ITEMS = 5; @@ -126,7 +127,7 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { const absoluteLocalDir = path.resolve(localDir); for (const pathString of remotePathStrings) { const node = await resolveRemoteNode(pathString); - const baseName = getName(node); + const baseName = sanitizePathSegmentForLocalFilesystem(getName(node)); const targetPath = path.join(absoluteLocalDir, baseName); await this.enqueueRemoteNode(node, targetPath); } @@ -134,7 +135,7 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { async enqueueRemoteFolderChildren(folderRemoteNode: MaybeNode, localParentPath: string): Promise { for await (const child of this.sdk.iterateFolderChildren(folderRemoteNode)) { - const baseName = getName(child); + const baseName = sanitizePathSegmentForLocalFilesystem(getName(child)); const childPath = path.join(localParentPath, baseName); await this.enqueueRemoteNode(child, childPath); } From 36d765b74e4f8f1886dfb44566d175fed58eebb0 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 12:51:01 +0000 Subject: [PATCH 672/732] 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 245eebbf5ebbd24025a7a4cd3292a36914e68b84 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 07:28:43 +0200 Subject: [PATCH 673/732] 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 488d0ae010213c293776ef823e3205ec8c626493 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 07:25:53 +0000 Subject: [PATCH 674/732] 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 515b84e4dd5a38dc3baba75c22f9408401ac362a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 15:46:37 +0200 Subject: [PATCH 675/732] 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 6b43975a55da87067cb9d576dd19752345186b78 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 08:42:19 +0000 Subject: [PATCH 676/732] 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 4081fc22a0c0edf3c0795751dc5225c5dd32f6eb Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 12:10:00 +0000 Subject: [PATCH 677/732] 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 155b29531be50cc19250d7af5bf2491954b2d699 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 14:11:22 +0200 Subject: [PATCH 678/732] 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 4368aa127d1f6f95d74f7790d7a08f49cf237bbe Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 13:33:39 +0000 Subject: [PATCH 679/732] 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 a79fe8f2b5a4e7da754a0a114f90f51db955e94b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 14:10:15 +0000 Subject: [PATCH 680/732] 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 a9cf145153d1ad57fa6a74d3c3fbe1285f39b2ee Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 07:17:17 +0000 Subject: [PATCH 681/732] 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 07481fe721f87c6965a943c949ef85a9e7d3a700 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 09:51:16 +0200 Subject: [PATCH 682/732] 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 a6e85fdf6ae0f0017adee604183239eac352a86b Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 14:12:07 +0000 Subject: [PATCH 683/732] 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 684/732] 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 de994b3faaefacdb8e38b2879c0eb03c325a47b9 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 16:48:02 +0000 Subject: [PATCH 685/732] 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 5acfd8b41c26e3a10be8cd955564d7c8fffab50e Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 28 May 2026 16:23:52 +0000 Subject: [PATCH 686/732] 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 063972ba1d728830fa50e84543ba5a6f1d25f939 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 06:37:52 +0200 Subject: [PATCH 687/732] 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 9f9321307f089e69051c2e6a4ebcbadc41b55104 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:14:00 +0000 Subject: [PATCH 688/732] 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 8071f9802f74cf75368b3dc81537a68c7b545312 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:16:33 +0000 Subject: [PATCH 689/732] 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 ed329cdbb3f14b9fce206ae478da7243309c47ab Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:24:13 +0000 Subject: [PATCH 690/732] Update changelog for js/v0.16.0 --- js/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index ed7f6acd..0378be43 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## js/v0.16.0 (2026-05-29) + +* Process automatically events converting external invitations +* Add validation_error category for upload & download telemetry events +* Drop rounding for crypto performance telemetry +* Verify added by email fields +* Add method to iterate events +* Add method to return node hierarchy +* Prefer iterate over UIDs over nodes +* Update docs +* Retry block encryption and report metric +* Export CoreEventInput type to prevent casting on client + ## js/v0.15.2 (2026-05-19) * Support copy on save for not owned album From 0931f6ba2c7e9e62f6fdab3a04475cb3d97aad8b Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 11:45:01 +0000 Subject: [PATCH 691/732] Update changelog for cs/v0.16.0 --- cs/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md index 32fd4ea7..7f9177e0 100644 --- a/cs/CHANGELOG.md +++ b/cs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## cs/v0.16.0 (2026-05-29) + +* Add validation_error category for upload & download telemetry events +* Support more modification time formats and report invalid ones as node errors + ## cs/v0.15.1 (2026-05-28) * Fix node secrets not being read from cache From 4d1a8d344d7c1702abab8e9c85bc78bc3742ea82 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 29 May 2026 15:50:22 +0200 Subject: [PATCH 692/732] Use Bun's native image library for thumbnail generation --- js/cli/README.md | 14 +--- js/cli/bun.lock | 67 ++----------------- js/cli/package.json | 5 +- js/cli/scripts/build-cli.mjs | 6 -- .../commands/fileSystem/generateThumbnails.ts | 59 +++++----------- 5 files changed, 27 insertions(+), 124 deletions(-) diff --git a/js/cli/README.md b/js/cli/README.md index 080fd7b9..f1396c27 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -6,13 +6,12 @@ The CLI is built with [Bun](https://bun.sh) and uses the Drive SDK under the hoo ## Requirements -- [Bun](https://bun.sh) (for development builds from this repository) +- [Bun](https://bun.sh) 1.3.14 or newer (for development builds from this repository) - A Proton account and browser access for sign-in - A secret store provided by your OS: - Windows: Windows Credential Manager - macOS: Keychain Services - Linux: libsecret (e.g., GNOME Keyring, KWallet) -- [sharp](https://www.npmjs.com/package/sharp/) if you want thumbnails generated on image upload (see [Thumbnails](#thumbnails)) ## Install and build (from source) @@ -23,9 +22,7 @@ bun install bun run build ``` -This produces a standalone executable at **`release/proton-drive`**. Add that directory to your `PATH`, or invoke it with a full path. - -The build embeds Bun and minifies the bundle; `sharp` library is not bundled, see [Thumbnails](#thumbnails) for how to supply it to the binary. +This produces a standalone executable at **`release/proton-drive`** with embedded Bun. Add that directory to your `PATH`, or invoke it with a full path. ## Authentication @@ -72,12 +69,7 @@ After a successful login, the CLI stores the session in the **OS secret store** ## Thumbnails -The CLI uses `sharp` library to generate thumbnails for images on upload for common image types (JPEG, PNG, GIF, BMP, TIFF, WebP). The library is not bundled with the CLI. To enable this feature, you need to install it with one of the following methods: - -1. Install `sharp` globally (`bun install -g sharp`) and provide `NODE_PATH=~/.bun/install/global/node_modules` environment variable to the CLI executable. -2. Install `sharp` locally (`bun install sharp`) and run the CLI from the directory with the `package.json` file. - -If `sharp` is missing, thumbnail generation fails with a clear error; you can skip thumbnail generation with **`--skip-thumbnails`** (**`-t`**) on `filesystem upload` when you do not need previews. +On upload, the CLI generates WebP thumbnails for common image types (JPEG, PNG, GIF, BMP, TIFF, WebP) using [Bun's built-in image API](https://bun.com/docs/runtime/image) (requires Bun 1.3.14+). Use **`--skip-thumbnails`** (**`-t`**) on `filesystem upload` when you do not need previews or your system doesn't support Bun's image API. ## Environment variables diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 13ed8cc4..229ffe49 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -10,17 +10,16 @@ "@protontech/drive-sdk": "file:../sdk", "@sentry/bun": "^10.53.1", "ky": "^1.14.3", - "sharp": "^0.34.5", "ttag": "^1.8.7", }, "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bun": "^1.2.18", + "@types/bun": "^1.3.14", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", "@typescript-eslint/eslint-plugin": "^8.55.0", - "bun-types": "^1.1.31", + "bun-types": "^1.3.14", "eslint": "^8.57.1", "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", @@ -287,8 +286,6 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -359,56 +356,6 @@ "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "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" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], @@ -677,7 +624,7 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -871,7 +818,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -965,8 +912,6 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], @@ -1565,8 +1510,6 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1637,8 +1580,6 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], "ttag": ["ttag@1.8.12", "", { "dependencies": { "dedent": "1.5.1", "plural-forms": "^0.5.3" } }, "sha512-bg8IqDOY1MtJrWmBiY+Frl8T5dhDJ00du4jIaDh/Uwbo+UPyNPvBJcgfin/W+yTMEKLEs4Pve8YaygzJCMuvOg=="], diff --git a/js/cli/package.json b/js/cli/package.json index 32ce357b..a992e835 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -17,11 +17,11 @@ "devDependencies": { "@swc/core": "^1.12.3", "@swc/jest": "^0.2.38", - "@types/bun": "^1.2.18", + "@types/bun": "^1.3.14", "@types/jest": "^29.5.14", "@types/sharedworker": "^0.0.130", "@typescript-eslint/eslint-plugin": "^8.55.0", - "bun-types": "^1.1.31", + "bun-types": "^1.3.14", "eslint": "^8.57.1", "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", @@ -38,7 +38,6 @@ "@protontech/drive-sdk": "file:../sdk", "@sentry/bun": "^10.53.1", "ky": "^1.14.3", - "sharp": "^0.34.5", "ttag": "^1.8.7" }, "patchedDependencies": { diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index 6b818c11..00f0e8b8 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -28,12 +28,6 @@ const args = [ '--minify', // Include source maps for readable stack traces. '--sourcemap=inline', - // Sharp requires a native module which we keep out of the bundle. - // User must install sharp separately by installing the package - // at the same directory as the CLI. The proton-drive CLI then must - // load the user's package.json. - '--external=sharp', - '--compile-autoload-package-json', '--define', `APP_VERSION=${JSON.stringify(`${process.env.CLI_APP_VERSION_NAME || 'external-drive-sdkclijs'}@${getVersion('cli') || '0.0.0'}`)}`, '--define', diff --git a/js/cli/src/commands/fileSystem/generateThumbnails.ts b/js/cli/src/commands/fileSystem/generateThumbnails.ts index 32b607aa..41f88735 100644 --- a/js/cli/src/commands/fileSystem/generateThumbnails.ts +++ b/js/cli/src/commands/fileSystem/generateThumbnails.ts @@ -1,5 +1,3 @@ -import type sharp from 'sharp'; - import { Thumbnail, ThumbnailType } from '@protontech/drive-sdk'; const MAX_THUMBNAIL_SIDE = 512; @@ -18,43 +16,20 @@ const IMAGE_MEDIA_TYPES = new Set([ 'image/webp', ]); -type SharpLib = typeof sharp; - -let sharpPromise: Promise | undefined; - -// Lazy load to avoid requiring the sharp package when not needed. -// It is external dependency that might be missing when running the CLI. -async function loadSharp(): Promise { - if (!sharpPromise) { - sharpPromise = import('sharp') - .then((m) => (m as { default: SharpLib }).default) - .catch((error: unknown) => { - if (error instanceof ResolveMessage) { - throw new Error( - 'Sharp package required for thumbnails generation is not installed. Please install it by running `bun install sharp` in the CLI directory.', - ); - } - throw error; - }); - } - return sharpPromise; -} - export async function generateThumbnails(mediaType: string, localPath: string): Promise { if (!IMAGE_MEDIA_TYPES.has(mediaType.trim().toLowerCase())) { return []; } - const sharp = await loadSharp(); + const metadata = await imageFromPath(localPath).metadata(); + const width = metadata.width; + const height = metadata.height; - const metadata = await sharp(localPath, { failOn: 'none' }).metadata(); - const width = metadata.width ?? 0; - const height = metadata.height ?? 0; if (width === 0 || height === 0) { return []; } - const type1Buffer = await webpFitMaxBytes(sharp, localPath, MAX_THUMBNAIL_SIDE, MAX_THUMBNAIL_BYTES); + const type1Buffer = await webpFitMaxBytes(localPath, MAX_THUMBNAIL_SIDE, MAX_THUMBNAIL_BYTES); if (!type1Buffer) { return []; } @@ -67,7 +42,7 @@ export async function generateThumbnails(mediaType: string, localPath: string): ]; if (shouldGenerateHdThumbnail(width, height, mediaType)) { - const type2Buffer = await webpFitMaxBytes(sharp, localPath, MAX_HD_THUMBNAIL_SIDE, MAX_HD_THUMBNAIL_BYTES); + const type2Buffer = await webpFitMaxBytes(localPath, MAX_HD_THUMBNAIL_SIDE, MAX_HD_THUMBNAIL_BYTES); if (type2Buffer) { thumbnails.push({ type: ThumbnailType.Type2, @@ -79,6 +54,10 @@ export async function generateThumbnails(mediaType: string, localPath: string): return thumbnails; } +function imageFromPath(localPath: string): Bun.Image { + return new Bun.Image(localPath, { autoOrient: true }); +} + function shouldGenerateHdThumbnail(width: number, height: number, mediaType: string): boolean { const mt = mediaType.trim().toLowerCase(); const isJpeg = mt === 'image/jpeg' || mt === 'image/jpg'; @@ -89,20 +68,18 @@ function shouldGenerateHdThumbnail(width: number, height: number, mediaType: str return !isJpeg && !isWebp; } -async function webpFitMaxBytes( - sharp: SharpLib, - localPath: string, - maxSide: number, - maxBytes: number, -): Promise { +async function webpFitMaxBytes(localPath: string, maxSide: number, maxBytes: number): Promise { let quality = 90; let out: Buffer | undefined; while (quality > 0) { - out = await sharp(localPath, { failOn: 'none' }) - .rotate() - .resize(maxSide, maxSide, { fit: 'inside', withoutEnlargement: true }) - .webp({ quality, effort: 4 }) - .toBuffer(); + try { + out = await imageFromPath(localPath) + .resize(maxSide, maxSide, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality }) + .buffer(); + } catch { + return undefined; + } if (out.length <= maxBytes) { break; } From 99e5a15c13830e3322f83d26f751f05a06dfeaf6 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 21 May 2026 09:12:42 +0200 Subject: [PATCH 693/732] Add metrics handler --- js/cli/bun.lock | 105 ++- js/cli/package.json | 2 + js/cli/src/api/index.ts | 2 + js/cli/src/cli/run.ts | 11 +- js/cli/src/config.ts | 6 +- js/cli/src/init.ts | 9 +- js/cli/src/proton-drive.ts | 5 +- js/cli/src/telemetry/consoleHandler.ts | 13 + js/cli/src/telemetry/fileHandler.ts | 37 + js/cli/src/telemetry/index.ts | 3 + js/cli/src/telemetry/logFormatters.ts | 19 + js/cli/src/telemetry/metricHandler.test.ts | 861 ++++++++++++++++++ js/cli/src/telemetry/metricHandler.ts | 273 ++++++ js/cli/src/telemetry/metrics/Metrics.ts | 64 ++ js/cli/src/telemetry/metrics/constants.ts | 3 + js/cli/src/telemetry/metrics/index.ts | 2 + js/cli/src/telemetry/metrics/lib/counter.ts | 11 + js/cli/src/telemetry/metrics/lib/histogram.ts | 8 + js/cli/src/telemetry/metrics/lib/metric.ts | 27 + .../telemetry/metrics/lib/requestService.ts | 81 ++ js/cli/src/telemetry/metrics/lib/types.ts | 11 + ...k_api_retry_succeeded_total_v1.schema.d.ts | 16 + .../drive_sdk_debounce_total_v1.schema.d.ts | 14 + ...wnload_erroring_users_total_v1.schema.d.ts | 17 + ..._errors_file_size_histogram_v1.schema.d.ts | 14 + ...e_sdk_download_errors_total_v1.schema.d.ts | 24 + ...ors_transfer_size_histogram_v1.schema.d.ts | 14 + ...download_success_rate_total_v1.schema.d.ts | 17 + ...k_verification_errors_total_v1.schema.d.ts | 16 + ...ity_decryption_errors_total_v1.schema.d.ts | 26 + ...egrity_erroring_users_total_v1.schema.d.ts | 17 + ...y_verification_errors_total_v1.schema.d.ts | 28 + ...upload_erroring_users_total_v1.schema.d.ts | 17 + ..._errors_file_size_histogram_v1.schema.d.ts | 14 + ...ive_sdk_upload_errors_total_v1.schema.d.ts | 17 + ...ors_transfer_size_histogram_v1.schema.d.ts | 14 + ...k_upload_success_rate_total_v1.schema.d.ts | 17 + ...nts_subscriptions_histogram_v1.schema.d.ts | 16 + js/cli/src/telemetry/sentry.ts | 150 +++ js/cli/src/telemetry/sentryHandler.ts | 36 + js/cli/src/telemetry/telemetry.ts | 52 ++ 41 files changed, 2056 insertions(+), 33 deletions(-) create mode 100644 js/cli/src/telemetry/consoleHandler.ts create mode 100644 js/cli/src/telemetry/fileHandler.ts create mode 100644 js/cli/src/telemetry/index.ts create mode 100644 js/cli/src/telemetry/logFormatters.ts create mode 100644 js/cli/src/telemetry/metricHandler.test.ts create mode 100644 js/cli/src/telemetry/metricHandler.ts create mode 100644 js/cli/src/telemetry/metrics/Metrics.ts create mode 100644 js/cli/src/telemetry/metrics/constants.ts create mode 100644 js/cli/src/telemetry/metrics/index.ts create mode 100644 js/cli/src/telemetry/metrics/lib/counter.ts create mode 100644 js/cli/src/telemetry/metrics/lib/histogram.ts create mode 100644 js/cli/src/telemetry/metrics/lib/metric.ts create mode 100644 js/cli/src/telemetry/metrics/lib/requestService.ts create mode 100644 js/cli/src/telemetry/metrics/lib/types.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_api_retry_succeeded_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_debounce_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_download_erroring_users_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_file_size_histogram_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_transfer_size_histogram_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_download_success_rate_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_integrity_block_verification_errors_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_integrity_decryption_errors_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_integrity_erroring_users_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_integrity_verification_errors_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_upload_erroring_users_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_file_size_histogram_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_upload_success_rate_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_sdk_volume_events_subscriptions_histogram_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/sentry.ts create mode 100644 js/cli/src/telemetry/sentryHandler.ts create mode 100644 js/cli/src/telemetry/telemetry.ts diff --git a/js/cli/bun.lock b/js/cli/bun.lock index 229ffe49..056c0902 100644 --- a/js/cli/bun.lock +++ b/js/cli/bun.lock @@ -24,6 +24,7 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", + "json-schema-to-typescript": "^13.1.2", "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", "typescript": "^5.9.3", @@ -284,6 +285,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcherny/json-schema-ref-parser": ["@bcherny/json-schema-ref-parser@10.0.5-fork", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.6", "call-me-maybe": "^1.0.1", "js-yaml": "^4.1.0" } }, "sha512-E/jKbPoca1tfUPj3iSbitDZTGnq6FUFjkH6L8U2oDwSuwK1WhnnVtCG7oFOTg/DDnyoXbQYUiUiGOibHqaGVnw=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], @@ -404,6 +407,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@mdn/browser-compat-data": ["@mdn/browser-compat-data@4.2.1", "", {}, "sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA=="], "@microsoft/api-extractor": ["@microsoft/api-extractor@7.58.7", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.8", "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.23.1", "@rushstack/rig-package": "0.7.3", "@rushstack/terminal": "0.24.0", "@rushstack/ts-command-line": "5.3.9", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw=="], @@ -640,6 +645,8 @@ "@types/formidable": ["@types/formidable@1.2.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-6psvrUy5VDYb+yaPJReF1WrRsz+FBwyJutK9Twz1Efa27tm07bARNIkK2B8ZPWq80dXqpKfrxTO96xrtPp+AuA=="], + "@types/glob": ["@types/glob@7.2.0", "", { "dependencies": { "@types/minimatch": "*", "@types/node": "*" } }, "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA=="], + "@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -656,12 +663,18 @@ "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/keygrip": ["@types/keygrip@1.0.6", "", {}, "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ=="], "@types/koa": ["@types/koa@2.15.0", "", { "dependencies": { "@types/accepts": "*", "@types/content-disposition": "*", "@types/cookies": "*", "@types/http-assert": "*", "@types/http-errors": "*", "@types/keygrip": "*", "@types/koa-compose": "*", "@types/node": "*" } }, "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g=="], "@types/koa-compose": ["@types/koa-compose@3.2.9", "", { "dependencies": { "@types/koa": "*" } }, "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + + "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], + "@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="], "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], @@ -676,6 +689,8 @@ "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], + "@types/prettier": ["@types/prettier@2.7.3", "", {}, "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA=="], + "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -770,6 +785,8 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -828,6 +845,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -846,6 +865,8 @@ "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + "cli-color": ["cli-color@2.0.4", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.64", "es6-iterator": "^2.0.3", "memoizee": "^0.4.15", "timers-ext": "^0.1.7" } }, "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA=="], + "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], "cli-spinners": ["cli-spinners@1.3.1", "", {}, "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg=="], @@ -892,6 +913,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -946,6 +969,14 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + + "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], + + "es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], + + "es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -964,6 +995,8 @@ "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -980,6 +1013,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -992,6 +1027,8 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -1046,14 +1083,18 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stdin": ["get-stdin@8.0.0", "", {}, "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg=="], + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "gettext-parser": ["gettext-parser@6.0.0", "", { "dependencies": { "content-type": "^1.0.4", "encoding": "^0.1.13", "readable-stream": "^4.1.0", "safe-buffer": "^5.2.1" } }, "sha512-eWFsR78gc/eKnzDgc919Us3cbxQbzxK1L8vAIZrKMQqOUgULyeqmczNlBjTlVTk2FaB7nV9C1oobd/PGBOqNmg=="], - "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob-promise": ["glob-promise@4.2.2", "", { "dependencies": { "@types/glob": "^7.1.3" }, "peerDependencies": { "glob": "^7.1.6" } }, "sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw=="], + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1128,6 +1169,8 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -1218,6 +1261,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-to-typescript": ["json-schema-to-typescript@13.1.2", "", { "dependencies": { "@bcherny/json-schema-ref-parser": "10.0.5-fork", "@types/json-schema": "^7.0.11", "@types/lodash": "^4.14.182", "@types/prettier": "^2.6.1", "cli-color": "^2.0.2", "get-stdin": "^8.0.0", "glob": "^7.1.6", "glob-promise": "^4.2.2", "is-glob": "^4.0.3", "lodash": "^4.17.21", "minimist": "^1.2.6", "mkdirp": "^1.0.4", "mz": "^2.7.0", "prettier": "^2.6.2" }, "bin": { "json2ts": "dist/src/cli.js" } }, "sha512-17G+mjx4nunvOpkPvcz7fdwUwYCEwyH8vR3Ym3rFiQ8uzAL3go+c1306Kk7iGRk8HuXBXqy+JJJmpYl0cvOllw=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], @@ -1266,6 +1311,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.mapvalues": ["lodash.mapvalues@4.6.0", "", {}, "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ=="], @@ -1276,6 +1323,8 @@ "lru-cache": ["lru-cache@8.0.5", "", {}, "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA=="], + "lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], + "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1292,6 +1341,8 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -1310,7 +1361,7 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -1320,12 +1371,16 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], @@ -1336,6 +1391,8 @@ "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1566,6 +1623,12 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], @@ -1586,6 +1649,8 @@ "ttag-cli": ["ttag-cli@1.11.2", "", { "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" } }, "sha512-unLFfw4ZxRcEjO7rqwESwiuchtd8BdzyvnGVDtqGLUvdZFvm2zLu311aBcTNS31pwKt5JMQB03G3Or3LlEe4/g=="], + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -1706,8 +1771,6 @@ "@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], - "@jest/reporters/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "@microsoft/api-extractor/@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], "@microsoft/api-extractor/@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg=="], @@ -1728,6 +1791,8 @@ "@protontech/drive-sdk/@protontech/crypto": ["@protontech/crypto@2.0.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@openpgp/web-stream-tools": "~0.3.1", "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" } }, "sha512-PMvQi1L98eU9tCVNvoLIEq21Jwfs2a4LxJwoEVbEIhvZVVprCf4lRrwqce2HhPmyjQuQJDcSlqbma+o++G2LEw=="], + "@protontech/drive-sdk/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -1738,6 +1803,8 @@ "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@types/minimatch/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1762,8 +1829,6 @@ "babel-plugin-ttag/ajv": ["ajv@6.12.3", "", { "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" } }, "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA=="], - "babel-plugin-ttag/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1776,8 +1841,6 @@ "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "has-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], "http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], @@ -1786,16 +1849,14 @@ "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-config/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "jest-config/parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "jest-runtime/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "json-schema-to-typescript/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "koa-static/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "log-symbols/chalk": ["chalk@1.1.3", "", { "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" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], @@ -1824,18 +1885,16 @@ "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "rimraf/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "test-exclude/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "ttag-cli/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "ttag-cli/cross-spawn": ["cross-spawn@5.1.0", "", { "dependencies": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A=="], "ttag-cli/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "ttag-cli/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "ttag-cli/plural-forms": ["plural-forms@0.5.3", "", {}, "sha512-t/hkjsTeDwaK9n/z6tUiSHySTC8sPnTiS5YF3Y5p4L+eomzXh7O0vEemkjwb68/82w0Rjw4uED3X84X7vXf9lg=="], "ttag-cli/yargs": ["yargs@15.4.1", "", { "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" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], @@ -1876,10 +1935,14 @@ "@protontech/drive-sdk/@protontech/crypto/@openpgp/web-stream-tools": ["@openpgp/web-stream-tools@0.3.1", "", { "peerDependencies": { "@types/node": ">=18.0.0", "typescript": ">=4.7" }, "optionalPeers": ["@types/node", "typescript"] }, "sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA=="], + "@protontech/drive-sdk/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@types/minimatch/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -1934,8 +1997,6 @@ "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "log-symbols/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -2004,9 +2065,11 @@ "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@protontech/drive-sdk/glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@types/minimatch/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "log-symbols/chalk/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], @@ -2030,6 +2093,8 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "@protontech/drive-sdk/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "ttag-cli/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/js/cli/package.json b/js/cli/package.json index a992e835..b06dc573 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -8,6 +8,7 @@ "build:internal": "bun ./scripts/build-cli.mjs internal", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/api/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/api/api-auth-types.ts", + "generate-metric-schemas": "bun ./scripts/generate-metric-schemas.ts", "lint": "eslint src integration-tests --ext .ts --cache --ignore-pattern '**/api/*-types.ts'", "pretty": "prettier --write $(find src integration-tests -type f -name '*.ts')", "test": "jest src", @@ -26,6 +27,7 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-tsdoc": "^0.3.0", "jest": "^29.7.0", + "json-schema-to-typescript": "^13.1.2", "openapi-typescript": "^7.4.1", "prettier": "^3.6.2", "typescript": "^5.9.3", diff --git a/js/cli/src/api/index.ts b/js/cli/src/api/index.ts index fcafafc8..5c6e2dd8 100644 --- a/js/cli/src/api/index.ts +++ b/js/cli/src/api/index.ts @@ -10,6 +10,7 @@ import { HTTPClient } from './httpClient'; import { Srp } from './srp'; export type { Addresses } from './addresses'; +export type { ApiClient } from './apiClient'; export type { Auth } from './auth'; export type { Srp } from './srp'; @@ -28,6 +29,7 @@ export async function initApi(config: Config, credentials: Credentials, logger: addresses, auth, srp, + apiClient, httpClient, }; } diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 9d00f8a9..29d1b589 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -1,6 +1,6 @@ import { InitConfig } from '../config'; import { init } from '../init'; -import { captureException, flushSentry } from '../sentry'; +import { captureException, flushSentry } from '../telemetry'; import { AuthRequiredError, isRecoverableReplError } from './errors'; import { formatReadableJson } from './formatters'; import { printCommandUsage } from './help'; @@ -9,14 +9,9 @@ import { getCommand, getCommandArguments } from './registryCore'; export type CliSession = Awaited>; -export async function runSingleInvocation( - commands: Command[], - argv: string[], - initOptions: InitConfig, - session?: CliSession, -): Promise { +export async function runSingleInvocation(commands: Command[], argv: string[], initOptions: InitConfig): Promise { try { - await runCommand(commands, argv, initOptions, session); + await runCommand(commands, argv, initOptions); } catch (error: unknown) { if (isRecoverableReplError(error)) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/js/cli/src/config.ts b/js/cli/src/config.ts index 8b0559ea..b941f8f6 100644 --- a/js/cli/src/config.ts +++ b/js/cli/src/config.ts @@ -6,11 +6,12 @@ import { LogLevel } from '@protontech/drive-sdk/telemetry'; const APP_DIR_NAME = 'proton-drive-cli'; export interface InitConfig { - enableConsoleLog?: boolean; appVersion: string; sdkVersion?: string; clientUidPrefix: string; enablePersistedEvents?: boolean; + enableConsoleLog?: boolean; + enableMetrics?: boolean; flags?: Record; } @@ -37,6 +38,8 @@ export interface Config { enablePersistedEvents: boolean; /** Whether to enable printing logs to the console. */ enableConsoleLog: boolean; + /** Whether to enable sending anonymized operational metrics. */ + enableMetrics: boolean; /** Level of logging to both console and log file. */ logLevel: LogLevel; @@ -67,6 +70,7 @@ export function getConfig(options: InitConfig): Config { logDir, enablePersistedEvents: options.enablePersistedEvents || false, enableConsoleLog: options.enableConsoleLog || false, + enableMetrics: options.enableMetrics || false, logLevel, unsafeSecrets, unsafeCache: unsafeSecrets, diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index b66c91d4..6fef2500 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -26,12 +26,15 @@ export async function init(configOptions: InitConfig) { mkdir(config.logDir, { recursive: true }), ]); - const telemetry = initTelemetry(config); + const { telemetry, initMetrics, flush: flushTelemetry } = initTelemetry(config); const logger = telemetry.getLogger('cli'); const openPGPCryptoModule = initOpenPGPCryptoModule(); const credentials = initCredentials(config, logger); - const { auth, addresses, srp, httpClient } = await initApi(config, credentials, logger); + const { auth, addresses, srp, httpClient, apiClient } = await initApi(config, credentials, logger); + + // TODO: Once we have Account SDK, get the user plan from the auth object. + initMetrics(apiClient, 'unknown'); const clientUid = await getOrGenerateClientUid(config, logger); const caches = createCaches(config, credentials, logger); @@ -75,7 +78,7 @@ export async function init(configOptions: InitConfig) { eventsManager, eventsProvider, dispose: async () => { - await eventsManager.dispose(); + await Promise.all([flushTelemetry(), eventsManager.dispose()]); }, clearCaches: async () => { logger.debug('Clearing caches'); diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 85cc48d3..0250f898 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -2,7 +2,7 @@ import { FeatureFlags } from '@protontech/drive-sdk'; import { run } from './cli'; import { COMMANDS } from './commands'; -import { captureException, flushSentry, initSentry } from './sentry'; +import { captureException, flushSentry, initSentry } from './telemetry'; // Two or three dash-separated parts: platform, product, optional section (e.g. sdkclijs not sdk-cli-js). declare const APP_VERSION: string; @@ -19,6 +19,9 @@ try { appVersion: APP_VERSION, sdkVersion: SDK_VERSION, enablePersistedEvents: true, + // Only official builds should send metrics. + // Only official builds have a SENTRY_DSN set. + enableMetrics: !!SENTRY_DSN, // TODO: Configure flags via Unleash. flags: { [FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead]: true, diff --git a/js/cli/src/telemetry/consoleHandler.ts b/js/cli/src/telemetry/consoleHandler.ts new file mode 100644 index 00000000..9f6f05d7 --- /dev/null +++ b/js/cli/src/telemetry/consoleHandler.ts @@ -0,0 +1,13 @@ +import { + MetricEvent, + MetricHandler, + MetricRecord, +} from '@protontech/drive-sdk/telemetry'; + +export class ColoredConsoleMetricHandler implements MetricHandler { + onEvent(metric: MetricRecord) { + console.info( + `\x1b[1;32m${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}\x1b[0m`, + ); + } +} diff --git a/js/cli/src/telemetry/fileHandler.ts b/js/cli/src/telemetry/fileHandler.ts new file mode 100644 index 00000000..d503680c --- /dev/null +++ b/js/cli/src/telemetry/fileHandler.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; + +import { + BasicLogFormatter, + LogFormatter, + LogHandler, + LogRecord, + MetricEvent, + MetricRecord, +} from '@protontech/drive-sdk/telemetry'; + +export class FileLogHandler implements LogHandler { + private formatter: LogFormatter; + private writer: Bun.FileSink; + + constructor(logDir: string) { + this.formatter = new BasicLogFormatter(); + + const logFile = path.join(logDir, 'proton-drive.log'); + const file = Bun.file(logFile); + this.writer = file.writer(); + } + + log(log: LogRecord) { + const message = this.formatter.format(log); + void this.writer.write(message); + void this.writer.write('\n'); + void this.writer.flush(); + } + + onEvent(metric: MetricRecord) { + const message = JSON.stringify(metric); + void this.writer.write(message); + void this.writer.write('\n'); + void this.writer.flush(); + } +} diff --git a/js/cli/src/telemetry/index.ts b/js/cli/src/telemetry/index.ts new file mode 100644 index 00000000..800155ec --- /dev/null +++ b/js/cli/src/telemetry/index.ts @@ -0,0 +1,3 @@ +export type { SeverityLevel } from './sentry'; +export { captureException, flushSentry, initSentry } from './sentry'; +export { initTelemetry } from './telemetry'; diff --git a/js/cli/src/telemetry/logFormatters.ts b/js/cli/src/telemetry/logFormatters.ts new file mode 100644 index 00000000..c159f68f --- /dev/null +++ b/js/cli/src/telemetry/logFormatters.ts @@ -0,0 +1,19 @@ +import { + BasicLogFormatter, + LogLevel, + LogRecord, +} from '@protontech/drive-sdk/telemetry'; + +export class ColoredLogFormatter extends BasicLogFormatter { + format(log: LogRecord) { + const color = { + [LogLevel.DEBUG]: '\x1b[1;90m', + [LogLevel.INFO]: '\x1b[1;34m', + [LogLevel.WARNING]: '\x1b[1;33m', + [LogLevel.ERROR]: '\x1b[1;31m', + }[log.level]; + + const result = super.format(log); + return `${color}${result}\x1b[0m`; + } +} diff --git a/js/cli/src/telemetry/metricHandler.test.ts b/js/cli/src/telemetry/metricHandler.test.ts new file mode 100644 index 00000000..7e0131db --- /dev/null +++ b/js/cli/src/telemetry/metricHandler.test.ts @@ -0,0 +1,861 @@ +import { MetricVolumeType } from '@protontech/drive-sdk'; + +import type { ApiClient } from '../api'; +import { MetricHandler } from './metricHandler'; +import { Metrics, MetricsRequestService } from './metrics'; +import { captureMessage } from './sentry'; + +jest.mock('./sentry', () => ({ + captureMessage: jest.fn(), +})); + +const metricsMocks = { + drive_sdk_api_retry_succeeded_total: { increment: jest.fn() }, + drive_sdk_debounce_total: { increment: jest.fn() }, + drive_sdk_upload_success_rate_total: { increment: jest.fn() }, + drive_sdk_upload_errors_total: { increment: jest.fn() }, + drive_sdk_upload_errors_transfer_size_histogram: { observe: jest.fn() }, + drive_sdk_upload_errors_file_size_histogram: { observe: jest.fn() }, + drive_sdk_upload_erroring_users_total: { increment: jest.fn() }, + drive_sdk_download_success_rate_total: { increment: jest.fn() }, + drive_sdk_download_errors_total: { increment: jest.fn() }, + drive_sdk_download_errors_transfer_size_histogram: { observe: jest.fn() }, + drive_sdk_download_errors_file_size_histogram: { observe: jest.fn() }, + drive_sdk_download_erroring_users_total: { increment: jest.fn() }, + drive_sdk_integrity_decryption_errors_total: { increment: jest.fn() }, + drive_sdk_integrity_verification_errors_total: { increment: jest.fn() }, + drive_sdk_integrity_erroring_users_total: { increment: jest.fn() }, + drive_sdk_integrity_block_verification_errors_total: { increment: jest.fn() }, + drive_sdk_volume_events_subscriptions_histogram: { observe: jest.fn() }, +}; + +jest.mock('./metrics', () => ({ + Metrics: jest.fn(() => metricsMocks), + MetricsRequestService: jest.fn(), +})); + +const mockApiClient = { + authenticatedRequest: jest.fn(), + baseUrlWithProtocol: 'https://api.proton.local', +} as unknown as ApiClient; + +describe('MetricHandler', () => { + let metricHandler: MetricHandler; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + metricHandler = new MetricHandler(); + metricHandler.init(mockApiClient, 'free'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('initializes metrics with the API client', () => { + expect(MetricsRequestService).toHaveBeenCalledWith( + mockApiClient.authenticatedRequest, + 'https://api.proton.local/api/data/v1/metrics', + ); + expect(Metrics).toHaveBeenCalled(); + }); + + describe('apiRetrySucceeded', () => { + it('should report api retry succeeded metric', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'apiRetrySucceeded', + url: 'https://api.proton.me/drive/v1/files', + failedAttempts: 3, + }, + }); + + expect(metricsMocks.drive_sdk_api_retry_succeeded_total.increment).toHaveBeenCalledWith({ + volumeType: 'unknown', + }); + }); + }); + + describe('debounceLongWait', () => { + it('should report debounce metric', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'debounceLongWait', + }, + }); + + expect(metricsMocks.drive_sdk_debounce_total.increment).toHaveBeenCalledWith({}); + }); + }); + + describe('upload', () => { + describe('successful upload', () => { + it('should report success metrics without error tracking', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 1024, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + }, + }); + + expect(metricsMocks.drive_sdk_upload_success_rate_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + status: 'success', + }); + + expect(metricsMocks.drive_sdk_upload_errors_total.increment).not.toHaveBeenCalled(); + expect( + metricsMocks.drive_sdk_upload_errors_transfer_size_histogram.observe, + ).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_upload_errors_file_size_histogram.observe).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + }); + + describe('unsuccessful upload', () => { + it('should report failure metrics for known error', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: '4xx', + }, + }); + + expect(metricsMocks.drive_sdk_upload_success_rate_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + status: 'failure', + }); + expect(metricsMocks.drive_sdk_upload_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + type: '4xx', + }); + expect(metricsMocks.drive_sdk_upload_errors_transfer_size_histogram.observe).toHaveBeenCalledWith({ + Value: 512, + Labels: {}, + }); + expect(metricsMocks.drive_sdk_upload_errors_file_size_histogram.observe).toHaveBeenCalledWith({ + Value: 4095, + Labels: {}, + }); + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + userPlan: 'free', + }); + expect(captureMessage).not.toHaveBeenCalled(); + }); + + it('should capture sentry message for unknown upload error', () => { + const originalError = new Error('some error'); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'unknown', + originalError, + }, + }); + + expect(captureMessage).toHaveBeenCalledWith('Metric event details: upload error', { + level: 'debug', + tags: { + driveSdkMetricEvent: 'uploadError', + }, + extra: { + error: originalError, + errorType: 'unknown', + }, + }); + }); + + it('should not report success rate or erroring users for network_error', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'network_error', + }, + }); + + expect(metricsMocks.drive_sdk_upload_success_rate_total.increment).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_upload_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + type: 'network_error', + }); + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + }); + + describe('erroring users reporting', () => { + it('should report erroring users once for single event', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'unknown', + }, + }); + + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledTimes(1); + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + userPlan: 'free', + }); + }); + + it('should not report erroring users twice for events right after each other', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'unknown', + }, + }); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 256, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'server_error', + }, + }); + + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledTimes(1); + }); + + it('should report erroring users twice for events five minutes apart', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 512, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'unknown', + }, + }); + + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'upload', + volumeType: MetricVolumeType.OwnVolume, + uploadedSize: 256, + approximateUploadedSize: 4095, + expectedSize: 1024, + approximateExpectedSize: 4095, + error: 'server_error', + }, + }); + + expect(metricsMocks.drive_sdk_upload_erroring_users_total.increment).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('download', () => { + describe('successful download', () => { + it('should report success metrics without error tracking', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 1024, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + }, + }); + + expect(metricsMocks.drive_sdk_download_success_rate_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + status: 'success', + }); + + expect(metricsMocks.drive_sdk_download_errors_total.increment).not.toHaveBeenCalled(); + expect( + metricsMocks.drive_sdk_download_errors_transfer_size_histogram.observe, + ).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_download_errors_file_size_histogram.observe).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + }); + + describe('unsuccessful download', () => { + it('should report failure metrics for known error', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: '4xx', + }, + }); + + expect(metricsMocks.drive_sdk_download_success_rate_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + status: 'failure', + }); + expect(metricsMocks.drive_sdk_download_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + type: '4xx', + }); + expect(metricsMocks.drive_sdk_download_errors_transfer_size_histogram.observe).toHaveBeenCalledWith({ + Value: 512, + Labels: {}, + }); + expect(metricsMocks.drive_sdk_download_errors_file_size_histogram.observe).toHaveBeenCalledWith({ + Value: 4095, + Labels: {}, + }); + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + userPlan: 'free', + }); + expect(captureMessage).not.toHaveBeenCalled(); + }); + + it('should capture sentry message for unknown download error', () => { + const originalError = new Error('some error'); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'unknown', + originalError, + }, + }); + + expect(captureMessage).toHaveBeenCalledWith('Metric event details: download error', { + level: 'debug', + tags: { + driveSdkMetricEvent: 'downloadError', + }, + extra: { + error: originalError, + }, + }); + }); + + it('should not report success rate or erroring users for network_error', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'network_error', + }, + }); + + expect(metricsMocks.drive_sdk_download_success_rate_total.increment).not.toHaveBeenCalled(); + expect(metricsMocks.drive_sdk_download_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + type: 'network_error', + }); + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + + it('should handle missing claimedFileSize', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + error: 'network_error', + }, + }); + + expect(metricsMocks.drive_sdk_download_errors_file_size_histogram.observe).toHaveBeenCalledWith({ + Value: 0, + Labels: {}, + }); + }); + }); + + describe('erroring users reporting', () => { + it('should report erroring users once for single event', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'unknown', + }, + }); + + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledTimes(1); + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + userPlan: 'free', + }); + }); + + it('should not report erroring users twice for events right after each other', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'unknown', + }, + }); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 256, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'decryption_error', + }, + }); + + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledTimes(1); + }); + + it('should report erroring users twice for events five minutes apart', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 512, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'unknown', + }, + }); + + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'download', + volumeType: MetricVolumeType.OwnVolume, + downloadedSize: 256, + approximateDownloadedSize: 4095, + claimedFileSize: 1024, + approximateClaimedFileSize: 4095, + error: 'decryption_error', + }, + }); + + expect(metricsMocks.drive_sdk_download_erroring_users_total.increment).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('decryptionError', () => { + it('should report decryption error metrics', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: true, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_decryption_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + field: 'nodeKey', + fromBefore2024: 'yes', + }); + }); + + it('should handle undefined fromBefore2024', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: undefined, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_decryption_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + field: 'nodeKey', + fromBefore2024: 'unknown', + }); + }); + + it('should report erroring users when not from before 2024', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: false, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + userPlan: 'free', + }); + }); + + it('should capture sentry message for new decryption errors', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: false, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(captureMessage).toHaveBeenCalledWith('Metric event details: decryption error', { + level: 'error', + tags: { + driveSdkMetricEvent: 'decryptionError', + }, + extra: { + volumeType: MetricVolumeType.OwnVolume, + uid: 'uid', + field: 'nodeKey', + fromBefore2024: false, + error: 'Invalid key', + }, + }); + }); + + it('should not capture sentry message when fromBefore2024 is true', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: true, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(captureMessage).not.toHaveBeenCalled(); + }); + + it('should not report erroring users when fromBefore2024 is true', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: true, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + + describe('erroring users reporting', () => { + it('should not report erroring users twice for events right after each other', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: false, + error: 'Invalid key', + uid: 'uid', + }, + }); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'verificationError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeName', + fromBefore2024: false, + addressMatchingDefaultShare: true, + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).toHaveBeenCalledTimes(1); + }); + + it('should report erroring users twice for events five minutes apart', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'decryptionError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeKey', + fromBefore2024: false, + error: 'Invalid key', + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'verificationError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeName', + fromBefore2024: false, + addressMatchingDefaultShare: true, + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('verificationError', () => { + it('should report verification error metrics', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'verificationError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeName', + fromBefore2024: false, + addressMatchingDefaultShare: true, + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_verification_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + field: 'nodeName', + addressMatchingDefaultShare: 'yes', + fromBefore2024: 'no', + }); + }); + + it('should handle undefined boolean values', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'verificationError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeName', + fromBefore2024: undefined, + addressMatchingDefaultShare: undefined, + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_verification_errors_total.increment).toHaveBeenCalledWith({ + volumeType: 'own_volume', + field: 'nodeName', + addressMatchingDefaultShare: 'unknown', + fromBefore2024: 'unknown', + }); + }); + + it('should not report erroring users unless address matches default share', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'verificationError', + volumeType: MetricVolumeType.OwnVolume, + field: 'nodeName', + fromBefore2024: false, + addressMatchingDefaultShare: false, + uid: 'uid', + }, + }); + + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + }); + + describe('blockVerificationError', () => { + it('should report block verification error metrics', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'blockVerificationError', + retryHelped: true, + }, + }); + + expect(metricsMocks.drive_sdk_integrity_block_verification_errors_total.increment).toHaveBeenCalledWith({ + retryHelped: 'yes', + }); + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).not.toHaveBeenCalled(); + }); + + it('should report erroring users when retry did not help', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'blockVerificationError', + retryHelped: false, + }, + }); + + expect(metricsMocks.drive_sdk_integrity_block_verification_errors_total.increment).toHaveBeenCalledWith({ + retryHelped: 'no', + }); + expect(metricsMocks.drive_sdk_integrity_erroring_users_total.increment).toHaveBeenCalledWith({ + volumeType: 'unknown', + userPlan: 'free', + }); + }); + }); + + describe('volumeEventsSubscriptionsChanged', () => { + it('should report volume events subscriptions histogram', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'volumeEventsSubscriptionsChanged', + numberOfVolumeSubscriptions: 5, + }, + }); + + expect(metricsMocks.drive_sdk_volume_events_subscriptions_histogram.observe).toHaveBeenCalledWith({ + Value: 5, + Labels: { + userPlan: 'free', + }, + }); + }); + }); + + describe('unknown event', () => { + it('should not report metrics for unknown event types', () => { + metricHandler.onEvent({ + time: new Date(), + event: { + eventName: 'unknownEvent', + someProperty: 'value', + } as never, + }); + + Object.values(metricsMocks).forEach((metric) => { + if ('increment' in metric) { + expect(metric.increment).not.toHaveBeenCalled(); + } + if ('observe' in metric) { + expect(metric.observe).not.toHaveBeenCalled(); + } + }); + }); + }); + + describe('before init', () => { + it('should not report metrics when handler is not initialized', () => { + const uninitializedHandler = new MetricHandler(); + + uninitializedHandler.onEvent({ + time: new Date(), + event: { + eventName: 'apiRetrySucceeded', + url: 'https://api.proton.me/drive/v1/files', + failedAttempts: 1, + }, + }); + + expect(metricsMocks.drive_sdk_api_retry_succeeded_total.increment).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/js/cli/src/telemetry/metricHandler.ts b/js/cli/src/telemetry/metricHandler.ts new file mode 100644 index 00000000..956b02aa --- /dev/null +++ b/js/cli/src/telemetry/metricHandler.ts @@ -0,0 +1,273 @@ +import type { + MetricBlockVerificationErrorEvent, + MetricDecryptionErrorEvent, + MetricDownloadEvent, + MetricEvent, + MetricUploadEvent, + MetricVerificationErrorEvent, + MetricVolumeEventsSubscriptionsChangedEvent, +} from '@protontech/drive-sdk'; +import type { MetricHandler as MetricHandlerType, MetricRecord } from '@protontech/drive-sdk/telemetry'; +import { reduceSizePrecision } from '@protontech/drive-sdk/telemetry'; + +import type { ApiClient } from '../api'; +import { Metrics, MetricsRequestService } from './metrics'; +import { captureMessage } from './sentry'; + +export type UserPlan = 'free' | 'paid' | 'anonymous' | 'unknown'; + +const REPORT_ERRORING_USERS_INTERVAL_MS = 5 * 60 * 1000; + +export class MetricHandler implements MetricHandlerType { + private handler?: ConfiguredMetricHandler; + private requestService?: MetricsRequestService; + + init(apiClient: ApiClient, userPlan: UserPlan) { + this.requestService = new MetricsRequestService( + apiClient.authenticatedRequest, + `${apiClient.baseUrlWithProtocol}/api/data/v1/metrics`, + ); + const metrics = new Metrics(this.requestService); + this.handler = new ConfiguredMetricHandler(metrics, userPlan); + } + + async flush() { + await this.requestService?.processAllRequests(); + } + + onEvent(metric: MetricRecord): void { + this.handler?.onEvent(metric); + } +} + +class ConfiguredMetricHandler { + private lastUploadError: Date | undefined; + private lastDownloadError: Date | undefined; + private lastIntegrityError: Date | undefined; + + constructor( + private readonly metrics: Metrics, + private readonly userPlan: UserPlan, + ) {} + + onEvent(metric: MetricRecord): void { + switch (metric.event.eventName) { + case 'apiRetrySucceeded': + this.onApiRetrySucceeded(); + break; + case 'debounceLongWait': + this.onDebounceLongWait(); + break; + case 'upload': + this.onUpload(metric.event); + break; + case 'download': + this.onDownload(metric.event); + break; + case 'decryptionError': + this.onDecryptionError(metric.event); + break; + case 'verificationError': + this.onVerificationError(metric.event); + break; + case 'blockVerificationError': + this.onBlockVerificationError(metric.event); + break; + case 'volumeEventsSubscriptionsChanged': + this.onVolumeEventsSubscriptionsChanged(metric.event); + break; + default: + break; + } + } + + private onApiRetrySucceeded() { + this.metrics.drive_sdk_api_retry_succeeded_total.increment({ + volumeType: 'unknown', + }); + } + + private onDebounceLongWait() { + this.metrics.drive_sdk_debounce_total.increment({}); + } + + private onUpload(metric: MetricUploadEvent) { + if (metric.error !== 'network_error') { + this.metrics.drive_sdk_upload_success_rate_total.increment({ + volumeType: metric.volumeType, + status: !metric.error ? 'success' : 'failure', + }); + } + + if (!metric.error) { + return; + } + + this.metrics.drive_sdk_upload_errors_total.increment({ + volumeType: metric.volumeType, + type: metric.error, + }); + this.metrics.drive_sdk_upload_errors_transfer_size_histogram.observe({ + Value: metric.uploadedSize, + Labels: {}, + }); + this.metrics.drive_sdk_upload_errors_file_size_histogram.observe({ + Value: reduceSizePrecision(metric.expectedSize), + Labels: {}, + }); + + if ( + metric.error !== 'network_error' && + (!this.lastUploadError || this.lastUploadError.getTime() < Date.now() - REPORT_ERRORING_USERS_INTERVAL_MS) + ) { + this.metrics.drive_sdk_upload_erroring_users_total.increment({ + volumeType: metric.volumeType, + userPlan: this.userPlan, + }); + this.lastUploadError = new Date(); + } + + if (metric.error === 'unknown' || metric.error === 'integrity_error') { + captureMessage('Metric event details: upload error', { + level: 'debug', // Debug as we need it only when we investigate metric reports. + tags: { + driveSdkMetricEvent: 'uploadError', + }, + extra: { + error: metric.originalError, + errorType: metric.error, + }, + }); + } + } + + private onDownload(metric: MetricDownloadEvent) { + if (metric.error !== 'network_error') { + this.metrics.drive_sdk_download_success_rate_total.increment({ + volumeType: metric.volumeType, + status: !metric.error ? 'success' : 'failure', + }); + } + + if (!metric.error) { + return; + } + + this.metrics.drive_sdk_download_errors_total.increment({ + volumeType: metric.volumeType, + type: metric.error, + }); + this.metrics.drive_sdk_download_errors_transfer_size_histogram.observe({ + Value: metric.downloadedSize, + Labels: {}, + }); + this.metrics.drive_sdk_download_errors_file_size_histogram.observe({ + Value: reduceSizePrecision(metric.claimedFileSize || 0), + Labels: {}, + }); + + if ( + metric.error !== 'network_error' && + (!this.lastDownloadError || + this.lastDownloadError.getTime() < Date.now() - REPORT_ERRORING_USERS_INTERVAL_MS) + ) { + this.metrics.drive_sdk_download_erroring_users_total.increment({ + volumeType: metric.volumeType, + userPlan: this.userPlan, + }); + this.lastDownloadError = new Date(); + } + + if (metric.error === 'unknown' || metric.error === 'integrity_error') { + captureMessage('Metric event details: download error', { + level: 'debug', // Debug as we need it only when we investigate metric reports. + tags: { + driveSdkMetricEvent: 'downloadError', + }, + extra: { + error: metric.originalError, + }, + }); + } + } + + private onDecryptionError(metric: MetricDecryptionErrorEvent) { + this.metrics.drive_sdk_integrity_decryption_errors_total.increment({ + volumeType: metric.volumeType, + field: metric.field, + fromBefore2024: getYesNoUnknown(metric.fromBefore2024), + }); + + if (metric.fromBefore2024 === false) { + this.reportIntegrityErroringUsers(metric); + + captureMessage('Metric event details: decryption error', { + level: 'error', + tags: { + driveSdkMetricEvent: 'decryptionError', + }, + extra: { + volumeType: metric.volumeType, + uid: metric.uid, + field: metric.field, + fromBefore2024: metric.fromBefore2024, + error: metric.error, + }, + }); + } + } + + private onVerificationError(metric: MetricVerificationErrorEvent) { + this.metrics.drive_sdk_integrity_verification_errors_total.increment({ + volumeType: metric.volumeType, + field: metric.field, + addressMatchingDefaultShare: getYesNoUnknown(metric.addressMatchingDefaultShare), + fromBefore2024: getYesNoUnknown(metric.fromBefore2024), + }); + + if (metric.fromBefore2024 === false && metric.addressMatchingDefaultShare === true) { + this.reportIntegrityErroringUsers(metric); + } + } + + private onBlockVerificationError(metric: MetricBlockVerificationErrorEvent) { + this.metrics.drive_sdk_integrity_block_verification_errors_total.increment({ + retryHelped: metric.retryHelped ? 'yes' : 'no', + }); + + if (!metric.retryHelped) { + this.reportIntegrityErroringUsers(metric); + } + } + + private reportIntegrityErroringUsers( + metric: MetricDecryptionErrorEvent | MetricVerificationErrorEvent | MetricBlockVerificationErrorEvent, + ) { + if ( + !this.lastIntegrityError || + this.lastIntegrityError.getTime() < Date.now() - REPORT_ERRORING_USERS_INTERVAL_MS + ) { + this.metrics.drive_sdk_integrity_erroring_users_total.increment({ + volumeType: 'volumeType' in metric ? metric.volumeType : 'unknown', + userPlan: this.userPlan, + }); + this.lastIntegrityError = new Date(); + } + } + + private onVolumeEventsSubscriptionsChanged(metric: MetricVolumeEventsSubscriptionsChangedEvent) { + this.metrics.drive_sdk_volume_events_subscriptions_histogram.observe({ + Value: metric.numberOfVolumeSubscriptions, + Labels: { + userPlan: this.userPlan, + }, + }); + } +} + +function getYesNoUnknown(value: boolean | undefined): 'yes' | 'no' | 'unknown' { + if (value === undefined) { + return 'unknown'; + } + return value ? 'yes' : 'no'; +} diff --git a/js/cli/src/telemetry/metrics/Metrics.ts b/js/cli/src/telemetry/metrics/Metrics.ts new file mode 100644 index 00000000..0f6102e9 --- /dev/null +++ b/js/cli/src/telemetry/metrics/Metrics.ts @@ -0,0 +1,64 @@ +/** + * THIS CODE IS AUTOGENERATED + * Run `bun run generate-metric-schemas` to regenerate. + */ +import { Counter } from './lib/counter'; +import { Histogram } from './lib/histogram'; +import type { MetricsReporter } from './lib/metric'; +import type { HttpsProtonMeDriveSdkApiRetrySucceededTotalV1SchemaJson } from './types/drive_sdk_api_retry_succeeded_total_v1.schema'; +import type { HttpsProtonMeDriveSdkDebounceTotalV1SchemaJson } from './types/drive_sdk_debounce_total_v1.schema'; +import type { HttpsProtonMeDriveSdkDownloadErroringUsersTotalV1SchemaJson } from './types/drive_sdk_download_erroring_users_total_v1.schema'; +import type { HttpsProtonMeDriveSdkDownloadErrorsFileSizeHistogramV1SchemaJson } from './types/drive_sdk_download_errors_file_size_histogram_v1.schema'; +import type { HttpsProtonMeDriveSdkDownloadErrorsTotalV1SchemaJson } from './types/drive_sdk_download_errors_total_v1.schema'; +import type { HttpsProtonMeDriveSdkDownloadErrorsTransferSizeHistogramV1SchemaJson } from './types/drive_sdk_download_errors_transfer_size_histogram_v1.schema'; +import type { HttpsProtonMeDriveSdkDownloadSuccessRateTotalV1SchemaJson } from './types/drive_sdk_download_success_rate_total_v1.schema'; +import type { HttpsProtonMeDriveSdkIntegrityBlockVerificationErrorsTotalV1SchemaJson } from './types/drive_sdk_integrity_block_verification_errors_total_v1.schema'; +import type { HttpsProtonMeDriveSdkIntegrityDecryptionErrorsTotalV1SchemaJson } from './types/drive_sdk_integrity_decryption_errors_total_v1.schema'; +import type { HttpsProtonMeDriveSdkIntegrityErroringUsersTotalV1SchemaJson } from './types/drive_sdk_integrity_erroring_users_total_v1.schema'; +import type { HttpsProtonMeDriveSdkIntegrityVerificationErrorsTotalV1SchemaJson } from './types/drive_sdk_integrity_verification_errors_total_v1.schema'; +import type { HttpsProtonMeDriveSdkUploadErroringUsersTotalV1SchemaJson } from './types/drive_sdk_upload_erroring_users_total_v1.schema'; +import type { HttpsProtonMeDriveSdkUploadErrorsFileSizeHistogramV1SchemaJson } from './types/drive_sdk_upload_errors_file_size_histogram_v1.schema'; +import type { HttpsProtonMeDriveSdkUploadErrorsTotalV1SchemaJson } from './types/drive_sdk_upload_errors_total_v1.schema'; +import type { HttpsProtonMeDriveSdkUploadErrorsTransferSizeHistogramV1SchemaJson } from './types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema'; +import type { HttpsProtonMeDriveSdkUploadSuccessRateTotalV1SchemaJson } from './types/drive_sdk_upload_success_rate_total_v1.schema'; +import type { HttpsProtonMeDriveSdkVolumeEventsSubscriptionsHistogramV1SchemaJson } from './types/drive_sdk_volume_events_subscriptions_histogram_v1.schema'; + +export class Metrics { + public drive_sdk_api_retry_succeeded_total: Counter; + public drive_sdk_debounce_total: Counter; + public drive_sdk_download_erroring_users_total: Counter; + public drive_sdk_download_errors_file_size_histogram: Histogram; + public drive_sdk_download_errors_total: Counter; + public drive_sdk_download_errors_transfer_size_histogram: Histogram; + public drive_sdk_download_success_rate_total: Counter; + public drive_sdk_integrity_block_verification_errors_total: Counter; + public drive_sdk_integrity_decryption_errors_total: Counter; + public drive_sdk_integrity_erroring_users_total: Counter; + public drive_sdk_integrity_verification_errors_total: Counter; + public drive_sdk_upload_erroring_users_total: Counter; + public drive_sdk_upload_errors_file_size_histogram: Histogram; + public drive_sdk_upload_errors_total: Counter; + public drive_sdk_upload_errors_transfer_size_histogram: Histogram; + public drive_sdk_upload_success_rate_total: Counter; + public drive_sdk_volume_events_subscriptions_histogram: Histogram; + + constructor(requestService: MetricsReporter) { + this.drive_sdk_api_retry_succeeded_total = new Counter({ name: 'drive_sdk_api_retry_succeeded_total', version: 1 }, requestService); + this.drive_sdk_debounce_total = new Counter({ name: 'drive_sdk_debounce_total', version: 1 }, requestService); + this.drive_sdk_download_erroring_users_total = new Counter({ name: 'drive_sdk_download_erroring_users_total', version: 1 }, requestService); + this.drive_sdk_download_errors_file_size_histogram = new Histogram({ name: 'drive_sdk_download_errors_file_size_histogram', version: 1 }, requestService); + this.drive_sdk_download_errors_total = new Counter({ name: 'drive_sdk_download_errors_total', version: 1 }, requestService); + this.drive_sdk_download_errors_transfer_size_histogram = new Histogram({ name: 'drive_sdk_download_errors_transfer_size_histogram', version: 1 }, requestService); + this.drive_sdk_download_success_rate_total = new Counter({ name: 'drive_sdk_download_success_rate_total', version: 1 }, requestService); + this.drive_sdk_integrity_block_verification_errors_total = new Counter({ name: 'drive_sdk_integrity_block_verification_errors_total', version: 1 }, requestService); + this.drive_sdk_integrity_decryption_errors_total = new Counter({ name: 'drive_sdk_integrity_decryption_errors_total', version: 1 }, requestService); + this.drive_sdk_integrity_erroring_users_total = new Counter({ name: 'drive_sdk_integrity_erroring_users_total', version: 1 }, requestService); + this.drive_sdk_integrity_verification_errors_total = new Counter({ name: 'drive_sdk_integrity_verification_errors_total', version: 1 }, requestService); + this.drive_sdk_upload_erroring_users_total = new Counter({ name: 'drive_sdk_upload_erroring_users_total', version: 1 }, requestService); + this.drive_sdk_upload_errors_file_size_histogram = new Histogram({ name: 'drive_sdk_upload_errors_file_size_histogram', version: 1 }, requestService); + this.drive_sdk_upload_errors_total = new Counter({ name: 'drive_sdk_upload_errors_total', version: 1 }, requestService); + this.drive_sdk_upload_errors_transfer_size_histogram = new Histogram({ name: 'drive_sdk_upload_errors_transfer_size_histogram', version: 1 }, requestService); + this.drive_sdk_upload_success_rate_total = new Counter({ name: 'drive_sdk_upload_success_rate_total', version: 1 }, requestService); + this.drive_sdk_volume_events_subscriptions_histogram = new Histogram({ name: 'drive_sdk_volume_events_subscriptions_histogram', version: 1 }, requestService); + } +} diff --git a/js/cli/src/telemetry/metrics/constants.ts b/js/cli/src/telemetry/metrics/constants.ts new file mode 100644 index 00000000..5fe8af37 --- /dev/null +++ b/js/cli/src/telemetry/metrics/constants.ts @@ -0,0 +1,3 @@ +export const METRICS_BATCH_SIZE = 100; +export const METRICS_REQUEST_FREQUENCY_MS = 6_000; +export const METRICS_MAX_JAIL = 3; diff --git a/js/cli/src/telemetry/metrics/index.ts b/js/cli/src/telemetry/metrics/index.ts new file mode 100644 index 00000000..7b8d8d20 --- /dev/null +++ b/js/cli/src/telemetry/metrics/index.ts @@ -0,0 +1,2 @@ +export { MetricsRequestService } from './lib/requestService'; +export { Metrics } from './Metrics'; diff --git a/js/cli/src/telemetry/metrics/lib/counter.ts b/js/cli/src/telemetry/metrics/lib/counter.ts new file mode 100644 index 00000000..b5eba3af --- /dev/null +++ b/js/cli/src/telemetry/metrics/lib/counter.ts @@ -0,0 +1,11 @@ +import { Metric } from './metric'; +import type { MetricSchema } from './types'; + +export class Counter extends Metric { + increment(labels: D['Labels'], value = 1) { + this.addToRequestQueue({ + Value: value, + Labels: labels, + } as D); + } +} diff --git a/js/cli/src/telemetry/metrics/lib/histogram.ts b/js/cli/src/telemetry/metrics/lib/histogram.ts new file mode 100644 index 00000000..1f363610 --- /dev/null +++ b/js/cli/src/telemetry/metrics/lib/histogram.ts @@ -0,0 +1,8 @@ +import { Metric } from './metric'; +import type { MetricSchema } from './types'; + +export class Histogram extends Metric { + observe(data: D) { + this.addToRequestQueue(data); + } +} diff --git a/js/cli/src/telemetry/metrics/lib/metric.ts b/js/cli/src/telemetry/metrics/lib/metric.ts new file mode 100644 index 00000000..fc0f61c3 --- /dev/null +++ b/js/cli/src/telemetry/metrics/lib/metric.ts @@ -0,0 +1,27 @@ +import type { MetricRequest, MetricSchema } from './types'; + +const metricNamePattern = /^[a-zA-Z]+(?:_[a-zA-Z0-9]+)*$/; + +export interface MetricsReporter { + report(request: MetricRequest): void; +} + +export abstract class Metric { + constructor( + private readonly config: { name: string; version: number }, + private readonly reporter: MetricsReporter, + ) { + if (!metricNamePattern.test(config.name)) { + throw new Error(`Invalid metric name ${config.name}`); + } + } + + protected addToRequestQueue(data: D) { + this.reporter.report({ + Name: this.config.name, + Version: this.config.version, + Timestamp: Math.round(Date.now() / 1000), + Data: data, + }); + } +} diff --git a/js/cli/src/telemetry/metrics/lib/requestService.ts b/js/cli/src/telemetry/metrics/lib/requestService.ts new file mode 100644 index 00000000..1d834ec9 --- /dev/null +++ b/js/cli/src/telemetry/metrics/lib/requestService.ts @@ -0,0 +1,81 @@ +import type { KyInstance } from 'ky'; + +import { METRICS_BATCH_SIZE, METRICS_MAX_JAIL, METRICS_REQUEST_FREQUENCY_MS } from '../constants'; +import type { MetricRequest } from './types'; + +export class MetricsRequestService { + private readonly queue: MetricRequest[] = []; + private intervalId: ReturnType | null = null; + private jailCount = 0; + + constructor( + private readonly authenticatedRequest: KyInstance, + private readonly metricsUrl: string, + ) {} + + report(request: MetricRequest) { + if (this.isJailed()) { + return; + } + + if (this.intervalId === null) { + this.intervalId = setInterval(() => { + void this.processNextBatch(); + }, METRICS_REQUEST_FREQUENCY_MS); + } + + this.queue.push(request); + } + + async processAllRequests() { + if (this.queue.length === 0) { + return; + } + + const items = this.queue.splice(0); + await this.send(items); + } + + private async processNextBatch() { + if (this.queue.length === 0) { + this.stopBatching(); + return; + } + + const items = this.queue.splice(0, METRICS_BATCH_SIZE); + await this.send(items); + } + + private stopBatching() { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + private isJailed() { + return this.jailCount >= METRICS_MAX_JAIL; + } + + private async send(metrics: MetricRequest[]) { + if (metrics.length === 0 || this.isJailed()) { + return; + } + + try { + const response = await this.authenticatedRequest.post(this.metricsUrl, { + json: { Metrics: metrics }, + throwHttpErrors: false, + }); + + if (response.ok) { + this.jailCount = 0; + return; + } + + this.jailCount += 1; + } catch { + this.jailCount += 1; + } + } +} diff --git a/js/cli/src/telemetry/metrics/lib/types.ts b/js/cli/src/telemetry/metrics/lib/types.ts new file mode 100644 index 00000000..cfc748af --- /dev/null +++ b/js/cli/src/telemetry/metrics/lib/types.ts @@ -0,0 +1,11 @@ +export interface MetricSchema { + Labels: object; + Value: number; +} + +export interface MetricRequest { + Name: string; + Version: number; + Timestamp: number; + Data: object; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_api_retry_succeeded_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_api_retry_succeeded_total_v1.schema.d.ts new file mode 100644 index 00000000..9b7503dc --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_api_retry_succeeded_total_v1.schema.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Count number of API retry events that succeeded after retry + */ +export interface HttpsProtonMeDriveSdkApiRetrySucceededTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_debounce_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_debounce_total_v1.schema.d.ts new file mode 100644 index 00000000..b51f51e3 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_debounce_total_v1.schema.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Count number of accesses that took too long due to being loaded in parallel by another call + */ +export interface HttpsProtonMeDriveSdkDebounceTotalV1SchemaJson { + Labels: {}; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_erroring_users_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_erroring_users_total_v1.schema.d.ts new file mode 100644 index 00000000..8e80b64b --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_erroring_users_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * How many users experienced download error in the past 5 minutes + */ +export interface HttpsProtonMeDriveSdkDownloadErroringUsersTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + userPlan: "free" | "paid" | "anonymous" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_file_size_histogram_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_file_size_histogram_v1.schema.d.ts new file mode 100644 index 00000000..c6745f74 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_file_size_histogram_v1.schema.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Total file size of the errored download + */ +export interface HttpsProtonMeDriveSdkDownloadErrorsFileSizeHistogramV1SchemaJson { + Value: number; + Labels: {}; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts new file mode 100644 index 00000000..18ac943f --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts @@ -0,0 +1,24 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Counts of error categories during download + */ +export interface HttpsProtonMeDriveSdkDownloadErrorsTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + type: + | "server_error" + | "network_error" + | "decryption_error" + | "integrity_error" + | "rate_limited" + | "4xx" + | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_transfer_size_histogram_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_transfer_size_histogram_v1.schema.d.ts new file mode 100644 index 00000000..bf3382fd --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_transfer_size_histogram_v1.schema.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * At what point the download failed - how many bytes were downloaded before failure + */ +export interface HttpsProtonMeDriveSdkDownloadErrorsTransferSizeHistogramV1SchemaJson { + Value: number; + Labels: {}; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_success_rate_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_success_rate_total_v1.schema.d.ts new file mode 100644 index 00000000..bd285a76 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_success_rate_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Count of unique successful or failed downloads + */ +export interface HttpsProtonMeDriveSdkDownloadSuccessRateTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + status: "success" | "failure"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_block_verification_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_block_verification_errors_total_v1.schema.d.ts new file mode 100644 index 00000000..74b373e5 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_block_verification_errors_total_v1.schema.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Drive verification issues + */ +export interface HttpsProtonMeDriveSdkIntegrityBlockVerificationErrorsTotalV1SchemaJson { + Labels: { + retryHelped: "yes" | "no"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_decryption_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_decryption_errors_total_v1.schema.d.ts new file mode 100644 index 00000000..7abbb9a9 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_decryption_errors_total_v1.schema.d.ts @@ -0,0 +1,26 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Counts of decryption issues + */ +export interface HttpsProtonMeDriveSdkIntegrityDecryptionErrorsTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + field: + | "shareKey" + | "shareUrlPassword" + | "nodeKey" + | "nodeName" + | "nodeHashKey" + | "nodeExtendedAttributes" + | "nodeContentKey" + | "content"; + fromBefore2024: "yes" | "no" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_erroring_users_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_erroring_users_total_v1.schema.d.ts new file mode 100644 index 00000000..d91a97ef --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_erroring_users_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * How many users experienced decryption or verification error in the past 5 minutes + */ +export interface HttpsProtonMeDriveSdkIntegrityErroringUsersTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + userPlan: "free" | "paid" | "anonymous" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_verification_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_verification_errors_total_v1.schema.d.ts new file mode 100644 index 00000000..ba14183f --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_integrity_verification_errors_total_v1.schema.d.ts @@ -0,0 +1,28 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Drive verification issues + */ +export interface HttpsProtonMeDriveSdkIntegrityVerificationErrorsTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + field: + | "shareKey" + | "membershipInviter" + | "membershipInvitee" + | "nodeKey" + | "nodeName" + | "nodeHashKey" + | "nodeExtendedAttributes" + | "nodeContentKey" + | "content"; + addressMatchingDefaultShare: "yes" | "no" | "unknown"; + fromBefore2024: "yes" | "no" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_erroring_users_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_erroring_users_total_v1.schema.d.ts new file mode 100644 index 00000000..a26e6725 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_erroring_users_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * How many users experienced upload error in the past 5 minutes + */ +export interface HttpsProtonMeDriveSdkUploadErroringUsersTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + userPlan: "free" | "paid" | "anonymous" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_file_size_histogram_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_file_size_histogram_v1.schema.d.ts new file mode 100644 index 00000000..31aa6d49 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_file_size_histogram_v1.schema.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Total file size of the errored upload + */ +export interface HttpsProtonMeDriveSdkUploadErrorsFileSizeHistogramV1SchemaJson { + Value: number; + Labels: {}; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts new file mode 100644 index 00000000..959d7b8a --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Counts of error categories during upload + */ +export interface HttpsProtonMeDriveSdkUploadErrorsTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + type: "server_error" | "network_error" | "integrity_error" | "rate_limited" | "4xx" | "unknown"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema.d.ts new file mode 100644 index 00000000..8d535591 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * At what point the upload failed - how many bytes were uploaded before failure + */ +export interface HttpsProtonMeDriveSdkUploadErrorsTransferSizeHistogramV1SchemaJson { + Value: number; + Labels: {}; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_success_rate_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_success_rate_total_v1.schema.d.ts new file mode 100644 index 00000000..67b6ff32 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_success_rate_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Count of unique successful or failed uploads + */ +export interface HttpsProtonMeDriveSdkUploadSuccessRateTotalV1SchemaJson { + Labels: { + volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; + status: "success" | "failure"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_volume_events_subscriptions_histogram_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_volume_events_subscriptions_histogram_v1.schema.d.ts new file mode 100644 index 00000000..e1569e3c --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_volume_events_subscriptions_histogram_v1.schema.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * How many volumes the client is subscribed to + */ +export interface HttpsProtonMeDriveSdkVolumeEventsSubscriptionsHistogramV1SchemaJson { + Value: number; + Labels: { + userPlan: "free" | "paid" | "anonymous" | "unknown"; + }; +} diff --git a/js/cli/src/telemetry/sentry.ts b/js/cli/src/telemetry/sentry.ts new file mode 100644 index 00000000..a91cc94a --- /dev/null +++ b/js/cli/src/telemetry/sentry.ts @@ -0,0 +1,150 @@ +import type { ErrorEvent, StackFrame } from '@sentry/bun'; +import * as Sentry from '@sentry/bun'; + +export type { SeverityLevel } from '@sentry/bun'; + +let sentryEnabled = false; + +const PATH_REDACT = '[PATH]'; +const URL_REDACT = '[URL]'; + +export function initSentry(options: { dsn?: string; appVersion: string; sdkVersion?: string }) { + const dsn = options.dsn; + if (!dsn?.trim()) { + return; + } + + sentryEnabled = true; + + Sentry.init({ + dsn: dsn.trim(), + release: `${options.appVersion} (sdk: ${options.sdkVersion ?? 'unknown'})`, + includeServerName: false, + sendDefaultPii: false, + beforeSend(event) { + return scrubErrorEventForLocalPii(event); + }, + // Do not send console outputs to Sentry to avoid sending PII. + integrations: (defaults) => { + return defaults.filter((integration) => integration.name !== 'Console'); + }, + }); +} + +function scrubErrorEventForLocalPii(event: ErrorEvent): ErrorEvent { + if (event.message) { + event.message = scrubPathsInString(event.message); + } + if (event.logentry?.message) { + event.logentry.message = scrubPathsInString(event.logentry.message); + } + + const exceptions = event.exception?.values; + if (exceptions) { + for (const ex of exceptions) { + if (ex.value) { + ex.value = scrubPathsInString(ex.value); + } + const frames = ex.stacktrace?.frames; + if (frames) { + for (const frame of frames) { + scrubStackFrame(frame); + } + } + } + } + + const threads = event.threads?.values; + if (threads) { + for (const thread of threads) { + const frames = thread.stacktrace?.frames; + if (frames) { + for (const frame of frames) { + scrubStackFrame(frame); + } + } + } + } + + return event; +} + +function scrubStackFrame(frame: StackFrame): void { + if (frame.filename) { + frame.filename = scrubPathsInString(frame.filename); + } + if (frame.abs_path) { + frame.abs_path = scrubPathsInString(frame.abs_path); + } + if (frame.context_line) { + frame.context_line = scrubPathsInString(frame.context_line); + } + if (frame.pre_context?.length) { + frame.pre_context = frame.pre_context.map(scrubPathsInString); + } + if (frame.post_context?.length) { + frame.post_context = frame.post_context.map(scrubPathsInString); + } + if (frame.vars && typeof frame.vars === 'object') { + for (const key of Object.keys(frame.vars)) { + const v = frame.vars[key]; + if (typeof v === 'string') { + frame.vars[key] = scrubPathsInString(v); + } + } + } +} + +/** + * Redacts path-like spans (and full http(s)/file URLs) so directory layout and user home are not sent. + */ +function scrubPathsInString(str: string): string { + const unixPathRe = new RegExp(String.raw`(? { + if (!sentryEnabled) { + return; + } + await Sentry.flush(timeoutMs); +} diff --git a/js/cli/src/telemetry/sentryHandler.ts b/js/cli/src/telemetry/sentryHandler.ts new file mode 100644 index 00000000..65d973c7 --- /dev/null +++ b/js/cli/src/telemetry/sentryHandler.ts @@ -0,0 +1,36 @@ +import { + LogHandler, + LogLevel, + LogRecord, + MetricEvent, + MetricRecord, +} from '@protontech/drive-sdk/telemetry'; + +import { addBreadcrumb, SeverityLevel } from './sentry'; + +export class SentryLogHandler implements LogHandler { + log(log: LogRecord) { + const level = { + [LogLevel.DEBUG]: 'debug', + [LogLevel.INFO]: 'info', + [LogLevel.WARNING]: 'warning', + [LogLevel.ERROR]: 'error', + }[log.level] as SeverityLevel; + addBreadcrumb({ + message: log.message, + level, + category: log.loggerName, + data: { + error: log.error, + }, + }); + } + + onEvent(metric: MetricRecord) { + addBreadcrumb({ + message: metric.event.eventName, + level: 'info', + category: 'metric', + }); + } +} diff --git a/js/cli/src/telemetry/telemetry.ts b/js/cli/src/telemetry/telemetry.ts new file mode 100644 index 00000000..486b0d85 --- /dev/null +++ b/js/cli/src/telemetry/telemetry.ts @@ -0,0 +1,52 @@ +import { + ConsoleLogHandler, + LogFilter, + LogHandler, + MetricEvent, + MetricHandler as MetricHandlerType, + Telemetry, +} from '@protontech/drive-sdk/telemetry'; + +import { ApiClient } from '../api'; +import { Config } from '../config'; +import { ColoredConsoleMetricHandler } from './consoleHandler'; +import { FileLogHandler } from './fileHandler'; +import { ColoredLogFormatter } from './logFormatters'; +import { MetricHandler, UserPlan } from './metricHandler'; +import { flushSentry } from './sentry'; +import { SentryLogHandler } from './sentryHandler'; + +export function initTelemetry(config: Config) { + const fileHandler = new FileLogHandler(config.logDir); + const sentryHandler = new SentryLogHandler(); + const metricsHandler = config.enableMetrics ? new MetricHandler() : undefined; + + const logHandlers: LogHandler[] = [fileHandler, sentryHandler]; + if (config.enableConsoleLog) { + logHandlers.push(new ConsoleLogHandler(new ColoredLogFormatter())); + } + + const metricHandlers: MetricHandlerType[] = [fileHandler, sentryHandler]; + if (metricsHandler) { + metricHandlers.push(metricsHandler); + } + if (config.enableConsoleLog) { + metricHandlers.push(new ColoredConsoleMetricHandler()); + } + + const telemetry = new Telemetry({ + logFilter: new LogFilter({ globalLevel: config.logLevel }), + logHandlers, + metricHandlers, + }); + + return { + telemetry, + initMetrics: (apiClient: ApiClient, userPlan: UserPlan) => { + metricsHandler?.init(apiClient, userPlan); + }, + flush: async () => { + await Promise.all([flushSentry(), metricsHandler?.flush()]); + }, + }; +} From 106247b40611268ac588f4b15a50146fb98659eb Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 26 May 2026 07:39:13 +0200 Subject: [PATCH 694/732] Use error object instead of string in all NodeResults --- .../fileSystem/commandFileSystemCopy.ts | 4 +- .../fileSystem/commandFileSystemDelete.ts | 2 +- .../fileSystem/commandFileSystemMove.ts | 2 +- .../fileSystem/commandFileSystemRestore.ts | 2 +- .../fileSystem/commandFileSystemTrash.ts | 2 +- .../fileSystem/commandFileSystemUpload.ts | 2 +- js/sdk/src/interface/index.ts | 1 - js/sdk/src/interface/nodes.ts | 4 +- js/sdk/src/internal/apiService/errors.ts | 6 +- js/sdk/src/internal/apiService/index.ts | 6 + js/sdk/src/internal/nodes/apiService.test.ts | 26 +-- js/sdk/src/internal/nodes/apiService.ts | 15 +- .../internal/nodes/nodesManagement.test.ts | 8 +- js/sdk/src/internal/nodes/nodesManagement.ts | 16 +- js/sdk/src/internal/photos/addToAlbum.test.ts | 207 ++++++++++-------- js/sdk/src/internal/photos/addToAlbum.ts | 16 +- js/sdk/src/internal/photos/albumsManager.ts | 10 +- js/sdk/src/internal/photos/apiService.ts | 8 +- js/sdk/src/internal/photos/photosManager.ts | 6 +- js/sdk/src/protonDriveClient.ts | 3 +- js/sdk/src/protonDrivePhotosClient.ts | 9 +- 21 files changed, 184 insertions(+), 171 deletions(-) diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index a42e3f88..22e76d0f 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -53,14 +53,14 @@ export class CommandFileSystemCopy implements Command { name: string, ) { await printIterable(sdk.copyNodes([{ uid: getNodeUid(sourceNode), name }], targetNode), json, (result) => { - console.log(sanitizeTerminalText(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error.message}`)); }); } private async copyNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { await printIterable(sdk.copyNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); - console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts index 483fee7d..5154c2f9 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts @@ -13,7 +13,7 @@ export class CommandFileSystemDelete implements Command { await printIterable(nodePaths[0].sdk.deleteNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index c4b14d69..20d96298 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -22,7 +22,7 @@ export class CommandFileSystemMove implements Command { private async moveNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { await printIterable(sdk.moveNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); - console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts index 5c4c3c27..23be2ae0 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts @@ -13,7 +13,7 @@ export class CommandFileSystemRestore implements Command { await printIterable(nodePaths[0].sdk.restoreNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts index fae995e8..00e30fdd 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts @@ -13,7 +13,7 @@ export class CommandFileSystemTrash implements Command { await printIterable(nodePaths[0].sdk.trashNodes(nodes), json, (result) => { const nodeName = findName(nodes, result.uid); - console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error}`)); + console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); }); } } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 6bc9fb2c..649d411c 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -236,7 +236,7 @@ export class CommandFileSystemUpload implements Command { private async trashConflictingNode(ctx: UploadContext, node: MaybeNode): Promise { for await (const result of ctx.sdk.trashNodes([node])) { if (!result.ok) { - throw new ValidationError(result.error); + throw result.error; } } } diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index 68a40af6..eb88214a 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -41,7 +41,6 @@ export type { NodeEntity, NodeOrUid, NodeResult, - NodeResultWithError, NodeResultWithNewUid, Revision, RevisionOrUid, diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index e90bea7f..5ae821ec 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -235,7 +235,5 @@ 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 NodeResultWithError = { uid: string; ok: true } | { uid: string; ok: false; error: Error }; +export type NodeResult = { 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/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts index aa2a0658..f6f994ed 100644 --- a/js/sdk/src/internal/apiService/errors.ts +++ b/js/sdk/src/internal/apiService/errors.ts @@ -8,7 +8,7 @@ export function apiErrorFactory({ result, error, }: { - response: Response; + response?: Response; result?: unknown; error?: unknown; }): ServerError { @@ -19,9 +19,9 @@ export function apiErrorFactory({ // 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) { + if (response?.status === HTTPErrorCode.NOT_FOUND || !result) { const fallbackMessage = error instanceof Error ? error.message : c('Error').t`Unknown error`; - const apiHttpError = new APIHTTPError(response.statusText || fallbackMessage, response.status); + const apiHttpError = new APIHTTPError(response?.statusText || fallbackMessage, response?.status || 500); apiHttpError.cause = error; return apiHttpError; } diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts index 77575d05..e5af112c 100644 --- a/js/sdk/src/internal/apiService/index.ts +++ b/js/sdk/src/internal/apiService/index.ts @@ -5,3 +5,9 @@ export { ErrorCode, HTTPErrorCode, isCodeOk, isCodeOkAsync } from './errorCodes' export * from './errors'; export { ObserverStream } from './observerStream'; export { memberRoleToPermission, nodeTypeNumberToNodeType, permissionsToMemberRole } from './transformers'; + +import { apiErrorFactory } from './errors'; + +export function getErrorFromResult(result: { Code?: number; Error?: string }) { + return apiErrorFactory({ result }); +} diff --git a/js/sdk/src/internal/nodes/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts index be456277..9e7bc6ce 100644 --- a/js/sdk/src/internal/nodes/apiService.test.ts +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -1,7 +1,7 @@ import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; import { MemberRole, NodeType } from '../../interface'; import { getMockLogger } from '../../tests/logger'; -import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; +import { APICodeError, DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; import { groupNodeUidsByVolumeAndIteratePerBatch, NodeAPIService } from './apiService'; import { NodeOutOfSyncError } from './errors'; @@ -244,7 +244,7 @@ describe('nodeAPIService', () => { await testIterateNodes(generateAPIFileNode(), generateFileNode()); }); - fit('should skip file draft node without an error', async () => { + it('should skip file draft node without an error', async () => { await testIterateNodes(generateAPIFileNode({}, {}, { ActiveRevision: null }), undefined); }); @@ -479,8 +479,8 @@ describe('nodeAPIService', () => { { LinkID: 'nodeId2', Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE', + Code: 2011, + Error: 'not enough permissions', }, }, ], @@ -490,7 +490,7 @@ describe('nodeAPIService', () => { const result = await Array.fromAsync(api.trashNodes(['volumeId~nodeId1', 'volumeId~nodeId2'])); expect(result).toEqual([ { uid: 'volumeId~nodeId1', ok: true }, - { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, + { uid: 'volumeId~nodeId2', ok: false, error: new ValidationError('not enough permissions') }, ]); }); @@ -548,14 +548,14 @@ describe('nodeAPIService', () => { { LinkID: 'nodeId2', Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE', + Code: 2011, + Error: 'not enough permissions', }, }, { LinkID: 'nodeId3', Response: { - Code: 2000, + Code: 123456, }, }, ], @@ -567,8 +567,8 @@ describe('nodeAPIService', () => { ); expect(result).toEqual([ { uid: 'volumeId~nodeId1', ok: true }, - { uid: 'volumeId~nodeId2', ok: false, error: 'INSUFFICIENT_SCOPE' }, - { uid: 'volumeId~nodeId3', ok: false, error: 'Unknown error 2000' }, + { uid: 'volumeId~nodeId2', ok: false, error: new ValidationError('not enough permissions') }, + { uid: 'volumeId~nodeId3', ok: false, error: new APICodeError('Unknown error', 123456) }, ]); }); @@ -608,8 +608,8 @@ describe('nodeAPIService', () => { { LinkID: 'nodeId2', Response: { - Code: 2027, - Error: 'INSUFFICIENT_SCOPE', + Code: 2011, + Error: 'not enough permissions', }, }, ], @@ -619,7 +619,7 @@ describe('nodeAPIService', () => { 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' }, + { uid: 'volumeId~nodeId2', ok: false, error: new ValidationError('not enough permissions') }, ]); }); diff --git a/js/sdk/src/internal/nodes/apiService.ts b/js/sdk/src/internal/nodes/apiService.ts index 7c5fe1d9..c9d11d19 100644 --- a/js/sdk/src/internal/nodes/apiService.ts +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -6,6 +6,7 @@ import { DriveAPIService, drivePaths, ErrorCode, + getErrorFromResult, InvalidRequirementsAPIError, isCodeOk, nodeTypeNumberToNodeType, @@ -444,8 +445,7 @@ export abstract class NodeAPIServiceBase< signal, ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses); } } @@ -463,8 +463,7 @@ export abstract class NodeAPIServiceBase< signal, ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses); } } @@ -478,8 +477,7 @@ export abstract class NodeAPIServiceBase< signal, ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses); } } @@ -493,8 +491,7 @@ export abstract class NodeAPIServiceBase< signal, ); - // TODO: remove `as` when backend fixes OpenAPI schema. - yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + yield * handleResponseErrors(batchNodeUids, volumeId, response.Responses); } } @@ -656,7 +653,7 @@ function* handleResponseErrors( 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 || c('Error').t`Unknown error ${response.Response.Code}`); + errors.set(nodeUid, getErrorFromResult(response.Response)); } }); diff --git a/js/sdk/src/internal/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts index c71ec8f8..278eadc4 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.test.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -1,5 +1,5 @@ import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; -import { NodeResult, NodeResultWithError } from '../../interface'; +import { NodeResult } from '../../interface'; import { NodeAPIService } from './apiService'; import { NodesCryptoCache } from './cryptoCache'; import { NodesCryptoService } from './cryptoService'; @@ -276,7 +276,7 @@ describe('NodesManagement', () => { const error = new NodeWithSameNameExistsValidationError('Node with same name exists', 2500, 'existingNodeUid'); apiService.moveNode = jest.fn().mockRejectedValue(error); - const results: NodeResultWithError[] = []; + const results: NodeResult[] = []; for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) { results.push(result); } @@ -286,11 +286,11 @@ describe('NodesManagement', () => { expect(results[0].ok === false && results[0].error).toBeInstanceOf(NodeWithSameNameExistsValidationError); }); - it('moveNodes yields NodeResultWithError with Error on failure', async () => { + it('moveNodes yields NodeResult with Error on failure', async () => { const error = new Error('move failed'); cryptoService.encryptNodeWithNewParent = jest.fn().mockRejectedValue(error); - const results: NodeResultWithError[] = []; + const results: NodeResult[] = []; for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) { results.push(result); } diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index deff50c9..f282861b 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -1,15 +1,7 @@ import { c } from 'ttag'; import { AbortError, ValidationError } from '../../errors'; -import { - InvalidNameError, - MemberRole, - NodeResult, - NodeResultWithError, - NodeResultWithNewUid, - NodeType, - resultOk, -} from '../../interface'; +import { InvalidNameError, MemberRole, NodeResult, NodeResultWithNewUid, NodeType, resultOk } from '../../interface'; import { createErrorFromUnknown, getErrorMessage } from '../errors'; import { splitNodeUid } from '../uids'; import { NodeAPIServiceBase } from './apiService'; @@ -115,11 +107,7 @@ 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`); diff --git a/js/sdk/src/internal/photos/addToAlbum.test.ts b/js/sdk/src/internal/photos/addToAlbum.test.ts index 8d6a1460..3b2ca4bb 100644 --- a/js/sdk/src/internal/photos/addToAlbum.test.ts +++ b/js/sdk/src/internal/photos/addToAlbum.test.ts @@ -1,4 +1,4 @@ -import { NodeResultWithError } from '../../interface'; +import { NodeResult } from '../../interface'; import { getMockLogger } from '../../tests/logger'; import { AddToAlbumProcess } from './addToAlbum'; import { AlbumsCryptoService } from './albumsCrypto'; @@ -10,10 +10,7 @@ import { PhotosNodesAccess } from './nodes'; /** * Helper to create a mock photo node with minimal required properties. */ -function createMockPhotoNode( - uid: string, - overrides: Partial = {}, -): DecryptedPhotoNode { +function createMockPhotoNode(uid: string, overrides: Partial = {}): DecryptedPhotoNode { return { uid, parentUid: 'volume1~parent', @@ -86,7 +83,7 @@ describe('AddToAlbumProcess', () => { }; }); - function executeProcess(photoUids: string[]): Promise { + function executeProcess(photoUids: string[]): Promise { const process = new AddToAlbumProcess( 'volume1~album', albumKeys as any, @@ -109,7 +106,10 @@ describe('AddToAlbumProcess', () => { if (relatedMatch) { const [, volumeId, mainPhoto, countStr] = relatedMatch; const count = parseInt(countStr, 10); - photoNode.photo!.relatedPhotoNodeUids = Array.from({ length: count }, (_, idx) => `${volumeId}~related${idx + 1}`); + photoNode.photo!.relatedPhotoNodeUids = Array.from( + { length: count }, + (_, idx) => `${volumeId}~related${idx + 1}`, + ); } yield photoNode; @@ -169,7 +169,7 @@ describe('AddToAlbumProcess', () => { } return `volume1~copied${payload.nodeUid}`; }); - }) + }); describe('Adding photos to the same volume', () => { it('should prepare photo payloads in parallel without blocking', async () => { @@ -209,10 +209,12 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([mainPhotoUid]); - expect(results).toMatchObject([{ - uid: mainPhotoUid, - ok: true, - }]) + expect(results).toMatchObject([ + { + uid: mainPhotoUid, + ok: true, + }, + ]); expect(apiService.addPhotosToAlbum).toHaveBeenCalledTimes(1); const params = apiService.addPhotosToAlbum.mock.calls[0]; @@ -234,10 +236,12 @@ describe('AddToAlbumProcess', () => { ); const results = await Array.fromAsync(process.execute([photoUid])); - expect(results).toMatchObject([{ - uid: photoUid, - ok: true, - }]) + 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 }); @@ -247,11 +251,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: new MissingRelatedPhotosError(['volume1~missingRelatedOnce1']), - }]) + 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 }); @@ -264,11 +270,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: cryptoError, - }]) + expect(results).toMatchObject([ + { + uid: photoUid, + ok: false, + error: cryptoError, + }, + ]); }); it('should return error when getNodePrivateAndSessionKeys fails', async () => { @@ -279,21 +287,25 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: keysError, - }]) + 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(results).toMatchObject([ + { + uid: photoUid, + ok: true, + }, + ]); expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(1); expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith(photoUid); }); @@ -307,11 +319,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: new Error('API error'), - }]) + expect(results).toMatchObject([ + { + uid: photoUid, + ok: false, + error: new Error('API error'), + }, + ]); expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled(); }); }); @@ -347,10 +361,12 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([mainPhotoUid]); - expect(results).toMatchObject([{ - uid: mainPhotoUid, - ok: true, - }]) + expect(results).toMatchObject([ + { + uid: mainPhotoUid, + ok: true, + }, + ]); expect(apiService.copyPhoto).toHaveBeenCalledTimes(1); const params = apiService.copyPhoto.mock.calls[0]; expect(params[1].relatedPhotos?.length).toBe(15); @@ -361,10 +377,12 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: true, - }]); + expect(results).toMatchObject([ + { + uid: photoUid, + ok: true, + }, + ]); expect(nodesService.iterateNodes).toHaveBeenCalledTimes(3); // main photo + related photo + missing related photo expect(apiService.copyPhoto).toHaveBeenCalledTimes(2); // two attempts }); @@ -374,11 +392,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: new MissingRelatedPhotosError(['volume2~missingRelatedOnce1']), - }]); + 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.copyPhoto).toHaveBeenCalledTimes(2); // two attempts }); @@ -391,11 +411,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: cryptoError, - }]); + expect(results).toMatchObject([ + { + uid: photoUid, + ok: false, + error: cryptoError, + }, + ]); }); it('should return error when getNodePrivateAndSessionKeys fails', async () => { @@ -406,21 +428,25 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: keysError, - }]); + 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(results).toMatchObject([ + { + uid: photoUid, + ok: true, + }, + ]); expect(nodesService.notifyChildCreated).toHaveBeenCalledTimes(1); expect(nodesService.notifyChildCreated.mock.calls[0][0]).toContain('volume1~copied'); }); @@ -432,11 +458,13 @@ describe('AddToAlbumProcess', () => { const results = await executeProcess([photoUid]); - expect(results).toMatchObject([{ - uid: photoUid, - ok: false, - error: new Error('API error'), - }]) + expect(results).toMatchObject([ + { + uid: photoUid, + ok: false, + error: new Error('API error'), + }, + ]); expect(nodesService.notifyChildCreated).not.toHaveBeenCalled(); }); }); @@ -449,24 +477,31 @@ describe('AddToAlbumProcess', () => { 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(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.addPhotosToAlbum.mock.calls[0][1].map(({ nodeUid }) => nodeUid)).toMatchObject( + sameVolumeUids, + ); 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]); diff --git a/js/sdk/src/internal/photos/addToAlbum.ts b/js/sdk/src/internal/photos/addToAlbum.ts index 44db056e..87fca4bd 100644 --- a/js/sdk/src/internal/photos/addToAlbum.ts +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { Logger, NodeResultWithError } from '../../interface'; +import { Logger, NodeResult } from '../../interface'; import { DecryptedNodeKeys, NodeSigningKeys } from '../nodes/interface'; import { splitNodeUid } from '../uids'; import { AlbumsCryptoService } from './albumsCrypto'; @@ -62,14 +62,14 @@ export class AddToAlbumProcess { this.payloadBuilder = new PhotoTransferPayloadBuilder(cryptoService, nodesService); } - async *execute(photoNodeUids: string[]): AsyncGenerator { + 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 { + private async *processSameVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { while (queue.length > 0) { const items = queue.splice(0, BATCH_LOADING_SIZE); const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads( @@ -101,7 +101,7 @@ export class AddToAlbumProcess { } } - private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { + private async *processDifferentVolumeQueue(queue: PhotoQueueItem[]): AsyncGenerator { while (queue.length > 0) { const items = queue.splice(0, BATCH_LOADING_SIZE); const { payloads, errors } = await this.payloadBuilder.preparePhotoPayloads( @@ -118,11 +118,7 @@ export class AddToAlbumProcess { for (const payload of payloads) { try { - const newPhotoNodeUid = await this.apiService.copyPhoto( - this.albumNodeUid, - payload, - this.signal, - ); + const newPhotoNodeUid = await this.apiService.copyPhoto(this.albumNodeUid, payload, this.signal); await this.nodesService.notifyChildCreated(newPhotoNodeUid); yield { uid: payload.nodeUid, ok: true }; } catch (error) { @@ -148,7 +144,7 @@ export class AddToAlbumProcess { * 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 { + private handleMissingRelatedPhotosError(result: NodeResult): PhotoQueueItem | undefined { if (!result.ok && result.error instanceof MissingRelatedPhotosError) { return this.createRetryQueueItem(result.uid, result.error); } diff --git a/js/sdk/src/internal/photos/albumsManager.ts b/js/sdk/src/internal/photos/albumsManager.ts index 53910262..96597b63 100644 --- a/js/sdk/src/internal/photos/albumsManager.ts +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -1,4 +1,4 @@ -import { Logger, MemberRole, NodeResultWithError, NodeType, ProtonDriveTelemetry, resultOk } from '../../interface'; +import { Logger, MemberRole, NodeResult, NodeType, ProtonDriveTelemetry, resultOk } from '../../interface'; import { BatchLoading } from '../batchLoading'; import { DecryptedNode } from '../nodes'; import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes'; @@ -199,11 +199,7 @@ export class AlbumsManager { await this.nodesService.notifyNodeDeleted(nodeUid); } - async *addPhotos( - albumNodeUid: string, - photoNodeUids: string[], - signal?: AbortSignal, - ): AsyncGenerator { + 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'); @@ -231,7 +227,7 @@ export class AlbumsManager { albumNodeUid: string, photoNodeUids: string[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { try { for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { if (result.ok) { diff --git a/js/sdk/src/internal/photos/apiService.ts b/js/sdk/src/internal/photos/apiService.ts index 54ea7c9a..7d8f87fe 100644 --- a/js/sdk/src/internal/photos/apiService.ts +++ b/js/sdk/src/internal/photos/apiService.ts @@ -1,6 +1,6 @@ import { c } from 'ttag'; -import { NodeResultWithError, PhotoTag } from '../../interface'; +import { NodeResult, PhotoTag } from '../../interface'; import { APICodeError, DriveAPIService, drivePaths, InvalidRequirementsAPIError, isCodeOk } from '../apiService'; import { batch } from '../batch'; import { EncryptedRootShare, EncryptedShareCrypto, ShareType } from '../shares/interface'; @@ -366,7 +366,7 @@ export class PhotosAPIService { albumNodeUid: string, photoPayloads: TransferEncryptedPhotoPayload[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); const allPhotoPayloads = photoPayloads.flatMap((photoPayload) => [photoPayload, ...photoPayload.relatedPhotos]); @@ -491,7 +491,7 @@ export class PhotosAPIService { albumNodeUid: string, photoNodeUids: string[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); const batchSize = 10; @@ -574,7 +574,7 @@ export class PhotosAPIService { newParentNodeUid: string, photoPayloads: TransferEncryptedPhotoPayload[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { const { volumeId, nodeId: newParentNodeId } = splitNodeUid(newParentNodeUid); if (photoPayloads.length === 0) { diff --git a/js/sdk/src/internal/photos/photosManager.ts b/js/sdk/src/internal/photos/photosManager.ts index f1fe96f6..4d212b49 100644 --- a/js/sdk/src/internal/photos/photosManager.ts +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -1,7 +1,7 @@ import { c } from 'ttag'; import { AbortError } from '../../errors'; -import { Logger, NodeResultWithError, PhotoTag } from '../../interface'; +import { Logger, NodeResult, PhotoTag } from '../../interface'; import { batch } from '../batch'; import { splitNodeUid } from '../uids'; import { createBatches } from './addToAlbum'; @@ -42,7 +42,7 @@ export class PhotosManager { this.payloadBuilder = new PhotoTransferPayloadBuilder(albumsCryptoService, nodesService); } - async *saveToTimeline(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + 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); @@ -130,7 +130,7 @@ export class PhotosManager { return this.apiService.copyPhoto(rootNode.uid, payload, signal); } - async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator { + async *updatePhotos(photos: UpdatePhotoSettings[], signal?: AbortSignal): AsyncGenerator { for await (const { photoSettings: { nodeUid, tagsToAdd, tagsToRemove }, payloadForFavorite, diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 0e7c5068..4f63a376 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -16,7 +16,6 @@ import { MemberRole, NodeOrUid, NodeResult, - NodeResultWithError, NodeResultWithNewUid, NodeType, NonProtonInvitationOrUid, @@ -542,7 +541,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); } diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 4fad1e0a..72710e38 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -10,7 +10,6 @@ import { MaybePhotoNode, NodeOrUid, NodeResult, - NodeResultWithError, NonProtonInvitationOrUid, PhotoTag, ProtonDriveClientContructorParameters, @@ -760,7 +759,7 @@ export class ProtonDrivePhotosClient { albumNodeUid: NodeOrUid, photoNodeUids: NodeOrUid[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Adding ${photoNodeUids.length} photos to album ${getUid(albumNodeUid)}`); yield* this.photos.albums.addPhotos(getUid(albumNodeUid), getUids(photoNodeUids), signal); } @@ -781,7 +780,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); } @@ -793,7 +792,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); } @@ -817,7 +816,7 @@ export class ProtonDrivePhotosClient { tagsToRemove?: PhotoTag[]; }[], signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Updating ${photos.length} photos`); yield* this.photos.photos.updatePhotos( photos.map((p) => ({ From 5e8380b94fafbd21a295b6b7434bd4c2e44afe4c Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Jun 2026 06:08:50 +0000 Subject: [PATCH 695/732] Add CLI deploy jobs --- js/cli/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/cli/README.md b/js/cli/README.md index f1396c27..50a4c34c 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -13,6 +13,10 @@ The CLI is built with [Bun](https://bun.sh) and uses the Drive SDK under the hoo - macOS: Keychain Services - Linux: libsecret (e.g., GNOME Keyring, KWallet) +## Download the CLI + +Download the latest release from [proton.me/download/drive/cli](https://proton.me/download/drive/cli/index.html). + ## Install and build (from source) From the `js/cli` directory: From ba9ad3210c9162ba7cf0fc3267d4066fabd8a32b Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 1 Jun 2026 08:34:24 +0200 Subject: [PATCH 696/732] Update metric schemas --- js/cli/src/telemetry/metricHandler.test.ts | 2 ++ .../types/drive_sdk_download_errors_total_v1.schema.d.ts | 1 + .../types/drive_sdk_upload_errors_total_v1.schema.d.ts | 9 ++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/js/cli/src/telemetry/metricHandler.test.ts b/js/cli/src/telemetry/metricHandler.test.ts index 7e0131db..8a2bd8c6 100644 --- a/js/cli/src/telemetry/metricHandler.test.ts +++ b/js/cli/src/telemetry/metricHandler.test.ts @@ -773,6 +773,7 @@ describe('MetricHandler', () => { time: new Date(), event: { eventName: 'blockVerificationError', + volumeType: MetricVolumeType.Unknown, retryHelped: true, }, }); @@ -788,6 +789,7 @@ describe('MetricHandler', () => { time: new Date(), event: { eventName: 'blockVerificationError', + volumeType: MetricVolumeType.Unknown, retryHelped: false, }, }); diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts index 18ac943f..30d9ff29 100644 --- a/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_download_errors_total_v1.schema.d.ts @@ -17,6 +17,7 @@ export interface HttpsProtonMeDriveSdkDownloadErrorsTotalV1SchemaJson { | "decryption_error" | "integrity_error" | "rate_limited" + | "validation_error" | "4xx" | "unknown"; }; diff --git a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts index 959d7b8a..8bf41298 100644 --- a/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts +++ b/js/cli/src/telemetry/metrics/types/drive_sdk_upload_errors_total_v1.schema.d.ts @@ -11,7 +11,14 @@ export interface HttpsProtonMeDriveSdkUploadErrorsTotalV1SchemaJson { Labels: { volumeType: "own_volume" | "own_photo_volume" | "shared" | "shared_public" | "unknown"; - type: "server_error" | "network_error" | "integrity_error" | "rate_limited" | "4xx" | "unknown"; + type: + | "server_error" + | "network_error" + | "integrity_error" + | "rate_limited" + | "validation_error" + | "4xx" + | "unknown"; }; Value: number; } From aaa47b391fffd4f9e6686a458b9336e47319d5d6 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 27 May 2026 09:33:06 +0200 Subject: [PATCH 697/732] Handle EOF (Ctrl+D) signal --- js/cli/src/cli/errors.ts | 2 ++ js/cli/src/cli/index.ts | 1 + js/cli/src/cli/readPasswordLine.ts | 2 +- js/cli/src/cli/readline.test.ts | 31 +++++++++++++++++++ js/cli/src/cli/readline.ts | 30 +++++++++++++++--- js/cli/src/cli/repl.ts | 7 +++-- js/cli/src/cli/run.ts | 6 +++- .../fileSystem/transferConflictResolver.ts | 8 ++++- js/cli/src/proton-drive.ts | 5 ++- 9 files changed, 82 insertions(+), 10 deletions(-) diff --git a/js/cli/src/cli/errors.ts b/js/cli/src/cli/errors.ts index ba040b62..8db42f75 100644 --- a/js/cli/src/cli/errors.ts +++ b/js/cli/src/cli/errors.ts @@ -3,6 +3,8 @@ import { ProtonDriveError } from '@protontech/drive-sdk'; import { AccountApiError } from '../api/accountApi'; import { ReplUnclosedQuoteError } from './splitQuotedLine'; +export class ExitError extends Error {} + export class CommandError extends Error {} export class CommandNotFoundError extends CommandError {} diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index f1e4c8e2..134acab0 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -3,6 +3,7 @@ import { Command } from './interface'; import { startRepl } from './repl'; import { runSingleInvocation } from './run'; +export { ExitError } from './errors'; export { formatAuthor, formatDate, diff --git a/js/cli/src/cli/readPasswordLine.ts b/js/cli/src/cli/readPasswordLine.ts index 275ea4e7..fae53476 100644 --- a/js/cli/src/cli/readPasswordLine.ts +++ b/js/cli/src/cli/readPasswordLine.ts @@ -9,7 +9,7 @@ export async function readPasswordLine(prompt: string): Promise { if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { const answer = await question(prompt); - return answer.trim(); + return (answer ?? '').trim(); } return new Promise((resolve) => { diff --git a/js/cli/src/cli/readline.test.ts b/js/cli/src/cli/readline.test.ts index 702da818..ead97eba 100644 --- a/js/cli/src/cli/readline.test.ts +++ b/js/cli/src/cli/readline.test.ts @@ -22,9 +22,23 @@ describe('readline.question', () => { }); function stubInterface(questionImpl: ReadlinePromisesInterface['question'], closeMock = jest.fn()) { + const listeners = new Map void>>(); return { question: questionImpl, close: closeMock, + once: jest.fn((event: string, listener: () => void) => { + let set = listeners.get(event); + if (!set) { + set = new Set(); + listeners.set(event, set); + } + set.add(listener); + }), + emit: (event: string) => { + for (const listener of listeners.get(event) ?? []) { + listener(); + } + }, } as unknown as ReadlinePromisesInterface; } @@ -100,4 +114,21 @@ describe('readline.question', () => { await expect(question('bad')).rejects.toThrow('boom'); await expect(question('good')).resolves.toBe('ok'); }); + + it('returns null when stdin reaches EOF before an answer', async () => { + let emitClose!: () => void; + mockedCreateInterface.mockImplementationOnce(() => { + const stub = stubInterface( + jest.fn(() => new Promise(() => {})), + jest.fn(), + ); + emitClose = () => stub.emit('close'); + return stub; + }); + + const pending = question('eof>'); + await flushUntil(() => mockedCreateInterface.mock.calls.length > 0); + emitClose(); + await expect(pending).resolves.toBeNull(); + }); }); diff --git a/js/cli/src/cli/readline.ts b/js/cli/src/cli/readline.ts index e4f7dc72..0809cfb6 100644 --- a/js/cli/src/cli/readline.ts +++ b/js/cli/src/cli/readline.ts @@ -5,17 +5,39 @@ let chain: Promise = Promise.resolve(); /** * Prompt user for input. Only one prompt can be active at a time. When any * another question comes in, it waits for the previous input to be processed. + * + * Returns `null` when stdin reaches EOF (e.g. Ctrl-D on an empty line). */ -export async function question(prompt: string): Promise { - const task = chain.then(async (): Promise => { +export function question(prompt: string): Promise { + const task = chain.then(async (): Promise => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); + let closed = false; try { - return await rl.question(prompt); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + + rl.once('close', () => { + closed = true; + settle(() => resolve(null)); + }); + rl.question(prompt) + .then((value) => settle(() => resolve(value))) + .catch((error) => settle(() => reject(error))); + }); } finally { - rl.close(); + if (!closed) { + rl.close(); + } } }); chain = task.catch(() => undefined); diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index fd8fd881..29522a47 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -1,6 +1,6 @@ import { InitConfig } from '../config'; import { init } from '../init'; -import { isRecoverableReplError } from './errors'; +import { ExitError, isRecoverableReplError } from './errors'; import { Command } from './interface'; import { question } from './readline'; import { runCommand } from './run'; @@ -12,12 +12,15 @@ export async function startRepl(commands: Command[], initOptions: InitConfig): P try { while (true) { const line = await question('proton-drive> '); + if (line === null) { + throw new ExitError(); + } const trimmed = line.trim(); if (trimmed === '') { continue; } if (trimmed === 'exit' || trimmed === 'quit') { - break; + throw new ExitError(); } try { const parts = splitQuotedLine(trimmed); diff --git a/js/cli/src/cli/run.ts b/js/cli/src/cli/run.ts index 29d1b589..2dd1f1e6 100644 --- a/js/cli/src/cli/run.ts +++ b/js/cli/src/cli/run.ts @@ -1,7 +1,7 @@ import { InitConfig } from '../config'; import { init } from '../init'; import { captureException, flushSentry } from '../telemetry'; -import { AuthRequiredError, isRecoverableReplError } from './errors'; +import { AuthRequiredError, ExitError, isRecoverableReplError } from './errors'; import { formatReadableJson } from './formatters'; import { printCommandUsage } from './help'; import { Command } from './interface'; @@ -13,6 +13,10 @@ export async function runSingleInvocation(commands: Command[], argv: string[], i try { await runCommand(commands, argv, initOptions); } catch (error: unknown) { + if (error instanceof ExitError) { + process.exit(2); + return; + } if (isRecoverableReplError(error)) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/js/cli/src/commands/fileSystem/transferConflictResolver.ts b/js/cli/src/commands/fileSystem/transferConflictResolver.ts index 15e8412a..0572bdfd 100644 --- a/js/cli/src/commands/fileSystem/transferConflictResolver.ts +++ b/js/cli/src/commands/fileSystem/transferConflictResolver.ts @@ -116,6 +116,9 @@ export class TransferConflictResolver { const hint = `Type a strategy (${choices.join(', ')}; or abbreviations), or [a]pply-to-all`; while (true) { const line = await question(`Conflict on "${sanitizeTerminalText(name)}" (${kind}). ${hint}\n> `); + if (line === null) { + return ConflictChoice.Skip; + } const trimmed = line.trim().toLowerCase(); if (trimmed === 'a' || trimmed === 'apply' || trimmed === 'apply-to-all') { const applyAllLine = await question(`Default for all ${kind} conflicts:\n> `); @@ -137,7 +140,10 @@ export class TransferConflictResolver { } } - private parsePromptStrategy(line: string, choices: readonly ConflictChoice[]): ConflictChoice | undefined { + private parsePromptStrategy(line: string | null, choices: readonly ConflictChoice[]): ConflictChoice | undefined { + if (line === null) { + return ConflictChoice.Skip; + } try { const result = resolveConflictStrategy(line, choices); if (!result) { diff --git a/js/cli/src/proton-drive.ts b/js/cli/src/proton-drive.ts index 0250f898..f694d50f 100644 --- a/js/cli/src/proton-drive.ts +++ b/js/cli/src/proton-drive.ts @@ -1,6 +1,6 @@ import { FeatureFlags } from '@protontech/drive-sdk'; -import { run } from './cli'; +import { ExitError, run } from './cli'; import { COMMANDS } from './commands'; import { captureException, flushSentry, initSentry } from './telemetry'; @@ -30,6 +30,9 @@ try { }); process.exit(0); } catch (error: unknown) { + if (error instanceof ExitError) { + process.exit(2); + } console.error(error); captureException(error); await flushSentry(); From 0d84fcb76a9321fb9e57a1aaeb1cf81e0ba8f82b Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Jun 2026 06:08:23 +0000 Subject: [PATCH 698/732] Update changelog for cli/v0.0.1 --- js/cli/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 js/cli/CHANGELOG.md diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md new file mode 100644 index 00000000..89a1eed1 --- /dev/null +++ b/js/cli/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## cli/v0.0.1 (2026-06-02) + +* No changes From 3e2fbaff78e0fe0f948d681d7982afa04e55ba9a Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Jun 2026 16:41:37 +0200 Subject: [PATCH 699/732] Add method to enumerate events --- .../InteropConversionExtensions.cs | 4 +- .../Api/Events/LatestVolumeEventResponse.cs | 11 ++++ .../Api/Events/VolumeEventDto.cs | 15 ++++++ .../Api/Events/VolumeEventDtoExtensions.cs | 30 +++++++++++ .../Api/Events/VolumeEventLinkDto.cs | 17 +++++++ .../Api/Events/VolumeEventListResponse.cs | 19 +++++++ .../Api/Events/VolumeEventType.cs | 9 ++++ .../Api/Volumes/IVolumesApiClient.cs | 8 ++- .../Api/Volumes/VolumesApiClient.cs | 18 ++++++- .../src/Proton.Drive.Sdk/Events/DriveEvent.cs | 15 ++++++ .../Events/DriveEventScopeId.cs | 18 +++++++ .../Events/EventsContinuityLostEvent.cs | 9 ++++ .../Events/EventsCursorAdvancedEvent.cs | 10 ++++ .../Events/EventsScopeAccessLostEvent.cs | 10 ++++ .../Events/NodeDeletedEvent.cs | 13 +++++ .../src/Proton.Drive.Sdk/Events/NodeEvent.cs | 23 +++++++++ .../Events/NodeUpdatedEvent.cs | 30 +++++++++++ .../Events/SharedWithMeUpdatedEvent.cs | 10 ++++ cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs | 3 +- .../Proton.Drive.Sdk/Proton.Drive.Sdk.csproj | 6 +++ .../src/Proton.Drive.Sdk/ProtonDriveClient.cs | 10 ++++ .../DriveApiSerializerContext.cs | 3 ++ .../Volumes/VolumeOperations.cs | 50 +++++++++++++++++++ .../Authentication/AuthenticationResponse.cs | 2 +- .../Api/Events/EventListResponse.cs | 2 +- .../Proton.Sdk/Api/Events/EventsApiClient.cs | 4 +- .../Api/Events/LatestEventResponse.cs | 2 +- .../Events/{EventId.cs => DriveEventId.cs} | 10 ++-- .../src/Proton.Sdk/Serialization/IStrongId.cs | 2 +- .../Serialization/StrongIdJsonConverter.cs | 2 +- 30 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/LatestVolumeEventResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDtoExtensions.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventLinkDto.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventListResponse.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventType.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/DriveEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/DriveEventScopeId.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/EventsContinuityLostEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/EventsCursorAdvancedEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/EventsScopeAccessLostEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/NodeDeletedEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/NodeEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/NodeUpdatedEvent.cs create mode 100644 cs/sdk/src/Proton.Drive.Sdk/Events/SharedWithMeUpdatedEvent.cs rename cs/sdk/src/Proton.Sdk/Events/{EventId.cs => DriveEventId.cs} (50%) diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs index 9ba82aba..e6948777 100644 --- a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs @@ -32,7 +32,7 @@ private static FolderNode ToInterop(Nodes.FolderNode folderNode) var folderNodeProto = new FolderNode { Uid = folderNode.Uid.ToString(), - TreeEventScopeId = folderNode.TreeEventScopeId, + TreeEventScopeId = folderNode.TreeEventScopeId.ToString(), Name = folderNode.Name.ToInterop(), CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(), TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(), @@ -56,7 +56,7 @@ private static FileNode ToInterop(Nodes.FileNode fileNode) var fileNodeProto = new FileNode { Uid = fileNode.Uid.ToString(), - TreeEventScopeId = fileNode.TreeEventScopeId, + TreeEventScopeId = fileNode.TreeEventScopeId.ToString(), Name = fileNode.Name.ToInterop(), MediaType = fileNode.MediaType, CreationTime = fileNode.CreationTime.ToUniversalTime().ToTimestamp(), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/LatestVolumeEventResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/LatestVolumeEventResponse.cs new file mode 100644 index 00000000..e1a7dce6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/LatestVolumeEventResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Api.Events; + +internal sealed class LatestVolumeEventResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required DriveEventId EventId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDto.cs new file mode 100644 index 00000000..621d9c6c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Api.Events; + +internal sealed class VolumeEventDto +{ + [JsonPropertyName("EventID")] + public required DriveEventId Id { get; init; } + + [JsonPropertyName("EventType")] + public required VolumeEventType Type { get; init; } + + public required VolumeEventLinkDto Link { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDtoExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDtoExtensions.cs new file mode 100644 index 00000000..248e6e2f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventDtoExtensions.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Proton.Drive.Sdk.Events; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Events; + +internal static class VolumeEventDtoExtensions +{ + public static DriveEvent ToDriveEvent(this VolumeEventDto volumeEvent, VolumeId volumeId) + { + var nodeUid = new NodeUid(volumeId, volumeEvent.Link.Id); + NodeUid? parentNodeUid = volumeEvent.Link.ParentId is { } parentLinkId + ? new NodeUid(volumeId, parentLinkId) + : null; + + return volumeEvent.Type switch + { + VolumeEventType.Create or VolumeEventType.Update or VolumeEventType.UpdateMetadata => new NodeUpdatedEvent( + volumeEvent.Id, + nodeUid, + parentNodeUid, + volumeEvent.Link.IsTrashed, + volumeEvent.Link.IsShared), + VolumeEventType.Delete => new NodeDeletedEvent(volumeEvent.Id, nodeUid, parentNodeUid), + + _ => throw new InvalidEnumArgumentException(nameof(volumeEvent), (int)volumeEvent.Type, typeof(VolumeEventType)), + }; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventLinkDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventLinkDto.cs new file mode 100644 index 00000000..b4c01002 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventLinkDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Api.Events; + +internal sealed class VolumeEventLinkDto +{ + [JsonPropertyName("LinkID")] + public required LinkId Id { get; init; } + + [JsonPropertyName("ParentLinkID")] + public required LinkId? ParentId { get; init; } + + public required bool IsShared { get; init; } + + public required bool IsTrashed { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventListResponse.cs new file mode 100644 index 00000000..7136e21a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventListResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Api.Events; + +internal sealed class VolumeEventListResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required DriveEventId LastEventId { get; init; } + + public required IReadOnlyList Events { get; init; } + + [JsonPropertyName("More")] + public required bool MoreEntriesExist { get; init; } + + [JsonPropertyName("Refresh")] + public required bool RefreshRequired { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventType.cs new file mode 100644 index 00000000..e256f6a2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Events/VolumeEventType.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Api.Events; + +public enum VolumeEventType +{ + Delete = 0, + Create = 1, + Update = 2, + UpdateMetadata = 3, +} 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 3b9a4057..3835cfa5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -1,4 +1,6 @@ -using Proton.Drive.Sdk.Volumes; +using Proton.Drive.Sdk.Api.Events; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Events; namespace Proton.Drive.Sdk.Api.Volumes; @@ -7,4 +9,8 @@ internal interface IVolumesApiClient ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken); + + ValueTask GetLatestEventAsync(VolumeId volumeId, CancellationToken cancellationToken); + + ValueTask GetEventsAsync(VolumeId volumeId, DriveEventId cursorEventId, 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 c4c157f4..5466a731 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,7 @@ -using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Api.Events; +using Proton.Drive.Sdk.Serialization; using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Events; using Proton.Sdk.Http; namespace Proton.Drive.Sdk.Api.Volumes; @@ -21,4 +23,18 @@ public async ValueTask GetVolumeAsync(VolumeId volumeId, Cancell .Expecting(DriveApiSerializerContext.Default.VolumeResponse) .GetAsync($"volumes/{volumeId}", cancellationToken).ConfigureAwait(false); } + + public async ValueTask GetLatestEventAsync(VolumeId volumeId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.LatestVolumeEventResponse) + .GetAsync($"volumes/{volumeId}/events/latest", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetEventsAsync(VolumeId volumeId, DriveEventId cursorEventId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeEventListResponse) + .GetAsync($"v2/volumes/{volumeId}/events/{cursorEventId}", cancellationToken).ConfigureAwait(false); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEvent.cs new file mode 100644 index 00000000..13add0ff --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEvent.cs @@ -0,0 +1,15 @@ +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Base type for a remote data update event in an event scope. +/// +/// The unique event ID. +public abstract class DriveEvent(DriveEventId id) +{ + /// + /// The unique event ID. Consumers should persist it as the next cursor after applying the event. + /// + public DriveEventId Id { get; } = id; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEventScopeId.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEventScopeId.cs new file mode 100644 index 00000000..009eadb6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/DriveEventScopeId.cs @@ -0,0 +1,18 @@ +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Events; + +public readonly struct DriveEventScopeId +{ + internal DriveEventScopeId(VolumeId volumeId) + { + VolumeId = volumeId; + } + + internal VolumeId VolumeId { get; } + + public override string ToString() + { + return VolumeId.ToString(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/EventsContinuityLostEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsContinuityLostEvent.cs new file mode 100644 index 00000000..2a8e09dd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsContinuityLostEvent.cs @@ -0,0 +1,9 @@ +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that event continuity was lost. Consumers should mark their current state as stale and resync from the current server state. +/// +/// The unique event ID. +public sealed class EventsContinuityLostEvent(DriveEventId id) : DriveEvent(id); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/EventsCursorAdvancedEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsCursorAdvancedEvent.cs new file mode 100644 index 00000000..e886e036 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsCursorAdvancedEvent.cs @@ -0,0 +1,10 @@ +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that the cursor advanced without substantive data changes. +/// The cursor was moved forward to avoid loss of continuity. Consumers should use this ID as the cursor for the next request to enumerate events. +/// +/// The unique event ID. +public sealed class EventsCursorAdvancedEvent(DriveEventId id) : DriveEvent(id); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/EventsScopeAccessLostEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsScopeAccessLostEvent.cs new file mode 100644 index 00000000..9c189103 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/EventsScopeAccessLostEvent.cs @@ -0,0 +1,10 @@ +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that access to the event scope was lost. This implies loss of access to all trees under that scope. +/// Consumers should stop enumerating events for that scope, stop requesting files or folders in trees of that scope, and usually remove all local data for it. +/// +/// The unique event ID. +public sealed class EventsScopeAccessLostEvent(DriveEventId id) : DriveEvent(id); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/NodeDeletedEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeDeletedEvent.cs new file mode 100644 index 00000000..5ef29496 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeDeletedEvent.cs @@ -0,0 +1,13 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that a node was permanently deleted (from the user's trash), or made no longer accessible to the user. +/// +/// The unique event ID. +/// The node UID of the file or folder that was deleted. +/// The parent node UID, if any. +public sealed class NodeDeletedEvent(DriveEventId id, NodeUid nodeUid, NodeUid? parentNodeUid) + : NodeEvent(id, nodeUid, parentNodeUid); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/NodeEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeEvent.cs new file mode 100644 index 00000000..a892b39c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeEvent.cs @@ -0,0 +1,23 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents a drive event that pertains to a single node. +/// +/// The unique event ID. +/// The node UID of the affected file or folder. +/// The parent node UID, if known. +public abstract class NodeEvent(DriveEventId id, NodeUid nodeUid, NodeUid? parentNodeUid) : DriveEvent(id) +{ + /// + /// The node UID of the affected file or folder. + /// + public NodeUid NodeUid { get; } = nodeUid; + + /// + /// The parent node UID, if any. + /// + public NodeUid? ParentNodeUid { get; } = parentNodeUid; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/NodeUpdatedEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeUpdatedEvent.cs new file mode 100644 index 00000000..998177ff --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/NodeUpdatedEvent.cs @@ -0,0 +1,30 @@ +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that a node was created, updated, moved to trash, or made newly accessible to the user through a move operation or a change of permissions. +/// +/// The unique event ID. +/// The node UID of the affected file or folder. +/// The parent node UID, if any. +/// Whether the affected node is in the trash. +/// Whether the affected node is shared with others. +public sealed class NodeUpdatedEvent( + DriveEventId id, + NodeUid nodeUid, + NodeUid? parentNodeUid, + bool isTrashed, + bool isShared) : NodeEvent(id, nodeUid, parentNodeUid) +{ + /// + /// Whether the affected node is in the trash. + /// + public bool IsTrashed { get; } = isTrashed; + + /// + /// Whether the affected node is shared with others. + /// + public bool IsShared { get; } = isShared; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Events/SharedWithMeUpdatedEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Events/SharedWithMeUpdatedEvent.cs new file mode 100644 index 00000000..da551141 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Events/SharedWithMeUpdatedEvent.cs @@ -0,0 +1,10 @@ +using Proton.Sdk.Events; + +namespace Proton.Drive.Sdk.Events; + +/// +/// Represents an event indicating that items shared with the current user changed (such as a new share, an unshare, or a permission change). +/// Consumers should refresh the list of items shared with them and any pending invitations. +/// +/// The unique event ID. +public sealed class SharedWithMeUpdatedEvent(DriveEventId id) : DriveEvent(id); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs index ed51e580..39878716 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Events; using Proton.Sdk; namespace Proton.Drive.Sdk.Nodes; @@ -15,7 +16,7 @@ public abstract record Node public required NodeUid? ParentUid { get; init; } [JsonIgnore] - public string TreeEventScopeId => Uid.VolumeId.ToString(); + public DriveEventScopeId TreeEventScopeId => new(Uid.VolumeId); public required Result Name { 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..7b0ddbeb 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -9,6 +9,12 @@ snupkg + + + + + + diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs index c359d537..0c8e9fed 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.Api; using Proton.Drive.Sdk.Caching; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Events; using Proton.Drive.Sdk.Http; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Nodes.Download; @@ -13,6 +14,7 @@ using Proton.Drive.Sdk.Volumes; using Proton.Sdk; using Proton.Sdk.Caching; +using Proton.Sdk.Events; using Proton.Sdk.Http; using Proton.Sdk.Telemetry; @@ -338,4 +340,12 @@ public async ValueTask EmptyTrashAsync(CancellationToken cancellationToken) await VolumeOperations.EmptyTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false); } + + public IAsyncEnumerable EnumerateEventsAsync( + DriveEventScopeId eventScopeId, + DriveEventId? cursorEventId, + CancellationToken cancellationToken = default) + { + return VolumeOperations.EnumerateEventsAsync(this, eventScopeId.VolumeId, cursorEventId, cancellationToken); + } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index aa426eed..7b2a55cc 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.Events; using Proton.Drive.Sdk.Api.Files; using Proton.Drive.Sdk.Api.Folders; using Proton.Drive.Sdk.Api.Links; @@ -58,4 +59,6 @@ namespace Proton.Drive.Sdk.Serialization; [JsonSerializable(typeof(MultipleLinksNullaryRequest))] [JsonSerializable(typeof(AggregateApiResponse))] [JsonSerializable(typeof(VolumeTrashResponse))] +[JsonSerializable(typeof(LatestVolumeEventResponse))] +[JsonSerializable(typeof(VolumeEventListResponse))] 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 e188646d..9ca21229 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -1,11 +1,14 @@ using System.Runtime.CompilerServices; using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Events; using Proton.Drive.Sdk.Api.Photos; using Proton.Drive.Sdk.Api.Volumes; using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Events; using Proton.Drive.Sdk.Nodes; using Proton.Drive.Sdk.Shares; using Proton.Sdk.Addresses; +using Proton.Sdk.Events; namespace Proton.Drive.Sdk.Volumes; @@ -185,6 +188,53 @@ public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, VolumeId return myFilesFolder?.Uid.VolumeId; } + public static async IAsyncEnumerable EnumerateEventsAsync( + ProtonDriveClient client, + VolumeId volumeId, + DriveEventId? cursorEventIdOrNull, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (cursorEventIdOrNull is not { } cursorEventId) + { + var latestVolumeEventResponse = await client.Api.Volumes.GetLatestEventAsync(volumeId, cancellationToken).ConfigureAwait(false); + yield return new EventsCursorAdvancedEvent(latestVolumeEventResponse.EventId); + yield break; + } + + while (true) + { + var volumeEventsResponse = await client.Api.Volumes.GetEventsAsync(volumeId, cursorEventId, cancellationToken).ConfigureAwait(false); + + if (volumeEventsResponse.RefreshRequired) + { + yield return new EventsContinuityLostEvent(volumeEventsResponse.LastEventId); + yield break; + } + + if (volumeEventsResponse.Events.Count == 0) + { + if (volumeEventsResponse.LastEventId != cursorEventId) + { + yield return new EventsCursorAdvancedEvent(volumeEventsResponse.LastEventId); + } + + yield break; + } + + foreach (var volumeEvent in volumeEventsResponse.Events) + { + yield return volumeEvent.ToDriveEvent(volumeId); + } + + if (!volumeEventsResponse.MoreEntriesExist) + { + yield break; + } + + cursorEventId = volumeEventsResponse.LastEventId; + } + } + private static VolumeCreationRequest GetCreationRequest( AddressId addressId, AddressKeyId addressKeyId, diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs index ea52cdff..1fbdce5f 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs @@ -14,7 +14,7 @@ internal sealed class AuthenticationResponse : ApiResponse public required UserId UserId { get; init; } [JsonPropertyName("EventID")] - public EventId? EventId { get; init; } + public DriveEventId? EventId { get; init; } public required ReadOnlyMemory ServerProof { get; init; } diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs index 900e504d..c1549f22 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs @@ -7,7 +7,7 @@ namespace Proton.Sdk.Api.Events; internal sealed class EventListResponse : ApiResponse { [JsonPropertyName("EventID")] - public required EventId LastEventId { get; init; } + public required DriveEventId LastEventId { get; init; } [JsonPropertyName("More")] [JsonConverter(typeof(BooleanToIntegerJsonConverter))] diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs index d38b9461..0387b3cc 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs @@ -16,10 +16,10 @@ public async Task GetLatestEventAsync(CancellationToken can .GetAsync("core/v6/events/latest", cancellationToken).ConfigureAwait(false); } - public async Task GetEventsAsync(EventId baselineEventId, CancellationToken cancellationToken) + public async Task GetEventsAsync(DriveEventId cursorEventId, CancellationToken cancellationToken) { return await _httpClient .Expecting(ProtonApiSerializerContext.Default.EventListResponse) - .GetAsync($"core/v6/events/{baselineEventId}", cancellationToken).ConfigureAwait(false); + .GetAsync($"core/v6/events/{cursorEventId}", cancellationToken).ConfigureAwait(false); } } diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs index 5e2fbbb5..196f7ac8 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs @@ -6,5 +6,5 @@ namespace Proton.Sdk.Api.Events; internal sealed class LatestEventResponse : ApiResponse { [JsonPropertyName("EventID")] - public required EventId EventId { get; init; } + public required DriveEventId EventId { get; init; } } diff --git a/cs/sdk/src/Proton.Sdk/Events/EventId.cs b/cs/sdk/src/Proton.Sdk/Events/DriveEventId.cs similarity index 50% rename from cs/sdk/src/Proton.Sdk/Events/EventId.cs rename to cs/sdk/src/Proton.Sdk/Events/DriveEventId.cs index d8d71adf..adee2688 100644 --- a/cs/sdk/src/Proton.Sdk/Events/EventId.cs +++ b/cs/sdk/src/Proton.Sdk/Events/DriveEventId.cs @@ -3,19 +3,19 @@ namespace Proton.Sdk.Events; -[JsonConverter(typeof(StrongIdJsonConverter))] -public readonly record struct EventId : IStrongId +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct DriveEventId : IStrongId { private readonly string? _value; - internal EventId(string? value) + internal DriveEventId(string? value) { _value = value; } - public static explicit operator EventId(string? value) + public static explicit operator DriveEventId(string value) { - return new EventId(value); + return new DriveEventId(value); } public override string ToString() diff --git a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs index 3a61f547..8fa72c8f 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs @@ -4,7 +4,7 @@ internal interface IStrongId where T : IStrongId { public static virtual implicit operator string(T id) => id.ToString(); - public static abstract explicit operator T(string? value); + public static abstract explicit operator T(string value); public string ToString(); } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs index ffca0b91..568ea917 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 (T)value; + return value is not null ? (T)value : default; } public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) From 90cf4d21fe4baa6572b6f3292052a28b0ab27b16 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 2 Jun 2026 15:09:24 +0200 Subject: [PATCH 700/732] Remove node ID from exception messages to fix Sentry grouping --- .../Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs | 2 +- cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 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 ee0bac52..f392ff72 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -77,7 +77,7 @@ private async Task DownloadToStreamAsync( if (result is not FileNode fileNode) { - throw new NodeNotFoundException(_photoUid); + throw new NodeNotFoundException(_photoUid, "Photo node not found for download"); } if (!downloadStateTaskCompletionSource.Task.IsCompletedSuccessfully) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs index 12dbaa8c..4e213261 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeNotFoundException.cs @@ -18,8 +18,8 @@ public NodeNotFoundException(string message, Exception innerException) { } - public NodeNotFoundException(NodeUid nodeUid) - : base($"Node \"{nodeUid}\" not found") + public NodeNotFoundException(NodeUid nodeUid, string message = "Node not found") + : base(message) { Code = ResponseCode.DoesNotExist; NodeUid = nodeUid; From 40e0d8ef7a7a581c1b8a8955c3210c6f79ef086b Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Jun 2026 12:34:26 +0000 Subject: [PATCH 701/732] Log the interop error with exception --- cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs index f74f859c..6f530450 100644 --- a/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -21,7 +21,12 @@ public unsafe void Log(LogLevel logLevel, EventId eventId, TState state, var message = formatter.Invoke(state, exception); if (exception != null) { - message = message + Environment.NewLine + exception; + if (exception is InteropErrorException { Error: { } interopError }) + { + message += Environment.NewLine + interopError; + } + + message += Environment.NewLine + exception; } var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; From e8793e75c30edef1574d9a4bb73d8896473322e4 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Jun 2026 06:55:45 +0200 Subject: [PATCH 702/732] Output CLI binaries always as proton-drive --- js/cli/scripts/build-cli.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index 00f0e8b8..68107e11 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -8,13 +8,13 @@ const GIT_ROOT = path.resolve(import.meta.dir, '../../..'); const mode = process.argv[2] === 'internal' ? 'internal' : 'main'; const target = process.argv[3] || 'bun'; -let outfileSuffix = ''; +let outfileArchitecture = ''; if (target.startsWith('bun-')) { - outfileSuffix = '-' + target.slice(4); + outfileArchitecture = '/' + target.slice(4); } const entry = mode === 'internal' ? 'src/internal-cli/proton-drive-internal.ts' : 'src/proton-drive.ts'; -const outfile = mode === 'internal' ? 'release/proton-drive-internal' : 'release/proton-drive'; +const outfile = mode === 'internal' ? `release${outfileArchitecture}/proton-drive-internal` : `release${outfileArchitecture}/proton-drive`; const args = [ 'build', @@ -35,7 +35,7 @@ const args = [ '--define', `SENTRY_DSN=${JSON.stringify(process.env.SENTRY_DSN)}`, entry, - `--outfile=${outfile}${outfileSuffix}`, + `--outfile=${outfile}`, ]; const proc = Bun.spawn(['bun', ...args], { From 8d85d62022009eda64864d81b0cca4611288a9bf Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Jun 2026 16:23:11 +0200 Subject: [PATCH 703/732] Upgrade to latest cryptographic package interface --- cs/Directory.Packages.props | 2 +- .../src/Proton.Drive.Sdk/Api/Links/LinkDto.cs | 2 +- .../Api/Links/NodeCreationRequest.cs | 2 +- .../PhotosVolumeLinkCreationParameters.cs | 2 +- .../PhotosVolumeShareCreationParameters.cs | 2 +- .../Proton.Drive.Sdk/Api/Shares/ShareDto.cs | 2 +- .../Api/Shares/ShareResponse.cs | 2 +- .../Api/Volumes/VolumeCreationRequest.cs | 4 +- .../Nodes/Cryptography/NodeCrypto.cs | 7 +- .../Nodes/FolderOperations.cs | 6 +- .../Proton.Drive.Sdk/Nodes/NodeOperations.cs | 9 +- .../Nodes/Upload/BlockUploader.cs | 232 +++++++++--------- .../Nodes/Upload/NewFileDraftProvider.cs | 6 +- .../DriveApiSerializerContext.cs | 2 +- .../PhotosApiSerializerContext.cs | 2 +- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 2 +- .../Volumes/VolumeOperations.cs | 8 +- .../Proton.Sdk/Addresses/AddressOperations.cs | 2 +- .../Proton.Sdk/Api/Addresses/AddressKeyDto.cs | 2 +- cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs | 2 +- .../Cryptography/PgpArmoredPrivateKey.cs | 16 -- .../Cryptography/PgpArmoredSecretKey.cs | 15 ++ .../PgpArmoredPrivateKeyJsonConverter.cs | 11 - .../PgpArmoredSecretKeyJsonConverter.cs | 11 + .../ProtonApiSerializerContext.cs | 2 +- 25 files changed, 174 insertions(+), 179 deletions(-) delete mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs create mode 100644 cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs create mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 512b1897..bccce9b6 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -16,7 +16,7 @@ - + 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 57c1a304..bd78939b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs @@ -35,7 +35,7 @@ internal sealed class LinkDto public required ReadOnlyMemory NameHashDigest { get; init; } [JsonPropertyName("NodeKey")] - public required PgpArmoredPrivateKey Key { get; init; } + public required PgpArmoredSecretKey Key { get; init; } [JsonPropertyName("NodePassphrase")] public required PgpArmoredMessage Passphrase { get; init; } 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 1b271203..08e53f4c 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs @@ -22,5 +22,5 @@ internal abstract class NodeCreationRequest public required PgpArmoredSignature PassphraseSignature { get; init; } [JsonPropertyName("NodeKey")] - public required PgpArmoredPrivateKey Key { get; init; } + public required PgpArmoredSecretKey Key { get; init; } } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs index 8b7bda71..dbd7fd1e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs @@ -6,7 +6,7 @@ internal sealed class PhotosVolumeLinkCreationParameters { public required PgpArmoredMessage Name { get; init; } - public required PgpArmoredPrivateKey NodeKey { get; init; } + public required PgpArmoredSecretKey NodeKey { get; init; } public required PgpArmoredMessage NodePassphrase { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs index e1e45c92..08597a0e 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs @@ -12,7 +12,7 @@ internal sealed class PhotosVolumeShareCreationParameters [JsonPropertyName("AddressKeyID")] public required AddressKeyId AddressKeyId { get; init; } - public required PgpArmoredPrivateKey Key { get; init; } + public required PgpArmoredSecretKey Key { get; init; } public required PgpArmoredMessage Passphrase { get; init; } diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs index e08d01aa..19ae6d0f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs @@ -12,7 +12,7 @@ internal sealed class ShareDto [JsonPropertyName("CreatorEmail")] public required string CreatorEmailAddress { get; init; } - public required PgpArmoredPrivateKey Key { get; init; } + public required PgpArmoredSecretKey Key { get; init; } public required PgpArmoredMessage Passphrase { 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 9dc9583a..d47135df 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -41,7 +41,7 @@ internal sealed class ShareResponse : ApiResponse [JsonPropertyName("LinkType")] public required LinkType RootLinkType { get; init; } - public required PgpArmoredPrivateKey Key { get; init; } + public required PgpArmoredSecretKey Key { get; init; } [JsonPropertyName("Passphrase")] public required PgpArmoredMessage Passphrase { get; init; } 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 331db7f1..a8d50277 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs @@ -12,7 +12,7 @@ internal sealed class VolumeCreationRequest [JsonPropertyName("AddressKeyID")] public required AddressKeyId AddressKeyId { get; init; } - public required PgpArmoredPrivateKey ShareKey { get; init; } + public required PgpArmoredSecretKey ShareKey { get; init; } public required PgpArmoredMessage SharePassphrase { get; init; } @@ -20,7 +20,7 @@ internal sealed class VolumeCreationRequest public required PgpArmoredMessage FolderName { get; init; } - public required PgpArmoredPrivateKey FolderKey { get; init; } + public required PgpArmoredSecretKey FolderKey { get; init; } public required PgpArmoredMessage FolderPassphrase { 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 70a9c7e3..3b9cc87f 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>, ProtonDriveE } private static Result UnlockNodeKey( - PgpArmoredPrivateKey lockedKey, + PgpArmoredSecretKey lockedKey, Result>, ProtonDriveError> passphraseResult) { if (!passphraseResult.TryGetValueElseError(out var passphrase, out var error)) @@ -249,11 +249,10 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK var keys = new List([nodeKey]); if (authorshipClaim.Author != Author.Anonymous) { - keys.AddRange(authorshipClaim.Keys.AsEnumerable().Select(k => new PgpKey(k))); + keys.AddRange(authorshipClaim.Keys.Select(k => (PgpKey)k)); } - var keyRing = new PgpKeyRing(keys); - return keyRing; + return new PgpKeyRing(keys); } private static Result, ProtonDriveError> DecryptContentKey( diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs index a6b9a6b7..fcff56a1 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -89,13 +89,13 @@ public static async ValueTask CreateAsync( signingKey, PgpProfile.Proton, out var key, + out var lockedKey, out var nameSessionKey, out var passphraseSessionKey, out var encryptedName, out var nameHashDigest, out var encryptedKeyPassphrase, - out var keyPassphraseSignature, - out var armoredKey); + out var keyPassphraseSignature); var extendedAttributes = new ExtendedAttributes { @@ -117,7 +117,7 @@ public static async ValueTask CreateAsync( Passphrase = encryptedKeyPassphrase, PassphraseSignature = keyPassphraseSignature, SignatureEmailAddress = membershipAddress.EmailAddress, - Key = armoredKey, + Key = lockedKey, HashKey = key.EncryptAndSign(hashKey, key), ExtendedAttributes = encryptedExtendedAttributes, }; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs index 5a5a0929..c04cd1ec 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -125,15 +125,15 @@ public static void GetCommonCreationParameters( PgpPrivateKey signingKey, PgpProfile pgpProfile, out PgpPrivateKey key, + out PgpSecretKey lockedKey, out PgpSessionKey nameSessionKey, out PgpSessionKey passphraseSessionKey, out ArraySegment encryptedName, out ArraySegment nameHashDigest, out ArraySegment encryptedKeyPassphrase, - out ArraySegment passphraseSignature, - out ArraySegment lockedKeyBytes) + out ArraySegment passphraseSignature) { - key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default, profile: pgpProfile); + key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default, pgpProfile); nameSessionKey = PgpSessionKey.Generate(); Span passphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; @@ -144,8 +144,7 @@ public static void GetCommonCreationParameters( encryptedKeyPassphrase = PgpEncrypter.EncryptAndSign(passphrase, passphraseEncryptionSecrets, signingKey, out passphraseSignature); - using var lockedKey = key.Lock(passphrase); - lockedKeyBytes = lockedKey.ToBytes(); + lockedKey = key.Lock(passphrase); GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); } 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 8b72d177..55ccf148 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Security.Cryptography; +using CommunityToolkit.HighPerformance; using Microsoft.Extensions.Logging; using Microsoft.IO; using Polly; @@ -46,106 +47,102 @@ public async ValueTask UploadContentAsync( attempt++; plainData.Stream.Seek(0, SeekOrigin.Begin); - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + var signatureOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (signatureOutputStream.ConfigureAwait(false)) { - var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + + var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningReadStream( + plainData.Stream, + signatureOutputStream, + draft.SigningKey, + profile: pgpProfile, + aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); - await using (signatureStream.ConfigureAwait(false)) + await using (encryptingStream) { using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + var hashingStream = new HashingReadStream(encryptingStream, sha256, leaveOpen: true); await using (hashingStream.ConfigureAwait(false)) { - var signatureEncryptingStream = draft.FileKey.OpenEncryptingStream(signatureStream); - - await using (signatureEncryptingStream.ConfigureAwait(false)) + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.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); - } - } - } + await hashingStream.CopyToAsync(dataPacketStream, 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]; + var encryptedSignature = PgpEncrypter.Encrypt( + signatureOutputStream.GetBuffer().AsSpan()[..(int)signatureOutputStream.Length], + draft.FileKey); - 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 + // 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 - var plainDataPrefixLength = (int)Math.Min(draft.BlockVerifier.DataPacketPrefixMaxLength, plainData.Stream.Length); + var plainDataPrefixLength = (int)Math.Min(draft.BlockVerifier.DataPacketPrefixMaxLength, plainData.Stream.Length); - try - { - var verificationToken = draft.BlockVerifier.VerifyBlock( - dataPacketStream.GetFirstBytes(AeadChunkSize), - plainData.PrefixForVerification.AsSpan()[..plainDataPrefixLength]); - - if (integrityErrorEncountered) - { - await RecordBlockVerificationErrorAsync(draft, retryHelped: true, cancellationToken).ConfigureAwait(false); - } + 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); + await RecordBlockVerificationErrorAsync(draft, retryHelped: true, 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; + 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 = encryptedSignature, + 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; + } + } } } } @@ -157,53 +154,54 @@ public async ValueTask UploadThumbnailAsync(RevisionDraft dra { using (_logger.BeginScope("{ThumbnailType} block of revision #{RevisionUid}", thumbnail.Type, draft.Uid)) { - var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); - await using (dataPacketStream.ConfigureAwait(false)) + var pgpProfile = draft.ContentKey.IsAead() ? PgpProfile.ProtonAead : PgpProfile.Proton; + + var encryptingStream = draft.ContentKey.OpenEncryptingAndSigningReadStream( + thumbnail.Content.AsStream(), + draft.SigningKey, + profile: pgpProfile, + aeadStreamingChunkLength: PgpAeadStreamingChunkLength.ChunkLength); + + await using (encryptingStream.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 HashingReadStream(encryptingStream, sha256, leaveOpen: true); await using (hashingStream.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)) + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) { - await encryptingStream.WriteAsync(thumbnail.Content, cancellationToken).ConfigureAwait(false); - } - } + await hashingStream.CopyToAsync(dataPacketStream, cancellationToken).ConfigureAwait(false); - var sha256Digest = sha256.Hash ?? []; + var sha256Digest = sha256.GetCurrentHash(); - 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 + var request = new BlockUploadPreparationRequest { - Size = (int)dataPacketStream.Length, - Type = (Api.Files.ThumbnailType)thumbnail.Type, - HashDigest = sha256Digest, - }, - ], - }; + 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); - LogBlobUploaded(); + LogBlobUploaded(); - return new BlockUploadResult(0, sha256Digest); + return new BlockUploadResult(0, sha256Digest); + } + } } } } 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 9e8b7d08..da85aa7b 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -87,13 +87,13 @@ private static FileCreationRequest GetFileCreationRequest( signingKey, pgpProfile, out nodeKey, + out var lockedNodeKey, out nameSessionKey, out passphraseSessionKey, out var encryptedName, out var nameHashDigest, out var encryptedKeyPassphrase, - out var passphraseSignature, - out var lockedKeyBytes); + out var passphraseSignature); contentKey = useAeadFeatureFlag ? PgpSessionKey.GenerateForAead() : PgpSessionKey.Generate(); @@ -106,7 +106,7 @@ private static FileCreationRequest GetFileCreationRequest( Passphrase = encryptedKeyPassphrase, PassphraseSignature = passphraseSignature, SignatureEmailAddress = membershipEmailAddress, - Key = lockedKeyBytes, + Key = lockedNodeKey, MediaType = mediaType, ContentKeyPacket = nodeKey.EncryptSessionKey(contentKey), ContentKeySignature = nodeKey.Sign(contentKey.Export()), diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 7b2a55cc..3bc758d0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -20,7 +20,7 @@ namespace Proton.Drive.Sdk.Serialization; [ typeof(PgpArmoredMessageJsonConverter), typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredSecretKeyJsonConverter), typeof(PgpArmoredPublicKeyJsonConverter), ])] #pragma warning restore SA1114, SA1118 diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs index f53d01a4..57a37d58 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -14,7 +14,7 @@ namespace Proton.Drive.Sdk.Serialization; [ typeof(PgpArmoredMessageJsonConverter), typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredSecretKeyJsonConverter), typeof(PgpArmoredPublicKeyJsonConverter), ])] #pragma warning restore SA1114, SA1118 diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs index 910fd468..589a0de6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -11,7 +11,7 @@ internal static class ShareCrypto public static async ValueTask<(Share Share, PgpPrivateKey Key)> DecryptShareAsync( ProtonDriveClient client, ShareId shareId, - PgpArmoredPrivateKey lockedKey, + PgpArmoredSecretKey lockedKey, PgpArmoredMessage passphraseMessage, AddressId addressId, NodeUid rootFolderId, diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs index 9ca21229..737570a5 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -284,11 +284,11 @@ private static VolumeCreationRequest GetCreationRequest( { AddressId = addressId, AddressKeyId = addressKeyId, - ShareKey = lockedShareKey.ToBytes(), + ShareKey = lockedShareKey, SharePassphrase = encryptedSharePassphrase, SharePassphraseSignature = sharePassphraseSignature, FolderName = encryptedName, - FolderKey = lockedFolderKey.ToBytes(), + FolderKey = lockedFolderKey, FolderPassphrase = encryptedFolderPassphrase, FolderPassphraseSignature = folderPassphraseSignature, FolderHashKey = encryptedHashKey, @@ -346,14 +346,14 @@ private static PhotosVolumeCreationRequest GetPhotosCreationRequest( { AddressId = addressId, AddressKeyId = addressKeyId, - Key = lockedShareKey.ToBytes(), + Key = lockedShareKey, Passphrase = encryptedSharePassphrase, PassphraseSignature = sharePassphraseSignature, }, Link = new PhotosVolumeLinkCreationParameters { Name = encryptedName, - NodeKey = lockedFolderKey.ToBytes(), + NodeKey = lockedFolderKey, NodePassphrase = encryptedFolderPassphrase, NodePassphraseSignature = folderPassphraseSignature, NodeHashKey = encryptedHashKey, diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index 03a6e9bd..b8538641 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -213,7 +213,7 @@ private static async ValueTask
    ConvertFromDtoAsync( keys.Add(key); - if (keyDto.IsPrimary) + if (primaryKeyIndex is null && keyDto.IsPrimary) { primaryKeyIndex = keyIndex; } diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs index 7bf203c6..c9b5efa9 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs @@ -12,7 +12,7 @@ internal sealed class AddressKeyDto public required int Version { get; init; } - public PgpArmoredPrivateKey PrivateKey { get; init; } + public required PgpArmoredSecretKey PrivateKey { get; init; } public PgpArmoredMessage? Token { get; init; } diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs index ccd2530e..5323a8c5 100644 --- a/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs @@ -12,7 +12,7 @@ internal sealed class UserKeyDto public required int Version { get; init; } - public required PgpArmoredPrivateKey PrivateKey { get; init; } + public required PgpArmoredSecretKey PrivateKey { get; init; } [JsonPropertyName("Primary")] [JsonConverter(typeof(BooleanToIntegerJsonConverter))] diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs deleted file mode 100644 index 8413ad6d..00000000 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs +++ /dev/null @@ -1,16 +0,0 @@ -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 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/PgpArmoredSecretKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs new file mode 100644 index 00000000..b80bd248 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredSecretKey(PgpSecretKey secretKey) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = secretKey.ToBytes(); + + public static implicit operator PgpArmoredSecretKey(PgpSecretKey secretKey) => new(secretKey); + + public static implicit operator Stream(PgpArmoredSecretKey block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredSecretKey block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredSecretKey block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs deleted file mode 100644 index 69e8c5d7..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/PgpArmoredSecretKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs new file mode 100644 index 00000000..34cfc5e9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredSecretKeyJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.PrivateKey; + + protected override PgpArmoredSecretKey CreateValue(ReadOnlyMemory bytes) => new(PgpSecretKey.Import(bytes.Span)); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs index 3fc84359..b018ef52 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -18,7 +18,7 @@ namespace Proton.Sdk.Serialization; [ typeof(PgpArmoredMessageJsonConverter), typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredSecretKeyJsonConverter), typeof(PgpArmoredPublicKeyJsonConverter), ])] #pragma warning restore SA1114, SA1118 From 6bb8305fee9147232cedb3cd8a5b152e1259d592 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Jun 2026 18:06:12 +0200 Subject: [PATCH 704/732] Fix crypto package causing linking issues for apps relying on custom binaries --- cs/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index bccce9b6..677ecdd1 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -16,7 +16,7 @@ - + From 8c58ba5a199888762207dcc52fcd4f450ced9182 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Jun 2026 07:50:29 +0200 Subject: [PATCH 705/732] Fix core events subscriptions --- js/cli/src/events/manager.test.ts | 17 +++++++++++--- js/cli/src/events/manager.ts | 37 ++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/js/cli/src/events/manager.test.ts b/js/cli/src/events/manager.test.ts index 5ca7f122..f583b5e7 100644 --- a/js/cli/src/events/manager.test.ts +++ b/js/cli/src/events/manager.test.ts @@ -65,6 +65,18 @@ describe('Manager', () => { expect(await provider.getLatestEventId('core')).toBe('e2'); }); + it('create does not treat core scope as a volume scope', async () => { + const provider = new MemoryEventsProvider(); + await provider.setLatestEventId('drive', 'vol', 'vol-seed'); + await provider.setLatestEventId('drive', 'core', 'core-seed'); + + await Manager.create(logger, driveSdk, photosSdk, provider); + + expect(subscribeToTreeEvents).toHaveBeenCalledWith('vol', expect.any(Function)); + expect(subscribeToTreeEvents).not.toHaveBeenCalledWith('core', expect.any(Function)); + expect(subscribeToDriveEvents).toHaveBeenCalledWith(expect.any(Function)); + }); + it('TreeRemove removes scope and disposes subscription', async () => { const provider = new MemoryEventsProvider(); await provider.setLatestEventId('drive', 'vol', 'seed'); @@ -89,9 +101,8 @@ describe('Manager', () => { await m.subscribeDriveScope('extra'); expect(subscribeToTreeEvents).toHaveBeenCalledWith('extra', expect.any(Function)); - expect(provider.getInitialSubscriptionScopeIds()).toEqual([ - { context: 'drive', treeEventScopeIds: ['vol', 'extra'] }, - ]); + const driveScopeIds = provider.getInitialSubscriptionScopeIds()[0].treeEventScopeIds.sort(); + expect(driveScopeIds).toEqual(['core', 'extra', 'vol']); }); it('subscribePhotosScope uses photos SDK', async () => { diff --git a/js/cli/src/events/manager.ts b/js/cli/src/events/manager.ts index 58c9cfaf..f9d5c30e 100644 --- a/js/cli/src/events/manager.ts +++ b/js/cli/src/events/manager.ts @@ -9,6 +9,8 @@ import { import type { EventsContext, EventsProvider } from './interface'; +const CORE_TREE_EVENT_SCOPE_ID = 'core'; + type TreeSdk = Pick; export class Manager { @@ -48,13 +50,6 @@ export class Manager { } } - private async subscribeCoreEvents(): Promise { - if (this.coreSubscription) { - return; - } - this.coreSubscription = await this.driveSdk.subscribeToDriveEvents(this.createHandler('drive')); - } - private async subscribeInitialScopesFromProvider(): Promise { for (const { context, treeEventScopeIds } of this.provider.getInitialSubscriptionScopeIds()) { for (const scopeId of treeEventScopeIds) { @@ -68,17 +63,39 @@ export class Manager { } async subscribeDriveScope(scopeId: string): Promise { - await this.subscribeVolumeForSdk('drive', scopeId, this.driveSdk); + await this.subscribeForSdk('drive', scopeId, this.driveSdk); } async subscribePhotosScope(scopeId: string): Promise { - await this.subscribeVolumeForSdk('photos', scopeId, this.photosSdk); + await this.subscribeForSdk('photos', scopeId, this.photosSdk); } - private async subscribeVolumeForSdk(sdkContext: EventsContext, scopeId: string, sdk: TreeSdk): Promise { + private async subscribeForSdk(sdkContext: EventsContext, scopeId: string, sdk: TreeSdk): Promise { if (!this.provider.canListenForEvents()) { return; } + if (scopeId === CORE_TREE_EVENT_SCOPE_ID) { + await this.subscribeCoreEvents(); + return; + } + await this.subscribeVolumeForSdk(sdkContext, scopeId, sdk); + } + + private async subscribeCoreEvents(): Promise { + if (this.coreSubscription) { + return; + } + this.coreSubscription = await this.driveSdk.subscribeToDriveEvents(this.createHandler('drive')); + const latestEventId = this.coreSubscription.getLatestEventId(); + if (latestEventId) { + this.logger.debug( + `Subscribed to scope drive:${CORE_TREE_EVENT_SCOPE_ID} with latest event ID ${latestEventId}`, + ); + await this.provider.setLatestEventId('drive', CORE_TREE_EVENT_SCOPE_ID, latestEventId); + } + } + + private async subscribeVolumeForSdk(sdkContext: EventsContext, scopeId: string, sdk: TreeSdk): Promise { if (this.volumeSubscriptions[sdkContext].has(scopeId)) { return; } From 14893f4c082c4263d59978af0f944a9b1ca7ad85 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Jun 2026 04:28:27 +0000 Subject: [PATCH 706/732] Support command history in interactive shell --- js/cli/src/cli/readline.test.ts | 29 +++++++++++++++++++++ js/cli/src/cli/readline.ts | 46 +++++++++++++++++++++++++++++++-- js/cli/src/cli/repl.ts | 2 +- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/js/cli/src/cli/readline.test.ts b/js/cli/src/cli/readline.test.ts index ead97eba..9ce98022 100644 --- a/js/cli/src/cli/readline.test.ts +++ b/js/cli/src/cli/readline.test.ts @@ -115,6 +115,35 @@ describe('readline.question', () => { await expect(question('good')).resolves.toBe('ok'); }); + it('enableHistory passes prior lines to createInterface and persists non-empty answers', async () => { + mockedCreateInterface + .mockImplementationOnce(() => stubInterface(jest.fn(async () => 'first'))) + .mockImplementationOnce(() => stubInterface(jest.fn(async () => 'second'))); + + await expect(question('repl>', { enableHistory: true })).resolves.toBe('first'); + await expect(question('repl>', { enableHistory: true })).resolves.toBe('second'); + + const secondCallOptions = mockedCreateInterface.mock.calls[1]?.[0] as { history?: string[] }; + expect(secondCallOptions.history).toEqual(['first']); + }); + + it('enableHistory backup path trims persisted history to historySize', async () => { + for (let i = 0; i < 1002; i++) { + mockedCreateInterface.mockImplementationOnce(() => + stubInterface(jest.fn(async () => `line-${i}`)), + ); + } + + for (let i = 0; i < 1002; i++) { + await expect(question('repl>', { enableHistory: true })).resolves.toBe(`line-${i}`); + } + + const nextPromptOptions = mockedCreateInterface.mock.calls[1001]?.[0] as { history?: string[] }; + expect(nextPromptOptions.history).toHaveLength(1000); + expect(nextPromptOptions.history?.[0]).toBe('line-1000'); + expect(nextPromptOptions.history?.at(-1)).toBe('line-1'); + }); + it('returns null when stdin reaches EOF before an answer', async () => { let emitClose!: () => void; mockedCreateInterface.mockImplementationOnce(() => { diff --git a/js/cli/src/cli/readline.ts b/js/cli/src/cli/readline.ts index 0809cfb6..d2e71499 100644 --- a/js/cli/src/cli/readline.ts +++ b/js/cli/src/cli/readline.ts @@ -2,19 +2,31 @@ import * as readline from 'node:readline/promises'; let chain: Promise = Promise.resolve(); +const history: string[] = []; + +const historySize = 1000; + +export type QuestionOptions = { + /** When true, Up/Down arrows recall prior prompts (REPL-style). */ + enableHistory?: boolean; +}; + /** * Prompt user for input. Only one prompt can be active at a time. When any * another question comes in, it waits for the previous input to be processed. * * Returns `null` when stdin reaches EOF (e.g. Ctrl-D on an empty line). */ -export function question(prompt: string): Promise { +export function question(prompt: string, options: QuestionOptions = {}): Promise { + const enableHistory = options.enableHistory ?? false; const task = chain.then(async (): Promise => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, + ...(enableHistory ? { history: [...history], historySize } : {}), }); let closed = false; + let answer: string | null = null; try { return await new Promise((resolve, reject) => { let settled = false; @@ -31,10 +43,16 @@ export function question(prompt: string): Promise { settle(() => resolve(null)); }); rl.question(prompt) - .then((value) => settle(() => resolve(value))) + .then((value) => { + answer = value; + settle(() => resolve(value)); + }) .catch((error) => settle(() => reject(error))); }); } finally { + if (enableHistory) { + syncHistory(rl, answer); + } if (!closed) { rl.close(); } @@ -44,6 +62,30 @@ export function question(prompt: string): Promise { return task; } +function syncHistory(rl: readline.Interface, answer: string | null): void { + // Try first to use the whole history from the readline interface. + const rlHistory = (rl as readline.Interface & { history?: readonly string[] }).history; + if (rlHistory !== undefined && rlHistory.length > 0) { + history.length = 0; + history.push(...rlHistory); + trimHistory(); + return; + } + // As backup, use the typed answer and preserve previous history (newest-first, like Node readline). + if (answer !== null && answer.length > 0 && history[0] !== answer) { + history.unshift(answer); + trimHistory(); + } +} + +/** Drop oldest entries; keep the most recent `historySize` prompts (newest-first). */ +function trimHistory(): void { + if (history.length > historySize) { + history.splice(historySize); + } +} + export function resetForTests(): void { chain = Promise.resolve(); + history.length = 0; } diff --git a/js/cli/src/cli/repl.ts b/js/cli/src/cli/repl.ts index 29522a47..30e86559 100644 --- a/js/cli/src/cli/repl.ts +++ b/js/cli/src/cli/repl.ts @@ -11,7 +11,7 @@ export async function startRepl(commands: Command[], initOptions: InitConfig): P try { while (true) { - const line = await question('proton-drive> '); + const line = await question('proton-drive> ', { enableHistory: true }); if (line === null) { throw new ExitError(); } From 4f8c18e1cc575a096849d401d8cd2a662205eb83 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Jun 2026 04:28:52 +0000 Subject: [PATCH 707/732] Support escaping slash in node names --- js/cli/src/cli/help.ts | 5 +-- js/cli/src/cli/paths.test.ts | 31 +++++++++++++++++++ js/cli/src/cli/paths.ts | 59 +++++++++++++++++++++++++++++++----- 3 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 js/cli/src/cli/paths.test.ts diff --git a/js/cli/src/cli/help.ts b/js/cli/src/cli/help.ts index 0d5c6a1b..70773062 100644 --- a/js/cli/src/cli/help.ts +++ b/js/cli/src/cli/help.ts @@ -73,11 +73,12 @@ function printPathsHelp() { console.log(`${ROW_INDENT}Posix paths are always used, regardless of the host OS.`); console.log(`${ROW_INDENT}Node names are used for paths if available. When name cannot be decrypted`); console.log(`${ROW_INDENT}or conflicts with other node(s), node UIDs can be used instead.`); + console.log(`${ROW_INDENT}Escape / in node names with a backslash.`); console.log(``); console.log(`${ROW_INDENT}Examples:`); console.log(`${ROW_INDENT}- /my-files/folder/file.txt`); - console.log(`${ROW_INDENT}- /my-files/folder/[NODE-UID]/file.txt`); - console.log(`${ROW_INDENT}- /shared-with-me/[NODE-UID]/file.txt`); + console.log(`${ROW_INDENT}- /my-files/folder/foo\\/bar`); + console.log(`${ROW_INDENT}- /shared-with-me/NODE-UID/file.txt`); } function printCommandSynopsisLine(command: Command) { diff --git a/js/cli/src/cli/paths.test.ts b/js/cli/src/cli/paths.test.ts new file mode 100644 index 00000000..bb72104d --- /dev/null +++ b/js/cli/src/cli/paths.test.ts @@ -0,0 +1,31 @@ +import { pathBasename, pathDirname, splitPathSegments } from './paths'; + +describe('pathDirname', () => { + it('returns the parent path with slashes re-escaped in segment names', () => { + expect(pathDirname('/my-files/foo\\/bar/baz')).toBe('/my-files/foo\\/bar'); + }); +}); + +describe('pathBasename', () => { + it('returns the last segment with escapes decoded', () => { + expect(pathBasename('/my-files/foo\\/bar')).toBe('foo/bar'); + }); +}); + +describe('splitPathSegments', () => { + it('splits on unescaped slashes', () => { + expect(splitPathSegments('/my-files/foo/bar')).toEqual(['', 'my-files', 'foo', 'bar']); + }); + + it('treats escaped slash as part of the segment name', () => { + expect(splitPathSegments('/my-files/foo\\/bar/baz')).toEqual(['', 'my-files', 'foo/bar', 'baz']); + }); + + it('treats escaped backslash as a literal backslash', () => { + expect(splitPathSegments('/my-files/foo\\bar')).toEqual(['', 'my-files', 'foo\\bar']); + }); + + it('leaves a trailing backslash literal when not escaping', () => { + expect(splitPathSegments('/my-files/foo\\')).toEqual(['', 'my-files', 'foo\\']); + }); +}); diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index bc8572dd..ae9626c4 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -169,11 +169,11 @@ export class Path { } get parentPath() { - return new Path(this.driveSdk, this.photosSdk, PATH.dirname(this.fullPath), this.eventsManager); + return new Path(this.driveSdk, this.photosSdk, pathDirname(this.fullPath), this.eventsManager); } get name() { - return PATH.basename(this.fullPath); + return pathBasename(this.fullPath); } get sdk(): ProtonDriveClient | ProtonDrivePhotosClient { @@ -217,7 +217,7 @@ export class Path { return this.getNodeByPath(rootNode, this.sectionPathWithoutRoot); } if (this.type === PathType.Trash || this.type === PathType.PhotosTrash) { - const parts = this.sectionPath.split(PATH.sep); + const parts = splitPathSegments(this.sectionPath); if (parts.length > 1) { throw new ValidationError('Browsing trashed folders is not supported'); } @@ -243,17 +243,17 @@ export class Path { private get sectionPath() { // /my-files/foo/bar/baz -> foo/bar/baz - return this.fullPath.split(PATH.sep).slice(2).join(PATH.sep); + return joinPathSegments(splitPathSegments(this.fullPath).slice(2)); } private get sectionRootNodeName() { // /shared-with-me/foo/bar/baz -> foo - return this.sectionPath.split(PATH.sep)[0]; + return splitPathSegments(this.sectionPath)[0]; } private get sectionPathWithoutRoot() { // /shared-with-me/foo/bar/baz -> bar/baz - return this.fullPath.split(PATH.sep).slice(3).join(PATH.sep); + return joinPathSegments(splitPathSegments(this.fullPath).slice(3)); } private async getSharedWithMeRootFolder() { @@ -291,7 +291,7 @@ export class Path { private async getNodeByPath(parentNode: MaybeNode, pathString: string) { let node = parentNode; - const pathParts = pathString.split(PATH.sep); + const pathParts = splitPathSegments(pathString); for (const part of pathParts) { if (part === '') { continue; @@ -378,7 +378,7 @@ export class PublicLinkPath { private async getNodeByPath(parentNode: MaybeNode, pathString: string) { let node = parentNode; - const pathParts = pathString.split(PATH.sep); + const pathParts = splitPathSegments(pathString); for (const part of pathParts) { if (part === '') { continue; @@ -398,6 +398,49 @@ export class PublicLinkPath { } } +export function pathDirname(fullPath: string): string { + const segments = splitPathSegments(fullPath); + return joinPathSegments(segments.slice(0, -1)); +} + +export function pathBasename(fullPath: string): string { + const segments = splitPathSegments(fullPath); + return segments[segments.length - 1] ?? ''; +} + +/** + * Split a remote path into segments. Unescaped `/` separates segments; `\/` is literal `/`. + */ +export function splitPathSegments(path: string): string[] { + const segments: string[] = []; + let current = ''; + + for (let i = 0; i < path.length; i++) { + const char = path[i]; + if (char === '\\' && i + 1 < path.length) { + const next = path[i + 1]; + if (next === '/') { + current += next; + i++; + continue; + } + } + if (char === '/') { + segments.push(current); + current = ''; + continue; + } + current += char; + } + + segments.push(current); + return segments; +} + +function joinPathSegments(segments: string[]): string { + return segments.map((s) => s.replaceAll('/', '\\/')).join(PATH.sep); +} + function isNodeUid(pathString: string): boolean { return /^([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})~([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})$/.test(pathString); } From 40f61eb3d1f1bd3baf529c40774ac2f3ee923a23 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Jun 2026 20:06:44 +0200 Subject: [PATCH 708/732] Gracefully handle HTTP error responses with no body --- .../Http/HttpResponseMessageExtensions.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs index dc0421fe..7a50c415 100644 --- a/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs +++ b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs @@ -10,7 +10,6 @@ namespace Proton.Sdk.Http; internal static class HttpResponseMessageExtensions { - // TODO: add unit test public static async Task EnsureApiSuccessAsync( this HttpResponseMessage responseMessage, JsonTypeInfo failureTypeInfo, @@ -21,6 +20,7 @@ public static async Task EnsureApiSuccessAsync( { case HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict: { + EnsureNonEmptyContent(responseMessage); var response = await responseMessage.Content.ReadFromJsonAsync(failureTypeInfo, cancellationToken) .ConfigureAwait(false) ?? throw new JsonException(); @@ -29,17 +29,13 @@ public static async Task EnsureApiSuccessAsync( case HttpStatusCode.BadRequest: { - var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) - .ConfigureAwait(false) ?? throw new JsonException(); - + var response = await ReadApiResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false); throw new ProtonApiException(responseMessage.StatusCode, response); } case HttpStatusCode.TooManyRequests: { - var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) - .ConfigureAwait(false) ?? throw new JsonException(); - + var response = await ReadApiResponseAsync(responseMessage, cancellationToken).ConfigureAwait(false); throw new TooManyRequestsException(responseMessage.StatusCode, response, GetRetryAfter(responseMessage)); } @@ -49,6 +45,21 @@ public static async Task EnsureApiSuccessAsync( } } + private static async Task ReadApiResponseAsync(HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + EnsureNonEmptyContent(responseMessage); + return await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + } + + private static void EnsureNonEmptyContent(HttpResponseMessage responseMessage) + { + if (responseMessage.Content.Headers.ContentLength is 0) + { + throw new ProtonApiException(responseMessage.ReasonPhrase, (int)responseMessage.StatusCode, ResponseCode.Unknown); + } + } + private static DateTime? GetRetryAfter(HttpResponseMessage responseMessage) { var retryAfter = responseMessage.Headers.RetryAfter; From 616b5619b31dc596780790d87fa6a7da965c6ac5 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Jun 2026 05:23:40 +0000 Subject: [PATCH 709/732] Update changelog for cli/v0.0.2 --- js/cli/CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md index 89a1eed1..640cf9fb 100644 --- a/js/cli/CHANGELOG.md +++ b/js/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## cli/v0.0.2 (2026-06-05) + +* Support escaping slash in node names +* Support command history in interactive shell +* Fix core events subscriptions + ## cli/v0.0.1 (2026-06-02) -* No changes +* Support escaping slash in node names +* Support command history in interactive shell +* Fix core events subscriptions From 8dfe94a15b3d707958aaedb5e41384339e227322 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 4 Jun 2026 22:49:27 +0200 Subject: [PATCH 710/732] Fix flaky tests of FIFO semaphore --- .../src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs index fe5fea92..bf134570 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -51,7 +51,7 @@ public async ValueTask EnterAsync(int count, CancellationToken cancellationToken _waitingQueue.Enqueue((count, tcs)); } - var cancellationTokenRegistration = cancellationToken.Register(() => tcs.TrySetCanceled()); + var cancellationTokenRegistration = cancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetCanceled(), tcs); if (cancellationToken.IsCancellationRequested) { @@ -59,16 +59,9 @@ public async ValueTask EnterAsync(int count, CancellationToken cancellationToken return; } - await WaitAsync().ConfigureAwait(false); - - return; - - async ValueTask WaitAsync() + await using (cancellationTokenRegistration.ConfigureAwait(false)) { - await using (cancellationTokenRegistration.ConfigureAwait(false)) - { - await tcs.Task.ConfigureAwait(false); - } + await tcs.Task.ConfigureAwait(false); } } From b5d9ed9603887cedf8e093b6535d03838a458b93 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Jun 2026 08:45:28 +0200 Subject: [PATCH 711/732] Implement SHA1 verifier metrics --- js/cli/src/cli/interface.ts | 2 + .../fileSystem/commandFileSystemDownload.ts | 74 +++++++++++++------ .../fileSystem/commandFileSystemUpload.ts | 5 ++ js/cli/src/init.ts | 3 +- js/cli/src/telemetry/index.ts | 1 + js/cli/src/telemetry/metricHandler.ts | 35 ++++++++- js/cli/src/telemetry/metrics/Metrics.ts | 6 ++ ...oad_verifier_attempts_total_v1.schema.d.ts | 17 +++++ ...oad_verifier_attempts_total_v2.schema.d.ts | 18 +++++ ...oad_verifier_attempts_total_v1.schema.d.ts | 16 ++++ js/cli/src/telemetry/telemetry.ts | 3 +- .../telemetry/verifierFileSizeBucket.test.ts | 13 ++++ .../src/telemetry/verifierFileSizeBucket.ts | 18 +++++ 13 files changed, 184 insertions(+), 27 deletions(-) create mode 100644 js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v2.schema.d.ts create mode 100644 js/cli/src/telemetry/metrics/types/drive_upload_verifier_attempts_total_v1.schema.d.ts create mode 100644 js/cli/src/telemetry/verifierFileSizeBucket.test.ts create mode 100644 js/cli/src/telemetry/verifierFileSizeBucket.ts diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 2a6abf38..10c4e2ed 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -6,6 +6,7 @@ import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotos import { Auth } from '../api'; import type { Manager } from '../events'; +import type { CliMetrics } from '../telemetry'; import { Paths } from './paths'; export interface Command { @@ -36,6 +37,7 @@ export interface ActionArgs { sdkDiagnostic: Diagnostic; paths: Paths; eventsManager: Manager; + metrics?: CliMetrics; clearCaches: () => Promise; args: string[]; options: { [name: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 21256548..61e27faa 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { FileDownloader, IntegrityError, type Logger, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, getClaimedSize, Options, PathType } from '../../cli'; +import type { CliMetrics } from '../../telemetry'; import { getSha1 } from './digest'; import { assertDownloadDestination, assertValidDownloadRoot, assertValidPathSegment } from './downloadPathValidation'; import { resolveLocalPaths } from './localPath'; @@ -31,6 +32,7 @@ type DownloadContext = { downloadQueue: DownloadQueue; conflictResolver: TransferConflictResolver; downloadRoot: string; + metrics?: CliMetrics; }; export class CommandFileSystemDownload implements Command { @@ -65,6 +67,7 @@ export class CommandFileSystemDownload implements Command { logger, sdk, paths, + metrics, args, options: { json, @@ -118,6 +121,7 @@ export class CommandFileSystemDownload implements Command { downloadQueue, conflictResolver, downloadRoot, + metrics, }; try { @@ -202,13 +206,16 @@ export class CommandFileSystemDownload implements Command { } } - const expectedSha1 = - item.remoteNode.ok && item.remoteNode.value.activeRevision?.claimedDigests?.sha1Verified - ? item.remoteNode.value.activeRevision?.claimedDigests?.sha1 - : undefined; - + const claimedDigests = item.remoteNode.ok + ? item.remoteNode.value.activeRevision?.claimedDigests + : undefined; + const verification = { + expectedSha1: claimedDigests?.sha1, + sha1Verified: !!claimedDigests?.sha1Verified, + fileSize: getClaimedSize(item.remoteNode) ?? 0, + }; const downloader = await ctx.sdk.getFileDownloader(item.remoteNode); - await this.downloadToPath(ctx, item, downloader, targetPath, expectedSha1); + await this.downloadToPath(ctx, item, downloader, targetPath, verification); return; } } @@ -218,7 +225,7 @@ export class CommandFileSystemDownload implements Command { item: QueueItemFile<{ remoteNode: MaybeNode }>, downloader: FileDownloader, localPath: string, - expectedSha1: string | undefined, + verification: { expectedSha1?: string; sha1Verified: boolean; fileSize: number }, ): Promise { assertDownloadDestination(ctx.downloadRoot, localPath); @@ -237,8 +244,7 @@ export class CommandFileSystemDownload implements Command { locked: false, }; - const claimedSize = getClaimedSize(item.remoteNode); - const progressTracker = ctx.progress?.trackItem(item.baseName, claimedSize); + const progressTracker = ctx.progress?.trackItem(item.baseName, verification.fileSize); const controller = downloader.downloadToStream(writableStream, (downloadedBytes) => { progressTracker?.onProgress?.(downloadedBytes); @@ -247,19 +253,7 @@ export class CommandFileSystemDownload implements Command { try { await controller.completion(); await writer.end(); - - if (expectedSha1) { - const computedSha1 = await getSha1(localPath); - if (computedSha1 !== expectedSha1) { - ctx.logger.error( - `Integrity verification failed: computedSha1=${computedSha1} expectedSha1=${expectedSha1}`, - ); - throw new IntegrityError('Integrity verification failed', { - computedSha1, - expectedSha1, - }); - } - } + await this.verifyDownload(ctx, localPath, verification); } catch (error: unknown) { await unlink(localPath).catch(() => {}); await writer.end(error instanceof Error ? error : new Error('Unknown error', { cause: error })); @@ -268,6 +262,42 @@ export class CommandFileSystemDownload implements Command { progressTracker?.onFinished(); } } + + private async verifyDownload( + ctx: DownloadContext, + localPath: string, + verification: { expectedSha1?: string; sha1Verified: boolean; fileSize: number }, + ): Promise { + const { expectedSha1, sha1Verified, fileSize } = verification; + + if (!expectedSha1) { + ctx.metrics?.reportDownloadVerifierAttempt({ + result: 'skipped', + fileSize, + checksumVerified: false, + }); + return; + } + + const computedSha1 = await getSha1(localPath); + const matches = computedSha1 === expectedSha1; + + ctx.metrics?.reportDownloadVerifierAttempt({ + result: matches ? 'success' : 'failure', + fileSize, + checksumVerified: sha1Verified, + }); + + if (!matches && sha1Verified) { + ctx.logger.error( + `Integrity verification failed: computedSha1=${computedSha1} expectedSha1=${expectedSha1}`, + ); + throw new IntegrityError('Integrity verification failed', { + computedSha1, + expectedSha1, + }); + } + } } function isEexistError(error: unknown): boolean { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 649d411c..be8f941f 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -6,6 +6,7 @@ import { } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, Options, PathType } from '../../cli'; +import type { CliMetrics } from '../../telemetry'; import { getSha1 } from './digest'; import { generateThumbnails } from './generateThumbnails'; import { ConflictChoice, ConflictTargetKind, TransferConflictResolver } from './transferConflictResolver'; @@ -21,6 +22,7 @@ type UploadContext = { progress?: TransferProgressInterface; uploadQueue: UploadQueue; conflictResolver: TransferConflictResolver; + metrics?: CliMetrics; }; export class CommandFileSystemUpload implements Command { @@ -61,6 +63,7 @@ export class CommandFileSystemUpload implements Command { logger, sdk, paths, + metrics, args, options: { json, @@ -116,6 +119,7 @@ export class CommandFileSystemUpload implements Command { progress, uploadQueue, conflictResolver, + metrics, }; try { @@ -200,6 +204,7 @@ export class CommandFileSystemUpload implements Command { }); await controller.completion(); + ctx.metrics?.reportUploadVerifierAttempt(); return; } catch (error: unknown) { if (!(error instanceof NodeWithSameNameExistsValidationError)) { diff --git a/js/cli/src/init.ts b/js/cli/src/init.ts index 6fef2500..6bea66fb 100644 --- a/js/cli/src/init.ts +++ b/js/cli/src/init.ts @@ -26,7 +26,7 @@ export async function init(configOptions: InitConfig) { mkdir(config.logDir, { recursive: true }), ]); - const { telemetry, initMetrics, flush: flushTelemetry } = initTelemetry(config); + const { telemetry, metrics, initMetrics, flush: flushTelemetry } = initTelemetry(config); const logger = telemetry.getLogger('cli'); const openPGPCryptoModule = initOpenPGPCryptoModule(); @@ -77,6 +77,7 @@ export async function init(configOptions: InitConfig) { paths, eventsManager, eventsProvider, + metrics, dispose: async () => { await Promise.all([flushTelemetry(), eventsManager.dispose()]); }, diff --git a/js/cli/src/telemetry/index.ts b/js/cli/src/telemetry/index.ts index 800155ec..1ceed1be 100644 --- a/js/cli/src/telemetry/index.ts +++ b/js/cli/src/telemetry/index.ts @@ -1,3 +1,4 @@ +export type { CliMetrics } from './metricHandler'; export type { SeverityLevel } from './sentry'; export { captureException, flushSentry, initSentry } from './sentry'; export { initTelemetry } from './telemetry'; diff --git a/js/cli/src/telemetry/metricHandler.ts b/js/cli/src/telemetry/metricHandler.ts index 956b02aa..f92b7f24 100644 --- a/js/cli/src/telemetry/metricHandler.ts +++ b/js/cli/src/telemetry/metricHandler.ts @@ -13,12 +13,23 @@ import { reduceSizePrecision } from '@protontech/drive-sdk/telemetry'; import type { ApiClient } from '../api'; import { Metrics, MetricsRequestService } from './metrics'; import { captureMessage } from './sentry'; +import { getVerifierFileSizeBucket } from './verifierFileSizeBucket'; export type UserPlan = 'free' | 'paid' | 'anonymous' | 'unknown'; const REPORT_ERRORING_USERS_INTERVAL_MS = 5 * 60 * 1000; -export class MetricHandler implements MetricHandlerType { +export interface CliMetrics { + reportUploadVerifierAttempt(): void; + reportDownloadVerifierAttempt(params: { + result: 'success' | 'failure' | 'skipped'; + fileSize: number; + checksumVerified: boolean; + }): void; +} + +export class MetricHandler implements MetricHandlerType, CliMetrics { + private metrics?: Metrics; private handler?: ConfiguredMetricHandler; private requestService?: MetricsRequestService; @@ -27,8 +38,8 @@ export class MetricHandler implements MetricHandlerType { apiClient.authenticatedRequest, `${apiClient.baseUrlWithProtocol}/api/data/v1/metrics`, ); - const metrics = new Metrics(this.requestService); - this.handler = new ConfiguredMetricHandler(metrics, userPlan); + this.metrics = new Metrics(this.requestService); + this.handler = new ConfiguredMetricHandler(this.metrics, userPlan); } async flush() { @@ -38,6 +49,24 @@ export class MetricHandler implements MetricHandlerType { onEvent(metric: MetricRecord): void { this.handler?.onEvent(metric); } + + reportUploadVerifierAttempt(): void { + this.metrics?.drive_upload_verifier_attempts_total.increment({ + sha1Provided: 'true', + }); + } + + reportDownloadVerifierAttempt(params: { + result: 'success' | 'failure' | 'skipped'; + fileSize: number; + checksumVerified: boolean; + }): void { + this.metrics?.drive_download_verifier_attempts_total.increment({ + result: params.result, + fileSize: getVerifierFileSizeBucket(params.fileSize), + checksumVerified: params.checksumVerified ? 'true' : 'false', + }); + } } class ConfiguredMetricHandler { diff --git a/js/cli/src/telemetry/metrics/Metrics.ts b/js/cli/src/telemetry/metrics/Metrics.ts index 0f6102e9..cf5a2836 100644 --- a/js/cli/src/telemetry/metrics/Metrics.ts +++ b/js/cli/src/telemetry/metrics/Metrics.ts @@ -5,6 +5,7 @@ import { Counter } from './lib/counter'; import { Histogram } from './lib/histogram'; import type { MetricsReporter } from './lib/metric'; +import type { HttpsProtonMeDriveDownloadVerifierAttemptsTotalV2SchemaJson } from './types/drive_download_verifier_attempts_total_v2.schema'; import type { HttpsProtonMeDriveSdkApiRetrySucceededTotalV1SchemaJson } from './types/drive_sdk_api_retry_succeeded_total_v1.schema'; import type { HttpsProtonMeDriveSdkDebounceTotalV1SchemaJson } from './types/drive_sdk_debounce_total_v1.schema'; import type { HttpsProtonMeDriveSdkDownloadErroringUsersTotalV1SchemaJson } from './types/drive_sdk_download_erroring_users_total_v1.schema'; @@ -22,8 +23,10 @@ import type { HttpsProtonMeDriveSdkUploadErrorsTotalV1SchemaJson } from './types import type { HttpsProtonMeDriveSdkUploadErrorsTransferSizeHistogramV1SchemaJson } from './types/drive_sdk_upload_errors_transfer_size_histogram_v1.schema'; import type { HttpsProtonMeDriveSdkUploadSuccessRateTotalV1SchemaJson } from './types/drive_sdk_upload_success_rate_total_v1.schema'; import type { HttpsProtonMeDriveSdkVolumeEventsSubscriptionsHistogramV1SchemaJson } from './types/drive_sdk_volume_events_subscriptions_histogram_v1.schema'; +import type { HttpsProtonMeDriveUploadVerifierAttemptsTotalV1SchemaJson } from './types/drive_upload_verifier_attempts_total_v1.schema'; export class Metrics { + public drive_download_verifier_attempts_total: Counter; public drive_sdk_api_retry_succeeded_total: Counter; public drive_sdk_debounce_total: Counter; public drive_sdk_download_erroring_users_total: Counter; @@ -41,8 +44,10 @@ export class Metrics { public drive_sdk_upload_errors_transfer_size_histogram: Histogram; public drive_sdk_upload_success_rate_total: Counter; public drive_sdk_volume_events_subscriptions_histogram: Histogram; + public drive_upload_verifier_attempts_total: Counter; constructor(requestService: MetricsReporter) { + this.drive_download_verifier_attempts_total = new Counter({ name: 'drive_download_verifier_attempts_total', version: 2 }, requestService); this.drive_sdk_api_retry_succeeded_total = new Counter({ name: 'drive_sdk_api_retry_succeeded_total', version: 1 }, requestService); this.drive_sdk_debounce_total = new Counter({ name: 'drive_sdk_debounce_total', version: 1 }, requestService); this.drive_sdk_download_erroring_users_total = new Counter({ name: 'drive_sdk_download_erroring_users_total', version: 1 }, requestService); @@ -60,5 +65,6 @@ export class Metrics { this.drive_sdk_upload_errors_transfer_size_histogram = new Histogram({ name: 'drive_sdk_upload_errors_transfer_size_histogram', version: 1 }, requestService); this.drive_sdk_upload_success_rate_total = new Counter({ name: 'drive_sdk_upload_success_rate_total', version: 1 }, requestService); this.drive_sdk_volume_events_subscriptions_histogram = new Histogram({ name: 'drive_sdk_volume_events_subscriptions_histogram', version: 1 }, requestService); + this.drive_upload_verifier_attempts_total = new Counter({ name: 'drive_upload_verifier_attempts_total', version: 1 }, requestService); } } diff --git a/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v1.schema.d.ts new file mode 100644 index 00000000..54912e21 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v1.schema.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Drive file download verification + */ +export interface HttpsProtonMeDriveDownloadVerifierAttemptsTotalV1SchemaJson { + Labels: { + result: "success" | "failure" | "skipped"; + fileSize: "2**10" | "2**20" | "2**22" | "2**25" | "2**30" | "xxxxl"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v2.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v2.schema.d.ts new file mode 100644 index 00000000..f0f37b6c --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_download_verifier_attempts_total_v2.schema.d.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Drive file download verification + */ +export interface HttpsProtonMeDriveDownloadVerifierAttemptsTotalV2SchemaJson { + Labels: { + result: "success" | "failure" | "skipped"; + fileSize: "2**10" | "2**20" | "2**22" | "2**25" | "2**30" | "xxxxl"; + checksumVerified: "true" | "false"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/metrics/types/drive_upload_verifier_attempts_total_v1.schema.d.ts b/js/cli/src/telemetry/metrics/types/drive_upload_verifier_attempts_total_v1.schema.d.ts new file mode 100644 index 00000000..72a332a3 --- /dev/null +++ b/js/cli/src/telemetry/metrics/types/drive_upload_verifier_attempts_total_v1.schema.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Drive file upload verification + */ +export interface HttpsProtonMeDriveUploadVerifierAttemptsTotalV1SchemaJson { + Labels: { + sha1Provided: "true" | "false"; + }; + Value: number; +} diff --git a/js/cli/src/telemetry/telemetry.ts b/js/cli/src/telemetry/telemetry.ts index 486b0d85..70a33c2b 100644 --- a/js/cli/src/telemetry/telemetry.ts +++ b/js/cli/src/telemetry/telemetry.ts @@ -12,7 +12,7 @@ import { Config } from '../config'; import { ColoredConsoleMetricHandler } from './consoleHandler'; import { FileLogHandler } from './fileHandler'; import { ColoredLogFormatter } from './logFormatters'; -import { MetricHandler, UserPlan } from './metricHandler'; +import { CliMetrics, MetricHandler, UserPlan } from './metricHandler'; import { flushSentry } from './sentry'; import { SentryLogHandler } from './sentryHandler'; @@ -42,6 +42,7 @@ export function initTelemetry(config: Config) { return { telemetry, + metrics: metricsHandler as CliMetrics, initMetrics: (apiClient: ApiClient, userPlan: UserPlan) => { metricsHandler?.init(apiClient, userPlan); }, diff --git a/js/cli/src/telemetry/verifierFileSizeBucket.test.ts b/js/cli/src/telemetry/verifierFileSizeBucket.test.ts new file mode 100644 index 00000000..c0ecf25a --- /dev/null +++ b/js/cli/src/telemetry/verifierFileSizeBucket.test.ts @@ -0,0 +1,13 @@ +import { getVerifierFileSizeBucket } from './verifierFileSizeBucket'; + +describe('getVerifierFileSizeBucket', () => { + it('maps sizes to the smallest bucket that fits', () => { + expect(getVerifierFileSizeBucket(0)).toBe('2**10'); + expect(getVerifierFileSizeBucket(1 << 10)).toBe('2**10'); + expect(getVerifierFileSizeBucket((1 << 10) + 1)).toBe('2**20'); + expect(getVerifierFileSizeBucket(1 << 20)).toBe('2**20'); + expect(getVerifierFileSizeBucket((1 << 22) + 1)).toBe('2**25'); + expect(getVerifierFileSizeBucket(1 << 30)).toBe('2**30'); + expect(getVerifierFileSizeBucket((1 << 30) + 1)).toBe('xxxxl'); + }); +}); diff --git a/js/cli/src/telemetry/verifierFileSizeBucket.ts b/js/cli/src/telemetry/verifierFileSizeBucket.ts new file mode 100644 index 00000000..bdf32cac --- /dev/null +++ b/js/cli/src/telemetry/verifierFileSizeBucket.ts @@ -0,0 +1,18 @@ +export type VerifierFileSizeBucket = '2**10' | '2**20' | '2**22' | '2**25' | '2**30' | 'xxxxl'; + +const FILE_SIZE_BUCKETS: { max: number; label: VerifierFileSizeBucket }[] = [ + { max: 1 << 10, label: '2**10' }, + { max: 1 << 20, label: '2**20' }, + { max: 1 << 22, label: '2**22' }, + { max: 1 << 25, label: '2**25' }, + { max: 1 << 30, label: '2**30' }, +]; + +export function getVerifierFileSizeBucket(size: number): VerifierFileSizeBucket { + for (const bucket of FILE_SIZE_BUCKETS) { + if (size <= bucket.max) { + return bucket.label; + } + } + return 'xxxxl'; +} From 169597ed58416b584adafbd163837de916dad6b8 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Jun 2026 11:18:10 +0000 Subject: [PATCH 712/732] Update changelog for cli/v0.0.3 --- js/cli/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md index 640cf9fb..8d309e7f 100644 --- a/js/cli/CHANGELOG.md +++ b/js/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## cli/v0.0.3 (2026-06-05) + +* Implement SHA1 verifier metrics + ## cli/v0.0.2 (2026-06-05) * Support escaping slash in node names From 1e3edca6a3154b221cfe4560a0ddfa277e3ce2a3 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 5 Jun 2026 11:51:49 +0000 Subject: [PATCH 713/732] Merge node entities --- js/cli/src/cli/index.ts | 2 +- js/cli/src/cli/node.ts | 46 +++------ js/cli/src/cli/paths.ts | 58 +++++------ .../fileSystem/commandFileSystemCopy.ts | 11 +-- .../fileSystem/commandFileSystemDownload.ts | 20 ++-- .../fileSystem/commandFileSystemList.ts | 11 +-- .../fileSystem/commandFileSystemMove.ts | 4 +- .../fileSystem/commandFileSystemUpload.ts | 10 +- .../commands/fileSystem/transferQueue.test.ts | 95 ++++++++---------- .../src/commands/fileSystem/transferQueue.ts | 29 +++--- .../commands/sharing/commandSharingRemove.ts | 4 +- js/sdk/src/diagnostic/diagnostic.ts | 6 +- js/sdk/src/diagnostic/interface.ts | 8 +- js/sdk/src/diagnostic/nodeUtils.ts | 99 +++++++------------ js/sdk/src/diagnostic/sdkDiagnosticBase.ts | 80 ++++++--------- js/sdk/src/diagnostic/sdkDiagnosticMain.ts | 49 +++++---- js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts | 34 +++---- js/sdk/src/interface/index.ts | 11 +-- js/sdk/src/interface/nodes.ts | 38 +------ js/sdk/src/interface/photos.ts | 22 +---- js/sdk/src/protonDriveClient.ts | 20 ++-- js/sdk/src/protonDrivePhotosClient.ts | 20 ++-- js/sdk/src/protonDrivePublicLinkClient.ts | 16 +-- js/sdk/src/transformers.ts | 66 +++++-------- 24 files changed, 290 insertions(+), 469 deletions(-) diff --git a/js/cli/src/cli/index.ts b/js/cli/src/cli/index.ts index 134acab0..f42cc4aa 100644 --- a/js/cli/src/cli/index.ts +++ b/js/cli/src/cli/index.ts @@ -15,7 +15,7 @@ export { sanitizeTerminalText, } from './formatters'; export type { ActionArgs, Command, Options } from './interface'; -export { findName, getClaimedSize, getName, getNode, getNodeUid } from './node'; +export { findName, getClaimedSize, getName } from './node'; export { openBrowserUrl } from './openBrowserUrl'; export { Path, Paths, PathType } from './paths'; export { readPasswordLine } from './readPasswordLine'; diff --git a/js/cli/src/cli/node.ts b/js/cli/src/cli/node.ts index cf7209e0..c484819f 100644 --- a/js/cli/src/cli/node.ts +++ b/js/cli/src/cli/node.ts @@ -1,44 +1,20 @@ -import { DegradedNode, MaybeNode, NodeEntity } from '@protontech/drive-sdk'; +import { NodeEntity } from '@protontech/drive-sdk'; -export function getNodeUid(maybeNode: MaybeNode): string { - return maybeNode.ok ? maybeNode.value.uid : maybeNode.error.uid; -} - -export function getNode(maybeNode: MaybeNode): NodeEntity | DegradedNode { - return maybeNode.ok ? maybeNode.value : maybeNode.error; -} - -export function findName(maybeNodes: MaybeNode[], uid: string): string { - for (const maybeNode of maybeNodes) { - if (getNodeUid(maybeNode) === uid) { - return getName(maybeNode); +export function findName(nodes: NodeEntity[], uid: string): string { + for (const node of nodes) { + if (node.uid === uid) { + return getName(node); } } return uid; } -export function getName(maybeNode: MaybeNode): string { - let name; - let uid; - if (maybeNode.ok) { - name = maybeNode.value.name; - uid = maybeNode.value.uid; - } else { - name = maybeNode.error.name.ok ? maybeNode.error.name.value : maybeNode.error.name.error.name; - uid = maybeNode.error.uid; - } - return validateName(name) ? name : uid; -} - -export function getClaimedSize(maybeNode: MaybeNode): number | undefined { - if (maybeNode.ok) { - return maybeNode.value.activeRevision?.claimedSize; - } - if (maybeNode.error.activeRevision?.ok) { - return maybeNode.error.activeRevision.value.claimedSize; - } +export function getName(node: NodeEntity): string { + const name = node.name.ok ? node.name.value : ''; + return name.length > 0 ? name : node.uid; } -function validateName(name: string): boolean { - return name.length > 0; +export function getClaimedSize(node: NodeEntity): number | undefined { + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; + return activeRevision?.claimedSize; } diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index ae9626c4..0ef14842 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { MaybeMissingNode, MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { NodeEntity, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { ProtonDrivePublicLinkClient } from '@protontech/drive-sdk/protonDrivePublicLinkClient'; @@ -54,7 +54,7 @@ export class Paths { ].map((path) => `/${path}`); } - async getNodes(pathStrings: string[], supportedTypes?: PathType[]): Promise { + async getNodes(pathStrings: string[], supportedTypes?: PathType[]): Promise { const paths = this.getPaths(pathStrings, supportedTypes); const nodes = []; for (const path of paths) { @@ -86,7 +86,7 @@ export class Paths { return paths; } - async getNode(pathString: string, supportedTypes?: PathType[]): Promise { + async getNode(pathString: string, supportedTypes?: PathType[]): Promise { const path = this.getPath(pathString, supportedTypes); return await path.getNode(); } @@ -194,20 +194,19 @@ export class Path { return photoPaths.includes(this.type); } - async getNode(): Promise { + async getNode(): Promise { const node = await this.getNodeInternal(); - const scopeId = node.ok ? node.value.treeEventScopeId : node.error.treeEventScopeId; if (this.usePhotoSdk()) { - await this.eventsManager.subscribePhotosScope(scopeId); + await this.eventsManager.subscribePhotosScope(node.treeEventScopeId); } else { - await this.eventsManager.subscribeDriveScope(scopeId); + await this.eventsManager.subscribeDriveScope(node.treeEventScopeId); } return node; } - private async getNodeInternal(): Promise { + private async getNodeInternal(): Promise { if (this.type === PathType.MyFiles) { const rootNode = await this.driveSdk.getMyFilesRootFolder(); return this.getNodeByPath(rootNode, this.sectionPath); @@ -274,22 +273,21 @@ export class Path { throw new ValidationError('Trashed node not found'); } - private async getDevicesRootFolder(): Promise { + private async getDevicesRootFolder(): Promise { for await (const device of this.driveSdk.iterateDevices()) { const name = device.name.ok ? device.name.value : device.name.error.name; if (name === this.sectionRootNodeName) { const [maybeMissingNode] = await Array.fromAsync(this.sdk.iterateNodes([device.rootFolderUid])); - const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); - if (!maybeNode) { + if ('missingUid' in maybeMissingNode) { throw new ValidationError(`Node not found`); } - return maybeNode; + return maybeMissingNode; } } throw new ValidationError('Device not found'); } - private async getNodeByPath(parentNode: MaybeNode, pathString: string) { + private async getNodeByPath(parentNode: NodeEntity, pathString: string) { let node = parentNode; const pathParts = splitPathSegments(pathString); for (const part of pathParts) { @@ -305,7 +303,7 @@ export class Path { return node; } - private async getNodeByName(parentNode: MaybeNode, name: string) { + private async getNodeByName(parentNode: NodeEntity, name: string) { for await (const maybeChild of this.driveSdk.iterateFolderChildren(parentNode)) { if (getName(maybeChild) === name) { return maybeChild; @@ -314,35 +312,34 @@ export class Path { throw new ValidationError(`Node not found: ${name}`); } - private async getPhotoNodeByPath(pathString: string): Promise { + private async getPhotoNodeByPath(pathString: string): Promise { if (isNodeUid(pathString)) { return this.photosSdk.getNode(pathString); } return this.getPhotoByName(pathString); } - private async getPhotoByName(name: string): Promise { + private async getPhotoByName(name: string): Promise { const photoNodeUids = await Array.fromAsync(this.photosSdk.iterateTimeline(), (photo) => photo.nodeUid); for await (const maybeMissingNode of this.photosSdk.iterateNodes(photoNodeUids)) { - const maybeNode = getMaybeNodeAndIgnoreMissingNode(maybeMissingNode); - if (!maybeNode) { + if ('missingUid' in maybeMissingNode) { continue; } - if (getName(maybeNode) === name) { - return maybeNode; + if (getName(maybeMissingNode) === name) { + return maybeMissingNode; } } throw new ValidationError(`Photo not found: ${name}`); } - private async getAlbumNodeByPath(pathString: string): Promise { + private async getAlbumNodeByPath(pathString: string): Promise { if (isNodeUid(pathString)) { return this.photosSdk.getNode(pathString); } return this.getAlbumByName(pathString); } - private async getAlbumByName(name: string): Promise { + private async getAlbumByName(name: string): Promise { for await (const maybeAlbum of this.photosSdk.iterateAlbums()) { if (getName(maybeAlbum) === name) { return maybeAlbum; @@ -361,7 +358,7 @@ export class PublicLinkPath { this.fullPath = fullPath; } - async getNode(): Promise { + async getNode(): Promise { if (isNodeUid(this.fullPath)) { const nodeUid = this.fullPath; return this.publicLinkSdk.getNode(nodeUid); @@ -376,7 +373,7 @@ export class PublicLinkPath { return this.getNodeByName(node, name); } - private async getNodeByPath(parentNode: MaybeNode, pathString: string) { + private async getNodeByPath(parentNode: NodeEntity, pathString: string) { let node = parentNode; const pathParts = splitPathSegments(pathString); for (const part of pathParts) { @@ -388,7 +385,7 @@ export class PublicLinkPath { return node; } - private async getNodeByName(parentNode: MaybeNode, name: string) { + private async getNodeByName(parentNode: NodeEntity, name: string) { for await (const maybeChild of this.publicLinkSdk.iterateFolderChildren(parentNode)) { if (getName(maybeChild) === name) { return maybeChild; @@ -444,14 +441,3 @@ function joinPathSegments(segments: string[]): string { function isNodeUid(pathString: string): boolean { return /^([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})~([a-zA-Z0-9=_-]{88,108}|[a-zA-Z0-9_-]{22})$/.test(pathString); } - -function getMaybeNodeAndIgnoreMissingNode(maybeMissingNode: MaybeMissingNode): MaybeNode | undefined { - if (maybeMissingNode.ok) { - return maybeMissingNode; - } - const erroredNode = maybeMissingNode.error; - if ('missingUid' in erroredNode) { - return; - } - return { ok: false, error: erroredNode }; -} diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index 22e76d0f..0b2dced3 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -1,11 +1,10 @@ -import { MaybeNode, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { NodeEntity, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, findName, getName, - getNodeUid, Options, PathType, printIterable, @@ -47,17 +46,17 @@ export class CommandFileSystemCopy implements Command { private async copyNode( sdk: ProtonDriveClient, - sourceNode: MaybeNode, - targetNode: MaybeNode, + sourceNode: NodeEntity, + targetNode: NodeEntity, json: boolean, name: string, ) { - await printIterable(sdk.copyNodes([{ uid: getNodeUid(sourceNode), name }], targetNode), json, (result) => { + await printIterable(sdk.copyNodes([{ uid: sourceNode.uid, name }], targetNode), json, (result) => { console.log(sanitizeTerminalText(result.ok ? `✅ ${name}` : `❌ ${name}: ${result.error.message}`)); }); } - private async copyNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { + private async copyNodes(sdk: ProtonDriveClient, sourceNodes: NodeEntity[], targetNode: NodeEntity, json: boolean) { await printIterable(sdk.copyNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 61e27faa..31bcf7e2 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -1,7 +1,14 @@ import { mkdir, readdir, rm, stat, unlink } from 'node:fs/promises'; import path from 'node:path'; -import { FileDownloader, IntegrityError, type Logger, MaybeNode, type ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { + FileDownloader, + IntegrityError, + type Logger, + NodeEntity, + type ProtonDriveClient, + ValidationError, +} from '@protontech/drive-sdk'; import { type ActionArgs, type Command, getClaimedSize, Options, PathType } from '../../cli'; import type { CliMetrics } from '../../telemetry'; @@ -137,7 +144,7 @@ export class CommandFileSystemDownload implements Command { private async createLocalFolder( ctx: DownloadContext, - item: QueueItemDirectory<{ remoteNode: MaybeNode }>, + item: QueueItemDirectory<{ remoteNode: NodeEntity }>, ): Promise { const parentPath = path.dirname(item.localPath); let targetPath = item.localPath; @@ -175,7 +182,7 @@ export class CommandFileSystemDownload implements Command { } } - private async downloadFile(ctx: DownloadContext, item: QueueItemFile<{ remoteNode: MaybeNode }>): Promise { + private async downloadFile(ctx: DownloadContext, item: QueueItemFile<{ remoteNode: NodeEntity }>): Promise { const parentPath = path.dirname(item.localPath); let targetPath = item.localPath; let name = item.baseName; @@ -206,14 +213,15 @@ export class CommandFileSystemDownload implements Command { } } - const claimedDigests = item.remoteNode.ok - ? item.remoteNode.value.activeRevision?.claimedDigests + const claimedDigests = item.remoteNode.activeRevision?.ok + ? item.remoteNode.activeRevision.value.claimedDigests : undefined; const verification = { expectedSha1: claimedDigests?.sha1, sha1Verified: !!claimedDigests?.sha1Verified, fileSize: getClaimedSize(item.remoteNode) ?? 0, }; + const downloader = await ctx.sdk.getFileDownloader(item.remoteNode); await this.downloadToPath(ctx, item, downloader, targetPath, verification); return; @@ -222,7 +230,7 @@ export class CommandFileSystemDownload implements Command { private async downloadToPath( ctx: DownloadContext, - item: QueueItemFile<{ remoteNode: MaybeNode }>, + item: QueueItemFile<{ remoteNode: NodeEntity }>, downloader: FileDownloader, localPath: string, verification: { expectedSha1?: string; sha1Verified: boolean; fileSize: number }, diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts index 0f72c2c2..af95b956 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemList.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -1,4 +1,4 @@ -import { Device, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { Device, NodeEntity, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { @@ -10,7 +10,6 @@ import { formatSize, getClaimedSize, getName, - getNode, Options, Paths, PathType, @@ -130,17 +129,15 @@ export class CommandFileSystemList implements Command { await printIterable(sdk.iterateTrashedNodes(), options.json, (node) => this.printNodeHuman(node)); } - private printNodeHuman(maybeNode: MaybeNode): void { - const node = getNode(maybeNode); - + private printNodeHuman(node: NodeEntity): void { const type = node.type === 'file' ? '📄' : '🗂️'; const sharedFlag = node.isShared ? '🔗' : ' '; // Two spaces to align with the shared icon. const permissionFlag = formatMemberRole(node.directRole); const author = formatAuthor(node.keyAuthor); const created = formatDate(node.creationTime, true); - const claimedSize = getClaimedSize(maybeNode); + const claimedSize = getClaimedSize(node); const size = claimedSize ? formatSize(claimedSize) : '-'; - const name = getName(maybeNode); + const name = getName(node); console.log(sanitizeTerminalText(`${type}${sharedFlag}${permissionFlag} ${author} ${created} ${size} ${name}`)); } diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index 20d96298..45ca65ac 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -1,4 +1,4 @@ -import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; +import { NodeEntity, ProtonDriveClient } from '@protontech/drive-sdk'; import { type ActionArgs, type Command, findName, PathType, printIterable, sanitizeTerminalText } from '../../cli'; @@ -19,7 +19,7 @@ export class CommandFileSystemMove implements Command { await this.moveNodes(sdk, sourceNodes, targetNode, json); } - private async moveNodes(sdk: ProtonDriveClient, sourceNodes: MaybeNode[], targetNode: MaybeNode, json: boolean) { + private async moveNodes(sdk: ProtonDriveClient, sourceNodes: NodeEntity[], targetNode: NodeEntity, json: boolean) { await printIterable(sdk.moveNodes(sourceNodes, targetNode), json, (result) => { const nodeName = findName(sourceNodes, result.uid); console.log(sanitizeTerminalText(result.ok ? `✅ ${nodeName}` : `❌ ${nodeName}: ${result.error.message}`)); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index be8f941f..8eaa3fed 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -1,5 +1,5 @@ import { - MaybeNode, + NodeEntity, NodeWithSameNameExistsValidationError, type ProtonDriveClient, ValidationError, @@ -132,8 +132,8 @@ export class CommandFileSystemUpload implements Command { private async createFolder( ctx: UploadContext, - item: QueueItemDirectory<{ parentNode: MaybeNode }>, - ): Promise<{ node: MaybeNode } | undefined> { + item: QueueItemDirectory<{ parentNode: NodeEntity }>, + ): Promise<{ node: NodeEntity } | undefined> { let name = item.baseName; while (true) { @@ -170,7 +170,7 @@ export class CommandFileSystemUpload implements Command { } } - private async uploadFile(ctx: UploadContext, item: QueueItemFile<{ parentNode: MaybeNode }>): Promise { + private async uploadFile(ctx: UploadContext, item: QueueItemFile<{ parentNode: NodeEntity }>): Promise { const expectedSha1 = await getSha1(item.localPath); const file = Bun.file(item.localPath); const metadata = { @@ -238,7 +238,7 @@ export class CommandFileSystemUpload implements Command { } } - private async trashConflictingNode(ctx: UploadContext, node: MaybeNode): Promise { + private async trashConflictingNode(ctx: UploadContext, node: NodeEntity): Promise { for await (const result of ctx.sdk.trashNodes([node])) { if (!result.ok) { throw result.error; diff --git a/js/cli/src/commands/fileSystem/transferQueue.test.ts b/js/cli/src/commands/fileSystem/transferQueue.test.ts index 10e05fa8..67e5ee8d 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.test.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.test.ts @@ -1,7 +1,7 @@ import { Dirent } from 'node:fs'; import path from 'node:path'; -import { MaybeNode, MemberRole, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { MemberRole, NodeEntity, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; jest.mock('../../cli', () => jest.requireActual('../../cli/node')); @@ -48,48 +48,42 @@ function mockDirLstatResult(dev = 100): Awaited> { const mockAuthor = { ok: true as const, value: 'a@b.c' }; -function mockFolderMaybe(name: string, uid: string): MaybeNode { +function mockFolderMaybe(name: string, uid: string): NodeEntity { return { - ok: true, - value: { - uid, - name, - type: NodeType.Folder, - keyAuthor: mockAuthor, - nameAuthor: mockAuthor, - directRole: MemberRole.Admin, - ownedBy: {}, - isShared: false, - isSharedPublicly: false, - creationTime: new Date(), - modificationTime: new Date(), - treeEventScopeId: 'scope', - }, + uid, + name: { ok: true, value: name }, + type: NodeType.Folder, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', }; } -function mockFileMaybe(name: string, uid: string): MaybeNode { +function mockFileMaybe(name: string, uid: string): NodeEntity { return { - ok: true, - value: { - uid, - name, - type: NodeType.File, - keyAuthor: mockAuthor, - nameAuthor: mockAuthor, - directRole: MemberRole.Admin, - ownedBy: {}, - isShared: false, - isSharedPublicly: false, - creationTime: new Date(), - modificationTime: new Date(), - treeEventScopeId: 'scope', - }, + uid, + name: { ok: true, value: name }, + type: NodeType.File, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', }; } class SeededUploadQueue extends UploadQueue { - seed(items: QueueItem<{ parentNode: MaybeNode }>[]) { + seed(items: QueueItem<{ parentNode: NodeEntity }>[]) { this.queue.push(...items); } } @@ -138,7 +132,7 @@ describe('TransferQueue (via UploadQueue.processQueue)', () => { inFlight--; }); const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); - const items: QueueItem<{ parentNode: MaybeNode }>[] = []; + const items: QueueItem<{ parentNode: NodeEntity }>[] = []; for (let i = 0; i < 12; i++) { items.push({ kind: 'file', @@ -347,23 +341,20 @@ describe('DownloadQueue', () => { }); it('enqueueRemoteNode rejects unsupported node types', async () => { - const albumNode = { - ok: true, - value: { - uid: 'al', - name: 'album', - type: NodeType.Album, - keyAuthor: mockAuthor, - nameAuthor: mockAuthor, - directRole: MemberRole.Admin, - ownedBy: {}, - isShared: false, - isSharedPublicly: false, - creationTime: new Date(), - modificationTime: new Date(), - treeEventScopeId: 'scope', - }, - } as MaybeNode; + const albumNode: NodeEntity = { + uid: 'al', + name: { ok: true, value: 'album' }, + type: NodeType.Album, + keyAuthor: mockAuthor, + nameAuthor: mockAuthor, + directRole: MemberRole.Admin, + ownedBy: {}, + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + treeEventScopeId: 'scope', + }; const sdk = { iterateFolderChildren: jest.fn() }; const q = createQueue(sdk); await expect( diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts index cbef7d4a..0405e990 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -2,9 +2,9 @@ import type { Stats } from 'node:fs'; import { lstat, readdir } from 'node:fs/promises'; import path from 'node:path'; -import { Logger, MaybeNode, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; +import { Logger, NodeEntity, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; -import { getName, getNode } from '../../cli'; +import { getName } from '../../cli'; import { sanitizePathSegmentForLocalFilesystem } from './downloadPathValidation'; import { resolveLocalPaths } from './localPath'; @@ -61,8 +61,8 @@ class TransferQueue { } } -export class UploadQueue extends TransferQueue<{ parentNode: MaybeNode }> { - async enqueueLocalPaths(localPaths: string[], parentNode: MaybeNode): Promise { +export class UploadQueue extends TransferQueue<{ parentNode: NodeEntity }> { + async enqueueLocalPaths(localPaths: string[], parentNode: NodeEntity): Promise { for (const localPath of localPaths) { const expanded = await resolveLocalPaths(localPath); for (const absolutePath of expanded) { @@ -71,7 +71,7 @@ export class UploadQueue extends TransferQueue<{ parentNode: MaybeNode }> { } } - async enqueueLocalDirectoryChildren(absolutePath: string, parentNode: MaybeNode): Promise { + async enqueueLocalDirectoryChildren(absolutePath: string, parentNode: NodeEntity): Promise { const parentStats = await lstat(absolutePath); assertLocalPathIsUploadable(absolutePath, parentStats); if (!parentStats.isDirectory()) { @@ -87,7 +87,7 @@ export class UploadQueue extends TransferQueue<{ parentNode: MaybeNode }> { } } - private async enqueueLocalPath(absolutePath: string, parentNode: MaybeNode, parentDevice?: number): Promise { + private async enqueueLocalPath(absolutePath: string, parentNode: NodeEntity, parentDevice?: number): Promise { const stats = await lstat(absolutePath); assertLocalPathIsUploadable(absolutePath, stats); if (parentDevice !== undefined && stats.dev !== parentDevice) { @@ -110,11 +110,11 @@ function assertLocalPathIsUploadable(absolutePath: string, stats: Stats): void { } } -export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { +export class DownloadQueue extends TransferQueue<{ remoteNode: NodeEntity }> { constructor( logger: Logger, private readonly sdk: ProtonDriveClient, - handlers: TransferQueueHandlers<{ remoteNode: MaybeNode }>, + handlers: TransferQueueHandlers<{ remoteNode: NodeEntity }>, ) { super(logger, handlers); } @@ -122,7 +122,7 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { async enqueueRemotePaths( remotePathStrings: string[], localDir: string, - resolveRemoteNode: (pathString: string) => Promise, + resolveRemoteNode: (pathString: string) => Promise, ): Promise { const absoluteLocalDir = path.resolve(localDir); for (const pathString of remotePathStrings) { @@ -133,7 +133,7 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { } } - async enqueueRemoteFolderChildren(folderRemoteNode: MaybeNode, localParentPath: string): Promise { + async enqueueRemoteFolderChildren(folderRemoteNode: NodeEntity, localParentPath: string): Promise { for await (const child of this.sdk.iterateFolderChildren(folderRemoteNode)) { const baseName = sanitizePathSegmentForLocalFilesystem(getName(child)); const childPath = path.join(localParentPath, baseName); @@ -141,16 +141,15 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: MaybeNode }> { } } - private async enqueueRemoteNode(node: MaybeNode, localPath: string): Promise { + private async enqueueRemoteNode(node: NodeEntity, localPath: string): Promise { const absolutePath = path.resolve(localPath); const baseName = path.basename(absolutePath); - const type = getNode(node).type; - if (type === NodeType.Folder) { + if (node.type === NodeType.Folder) { this.queue.push({ kind: 'directory', remoteNode: node, localPath: absolutePath, baseName }); - } else if (type === NodeType.File) { + } else if (node.type === NodeType.File) { this.queue.push({ kind: 'file', remoteNode: node, localPath: absolutePath, baseName }); } else { - throw new ValidationError(`Unsupported node type for download: ${type}`); + throw new ValidationError(`Unsupported node type for download: ${node.type}`); } } } diff --git a/js/cli/src/commands/sharing/commandSharingRemove.ts b/js/cli/src/commands/sharing/commandSharingRemove.ts index 0f8e1706..d4449fe5 100644 --- a/js/cli/src/commands/sharing/commandSharingRemove.ts +++ b/js/cli/src/commands/sharing/commandSharingRemove.ts @@ -1,4 +1,4 @@ -import { MaybeNode, ProtonDriveClient } from '@protontech/drive-sdk'; +import { NodeEntity, ProtonDriveClient } from '@protontech/drive-sdk'; import { ProtonDrivePhotosClient } from '@protontech/drive-sdk/protonDrivePhotosClient'; import { type ActionArgs, type Command, Options, printObject } from '../../cli'; @@ -35,7 +35,7 @@ export class CommandSharingRemove implements Command { printObject(sharingInfo, json); } - private async getAllMembers(sdk: ProtonDriveClient | ProtonDrivePhotosClient, node: MaybeNode): Promise { + private async getAllMembers(sdk: ProtonDriveClient | ProtonDrivePhotosClient, node: NodeEntity): Promise { const sharingInfo = await sdk.getSharingInfo(node); return [ diff --git a/js/sdk/src/diagnostic/diagnostic.ts b/js/sdk/src/diagnostic/diagnostic.ts index 6158f079..98a8f8df 100644 --- a/js/sdk/src/diagnostic/diagnostic.ts +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -1,4 +1,4 @@ -import { MaybeNode } from '../interface'; +import { NodeEntity } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; import { DiagnosticHTTPClient } from './httpClient'; @@ -34,7 +34,7 @@ export class Diagnostic { } async *verifyNodeTree( - node: MaybeNode, + node: NodeEntity, options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator { @@ -58,7 +58,7 @@ export class Diagnostic { yield* zipGenerators(this.telemetry.iterateEvents(), this.httpClient.iterateEvents()); } - async getNodeTreeStructure(node: MaybeNode): Promise { + async getNodeTreeStructure(node: NodeEntity): Promise { const diagnostic = new SDKDiagnosticMain(this.protonDriveClient); return diagnostic.getStructure(node); } diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts index fb1a2067..81f240c7 100644 --- a/js/sdk/src/diagnostic/interface.ts +++ b/js/sdk/src/diagnostic/interface.ts @@ -1,4 +1,4 @@ -import { AnonymousUser, Author, MaybeNode, MetricEvent, NodeType } from '../interface'; +import { AnonymousUser, Author, MetricEvent, NodeEntity, NodeType } from '../interface'; import { LogRecord } from '../telemetry'; export interface Diagnostic { @@ -7,7 +7,7 @@ export interface Diagnostic { onProgress?: DiagnosticProgressCallback, ): AsyncGenerator; verifyNodeTree( - node: MaybeNode, + node: NodeEntity, options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator; @@ -15,7 +15,7 @@ export interface Diagnostic { options?: DiagnosticOptions, onProgress?: DiagnosticProgressCallback, ): AsyncGenerator; - getNodeTreeStructure(node: MaybeNode): Promise; + getNodeTreeStructure(node: NodeEntity): Promise; getPhotosTimelineStructure(): Promise; } @@ -230,5 +230,5 @@ export type NodeDetails = { error: unknown; }[]; }; - sensitiveNodeDetails: MaybeNode; + sensitiveNodeDetails: NodeEntity; }; diff --git a/js/sdk/src/diagnostic/nodeUtils.ts b/js/sdk/src/diagnostic/nodeUtils.ts index d8fef83f..c12d9adb 100644 --- a/js/sdk/src/diagnostic/nodeUtils.ts +++ b/js/sdk/src/diagnostic/nodeUtils.ts @@ -1,30 +1,28 @@ -import { MaybeNode, NodeType, Revision } from '../interface'; -import { - ExpectedTreeNode, - NodeDetails, -} from './interface'; +import { InvalidNameError, NodeEntity, Result } from '../interface'; +import { ExpectedTreeNode, NodeDetails } from './interface'; -export function getNodeDetails(node: MaybeNode): NodeDetails { +export function getNodeDetails(node: NodeEntity): 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 (node.name.ok === false) { + errors.push({ + field: 'name', + error: node.name.error, + }); + } + + if (node.activeRevision?.ok === false) { + errors.push({ + field: 'activeRevision', + error: node.activeRevision.error, + }); + } + + if (node.errors?.length) { + for (const error of node.errors) { if (error instanceof Error) { errors.push({ field: 'error', @@ -36,55 +34,20 @@ export function getNodeDetails(node: MaybeNode): NodeDetails { 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, + nodeUid: node.uid, + revisionUid: node.activeRevision?.ok ? node.activeRevision.value.uid : undefined, + nodeType: node.type, + mediaType: node.mediaType, + nodeCreationTime: node.creationTime, + keyAuthor: node.keyAuthor, + nameAuthor: node.nameAuthor, + contentAuthor: node.activeRevision?.ok ? node.activeRevision.value.contentAuthor : undefined, 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, @@ -94,7 +57,11 @@ export function getExpectedTreeNodeDetails(expectedNode: ExpectedTreeNode): Expe export function getTreeNodeChildByNodeName( expectedSubtree: ExpectedTreeNode | undefined, - nodeName: string, + nodeName: Result, ): ExpectedTreeNode | undefined { - return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeName); + if (!nodeName.ok) { + return undefined; + } + const nodeNameValue = nodeName.value; + return expectedSubtree?.children?.find((expectedNode) => expectedNode.name === nodeNameValue); } diff --git a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts index 964c3199..599ed5fb 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticBase.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -1,20 +1,8 @@ -import { Author, FileDownloader, MaybeNode, NodeOrUid, NodeType, ThumbnailResult, ThumbnailType } from '../interface'; +import { Author, FileDownloader, NodeEntity, NodeOrUid, NodeType, ThumbnailResult, ThumbnailType } from '../interface'; import { isProtonDocument, isProtonSheet } from '../internal/nodes/mediaTypes'; import { IntegrityVerificationStream } from './integrityVerificationStream'; -import { - DiagnosticOptions, - DiagnosticProgressCallback, - DiagnosticResult, - ExpectedTreeNode, -} from './interface'; -import { - getActiveRevision, - getExpectedTreeNodeDetails, - getMediaType, - getNodeDetails, - getNodeName, - getNodeType, -} from './nodeUtils'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, ExpectedTreeNode } from './interface'; +import { getExpectedTreeNodeDetails, getNodeDetails } from './nodeUtils'; const PROGRESS_REPORT_INTERVAL = 500; @@ -33,7 +21,7 @@ export class SDKDiagnosticBase { private onProgress?: DiagnosticProgressCallback; private progressReportInterval: NodeJS.Timeout | undefined; - protected nodesQueue: { node: MaybeNode; expected?: ExpectedTreeNode }[] = []; + protected nodesQueue: { node: NodeEntity; expected?: ExpectedTreeNode }[] = []; protected allNodesLoaded: boolean = false; protected loadedNodes: number = 0; protected checkedNodes: number = 0; @@ -78,7 +66,7 @@ export class SDKDiagnosticBase { protected async *verifyExpectedNodeChildren( parentNodeUid: string, - children: MaybeNode[], + children: NodeEntity[], expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { if (!expectedStructure) { @@ -86,7 +74,7 @@ export class SDKDiagnosticBase { } const expectedNodes = expectedStructure.children ?? []; - const actualNodeNames = children.map((child) => getNodeName(child)); + const actualNodeNames = children.map((child) => (child.name.ok ? child.name.value : 'N/A')); for (const expectedNode of expectedNodes) { if (!actualNodeNames.includes(expectedNode.name)) { @@ -99,7 +87,7 @@ export class SDKDiagnosticBase { } for (const child of children) { - const childName = getNodeName(child); + const childName = child.name.ok ? child.name.value : 'N/A'; const isExpected = expectedNodes.some((expectedNode) => expectedNode.name === childName); if (!isExpected) { @@ -124,23 +112,21 @@ export class SDKDiagnosticBase { } } - private async *verifyNode(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { - if (!node.ok) { + private async *verifyNode( + node: NodeEntity, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { + if (node.errors?.length) { yield { type: 'degraded_node', ...getNodeDetails(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, - ); + yield* this.verifyAuthor(node.keyAuthor, 'key', node, expectedStructure); + yield* this.verifyAuthor(node.nameAuthor, 'name', node, expectedStructure); - const activeRevision = getActiveRevision(node); + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; if (activeRevision) { yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node, expectedStructure); } @@ -157,8 +143,7 @@ export class SDKDiagnosticBase { } if (expectedStructure?.expectedMediaType) { - const mediaType = getMediaType(node); - if (mediaType !== expectedStructure.expectedMediaType) { + if (node.mediaType !== expectedStructure.expectedMediaType) { yield { type: 'expected_structure_integrity_error', expectedNode: getExpectedTreeNodeDetails(expectedStructure), @@ -171,7 +156,7 @@ export class SDKDiagnosticBase { private async *verifyAuthor( author: Author, authorType: 'key' | 'name' | 'content', - node: MaybeNode, + node: NodeEntity, expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { if (!author.ok) { @@ -206,10 +191,10 @@ export class SDKDiagnosticBase { } private async *verifyFileExtendedAttributes( - node: MaybeNode, + node: NodeEntity, expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { - const activeRevision = getActiveRevision(node); + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; const isNodeWithContent = this.isNodeWithContent(node); @@ -252,7 +237,7 @@ export class SDKDiagnosticBase { } } - private async *verifyContentPeak(node: MaybeNode): AsyncGenerator { + private async *verifyContentPeak(node: NodeEntity): AsyncGenerator { if (!this.isNodeWithContent(node)) { return; } @@ -263,7 +248,7 @@ export class SDKDiagnosticBase { } catch (error: unknown) { yield { type: 'sdk_error', - call: `getFileDownloader(${node.ok ? node.value.uid : node.error.uid})`, + call: `getFileDownloader(${node.uid})`, error, }; return; @@ -288,11 +273,11 @@ export class SDKDiagnosticBase { } } - private async *verifyContent(node: MaybeNode): AsyncGenerator { + private async *verifyContent(node: NodeEntity): AsyncGenerator { if (!this.isNodeWithContent(node)) { return; } - const activeRevision = getActiveRevision(node); + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; if (!activeRevision) { yield { type: 'content_file_missing_revision', @@ -307,7 +292,7 @@ export class SDKDiagnosticBase { } catch (error: unknown) { yield { type: 'sdk_error', - call: `getFileDownloader(${node.ok ? node.value.uid : node.error.uid})`, + call: `getFileDownloader(${node.uid})`, error, }; return; @@ -344,20 +329,18 @@ export class SDKDiagnosticBase { } } - private async *verifyThumbnails(node: MaybeNode): AsyncGenerator { + private async *verifyThumbnails(node: NodeEntity): AsyncGenerator { if (!this.isNodeWithContent(node)) { return; } - const nodeUid = node.ok ? node.value.uid : node.error.uid; - try { - const result = await Array.fromAsync(this.sdkClient.iterateThumbnails([nodeUid], ThumbnailType.Type1)); + const result = await Array.fromAsync(this.sdkClient.iterateThumbnails([node.uid], ThumbnailType.Type1)); if (result.length === 0) { yield { type: 'sdk_error', - call: `iterateThumbnails(${nodeUid})`, + call: `iterateThumbnails(${node.uid})`, error: new Error('No thumbnails found'), }; } @@ -372,17 +355,16 @@ export class SDKDiagnosticBase { } catch (error: unknown) { yield { type: 'sdk_error', - call: `iterateThumbnails(${nodeUid})`, + call: `iterateThumbnails(${node.uid})`, error, }; } } - private isNodeWithContent(node: MaybeNode): boolean { - const nodeType = getNodeType(node); - const isFile = nodeType === NodeType.File || nodeType === NodeType.Photo; + private isNodeWithContent(node: NodeEntity): boolean { + const isFile = node.type === NodeType.File || node.type === NodeType.Photo; - const mediaType = getMediaType(node); + const mediaType = node.mediaType; const isDocs = isProtonDocument(mediaType) || isProtonSheet(mediaType); return isFile && !isDocs; diff --git a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts index 33f712c0..d5b81153 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts @@ -1,4 +1,4 @@ -import { MaybeNode, NodeType } from '../interface'; +import { NodeEntity, NodeType } from '../interface'; import { ProtonDriveClient } from '../protonDriveClient'; import { DiagnosticOptions, @@ -7,7 +7,7 @@ import { ExpectedTreeNode, TreeNode, } from './interface'; -import { getActiveRevision, getNodeName, getNodeType, getTreeNodeChildByNodeName } from './nodeUtils'; +import { getTreeNodeChildByNodeName } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; import { zipGenerators } from './zipGenerators'; @@ -26,7 +26,7 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { } async *verifyMyFiles(expectedStructure?: ExpectedTreeNode): AsyncGenerator { - let myFilesRootFolder: MaybeNode; + let myFilesRootFolder: NodeEntity; try { myFilesRootFolder = await this.protonDriveClient.getMyFilesRootFolder(); @@ -42,7 +42,7 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { yield* this.verifyNodeTree(myFilesRootFolder, expectedStructure); } - async *verifyNodeTree(node: MaybeNode, expectedStructure?: ExpectedTreeNode): AsyncGenerator { + async *verifyNodeTree(node: NodeEntity, expectedStructure?: ExpectedTreeNode): AsyncGenerator { this.startProgress(); this.nodesQueue.push({ node, expected: expectedStructure }); this.loadedNodes++; @@ -51,10 +51,10 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { } private async *loadNodeTree( - parentNode: MaybeNode, + parentNode: NodeEntity, expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { - const isFolder = getNodeType(parentNode) === NodeType.Folder; + const isFolder = parentNode.type === NodeType.Folder; if (isFolder) { yield* this.loadNodeTreeRecursively(parentNode, expectedStructure); } @@ -62,56 +62,51 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { } private async *loadNodeTreeRecursively( - parentNode: MaybeNode, + parentNode: NodeEntity, expectedStructure?: ExpectedTreeNode, ): AsyncGenerator { - const parentNodeUid = parentNode.ok ? parentNode.value.uid : parentNode.error.uid; - const children: MaybeNode[] = []; + const children: NodeEntity[] = []; try { for await (const child of this.protonDriveClient.iterateFolderChildren(parentNode)) { children.push(child); this.nodesQueue.push({ node: child, - expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), + expected: getTreeNodeChildByNodeName(expectedStructure, child.name), }); this.loadedNodes++; } } catch (error: unknown) { yield { type: 'sdk_error', - call: `iterateFolderChildren(${parentNodeUid})`, + call: `iterateFolderChildren(${parentNode.uid})`, error, }; } if (expectedStructure) { - yield* this.verifyExpectedNodeChildren(parentNodeUid, children, expectedStructure); + yield * this.verifyExpectedNodeChildren(parentNode.uid, children, expectedStructure); } for (const child of children) { - if (getNodeType(child) === NodeType.Folder) { - yield* this.loadNodeTreeRecursively( - child, - getTreeNodeChildByNodeName(expectedStructure, getNodeName(child)), - ); + if (child.type === NodeType.Folder) { + yield* this.loadNodeTreeRecursively(child, getTreeNodeChildByNodeName(expectedStructure, child.name)); } } } - async getStructure(node: MaybeNode): Promise { - const nodeType = getNodeType(node); + async getStructure(node: NodeEntity): Promise { const treeNode: TreeNode = { - uid: node.ok ? node.value.uid : node.error.uid, - type: nodeType, - name: getNodeName(node), + uid: node.uid, + type: node.type, + name: node.name.ok ? node.name.value : 'N/A', }; - if (!node.ok) { - treeNode.error = node.error || 'degraded node'; + if (node.errors?.length) { + treeNode.error = node.errors; } - if (nodeType === NodeType.Folder) { + if (node.type === NodeType.Folder) { const children = []; for await (const child of this.protonDriveClient.iterateFolderChildren(node)) { @@ -123,8 +118,8 @@ export class SDKDiagnosticMain extends SDKDiagnosticBase { const childStructure = await this.getStructure(child); treeNode.children.push(childStructure); } - } else if (nodeType === NodeType.File) { - const activeRevision = getActiveRevision(node); + } else if (node.type === NodeType.File) { + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; treeNode.claimedSha1 = activeRevision?.claimedDigests?.sha1; treeNode.claimedSizeInBytes = activeRevision?.claimedSize; } diff --git a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts index e185da0f..dda42643 100644 --- a/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts +++ b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts @@ -1,4 +1,4 @@ -import { MaybeNode } from '../interface'; +import { NodeEntity } from '../interface'; import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; import { DiagnosticOptions, @@ -7,7 +7,7 @@ import { ExpectedTreeNode, TreeNode, } from './interface'; -import { getActiveRevision, getNodeName, getNodeType, getTreeNodeChildByNodeName } from './nodeUtils'; +import { getTreeNodeChildByNodeName } from './nodeUtils'; import { SDKDiagnosticBase } from './sdkDiagnosticBase'; import { zipGenerators } from './zipGenerators'; @@ -45,18 +45,18 @@ export class SDKDiagnosticPhotos extends SDKDiagnosticBase { }; } - const photos: MaybeNode[] = []; + const photos: NodeEntity[] = []; try { for await (const maybeMissingNode of this.protonDrivePhotosClient.iterateNodes(nodeUids)) { - if (!maybeMissingNode.ok && 'missingUid' in maybeMissingNode.error) { + if ('missingUid' in maybeMissingNode) { continue; } - const maybeNode = maybeMissingNode as MaybeNode; + const maybeNode = maybeMissingNode as NodeEntity; photos.push(maybeNode); this.nodesQueue.push({ node: maybeNode, - expected: getTreeNodeChildByNodeName(expectedStructure, getNodeName(maybeNode)), + expected: getTreeNodeChildByNodeName(expectedStructure, maybeNode.name), }); } } catch (error: unknown) { @@ -78,9 +78,9 @@ export class SDKDiagnosticPhotos extends SDKDiagnosticBase { const myPhotosRootFolder = await this.protonDrivePhotosClient.getMyPhotosRootFolder(); const treeNode: TreeNode = { - uid: myPhotosRootFolder.ok ? myPhotosRootFolder.value.uid : myPhotosRootFolder.error.uid, - type: getNodeType(myPhotosRootFolder), - name: getNodeName(myPhotosRootFolder), + uid: myPhotosRootFolder.uid, + type: myPhotosRootFolder.type, + name: myPhotosRootFolder.name.ok ? myPhotosRootFolder.name.value : 'N/A', }; const children = []; @@ -88,22 +88,22 @@ export class SDKDiagnosticPhotos extends SDKDiagnosticBase { const nodeUids = results.map((result) => result.nodeUid); for await (const maybeMissingNode of this.protonDrivePhotosClient.iterateNodes(nodeUids)) { - if (!maybeMissingNode.ok && 'missingUid' in maybeMissingNode.error) { + if ('missingUid' in maybeMissingNode) { continue; } - const node = maybeMissingNode as MaybeNode; + const node = maybeMissingNode; - const activeRevision = getActiveRevision(node); + const activeRevision = node.activeRevision?.ok ? node.activeRevision.value : undefined; const childNode: TreeNode = { - uid: node.ok ? node.value.uid : node.error.uid, - name: getNodeName(node), - type: getNodeType(node), + uid: node.uid, + name: node.name.ok ? node.name.value : 'N/A', + type: node.type, claimedSha1: activeRevision?.claimedDigests?.sha1, claimedSizeInBytes: activeRevision?.claimedSize, }; - if (!node.ok) { - childNode.error = node.error || 'degraded node'; + if (node.errors?.length) { + childNode.error = node.errors; } children.push(childNode); diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts index eb88214a..c01dfd22 100644 --- a/js/sdk/src/interface/index.ts +++ b/js/sdk/src/interface/index.ts @@ -32,10 +32,8 @@ export type { ProtonDriveHTTPClientJsonRequest, } from './httpClient'; export type { - DegradedNode, InvalidNameError, MaybeMissingNode, - MaybeNode, Membership, MissingNode, NodeEntity, @@ -46,14 +44,7 @@ export type { RevisionOrUid, } from './nodes'; export { MemberRole, NodeType, RevisionState } from './nodes'; -export type { - AlbumAttributes, - DegradedPhotoNode, - MaybeMissingPhotoNode, - MaybePhotoNode, - PhotoAttributes, - PhotoNode, -} from './photos'; +export type { AlbumAttributes, MaybeMissingPhotoNode, PhotoAttributes, PhotoNode } from './photos'; export { PhotoTag } from './photos'; export type { Result } from './result'; export { resultError, resultOk } from './result'; diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts index 5ae821ec..5ba6ab85 100644 --- a/js/sdk/src/interface/nodes.ts +++ b/js/sdk/src/interface/nodes.ts @@ -1,15 +1,6 @@ import { Author } from './author'; import { Result } from './result'; -/** - * 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, or missing node. * @@ -19,7 +10,7 @@ export type MaybeNode = Result; * 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 MaybeMissingNode = NodeEntity | MissingNode; export type MissingNode = { missingUid: string; @@ -40,7 +31,7 @@ export type MissingNode = { export type NodeEntity = { uid: string; parentUid?: string; - name: string; + name: Result; /** * Author of the node key. * @@ -108,7 +99,7 @@ export type NodeEntity = { * Total size of all revisions, encrypted size on the server. */ totalStorageSize?: number; - activeRevision?: Revision; + activeRevision?: Result; folder?: { claimedModificationTime?: Date; }; @@ -121,27 +112,6 @@ export type NodeEntity = { * 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. - */ -export type DegradedNode = Omit & { - name: Result; - activeRevision?: Result; /** * If the error is not related to any specific field, it is set here. * @@ -232,7 +202,7 @@ export enum RevisionState { Superseded = 'superseded', } -export type NodeOrUid = MaybeNode | NodeEntity | DegradedNode | string; +export type NodeOrUid = NodeEntity | string; export type RevisionOrUid = Revision | string; export type NodeResult = { uid: string; ok: true } | { uid: string; ok: false; error: Error }; diff --git a/js/sdk/src/interface/photos.ts b/js/sdk/src/interface/photos.ts index 86955678..34a3cba7 100644 --- a/js/sdk/src/interface/photos.ts +++ b/js/sdk/src/interface/photos.ts @@ -1,19 +1,11 @@ -import { DegradedNode, MissingNode, NodeEntity, NodeType } from './nodes'; -import { Result } from './result'; - -/** - * Node representing a photo or album for Photos SDK. - * - * See `MaybeNode` for more information. - */ -export type MaybePhotoNode = Result; +import { MissingNode, NodeEntity, NodeType } from './nodes'; /** * Node representing a photo or album, or missing node for Photos SDK. * * See `MaybeMissingNode` for more information. */ -export type MaybeMissingPhotoNode = Result; +export type MaybeMissingPhotoNode = PhotoNode | MissingNode; /** * Node representing a photo or album for Photos SDK. @@ -26,16 +18,6 @@ export type PhotoNode = NodeEntity & { album?: AlbumAttributes; }; -/** - * Degraded node representing a photo or album for Photos SDK. - * - * See `DegradedNode` for more information. - */ -export type DegradedPhotoNode = DegradedNode & { - photo?: PhotoAttributes; - album?: AlbumAttributes; -}; - /** * Attributes of a photo. * diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 4f63a376..0cc2a58f 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -12,8 +12,8 @@ import { Logger, MaybeBookmark, MaybeMissingNode, - MaybeNode, MemberRole, + NodeEntity, NodeOrUid, NodeResult, NodeResultWithNewUid, @@ -391,7 +391,7 @@ export class ProtonDriveClient { /** * @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.getVolumeRootFolder()); } @@ -442,7 +442,7 @@ export class ProtonDriveClient { parentNodeUid: NodeOrUid, filterOptions?: { type?: NodeType }, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); const iterator = this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), filterOptions, signal); yield* convertInternalNodeIterator(iterator); @@ -460,7 +460,7 @@ export class ProtonDriveClient { * @returns An async generator of the trashed nodes. * @deprecated Use `iterateTrashedNodeUids` instead. */ - async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } @@ -485,7 +485,7 @@ export class ProtonDriveClient { * @param nodeUid - Node entity or its UID string. * @returns The node entity. */ - 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))); } @@ -499,7 +499,7 @@ export class ProtonDriveClient { * @param nodeUid - Node entity or its UID string. * @returns The list of nodes from root to the given node. */ - async getNodeHierarchy(nodeUid: NodeOrUid): Promise { + 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); @@ -513,7 +513,7 @@ export class ProtonDriveClient { * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. * @throws {@link ValidationError} 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 ${getUid(nodeUid)}`); return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } @@ -658,7 +658,7 @@ export class ProtonDriveClient { * @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 in ${getUid(parentNodeUid)}`); return convertInternalNodePromise( this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime), @@ -743,7 +743,7 @@ export class ProtonDriveClient { * @returns An async generator of the shared nodes. * @deprecated Use `iterateSharedNodeUids` instead. */ - 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)); } @@ -761,7 +761,7 @@ export class ProtonDriveClient { * @returns An async generator of the shared nodes. * @deprecated Use `iterateSharedWithMeNodeUids` instead. */ - 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)) { diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts index 72710e38..981f1563 100644 --- a/js/sdk/src/protonDrivePhotosClient.ts +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -7,10 +7,10 @@ import { FileUploader, Logger, MaybeMissingPhotoNode, - MaybePhotoNode, NodeOrUid, NodeResult, NonProtonInvitationOrUid, + PhotoNode, PhotoTag, ProtonDriveClientContructorParameters, ProtonInvitation, @@ -290,7 +290,7 @@ export class ProtonDrivePhotosClient { /** * @returns The root folder to Photos section of the user. */ - async getMyPhotosRootFolder(): Promise { + async getMyPhotosRootFolder(): Promise { this.logger.info('Getting my photos root folder'); return convertInternalPhotoNodePromise(this.nodes.access.getVolumeRootFolder()); } @@ -329,7 +329,7 @@ export class ProtonDrivePhotosClient { * * @deprecated Use `iterateTrashedNodeUids` instead. */ - async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating trashed nodes'); yield* convertInternalPhotoNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); } @@ -350,7 +350,7 @@ 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 convertInternalPhotoNodePromise(this.nodes.access.getNode(getUid(nodeUid))); } @@ -360,7 +360,7 @@ 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 convertInternalPhotoNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); } @@ -430,7 +430,7 @@ export class ProtonDrivePhotosClient { * * @deprecated Use `iterateSharedNodeUids` instead. */ - async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { + async *iterateSharedNodes(signal?: AbortSignal): AsyncGenerator { this.logger.info('Iterating shared nodes by me'); yield* convertInternalPhotoNodeIterator(this.sharing.access.iterateSharedNodes(signal)); } @@ -442,7 +442,7 @@ export class ProtonDrivePhotosClient { * * @deprecated Use `iterateSharedWithMeNodeUids` instead. */ - 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)) { @@ -665,7 +665,7 @@ export class ProtonDrivePhotosClient { * @param name - The name for the new album. * @returns The created album node. */ - async createAlbum(name: string): Promise { + async createAlbum(name: string): Promise { this.logger.info('Creating album'); return convertInternalPhotoNodePromise(this.photos.albums.createAlbum(name)); } @@ -685,7 +685,7 @@ export class ProtonDrivePhotosClient { name?: string; coverPhotoNodeUid?: NodeOrUid; }, - ): Promise { + ): Promise { this.logger.info(`Updating album ${getUid(nodeUid)}`); const coverPhotoNodeUid = updates.coverPhotoNodeUid ? getUid(updates.coverPhotoNodeUid) : undefined; return convertInternalPhotoNodePromise( @@ -721,7 +721,7 @@ 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* convertInternalPhotoNodeIterator(this.photos.albums.iterateAlbums(signal)); diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts index 6c8b0bd1..29d45973 100644 --- a/js/sdk/src/protonDrivePublicLinkClient.ts +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -9,8 +9,8 @@ import { FileUploader, Logger, MaybeMissingNode, - MaybeNode, MemberRole, + NodeEntity, NodeOrUid, NodeResult, NodeType, @@ -87,7 +87,7 @@ 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; + createDocument: (parentNodeUid: NodeOrUid, documentName: string, documentType: 1 | 2) => Promise; /** * Experimental feature to get the session info for the public link. * @@ -220,7 +220,7 @@ export class ProtonDrivePublicLinkClient { parentNodeUid: NodeOrUid, documentName: string, documentType: 1 | 2, - ): Promise => { + ): Promise => { this.logger.debug(`Creating document in ${getUid(parentNodeUid)}`); return convertInternalNodePromise( this.sharingPublic.nodes.management.createDocument( @@ -250,7 +250,7 @@ export class ProtonDrivePublicLinkClient { /** * @returns The root folder to the public link. */ - async getRootNode(): Promise { + async getRootNode(): Promise { this.logger.info(`Getting root node`); const { rootNodeUid } = await this.sharingPublic.shares.getRootIDs(); return convertInternalNodePromise(this.sharingPublic.nodes.access.getNode(rootNodeUid)); @@ -281,7 +281,7 @@ export class ProtonDrivePublicLinkClient { parentUid: NodeOrUid, filterOptions?: { type?: NodeType }, signal?: AbortSignal, - ): AsyncGenerator { + ): AsyncGenerator { this.logger.info(`Iterating children of ${getUid(parentUid)}`); yield* convertInternalNodeIterator( this.sharingPublic.nodes.access.iterateFolderChildren(getUid(parentUid), filterOptions, signal), @@ -305,7 +305,7 @@ export class ProtonDrivePublicLinkClient { * * 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.sharingPublic.nodes.access.getNode(getUid(nodeUid))); } @@ -315,7 +315,7 @@ export class ProtonDrivePublicLinkClient { * * 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.sharingPublic.nodes.management.renameNode(getUid(nodeUid), newName)); } @@ -337,7 +337,7 @@ export class ProtonDrivePublicLinkClient { * * See `ProtonDriveClient.createFolder` for more information. */ - async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { + 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), diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts index c3c8eb87..85c3930d 100644 --- a/js/sdk/src/transformers.ts +++ b/js/sdk/src/transformers.ts @@ -1,15 +1,11 @@ import { - DegradedNode as PublicDegradedNode, - DegradedPhotoNode as PublicDegradedPhotoNode, MaybeMissingNode as PublicMaybeMissingNode, MaybeMissingPhotoNode as PublicMaybeMissingPhotoNode, - MaybeNode as PublicMaybeNode, - MaybePhotoNode as PublicMaybePhotoNode, MissingNode, + NodeEntity as PublicNode, + NodeType, PhotoNode as PublicPhotoNode, Result, - resultError, - resultOk, Revision as PublicRevision, } from './interface'; import { DecryptedNode as InternalNode, DecryptedRevision as InternalRevision } from './internal/nodes'; @@ -63,7 +59,7 @@ export function getUids(nodeUids: NodeUid[]): string[] { export async function* convertInternalNodeIterator( nodeIterator: AsyncGenerator, -): AsyncGenerator { +): AsyncGenerator { for await (const node of nodeIterator) { yield convertInternalNode(node); } @@ -74,22 +70,23 @@ export async function* convertInternalMissingNodeIterator( ): AsyncGenerator { for await (const node of nodeIterator) { if ('missingUid' in node) { - yield resultError(node); + yield node; } else { 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): PublicMaybeNode { - const baseNodeMetadata = { +export function convertInternalNode(node: InternalPartialNode): PublicNode { + return { uid: node.uid, parentUid: node.parentUid, + name: node.name, keyAuthor: node.keyAuthor, nameAuthor: node.nameAuthor, directRole: node.directRole, @@ -103,35 +100,19 @@ export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode modificationTime: node.modificationTime, trashTime: node.trashTime, totalStorageSize: node.totalStorageSize, + activeRevision: node.activeRevision?.ok + ? { ok: true, value: convertInternalRevision(node.activeRevision.value) } + : node.activeRevision, folder: node.folder, deprecatedShareId: node.shareId, treeEventScopeId: node.treeEventScopeId, + errors: node.errors, }; - - const name = node.name; - const activeRevision = node.activeRevision; - - if (node.errors?.length || !name.ok || (activeRevision && !activeRevision.ok)) { - return resultError({ - ...baseNodeMetadata, - name, - activeRevision: activeRevision?.ok - ? resultOk(convertInternalRevision(activeRevision.value)) - : activeRevision, - errors: node.errors, - } as PublicDegradedNode); - } - - return resultOk({ - ...baseNodeMetadata, - name: name.value, - activeRevision: activeRevision?.ok ? convertInternalRevision(activeRevision.value) : undefined, - }); } export async function* convertInternalPhotoNodeIterator( photoNodeIterator: AsyncGenerator, -): AsyncGenerator { +): AsyncGenerator { for await (const photoNode of photoNodeIterator) { yield convertInternalPhotoNode(photoNode); } @@ -142,7 +123,7 @@ export async function* convertInternalMissingPhotoNodeIterator( ): AsyncGenerator { for await (const photoNode of photoNodeIterator) { if ('missingUid' in photoNode) { - yield resultError(photoNode); + yield photoNode; } else { yield convertInternalPhotoNode(photoNode); } @@ -151,25 +132,22 @@ export async function* convertInternalMissingPhotoNodeIterator( export async function convertInternalPhotoNodePromise( photoNodePromise: Promise, -): Promise { +): Promise { const photoNode = await photoNodePromise; return convertInternalPhotoNode(photoNode); } -export function convertInternalPhotoNode(photoNode: InternalPartialPhotoNode): PublicMaybePhotoNode { +export function convertInternalPhotoNode(photoNode: InternalPartialPhotoNode): PublicPhotoNode { const node = convertInternalNode(photoNode); - if (node.ok) { - return resultOk({ - ...node.value, - photo: photoNode.photo, - album: photoNode.album, - } as PublicPhotoNode); + if (photoNode.type !== NodeType.Photo && photoNode.type !== NodeType.Album) { + throw new TypeError(`Invalid photo node type: ${photoNode.type}`); } - return resultError({ - ...node.error, + return { + ...node, + type: photoNode.type, photo: photoNode.photo, album: photoNode.album, - } as PublicDegradedPhotoNode); + }; } export async function* convertInternalRevisionIterator( From 6a38b9d421b8597ec65538b0a8a6c1a9c0bc1692 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 05:03:18 +0000 Subject: [PATCH 714/732] i18n(weekly-mr): Upgrade translations from crowdin (1e3edca6). --- 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 121e7a33..5cbe0c66 100644 --- a/js/sdk/locales/.locale-state.metadata +++ b/js/sdk/locales/.locale-state.metadata @@ -1,4 +1,4 @@ { "project": "fe-drive-sdk", - "locale": "a3571884f94608b8d67f20fee0002b27fd706a7f" + "locale": "8b83160991cdb992ada3f25c1720ded2dc2293c4" } \ No newline at end of file diff --git a/js/sdk/locales/be_BY.json b/js/sdk/locales/be_BY.json index 702f07af..5e7cedee 100644 --- a/js/sdk/locales/be_BY.json +++ b/js/sdk/locales/be_BY.json @@ -236,9 +236,6 @@ "Unknown error ${ code }": [ "Невядомая памылка ${ code }" ], - "Unknown error ${ response.Response.Code }": [ - "Невядомая памылка ${ response.Response.Code }" - ], "Verification keys are not available": [ "Ключы спраўджання недаступны" ], diff --git a/js/sdk/locales/ca_ES.json b/js/sdk/locales/ca_ES.json index 56210cc2..e2267b45 100644 --- a/js/sdk/locales/ca_ES.json +++ b/js/sdk/locales/ca_ES.json @@ -234,9 +234,6 @@ "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" ], diff --git a/js/sdk/locales/de_DE.json b/js/sdk/locales/de_DE.json index 2e0ef23b..b4ff7776 100644 --- a/js/sdk/locales/de_DE.json +++ b/js/sdk/locales/de_DE.json @@ -234,9 +234,6 @@ "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" ], diff --git a/js/sdk/locales/el_GR.json b/js/sdk/locales/el_GR.json index 59e3a4c4..869887a9 100644 --- a/js/sdk/locales/el_GR.json +++ b/js/sdk/locales/el_GR.json @@ -234,9 +234,6 @@ "Unknown error ${ code }": [ "Άγνωστο σφάλμα ${ code }" ], - "Unknown error ${ response.Response.Code }": [ - "Άγνωστο σφάλμα ${ response.Response.Code }" - ], "Verification keys are not available": [ "Τα κλειδιά επαλήθευσης δεν είναι διαθέσιμα" ], diff --git a/js/sdk/locales/es_ES.json b/js/sdk/locales/es_ES.json index 0dbb008e..2380b8e8 100644 --- a/js/sdk/locales/es_ES.json +++ b/js/sdk/locales/es_ES.json @@ -234,9 +234,6 @@ "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." ], diff --git a/js/sdk/locales/es_LA.json b/js/sdk/locales/es_LA.json index 97f47b56..9d888594 100644 --- a/js/sdk/locales/es_LA.json +++ b/js/sdk/locales/es_LA.json @@ -234,9 +234,6 @@ "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" ], diff --git a/js/sdk/locales/fr_FR.json b/js/sdk/locales/fr_FR.json index e7a1c176..67a6d6ad 100644 --- a/js/sdk/locales/fr_FR.json +++ b/js/sdk/locales/fr_FR.json @@ -234,9 +234,6 @@ "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." ], diff --git a/js/sdk/locales/it_IT.json b/js/sdk/locales/it_IT.json index be831aea..1bd6a8b4 100644 --- a/js/sdk/locales/it_IT.json +++ b/js/sdk/locales/it_IT.json @@ -234,9 +234,6 @@ "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" ], diff --git a/js/sdk/locales/ko_KR.json b/js/sdk/locales/ko_KR.json index 2a18de1a..75778c83 100644 --- a/js/sdk/locales/ko_KR.json +++ b/js/sdk/locales/ko_KR.json @@ -218,9 +218,6 @@ "Unknown error ${ code }": [ "알 수 없는 오류 ${ code }" ], - "Unknown error ${ response.Response.Code }": [ - "알 수 없는 오류 ${ response.Response.Code }" - ], "Verification keys are not available": [ "인증 키를 사용할 수 없습니다" ], diff --git a/js/sdk/locales/nl_NL.json b/js/sdk/locales/nl_NL.json index c202ae44..ec77cb97 100644 --- a/js/sdk/locales/nl_NL.json +++ b/js/sdk/locales/nl_NL.json @@ -234,9 +234,6 @@ "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" ], diff --git a/js/sdk/locales/pl_PL.json b/js/sdk/locales/pl_PL.json index d4b3cf11..97c22b4f 100644 --- a/js/sdk/locales/pl_PL.json +++ b/js/sdk/locales/pl_PL.json @@ -197,9 +197,6 @@ "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" ], diff --git a/js/sdk/locales/pt_BR.json b/js/sdk/locales/pt_BR.json index 39b2d54f..066b0188 100644 --- a/js/sdk/locales/pt_BR.json +++ b/js/sdk/locales/pt_BR.json @@ -219,9 +219,6 @@ "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" ], diff --git a/js/sdk/locales/pt_PT.json b/js/sdk/locales/pt_PT.json index 0b7cd641..1edb553d 100644 --- a/js/sdk/locales/pt_PT.json +++ b/js/sdk/locales/pt_PT.json @@ -159,9 +159,6 @@ "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." ], diff --git a/js/sdk/locales/ro_RO.json b/js/sdk/locales/ro_RO.json index 1cd0692a..7fdd2460 100644 --- a/js/sdk/locales/ro_RO.json +++ b/js/sdk/locales/ro_RO.json @@ -235,9 +235,6 @@ "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" ], diff --git a/js/sdk/locales/ru_RU.json b/js/sdk/locales/ru_RU.json index f6aaca60..efb7b3f4 100644 --- a/js/sdk/locales/ru_RU.json +++ b/js/sdk/locales/ru_RU.json @@ -176,9 +176,6 @@ "Unknown error ${ code }": [ "Неизвестная ошибка ${ code }" ], - "Unknown error ${ response.Response.Code }": [ - "Неизвестная ошибка ${ response.Response.Code }" - ], "Verification keys are not available": [ "Ключи проверки недоступны" ], diff --git a/js/sdk/locales/sk_SK.json b/js/sdk/locales/sk_SK.json index 9f2850c8..9525f8c9 100644 --- a/js/sdk/locales/sk_SK.json +++ b/js/sdk/locales/sk_SK.json @@ -236,9 +236,6 @@ "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" ], diff --git a/js/sdk/locales/tr_TR.json b/js/sdk/locales/tr_TR.json index 82ae1cbe..0b3c5b70 100644 --- a/js/sdk/locales/tr_TR.json +++ b/js/sdk/locales/tr_TR.json @@ -234,9 +234,6 @@ "Unknown error ${ code }": [ "Bilinmeyen hata ${ code }" ], - "Unknown error ${ response.Response.Code }": [ - "Bilinmeyen hata ${ response.Response.Code }" - ], "Verification keys are not available": [ "Doğrulama anahtarları kullanılamıyor" ], From af5ad749774c3ee35e28237154420aacda1aeeb7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 07:49:18 +0200 Subject: [PATCH 715/732] SDK method to prepare crypto material for Easy Switch import --- js/sdk/src/internal/nodes/nodesManagement.ts | 45 ++++++++++++++++++++ js/sdk/src/protonDriveClient.ts | 32 ++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/js/sdk/src/internal/nodes/nodesManagement.ts b/js/sdk/src/internal/nodes/nodesManagement.ts index f282861b..d7b40696 100644 --- a/js/sdk/src/internal/nodes/nodesManagement.ts +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -308,6 +308,51 @@ export abstract class NodesManagementBase< } } + async prepareImportFolderCryptoMaterial( + parentNodeUid: string, + folderName: string, + ): Promise<{ + encryptedName: string; + hash: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + armoredHashKey: string; + signatureEmail: string; + passphrase: string; + armoredExtendedAttributes?: string; + }> { + validateNodeName(folderName); + + 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 signingKeys = await this.nodesAccess.getNodeSigningKeys({ parentNodeUid }); + if (signingKeys.type !== 'userAddress') { + throw new Error('Import folder can only be created under a node owned by the user'); + } + + const { encryptedCrypto, keys } = await this.cryptoService.createFolder( + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + signingKeys, + folderName, + ); + + return { + encryptedName: encryptedCrypto.encryptedName, + hash: encryptedCrypto.hash, + armoredKey: encryptedCrypto.armoredKey, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + armoredHashKey: encryptedCrypto.folder.armoredHashKey, + signatureEmail: signingKeys.email, + passphrase: keys.passphrase, + armoredExtendedAttributes: encryptedCrypto.folder.armoredExtendedAttributes, + }; + } + // FIXME create test for create folder async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { validateNodeName(folderName); diff --git a/js/sdk/src/protonDriveClient.ts b/js/sdk/src/protonDriveClient.ts index 0cc2a58f..8c1047ca 100644 --- a/js/sdk/src/protonDriveClient.ts +++ b/js/sdk/src/protonDriveClient.ts @@ -130,6 +130,32 @@ export class ProtonDriveClient { * `core/v5/events/{id}` endpoint. */ processCoreEvent: (rawEvent: CoreApiEvent) => Promise; + /** + * Experimental feature to prepare the cryptographic material for an + * orphaned import folder used by the Easy Switch importer. + * + * The returned object contains all the encrypted fields needed to pass + * as the `drive.importFolder` payload when calling the importer start + * endpoint. No folder is created on the server — the caller is + * responsible for passing this data to the importer API. + * + * `passphrase` is the cleartext node passphrase (base64-encoded). + * The external importer service needs it because it has no access to + * the user's keys and cannot decrypt the encrypted passphrase itself. + * + * @param folderName - Name of the import folder (defaults to `'drive-import-'`). + */ + prepareImportFolder: (folderName?: string) => Promise<{ + encryptedName: string; + hash: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + armoredHashKey: string; + signatureEmail: string; + passphrase: string; + armoredExtendedAttributes?: string; + }>; }; constructor({ @@ -278,6 +304,12 @@ export class ProtonDriveClient { this.logger.debug(`Processing core event ${rawEvent.EventID}`); return this.events.processCoreEvent(rawEvent); }, + prepareImportFolder: async (folderName?: string) => { + const name = folderName ?? `drive-import-${new Date().toISOString()}`; + this.logger.info('Preparing import folder crypto material'); + const rootFolder = await this.nodes.access.getVolumeRootFolder(); + return this.nodes.management.prepareImportFolderCryptoMaterial(rootFolder.uid, name); + }, }; } From 386c83a8d14a9cbf6453d8ab4a4d950214a65996 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 10:30:07 +0000 Subject: [PATCH 716/732] Avoid using arrayBuffer method --- js/sdk/src/internal/download/apiService.ts | 3 ++- js/sdk/src/internal/upload/streamReader.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts index 2760acac..db3e0784 100644 --- a/js/sdk/src/internal/download/apiService.ts +++ b/js/sdk/src/internal/download/apiService.ts @@ -1,6 +1,7 @@ import { DriveAPIService, drivePaths, ObserverStream } from '../apiService'; import { batch } from '../batch'; import { makeNodeThumbnailUid, splitNodeRevisionUid, splitNodeThumbnailUid } from '../uids'; +import { readStreamToUint8Array } from '../upload/streamReader'; import { BlockMetadata } from './interface'; const BLOCKS_PAGE_SIZE = 20; @@ -99,7 +100,7 @@ export class DownloadAPIService { onProgress?.(value.length); }); const blockStream = rawBlockStream.pipeThrough(progressStream); - const encryptedBlock = new Uint8Array(await new Response(blockStream).arrayBuffer()); + const encryptedBlock = readStreamToUint8Array(blockStream); return encryptedBlock; } diff --git a/js/sdk/src/internal/upload/streamReader.ts b/js/sdk/src/internal/upload/streamReader.ts index 5615af8c..fe1b7bba 100644 --- a/js/sdk/src/internal/upload/streamReader.ts +++ b/js/sdk/src/internal/upload/streamReader.ts @@ -4,7 +4,7 @@ import { AbortError } from "../../errors"; * Reads a ReadableStream into a Uint8Array. */ export async function readStreamToUint8Array( - stream: ReadableStream>, + stream: ReadableStream>, signal?: AbortSignal, ): Promise> { const reader = stream.getReader(); From 1603bb4221db9e59ab89e0153ff771d9942bff54 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 14:04:12 +0000 Subject: [PATCH 717/732] Remove unneeded hash digest field on block listing --- cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs | 5 ----- 1 file changed, 5 deletions(-) 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 e7667853..2b2e33e2 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Api.Files; @@ -7,10 +6,6 @@ internal sealed class BlockDto { public required int Index { get; init; } - [JsonPropertyName("Hash")] - [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] - public required ReadOnlyMemory HashDigest { get; init; } - [JsonPropertyName("BareURL")] public required string BareUrl { get; init; } From 63999d359b7b054a26389fd3b7f9ff74b9848096 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 16:32:18 +0200 Subject: [PATCH 718/732] Upgrade crypto package to fix linking issues (again) and optimize deserialization of PGP blocks --- cs/Directory.Packages.props | 2 +- .../Nodes/Cryptography/NodeCrypto.cs | 26 +++++++++---------- .../Nodes/Download/RevisionReader.cs | 4 +-- .../Nodes/Upload/BlockUploader.cs | 3 ++- .../DriveApiSerializerContext.cs | 9 ++++--- .../PhotosApiSerializerContext.cs | 9 ++++--- .../Proton.Drive.Sdk/Shares/ShareCrypto.cs | 4 +-- .../Proton.Sdk/Addresses/AddressOperations.cs | 11 ++++---- .../Cryptography/IPgpArmoredBlock.cs | 13 ++++------ .../Cryptography/PgpArmoredMessage.cs | 24 ++++++++++++----- .../Cryptography/PgpArmoredPublicKey.cs | 25 +++++++++++------- .../Cryptography/PgpArmoredSecretKey.cs | 22 +++++++++++----- .../Cryptography/PgpArmoredSignature.cs | 21 ++++++++++----- cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs | 2 +- ...ase.cs => PgpArmoredBlockJsonConverter.cs} | 22 ++++------------ .../PgpArmoredMessageJsonConverter.cs | 11 -------- .../PgpArmoredPublicKeyJsonConverter.cs | 11 -------- .../PgpArmoredSecretKeyJsonConverter.cs | 11 -------- .../PgpArmoredSignatureJsonConverter.cs | 11 -------- .../ProtonApiSerializerContext.cs | 9 ++++--- 20 files changed, 114 insertions(+), 136 deletions(-) rename cs/sdk/src/Proton.Sdk/Serialization/{PgpArmoredBlockJsonConverterBase.cs => PgpArmoredBlockJsonConverter.cs} (63%) delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs delete mode 100644 cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index 677ecdd1..cb9d9cf9 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -16,7 +16,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 3b9cc87f..e0b36e2d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -100,7 +100,7 @@ public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHa { serializedExtendedAttributes = DecryptMessage( encryptedExtendedAttributes.Value, - detachedSignature: null, + detachedSignatureOrNull: null, nodeKey, authorshipClaim.GetKeyRing(nodeKey), out _, @@ -179,7 +179,7 @@ private static Result>, ProtonDriveE } private static Result UnlockNodeKey( - PgpArmoredSecretKey lockedKey, + PgpSecretKey lockedKey, Result>, ProtonDriveError> passphraseResult) { if (!passphraseResult.TryGetValueElseError(out var passphrase, out var error)) @@ -189,7 +189,7 @@ private static Result UnlockNodeKey( try { - return PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Data.Span); + return lockedKey.Unlock(passphrase.Data.Span); } catch (Exception e) { @@ -206,7 +206,7 @@ private static Result, ProtonDriveError> DecryptN { var nameUtf8Bytes = DecryptMessage( encryptedName, - detachedSignature: null, + detachedSignatureOrNull: null, parentNodeKey, authorshipClaim.GetKeyRing(parentNodeKey), out var sessionKey, @@ -235,7 +235,7 @@ private static Result>, ProtonDriveError> try { var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey, authorshipClaim); - var hashKey = DecryptMessage(encryptedHashKey, detachedSignature: null, nodeKey, verificationKeyRing, out _, out var author); + var hashKey = DecryptMessage(encryptedHashKey, detachedSignatureOrNull: null, nodeKey, verificationKeyRing, out _, out var author); return new DecryptionOutput>(hashKey, author); } catch (Exception e) @@ -258,7 +258,7 @@ private static PgpKeyRing GetContentKeyAndHashKeyVerificationKeyRing(PgpPrivateK private static Result, ProtonDriveError> DecryptContentKey( Result nodeKeyResult, ReadOnlyMemory contentKeyPacket, - PgpArmoredSignature? contentKeySignature, + PgpArmoredSignature? contentKeySignatureOrNull, AuthorshipClaim nodeAuthorshipClaim) { if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) @@ -281,8 +281,8 @@ private static Result, ProtonDriveError> Decrypt AuthorshipVerificationFailure? verificationFailure; try { - var verificationStatus = contentKeySignature is not null - ? verificationKeyRing.Verify(contentKey.Export(), contentKeySignature.Value).Status + var verificationStatus = contentKeySignatureOrNull is { } contentKeySignature + ? verificationKeyRing.Verify(contentKey.Export(), contentKeySignature.Unarmored.Span).Status : PgpVerificationStatus.NotSigned; verificationFailure = verificationStatus is not PgpVerificationStatus.Ok @@ -299,17 +299,17 @@ private static Result, ProtonDriveError> Decrypt private static ArraySegment DecryptMessage( PgpArmoredMessage encryptedMessage, - PgpArmoredSignature? detachedSignature, + PgpArmoredSignature? detachedSignatureOrNull, PgpPrivateKey decryptionKey, PgpKeyRing verificationKeyRing, out PgpSessionKey sessionKey, out AuthorshipVerificationFailure? authorshipVerificationFailure) { - sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); + sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage.Unarmored.Span); - 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); + var plaintext = detachedSignatureOrNull is { } detachedSignature + ? sessionKey.DecryptAndVerify(encryptedMessage.Unarmored.Span, detachedSignature.Unarmored.Span, verificationKeyRing, out var verificationResult) + : sessionKey.DecryptAndVerify(encryptedMessage.Unarmored.Span, verificationKeyRing, out verificationResult); authorshipVerificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok ? new AuthorshipVerificationFailure(verificationResult.Status) 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 71e44584..8045f99d 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -340,7 +340,7 @@ await _client.Api.Files.GetRevisionAsync( private async Task VerifyManifestAsync(Stream manifestStream, CancellationToken cancellationToken) { - if (_state.RevisionDto.ManifestSignature is null) + if (_state.RevisionDto.ManifestSignature is not { } manifestSignature) { return PgpVerificationStatus.NotSigned; } @@ -354,7 +354,7 @@ private async Task VerifyManifestAsync(Stream manifestStr return PgpVerificationStatus.NoVerifier; } - var verificationResult = new PgpKeyRing(verificationKeys).Verify(manifestStream, _state.RevisionDto.ManifestSignature.Value); + var verificationResult = new PgpKeyRing(verificationKeys).Verify(manifestStream, manifestSignature.Unarmored.Span); return verificationResult.Status; } 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 55ccf148..e7d42e5f 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -13,6 +13,7 @@ using Proton.Drive.Sdk.Resilience; using Proton.Drive.Sdk.Telemetry; using Proton.Sdk; +using Proton.Sdk.Cryptography; namespace Proton.Drive.Sdk.Nodes.Upload; @@ -117,7 +118,7 @@ public async ValueTask UploadContentAsync( Index = blockNumber, Size = (int)dataPacketStream.Length, HashDigest = result.Sha256Digest, - EncryptedSignature = encryptedSignature, + EncryptedSignature = new PgpArmoredMessage(encryptedSignature), VerificationOutput = new BlockVerificationOutput { Token = verificationToken.AsReadOnlyMemory() }, }, ], diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs index 3bc758d0..baacd965 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -6,6 +6,7 @@ using Proton.Drive.Sdk.Api.Shares; using Proton.Drive.Sdk.Api.Volumes; using Proton.Sdk.Api; +using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -18,10 +19,10 @@ namespace Proton.Drive.Sdk.Serialization; #endif Converters = [ - typeof(PgpArmoredMessageJsonConverter), - typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredSecretKeyJsonConverter), - typeof(PgpArmoredPublicKeyJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(VolumeCreationRequest))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs index 57a37d58..4fc04c19 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Proton.Drive.Sdk.Api.Photos; +using Proton.Sdk.Cryptography; using Proton.Sdk.Serialization; namespace Proton.Drive.Sdk.Serialization; @@ -12,10 +13,10 @@ namespace Proton.Drive.Sdk.Serialization; #endif Converters = [ - typeof(PgpArmoredMessageJsonConverter), - typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredSecretKeyJsonConverter), - typeof(PgpArmoredPublicKeyJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(PhotosVolumeCreationRequest))] diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs index 589a0de6..edc1f0a6 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -21,9 +21,9 @@ internal static class ShareCrypto 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 passphrase = new PgpPrivateKeyRing(addressKeys).Decrypt(passphraseMessage.Unarmored.Span); - var key = PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase); + var key = lockedKey.Unarmored.Unlock(passphrase); var share = new Share(shareId, rootFolderId, addressId, shareType); diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs index b8538641..37cf0048 100644 --- a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using CommunityToolkit.HighPerformance; +using Microsoft.Extensions.Logging; using Proton.Cryptography.Pgp; using Proton.Sdk.Api; using Proton.Sdk.Api.Addresses; @@ -133,7 +134,7 @@ public static async ValueTask> GetPublicKeysAsync( var publicKeyQuery = publicKeysResponse.Address.Keys .Where(x => x.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) - .Select(x => PgpPublicKey.Import(x.PublicKey)); + .Select(x => x.PublicKey.Unarmored); publicKeys.AddRange(publicKeyQuery); @@ -178,7 +179,7 @@ private static async ValueTask
    ConvertFromDtoAsync( 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); + unlockedKey = keyDto.PrivateKey.Unarmored.Unlock(passphrase.Span); } else { @@ -192,7 +193,7 @@ private static async ValueTask
    ConvertFromDtoAsync( continue; } - unlockedKey = PgpPrivateKey.ImportAndUnlock(keyDto.PrivateKey, passphrase.Value.Span); + unlockedKey = keyDto.PrivateKey.Unarmored.Unlock(passphrase.Value.Span); } unlockedKeys.Add(unlockedKey); @@ -237,7 +238,7 @@ private static ReadOnlyMemory GetAddressKeyTokenPassphrase( IReadOnlyList userKeys) { var userKeyRing = new PgpPrivateKeyRing(userKeys); - using var decryptingStream = PgpDecryptingStream.Open(token, userKeyRing, signature, userKeyRing); + using var decryptingStream = PgpDecryptingStream.Open(token.Unarmored.AsStream(), userKeyRing, signature.Unarmored, 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 ab2a557d..f2c4e5f3 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs @@ -1,13 +1,10 @@ -using CommunityToolkit.HighPerformance; +namespace Proton.Sdk.Cryptography; -namespace Proton.Sdk.Cryptography; - -internal interface IPgpArmoredBlock +internal interface IPgpArmoredBlock where T : IPgpArmoredBlock { - ReadOnlyMemory Bytes { get; } + static abstract T Create(ReadOnlySpan armoredBytes); - 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; + int GetExportRequiredBufferLength(); + int Export(Span outputBuffer); } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs index d0c5f1b7..0db99710 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs @@ -1,16 +1,26 @@ -using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredMessage(ReadOnlyMemory unarmored) : IPgpArmoredBlock { - public ReadOnlyMemory Bytes { get; } = bytes; + public ReadOnlyMemory Unarmored { get; } = unarmored; - 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 block) => block.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredMessage block) => block.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredMessage block) => block.Bytes.Span; + static PgpArmoredMessage IPgpArmoredBlock.Create(ReadOnlySpan armoredBytes) + { + return new PgpArmoredMessage(PgpArmorDecoder.Decode(armoredBytes)); + } + + int IPgpArmoredBlock.GetExportRequiredBufferLength() + { + return PgpArmorEncoder.GetMaxLengthAfterEncoding(Unarmored.Length); + } + + int IPgpArmoredBlock.Export(Span outputBuffer) + { + return PgpArmorEncoder.Encode(Unarmored.Span, PgpBlockType.Message, outputBuffer); + } } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs index e5df84e8..206bbaad 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs @@ -1,16 +1,23 @@ -using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredPublicKey(PgpPublicKey unarmored) : IPgpArmoredBlock { - public ReadOnlyMemory Bytes { get; } = bytes; + public PgpPublicKey Unarmored { get; } = unarmored; - 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 PgpArmoredPublicKey(PgpPublicKey publicKey) => new(publicKey); + public static implicit operator PgpPublicKey(PgpArmoredPublicKey block) => block.Unarmored; - 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; + static PgpArmoredPublicKey IPgpArmoredBlock.Create(ReadOnlySpan armoredBytes) + { + return new PgpArmoredPublicKey(PgpPublicKey.Import(armoredBytes, PgpEncoding.AsciiArmor)); + } + + int IPgpArmoredBlock.GetExportRequiredBufferLength() => 4096; + + int IPgpArmoredBlock.Export(Span outputBuffer) + { + return Unarmored.Export(outputBuffer, PgpEncoding.AsciiArmor); + } } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs index b80bd248..0e95c685 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSecretKey.cs @@ -1,15 +1,23 @@ -using CommunityToolkit.HighPerformance; -using Proton.Cryptography.Pgp; +using Proton.Cryptography.Pgp; namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredSecretKey(PgpSecretKey secretKey) : IPgpArmoredBlock +internal readonly struct PgpArmoredSecretKey(PgpSecretKey unarmored) : IPgpArmoredBlock { - public ReadOnlyMemory Bytes { get; } = secretKey.ToBytes(); + public PgpSecretKey Unarmored { get; } = unarmored; public static implicit operator PgpArmoredSecretKey(PgpSecretKey secretKey) => new(secretKey); + public static implicit operator PgpSecretKey(PgpArmoredSecretKey block) => block.Unarmored; - public static implicit operator Stream(PgpArmoredSecretKey block) => block.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredSecretKey block) => block.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredSecretKey block) => block.Bytes.Span; + static PgpArmoredSecretKey IPgpArmoredBlock.Create(ReadOnlySpan armoredBytes) + { + return new PgpArmoredSecretKey(PgpSecretKey.Import(armoredBytes, PgpEncoding.AsciiArmor)); + } + + int IPgpArmoredBlock.GetExportRequiredBufferLength() => 4096; + + int IPgpArmoredBlock.Export(Span outputBuffer) + { + return Unarmored.Export(outputBuffer, PgpEncoding.AsciiArmor); + } } diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs index b9281e5c..9f24056c 100644 --- a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs @@ -1,16 +1,23 @@ -using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; namespace Proton.Sdk.Cryptography; -internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpArmoredBlock +internal readonly struct PgpArmoredSignature(ReadOnlyMemory unarmored) : IPgpArmoredBlock { - public ReadOnlyMemory Bytes { get; } = bytes; + public ReadOnlyMemory Unarmored { get; } = unarmored; - 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 block) => block.Bytes.AsStream(); - public static implicit operator ReadOnlyMemory(PgpArmoredSignature block) => block.Bytes; - public static implicit operator ReadOnlySpan(PgpArmoredSignature block) => block.Bytes.Span; + static PgpArmoredSignature IPgpArmoredBlock.Create(ReadOnlySpan armoredBytes) + { + return new PgpArmoredSignature(PgpArmorDecoder.Decode(armoredBytes)); + } + + int IPgpArmoredBlock.GetExportRequiredBufferLength() => PgpArmorEncoder.GetMaxLengthAfterEncoding(Unarmored.Length); + + int IPgpArmoredBlock.Export(Span outputBuffer) + { + return PgpArmorEncoder.Encode(Unarmored.Span, PgpBlockType.Signature, outputBuffer); + } } diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs index 6393de6f..051d53ec 100644 --- a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -94,7 +94,7 @@ internal async ValueTask> GetUserKeysAsync(Cancella continue; } - var unlockedUserKey = PgpPrivateKey.ImportAndUnlock(userKey.PrivateKey.Bytes.Span, passphrase.Value.Span); + var unlockedUserKey = userKey.PrivateKey.Unarmored.Unlock(passphrase.Value.Span); unlockedKeys.Add(unlockedUserKey); } diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs similarity index 63% rename from cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs rename to cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs index 0b7e4344..ce8339f2 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverter.cs @@ -1,16 +1,13 @@ 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 abstract class PgpArmoredBlockJsonConverterBase : JsonConverter +internal sealed class PgpArmoredBlockJsonConverter : JsonConverter where T : IPgpArmoredBlock { - protected abstract PgpBlockType BlockType { get; } - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.String) @@ -21,7 +18,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial if (reader.HasUnescapedValueSpan) { - return Decode(reader.ValueSpan); + return T.Create(reader.ValueSpan); } var unescapedValueBuffer = ArrayPool.Shared.Rent(reader.GetValueLength()); @@ -30,7 +27,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial { var unescapedValueLength = reader.CopyString(unescapedValueBuffer); - return Decode(unescapedValueBuffer.AsSpan()[..unescapedValueLength]); + return T.Create(unescapedValueBuffer.AsSpan()[..unescapedValueLength]); } finally { @@ -40,11 +37,11 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - var buffer = ArrayPool.Shared.Rent(PgpArmorEncoder.GetMaxLengthAfterEncoding(value.Bytes.Length)); + var buffer = ArrayPool.Shared.Rent(value.GetExportRequiredBufferLength()); try { - var numberOfBytesWritten = PgpArmorEncoder.Encode(value, BlockType, buffer); + var numberOfBytesWritten = value.Export(buffer); writer.WriteStringValue(buffer.AsSpan()[..numberOfBytesWritten]); } @@ -53,13 +50,4 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions ArrayPool.Shared.Return(buffer); } } - - 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/PgpArmoredMessageJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs deleted file mode 100644 index b7ed872d..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/PgpArmoredPublicKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs deleted file mode 100644 index fce2f0cb..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/PgpArmoredSecretKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs deleted file mode 100644 index 34cfc5e9..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSecretKeyJsonConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Proton.Cryptography.Pgp; -using Proton.Sdk.Cryptography; - -namespace Proton.Sdk.Serialization; - -internal sealed class PgpArmoredSecretKeyJsonConverter : PgpArmoredBlockJsonConverterBase -{ - protected override PgpBlockType BlockType => PgpBlockType.PrivateKey; - - protected override PgpArmoredSecretKey CreateValue(ReadOnlyMemory bytes) => new(PgpSecretKey.Import(bytes.Span)); -} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs deleted file mode 100644 index 29783749..00000000 --- a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs index b018ef52..70267572 100644 --- a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -5,6 +5,7 @@ using Proton.Sdk.Api.Events; using Proton.Sdk.Api.Keys; using Proton.Sdk.Api.Users; +using Proton.Sdk.Cryptography; namespace Proton.Sdk.Serialization; @@ -16,10 +17,10 @@ namespace Proton.Sdk.Serialization; #endif Converters = [ - typeof(PgpArmoredMessageJsonConverter), - typeof(PgpArmoredSignatureJsonConverter), - typeof(PgpArmoredSecretKeyJsonConverter), - typeof(PgpArmoredPublicKeyJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), + typeof(PgpArmoredBlockJsonConverter), ])] #pragma warning restore SA1114, SA1118 [JsonSerializable(typeof(ApiResponse))] From 10e966884cf928608f37818104dc552ced3fcdb7 Mon Sep 17 00:00:00 2001 From: drive Date: Mon, 8 Jun 2026 07:55:41 +0200 Subject: [PATCH 719/732] Set up GitHub mirror for CLI --- js/cli/CHANGELOG.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 js/cli/CHANGELOG.md diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md deleted file mode 100644 index 8d309e7f..00000000 --- a/js/cli/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -## cli/v0.0.3 (2026-06-05) - -* Implement SHA1 verifier metrics - -## cli/v0.0.2 (2026-06-05) - -* Support escaping slash in node names -* Support command history in interactive shell -* Fix core events subscriptions - -## cli/v0.0.1 (2026-06-02) - -* Support escaping slash in node names -* Support command history in interactive shell -* Fix core events subscriptions From 61a70d4b6e5ccd6a1634bda87335d7a17f5a7d13 Mon Sep 17 00:00:00 2001 From: drive Date: Tue, 9 Jun 2026 04:37:43 +0000 Subject: [PATCH 720/732] Update changelog for cli/v0.4.2 --- js/cli/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 js/cli/CHANGELOG.md diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md new file mode 100644 index 00000000..1d29321a --- /dev/null +++ b/js/cli/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## cli/v0.4.2 (2026-06-09) + +* No changes From 8bf8997caa36b99023b303bc3f50c58bcb7050df Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Jun 2026 07:38:08 +0000 Subject: [PATCH 721/732] Do not log response data --- js/cli/src/api/apiClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/cli/src/api/apiClient.ts b/js/cli/src/api/apiClient.ts index 85390911..69b279b9 100644 --- a/js/cli/src/api/apiClient.ts +++ b/js/cli/src/api/apiClient.ts @@ -167,7 +167,7 @@ export class ApiClient { const uid = data.UID ?? this.credentials.uid; const accessToken = data.AccessToken; if (!uid || !accessToken) { - this.logger.error('Failed to refresh session: missing UID or AccessToken', data); + this.logger.error('Failed to refresh session: missing UID or AccessToken'); return false; } From 96612830b77ed899f552147f51976e4a613005c3 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Jun 2026 09:43:51 +0000 Subject: [PATCH 722/732] Add owned by in Kotlin bindings --- .../main/kotlin/me/proton/drive/sdk/entity/FileNode.kt | 1 + .../main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt | 1 + .../src/main/kotlin/me/proton/drive/sdk/entity/Node.kt | 1 + .../main/kotlin/me/proton/drive/sdk/entity/OwnedBy.kt | 6 ++++++ .../kotlin/me/proton/drive/sdk/extension/FileNode.kt | 1 + .../kotlin/me/proton/drive/sdk/extension/FolderNode.kt | 1 + .../main/kotlin/me/proton/drive/sdk/extension/OwnedBy.kt | 9 +++++++++ 7 files changed, 20 insertions(+) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/OwnedBy.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OwnedBy.kt 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 dd94dc81..40ee561a 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 @@ -12,6 +12,7 @@ data class FileNode( override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, + override val ownedBy: OwnedBy, val activeRevision: FileRevision, val totalSizeOnCloudStorage: Long, override val errors: 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 9e809703..acb162f2 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 @@ -11,5 +11,6 @@ data class FolderNode( override val trashTime: Instant?, override val nameAuthor: Result, override val author: Result, + override val ownedBy: OwnedBy, 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 40737090..2c5fbba6 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 @@ -11,5 +11,6 @@ sealed interface Node { val trashTime: Instant? val nameAuthor: Result val author: Result + val ownedBy: OwnedBy val errors: List } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/OwnedBy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/OwnedBy.kt new file mode 100644 index 00000000..013649d8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/OwnedBy.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class OwnedBy( + val email: String?, + val organization: String?, +) 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 6d9b53fc..d9fd847d 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 @@ -19,5 +19,6 @@ fun ProtonDriveSdk.FileNode.toEntity() = FileNode( author = author.toEntity(), activeRevision = activeRevision.toEntity(), totalSizeOnCloudStorage = totalSizeOnCloudStorage, + ownedBy = ownedBy.toEntity(), errors = errorsList.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 51546928..00ac6c47 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 @@ -16,5 +16,6 @@ fun ProtonDriveSdk.FolderNode.toEntity() = FolderNode( trashTime = trashTimeOrNull?.toInstant(), nameAuthor = nameAuthor.toEntity(), author = author.toEntity(), + ownedBy = ownedBy.toEntity(), errors = errorsList.map { it.toEntity() }, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OwnedBy.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OwnedBy.kt new file mode 100644 index 00000000..a0ed578b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/OwnedBy.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.OwnedBy +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.OwnedBy.toEntity() = OwnedBy( + email = takeIf { hasEmail() }?.email, + organization = takeIf { hasOrganization() }?.organization, +) From dab2ac0af8237bc7e581f488f2aff96a3a69e177 Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 10 Jun 2026 11:54:40 +0000 Subject: [PATCH 723/732] Fix false "Thumbnail not found" errors for large thumbnail batches --- cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs index 9a713608..b1e29dd0 100644 --- a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -136,12 +136,9 @@ public static async IAsyncEnumerable EnumerateThumbnailsAsync( yield return await task.ConfigureAwait(false); } - foreach (var (thumbnailId, nodeInfo) in thumbnailIds) + foreach (var thumbnailId in thumbnailIdBatch.Where(id => !processedThumbnailIds.Contains(id))) { - if (!processedThumbnailIds.Contains(thumbnailId)) - { - yield return new FileThumbnail(nodeInfo.Uid, new ProtonDriveError("Thumbnail not found")); - } + yield return new FileThumbnail(thumbnailIds[thumbnailId].Uid, new ProtonDriveError("Thumbnail not found")); } } } From 401aa8257d944b2aded2599436fde20693e9980f Mon Sep 17 00:00:00 2001 From: drive Date: Wed, 3 Jun 2026 14:45:53 +0200 Subject: [PATCH 724/732] Implement no waiting in Kotlin bindings --- .../me/proton/drive/sdk/ProtonDriveClient.kt | 9 ++++---- .../me/proton/drive/sdk/ProtonPhotosClient.kt | 7 +++--- .../drive/sdk/entity/FileDownloaderRequest.kt | 6 +++++ .../sdk/entity/FileRevisionUploaderRequest.kt | 1 + .../drive/sdk/entity/FileUploaderRequest.kt | 1 + .../sdk/entity/PhotosDownloaderRequest.kt | 6 +++++ .../drive/sdk/entity/PhotosUploaderRequest.kt | 11 +++++---- .../sdk/extension/FileDownloaderRequest.kt | 14 +++++++++++ .../sdk/extension/FileUploaderRequest.kt | 1 + .../GetFileRevisionUploaderRequest.kt | 2 +- .../sdk/extension/PhotosDownloaderRequest.kt | 14 +++++++++++ .../sdk/extension/PhotosUploaderRequest.kt | 2 +- .../sdk/internal/InteropProtonDriveClient.kt | 23 ++++++++----------- .../sdk/internal/InteropProtonPhotosClient.kt | 17 ++++++-------- .../drive/sdk/internal/JniFileDownloader.kt | 12 ++++------ .../drive/sdk/internal/JniPhotosDownloader.kt | 12 ++++------ 16 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileDownloaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosDownloaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileDownloaderRequest.kt create mode 100644 kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosDownloaderRequest.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 fdc88def..23097cda 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,14 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.flow.Flow +import me.proton.drive.sdk.entity.FileDownloaderRequest 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.Node import me.proton.drive.sdk.entity.NodeUid -import me.proton.drive.sdk.entity.RevisionUid import java.time.Instant -import kotlin.time.Duration interface ProtonDriveClient : ProtonSdkClient { suspend fun getAvailableName(parentFolderUid: NodeUid, name: String): String @@ -16,8 +15,8 @@ interface ProtonDriveClient : ProtonSdkClient { suspend fun createFolder(parentFolderUid: NodeUid, name: String, lastModification: Instant? = null): FolderNode suspend fun getMyFilesFolder(): FolderNode 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 + suspend fun downloader(request: FileDownloaderRequest): Downloader + suspend fun uploader(request: FileUploaderRequest): Uploader + suspend fun uploader(request: FileRevisionUploaderRequest): 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 850a504b..83d05847 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,14 +1,13 @@ package me.proton.drive.sdk import kotlinx.coroutines.flow.Flow -import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.PhotosDownloaderRequest import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.PhotosUploaderRequest -import kotlin.time.Duration interface ProtonPhotosClient : ProtonSdkClient { fun enumerateTimeline(): Flow - suspend fun downloader(photoUid: NodeUid, timeout: Duration): Downloader - suspend fun uploader(request: PhotosUploaderRequest, timeout: Duration): Uploader + suspend fun downloader(request: PhotosDownloaderRequest): Downloader + suspend fun uploader(request: PhotosUploaderRequest): Uploader } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileDownloaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileDownloaderRequest.kt new file mode 100644 index 00000000..3ed2918a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileDownloaderRequest.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class FileDownloaderRequest( + val revisionUid: RevisionUid, + val noWaiting: Boolean? = 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 index 88a3721e..aa03f5f3 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 @@ -6,4 +6,5 @@ data class FileRevisionUploaderRequest( val currentActiveRevisionUid: RevisionUid, val lastModificationTime: Instant?, val size: Long, + val noWaiting: Boolean? = 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 b77631c6..6c5619aa 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 @@ -10,4 +10,5 @@ data class FileUploaderRequest( val lastModificationTime: Instant?, val overrideExistingDraftByOtherClient: Boolean, val additionalMetadata: Map = emptyMap(), + val noWaiting: Boolean? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosDownloaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosDownloaderRequest.kt new file mode 100644 index 00000000..f42da909 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosDownloaderRequest.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class PhotosDownloaderRequest( + val nodeUid: NodeUid, + val noWaiting: Boolean? = 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 9e6775f9..123bc905 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 @@ -6,10 +6,11 @@ data class PhotosUploaderRequest( val name: String, val mediaType: String, val fileSize: Long, - val lastModificationTime: Instant?, // optional - val captureTime: Instant?, // optional - val mainPhotoUid: String? = null, // optional - val tags: List = emptyList(), // optional + val lastModificationTime: Instant?, + val captureTime: Instant?, + val mainPhotoUid: String? = null, + val tags: List = emptyList(), val overrideExistingDraftByOtherClient: Boolean, - val additionalMetadata: Map = emptyMap(), // optional + val additionalMetadata: Map = emptyMap(), + val noWaiting: Boolean? = null, ) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileDownloaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileDownloaderRequest.kt new file mode 100644 index 00000000..2e0de128 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileDownloaderRequest.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.FileDownloaderRequest +import proton.drive.sdk.driveClientGetFileDownloaderRequest + +internal fun FileDownloaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = driveClientGetFileDownloaderRequest { + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + this.revisionUid = this@toProtobuf.revisionUid.value + this@toProtobuf.noWaiting?.let { noWaiting = it } +} 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 25808021..1be8cf68 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 @@ -21,6 +21,7 @@ internal fun FileUploaderRequest.toProtobuf( this.utf8JsonValue = data.toByteString() } } + this@toProtobuf.noWaiting?.let { noWaiting = it } 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 e26c18f0..00b2d6d5 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.timestamp import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import proton.drive.sdk.driveClientGetFileRevisionUploaderRequest @@ -13,4 +12,5 @@ internal fun FileRevisionUploaderRequest.toProtobuf( this.currentActiveRevisionUid = this@toProtobuf.currentActiveRevisionUid.value this.size = this@toProtobuf.size this@toProtobuf.lastModificationTime?.toTimestamp()?.let { lastModificationTime = it } + this@toProtobuf.noWaiting?.let { noWaiting = it } } diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosDownloaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosDownloaderRequest.kt new file mode 100644 index 00000000..bfd31d89 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosDownloaderRequest.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.PhotosDownloaderRequest +import proton.drive.sdk.drivePhotosClientGetPhotoDownloaderRequest + +internal fun PhotosDownloaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = drivePhotosClientGetPhotoDownloaderRequest { + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + this.photoUid = this@toProtobuf.nodeUid.value + this@toProtobuf.noWaiting?.let { noWaiting = it } +} 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 3ed5ede2..7b9c4761 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 @@ -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.PhotosUploaderRequest import proton.drive.sdk.additionalMetadataProperty import proton.drive.sdk.drivePhotosClientGetPhotoUploaderRequest @@ -36,5 +35,6 @@ internal fun PhotosUploaderRequest.toProtobuf( } } overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient + this@toProtobuf.noWaiting?.let { noWaiting = it } this.cancellationTokenSourceHandle = cancellationTokenSourceHandle } 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 deb9c610..116faa47 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 @@ -2,7 +2,6 @@ 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 @@ -13,6 +12,7 @@ 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.FileDownloaderRequest import me.proton.drive.sdk.entity.FileRevisionUploaderRequest import me.proton.drive.sdk.entity.FileThumbnail import me.proton.drive.sdk.entity.FileUploaderRequest @@ -20,7 +20,6 @@ import me.proton.drive.sdk.entity.FolderNode 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 import me.proton.drive.sdk.entity.ThumbnailType import me.proton.drive.sdk.extension.toEntity import me.proton.drive.sdk.extension.toProto @@ -38,7 +37,6 @@ 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, @@ -229,18 +227,17 @@ internal class InteropProtonDriveClient internal constructor( } override suspend fun downloader( - revisionUid: RevisionUid, - timeout: Duration, - ): Downloader = withTimeout(timeout) { + request: FileDownloaderRequest, + ): Downloader { log(INFO, "downloader") - cancellationCoroutineScope { source -> + return cancellationCoroutineScope { source -> factory(JniFileDownloader()) { FileDownloader( client = this@InteropProtonDriveClient, handle = getFileDownloader( clientHandle = handle, cancellationTokenSourceHandle = source.handle, - revisionUid = revisionUid, + request = request, ), bridge = this, cancellationTokenSource = source, @@ -251,10 +248,9 @@ internal class InteropProtonDriveClient internal constructor( override suspend fun uploader( request: FileUploaderRequest, - timeout: Duration, - ): Uploader = withTimeout(timeout) { + ): Uploader { log(INFO, "fileUploader") - cancellationCoroutineScope { source -> + return cancellationCoroutineScope { source -> JniFileUploader().run { FileUploader( client = this@InteropProtonDriveClient, @@ -272,10 +268,9 @@ internal class InteropProtonDriveClient internal constructor( override suspend fun uploader( request: FileRevisionUploaderRequest, - timeout: Duration, - ): Uploader = withTimeout(timeout) { + ): Uploader { log(INFO, "fileRevisionUploader") - cancellationCoroutineScope { source -> + return cancellationCoroutineScope { source -> JniFileUploader().run { FileUploader( client = this@InteropProtonDriveClient, 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 03c3effd..1e22a7e3 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 @@ -2,7 +2,6 @@ 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 @@ -17,6 +16,7 @@ import me.proton.drive.sdk.entity.FileThumbnail 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.PhotosDownloaderRequest import me.proton.drive.sdk.entity.PhotosTimelineItem import me.proton.drive.sdk.entity.PhotosUploaderRequest import me.proton.drive.sdk.entity.ThumbnailType @@ -30,7 +30,6 @@ 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, @@ -157,18 +156,17 @@ internal class InteropProtonPhotosClient internal constructor( } override suspend fun downloader( - photoUid: NodeUid, - timeout: Duration, - ): Downloader = withTimeout(timeout) { + request: PhotosDownloaderRequest, + ): Downloader { log(INFO, "downloader") - cancellationCoroutineScope { source -> + return cancellationCoroutineScope { source -> factory(JniPhotosDownloader()) { PhotosDownloader( client = this@InteropProtonPhotosClient, handle = getPhotoDownloader( clientHandle = handle, cancellationTokenSourceHandle = source.handle, - photoUid = photoUid, + request = request, ), bridge = this, cancellationTokenSource = source, @@ -179,10 +177,9 @@ internal class InteropProtonPhotosClient internal constructor( override suspend fun uploader( request: PhotosUploaderRequest, - timeout: Duration, - ): Uploader = withTimeout(timeout) { + ): Uploader { log(INFO, "photosUploader") - cancellationCoroutineScope { source -> + return cancellationCoroutineScope { source -> JniPhotosUploader().run { PhotosUploader( client = this@InteropProtonPhotosClient, 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 faf8c01f..33b213ed 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,11 +1,11 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.entity.RevisionUid +import me.proton.drive.sdk.entity.FileDownloaderRequest 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.downloadToStreamRequest -import proton.drive.sdk.driveClientGetFileDownloaderRequest import proton.drive.sdk.fileDownloaderFreeRequest import proton.drive.sdk.request import java.nio.ByteBuffer @@ -15,13 +15,9 @@ class JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { suspend fun getFileDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, - revisionUid: RevisionUid, + request: FileDownloaderRequest, ): Long = executeOnce("create", LongResponseCallback) { - driveClientGetFileDownloader = driveClientGetFileDownloaderRequest { - this.revisionUid = revisionUid.value - this.clientHandle = clientHandle - this.cancellationTokenSourceHandle = cancellationTokenSourceHandle - } + driveClientGetFileDownloader = request.toProtobuf(clientHandle, cancellationTokenSourceHandle) } suspend fun downloadToStream( 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 8e24ff74..e3025ab3 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,12 +1,12 @@ package me.proton.drive.sdk.internal -import me.proton.drive.sdk.entity.NodeUid +import me.proton.drive.sdk.entity.PhotosDownloaderRequest 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.drivePhotosClientDownloadToStreamRequest import proton.drive.sdk.drivePhotosClientDownloaderFreeRequest -import proton.drive.sdk.drivePhotosClientGetPhotoDownloaderRequest import proton.drive.sdk.request import java.nio.ByteBuffer @@ -14,13 +14,9 @@ class JniPhotosDownloader internal constructor() : JniBaseProtonDriveSdk() { suspend fun getPhotoDownloader( clientHandle: Long, cancellationTokenSourceHandle: Long, - photoUid: NodeUid, + request: PhotosDownloaderRequest, ): Long = executeOnce("create", LongResponseCallback) { - drivePhotosClientGetPhotoDownloader = drivePhotosClientGetPhotoDownloaderRequest { - this.photoUid = photoUid.value - this.clientHandle = clientHandle - this.cancellationTokenSourceHandle = cancellationTokenSourceHandle - } + drivePhotosClientGetPhotoDownloader = request.toProtobuf(clientHandle, cancellationTokenSourceHandle) } suspend fun downloadToStream( From 44e23f75d729a62b9de3a82312ada6d3e8753be3 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Jun 2026 09:23:44 +0200 Subject: [PATCH 725/732] Fix download to existing folder on Windows --- .../fileSystem/commandFileSystemDownload.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 31bcf7e2..6ed8e56a 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -95,7 +95,7 @@ export class CommandFileSystemDownload implements Command { throw new ValidationError('Expected exactly one local path'); } const downloadRoot = assertValidDownloadRoot(resolvedLocalPaths[0]); - await mkdir(downloadRoot, { recursive: true }); + await ensureDirectory(downloadRoot); const progress = json ? undefined : createTransferProgress(); @@ -189,7 +189,7 @@ export class CommandFileSystemDownload implements Command { assertDownloadDestination(ctx.downloadRoot, parentPath); - await mkdir(parentPath, { recursive: true }); + await ensureDirectory(parentPath); while (true) { assertValidPathSegment(name); @@ -308,6 +308,21 @@ export class CommandFileSystemDownload implements Command { } } +async function ensureDirectory(dirPath: string): Promise { + try { + // mkdir should not throw when the path already exists, but there is a bug in Bun on Windows + await mkdir(dirPath, { recursive: true }); + } catch (error: unknown) { + if (!isEexistError(error)) { + throw error; + } + const st = await stat(dirPath); + if (!st.isDirectory()) { + throw error; + } + } +} + function isEexistError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST'; } From 169a12384cc6364ce6a8d1ac98942d65000df7d2 Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Jun 2026 07:44:56 +0000 Subject: [PATCH 726/732] Update changelog for js/v0.17.0 --- js/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 0378be43..38c98d3a 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## js/v0.17.0 (2026-06-11) + +* Avoid using arrayBuffer method +* SDK method to prepare crypto material for Easy Switch import +* Merge node entities +* Use error object instead of string in all NodeResults + ## js/v0.16.0 (2026-05-29) * Process automatically events converting external invitations From 86d4a0b6c19d197c8e65556c2ea3ee39b892cdcb Mon Sep 17 00:00:00 2001 From: drive Date: Thu, 11 Jun 2026 08:47:10 +0200 Subject: [PATCH 727/732] Add transfer summary --- .../fileSystem/commandFileSystemDownload.ts | 30 +++- .../fileSystem/commandFileSystemUpload.ts | 24 ++- .../commands/fileSystem/transferProgress.ts | 12 +- .../commands/fileSystem/transferQueue.test.ts | 158 ++++++++++++++---- .../src/commands/fileSystem/transferQueue.ts | 76 +++++++-- .../fileSystem/transferSummary.test.ts | 58 +++++++ .../commands/fileSystem/transferSummary.ts | 86 ++++++++++ js/cli/src/telemetry/metricHandler.ts | 6 +- 8 files changed, 389 insertions(+), 61 deletions(-) create mode 100644 js/cli/src/commands/fileSystem/transferSummary.test.ts create mode 100644 js/cli/src/commands/fileSystem/transferSummary.ts diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 6ed8e56a..9e78c933 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -22,6 +22,7 @@ import { } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; import { DownloadQueue, type QueueItemDirectory, type QueueItemFile } from './transferQueue'; +import { TransferSummary } from './transferSummary'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; @@ -97,7 +98,8 @@ export class CommandFileSystemDownload implements Command { const downloadRoot = assertValidDownloadRoot(resolvedLocalPaths[0]); await ensureDirectory(downloadRoot); - const progress = json ? undefined : createTransferProgress(); + const summary = new TransferSummary('download'); + const progress = json ? undefined : createTransferProgress(() => summary.formatProgressLine()); const conflictResolver = new TransferConflictResolver(logger, { fileStrategyChoices: FILE_DOWNLOAD_CONFLICT_STRATEGIES, @@ -108,15 +110,17 @@ export class CommandFileSystemDownload implements Command { onInteractivePromptEnd: () => progress?.resume(), }); - const downloadQueue = new DownloadQueue(logger, sdk, { + const downloadQueue = new DownloadQueue(logger, summary, sdk, { onDirectory: async (item) => { const createdPath = await this.createLocalFolder(ctx, item); if (createdPath) { await ctx.downloadQueue.enqueueRemoteFolderChildren(item.remoteNode, createdPath); + return true; } + return false; }, startFile: async (item) => { - await this.downloadFile(ctx, item); + return await this.downloadFile(ctx, item); }, }); @@ -139,6 +143,11 @@ export class CommandFileSystemDownload implements Command { await downloadQueue.processQueue(); } finally { progress?.dispose(); + summary.print({ json }); + } + + if (summary.failureCount > 0) { + throw new ValidationError(`${summary.failureCount} item(s) failed to download`); } } @@ -182,7 +191,10 @@ export class CommandFileSystemDownload implements Command { } } - private async downloadFile(ctx: DownloadContext, item: QueueItemFile<{ remoteNode: NodeEntity }>): Promise { + private async downloadFile( + ctx: DownloadContext, + item: QueueItemFile<{ remoteNode: NodeEntity }>, + ): Promise { const parentPath = path.dirname(item.localPath); let targetPath = item.localPath; let name = item.baseName; @@ -200,7 +212,7 @@ export class CommandFileSystemDownload implements Command { const choice = await ctx.conflictResolver.resolve(name, ConflictTargetKind.File); switch (choice) { case ConflictChoice.Skip: - return; + return false; case ConflictChoice.Replace: await unlink(targetPath); break; @@ -223,8 +235,8 @@ export class CommandFileSystemDownload implements Command { }; const downloader = await ctx.sdk.getFileDownloader(item.remoteNode); - await this.downloadToPath(ctx, item, downloader, targetPath, verification); - return; + const fileSize = await this.downloadToPath(ctx, item, downloader, targetPath, verification); + return fileSize; } } @@ -234,7 +246,7 @@ export class CommandFileSystemDownload implements Command { downloader: FileDownloader, localPath: string, verification: { expectedSha1?: string; sha1Verified: boolean; fileSize: number }, - ): Promise { + ): Promise { assertDownloadDestination(ctx.downloadRoot, localPath); const file = Bun.file(localPath); @@ -269,6 +281,8 @@ export class CommandFileSystemDownload implements Command { } finally { progressTracker?.onFinished(); } + + return file.size; } private async verifyDownload( diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 8eaa3fed..23ff9635 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -12,6 +12,7 @@ import { generateThumbnails } from './generateThumbnails'; import { ConflictChoice, ConflictTargetKind, TransferConflictResolver } from './transferConflictResolver'; import { createTransferProgress, TransferProgressInterface } from './transferProgress'; import { type QueueItemDirectory, type QueueItemFile, UploadQueue } from './transferQueue'; +import { TransferSummary } from './transferSummary'; const SUPPORTED_REMOTE_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.SharedWithMe]; @@ -90,7 +91,8 @@ export class CommandFileSystemUpload implements Command { const parentNode = await paths.getNode(parentPathString, SUPPORTED_REMOTE_PATH_TYPES); - const progress = json ? undefined : createTransferProgress(); + const summary = new TransferSummary('upload'); + const progress = json ? undefined : createTransferProgress(() => summary.formatProgressLine()); const conflictResolver = new TransferConflictResolver(logger, { forcedFileStrategy: fileConflictStrategy || conflictStrategy, @@ -100,15 +102,17 @@ export class CommandFileSystemUpload implements Command { onInteractivePromptEnd: () => progress?.resume(), }); - const uploadQueue = new UploadQueue(logger, { + const uploadQueue = new UploadQueue(logger, summary, { onDirectory: async (item) => { const pending = await this.createFolder(ctx, item); if (pending) { await ctx.uploadQueue.enqueueLocalDirectoryChildren(item.localPath, pending.node); + return true; } + return false; }, startFile: async (item) => { - await this.uploadFile(ctx, item); + return await this.uploadFile(ctx, item); }, }); @@ -127,6 +131,11 @@ export class CommandFileSystemUpload implements Command { await ctx.uploadQueue.processQueue(); } finally { progress?.dispose(); + summary.print({ json }); + } + + if (summary.failureCount > 0) { + throw new ValidationError(`${summary.failureCount} item(s) failed to upload`); } } @@ -170,7 +179,10 @@ export class CommandFileSystemUpload implements Command { } } - private async uploadFile(ctx: UploadContext, item: QueueItemFile<{ parentNode: NodeEntity }>): Promise { + private async uploadFile( + ctx: UploadContext, + item: QueueItemFile<{ parentNode: NodeEntity }>, + ): Promise { const expectedSha1 = await getSha1(item.localPath); const file = Bun.file(item.localPath); const metadata = { @@ -205,7 +217,7 @@ export class CommandFileSystemUpload implements Command { await controller.completion(); ctx.metrics?.reportUploadVerifierAttempt(); - return; + return file.size; } catch (error: unknown) { if (!(error instanceof NodeWithSameNameExistsValidationError)) { throw error; @@ -219,7 +231,7 @@ export class CommandFileSystemUpload implements Command { const choice = await ctx.conflictResolver.resolve(item.baseName, ConflictTargetKind.File); switch (choice) { case ConflictChoice.Skip: - return; + return false; case ConflictChoice.Merge: newRevisionForNodeUid = existingNodeUid; break; diff --git a/js/cli/src/commands/fileSystem/transferProgress.ts b/js/cli/src/commands/fileSystem/transferProgress.ts index 3ab561c4..88fbec26 100644 --- a/js/cli/src/commands/fileSystem/transferProgress.ts +++ b/js/cli/src/commands/fileSystem/transferProgress.ts @@ -17,11 +17,11 @@ interface TransferProgressItem { onFinished: () => void; } -export function createTransferProgress(): TransferProgressInterface { +export function createTransferProgress(getSummaryLine: () => string): TransferProgressInterface { if (!process.stdout.isTTY) { return noopSink; } - return new TransferProgress(); + return new TransferProgress(getSummaryLine); } const noopSink: TransferProgressInterface = { @@ -49,6 +49,8 @@ class TransferProgress implements TransferProgressInterface { private paused = false; private previousLineCount = 0; + constructor(private readonly getSummaryLine: () => string) {} + trackItem(name: string, size?: number): TransferProgressItem { const id = String(this.nextId++); this.active.set(id, { @@ -160,7 +162,8 @@ class TransferProgress implements TransferProgressInterface { } const frame = SPINNER_FRAMES[this.spinnerIndex % SPINNER_FRAMES.length]!; - const lines = + const summaryLine = `ℹ ${this.getSummaryLine()}`; + const itemLines = this.active.size === 0 ? [] : Array.from( @@ -168,8 +171,9 @@ class TransferProgress implements TransferProgressInterface { (item) => `${frame} ${getProgressPercentage(item)} ${sanitizeTerminalText(item.name)} (${formatSize(item.size, true)})`, ); + const lines = [summaryLine, ...itemLines]; - if (lines.length === 0 && this.previousLineCount === 0) { + if (itemLines.length === 0 && this.previousLineCount <= 1) { return; } diff --git a/js/cli/src/commands/fileSystem/transferQueue.test.ts b/js/cli/src/commands/fileSystem/transferQueue.test.ts index 67e5ee8d..07a532d1 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.test.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.test.ts @@ -1,9 +1,13 @@ import { Dirent } from 'node:fs'; +import { lstat, readdir } from 'node:fs/promises'; import path from 'node:path'; import { MemberRole, NodeEntity, NodeType, ProtonDriveClient, ValidationError } from '@protontech/drive-sdk'; import { getMockLogger } from '@protontech/drive-sdk/tests/logger'; +import { DownloadQueue, MAX_CONCURRENT_ITEMS, QueueItem, UploadQueue } from './transferQueue'; +import { TransferSummary } from './transferSummary'; + jest.mock('../../cli', () => jest.requireActual('../../cli/node')); jest.mock('node:fs/promises', () => ({ @@ -11,13 +15,21 @@ jest.mock('node:fs/promises', () => ({ lstat: jest.fn(), })); -import { lstat, readdir } from 'node:fs/promises'; - -import { DownloadQueue, MAX_CONCURRENT_ITEMS, QueueItem, UploadQueue } from './transferQueue'; - const readdirMock = readdir as jest.MockedFunction; const lstatMock = lstat as jest.MockedFunction; +function testSummary(): TransferSummary { + return new TransferSummary('upload'); +} + +function summaryAsJson(summary: TransferSummary) { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + summary.print({ json: true }); + const result = JSON.parse(logSpy.mock.calls[0]![0] as string); + logSpy.mockRestore(); + return result; +} + type ReaddirDirents = Awaited>; function mockFileLstatResult(dev = 100): Awaited> { @@ -84,7 +96,9 @@ function mockFileMaybe(name: string, uid: string): NodeEntity { class SeededUploadQueue extends UploadQueue { seed(items: QueueItem<{ parentNode: NodeEntity }>[]) { - this.queue.push(...items); + for (const item of items) { + this.enqueueItem(item); + } } } @@ -92,9 +106,9 @@ describe('TransferQueue (via UploadQueue.processQueue)', () => { const parent = mockFolderMaybe('parent', 'p1'); it('resolves immediately for an empty queue', async () => { - const onDirectory = jest.fn(); - const startFile = jest.fn(); - const q = new UploadQueue(getMockLogger(), { onDirectory, startFile }); + const onDirectory = jest.fn(async () => true); + const startFile = jest.fn(async () => 0); + const q = new UploadQueue(getMockLogger(), testSummary(), { onDirectory, startFile }); await q.processQueue(); expect(onDirectory).not.toHaveBeenCalled(); expect(startFile).not.toHaveBeenCalled(); @@ -106,11 +120,13 @@ describe('TransferQueue (via UploadQueue.processQueue)', () => { order.push('dir-start'); await new Promise((r) => setImmediate(r)); order.push('dir-end'); + return true; }); const startFile = jest.fn(async () => { order.push('file'); + return 0; }); - const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { onDirectory, startFile }); q.seed([ { kind: 'directory', localPath: '/a', baseName: 'a', parentNode: parent }, { kind: 'file', localPath: '/f', baseName: 'f', parentNode: parent }, @@ -124,14 +140,15 @@ describe('TransferQueue (via UploadQueue.processQueue)', () => { it('limits the number of concurrent file transfers', async () => { let inFlight = 0; let maxInFlight = 0; - const onDirectory = jest.fn(); + const onDirectory = jest.fn(async () => true); const startFile = jest.fn(async () => { inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); await new Promise((r) => setImmediate(r)); inFlight--; + return 0; }); - const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { onDirectory, startFile }); const items: QueueItem<{ parentNode: NodeEntity }>[] = []; for (let i = 0; i < 12; i++) { items.push({ @@ -147,16 +164,83 @@ describe('TransferQueue (via UploadQueue.processQueue)', () => { expect(startFile).toHaveBeenCalledTimes(12); }); + it('continues processing after a file transfer fails when summary is set', async () => { + const summary = new TransferSummary('upload'); + const onDirectory = jest.fn(async () => true); + const startFile = jest.fn(async (item: QueueItem<{ parentNode: NodeEntity }>) => { + if (item.baseName === 'bad') { + throw new Error('upload failed'); + } + return 100; + }); + const q = new SeededUploadQueue(getMockLogger(), summary, { onDirectory, startFile }); + q.seed([ + { kind: 'file', localPath: '/bad', baseName: 'bad', parentNode: parent }, + { kind: 'file', localPath: '/good', baseName: 'good', parentNode: parent }, + ]); + await q.processQueue(); + expect(startFile).toHaveBeenCalledTimes(2); + expect(summary.failureCount).toBe(1); + expect(summary.formatProgressLine()).toBe('Uploaded 1 | Failed 1 | Queued 0'); + expect(summaryAsJson(summary)).toMatchObject({ + transferredItems: 1, + transferredBytes: 100, + failedItems: 1, + failures: [{ name: 'bad', error: 'Error: upload failed' }], + }); + }); + + it('continues processing after a directory handler fails when summary is set', async () => { + const summary = new TransferSummary('upload'); + const onDirectory = jest.fn(async () => { + throw new Error('folder failed'); + }); + const startFile = jest.fn(async () => 0); + const q = new SeededUploadQueue(getMockLogger(), summary, { onDirectory, startFile }); + q.seed([ + { kind: 'directory', localPath: '/dir', baseName: 'dir', parentNode: parent }, + { kind: 'file', localPath: '/f', baseName: 'f', parentNode: parent }, + ]); + await q.processQueue(); + expect(onDirectory).toHaveBeenCalledTimes(1); + expect(startFile).toHaveBeenCalledTimes(1); + expect(summary.failureCount).toBe(1); + expect(summaryAsJson(summary).failures[0]).toMatchObject({ + name: 'dir', + error: 'Error: folder failed', + }); + }); + + it('records skipped items without counting them as successes', async () => { + const summary = new TransferSummary('upload'); + const onDirectory = jest.fn(async (): Promise => false); + const startFile = jest.fn(async (): Promise => false); + const q = new SeededUploadQueue(getMockLogger(), summary, { onDirectory, startFile }); + q.seed([ + { kind: 'directory', localPath: '/dir', baseName: 'dir', parentNode: parent }, + { kind: 'file', localPath: '/f', baseName: 'f', parentNode: parent }, + ]); + await q.processQueue(); + expect(summary.failureCount).toBe(0); + expect(summary.formatProgressLine()).toBe('Uploaded 0 | Skipped 2 | Queued 0'); + expect(summaryAsJson(summary)).toMatchObject({ + transferredItems: 0, + transferredBytes: 0, + failedItems: 0, + skippedItems: 2, + }); + }); + it('waits for all in-flight file transfers before finishing', async () => { const pending: Array<() => void> = []; - const onDirectory = jest.fn(); + const onDirectory = jest.fn(async () => true); const startFile = jest.fn( () => - new Promise((resolve) => { - pending.push(resolve); + new Promise((resolve) => { + pending.push(() => resolve(0)); }), ); - const q = new SeededUploadQueue(getMockLogger(), { onDirectory, startFile }); + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { onDirectory, startFile }); q.seed([{ kind: 'file', localPath: '/one', baseName: 'one', parentNode: parent }]); const done = q.processQueue(); await new Promise((r) => setImmediate(r)); @@ -177,9 +261,9 @@ describe('UploadQueue', () => { it('enqueueLocalPaths enqueues a file', async () => { lstatMock.mockResolvedValueOnce(mockFileLstatResult()); - const q = new SeededUploadQueue(getMockLogger(), { - onDirectory: jest.fn(), - startFile: jest.fn(), + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), }); await q.enqueueLocalPaths(['/tmp/x.txt'], parent); expect(q['queue']).toHaveLength(1); @@ -193,9 +277,9 @@ describe('UploadQueue', () => { it('enqueueLocalPaths enqueues a directory', async () => { lstatMock.mockResolvedValueOnce(mockDirLstatResult()); - const q = new SeededUploadQueue(getMockLogger(), { - onDirectory: jest.fn(), - startFile: jest.fn(), + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), }); await q.enqueueLocalPaths(['/tmp/mydir'], parent); expect(q['queue'][0]).toMatchObject({ @@ -217,7 +301,10 @@ describe('UploadQueue', () => { isBlockDevice: () => false, } as Awaited>; lstatMock.mockResolvedValue(charDev); - const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + const q = new UploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), + }); await expect(q.enqueueLocalPaths(['/dev/null'], parent)).rejects.toThrow(ValidationError); await expect(q.enqueueLocalPaths(['/dev/null'], parent)).rejects.toThrow( 'Not a regular file or directory: /dev/null', @@ -235,7 +322,10 @@ describe('UploadQueue', () => { isCharacterDevice: () => false, isBlockDevice: () => false, } as Awaited>); - const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + const q = new UploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), + }); await expect(q.enqueueLocalPaths(['/tmp/alink'], parent)).rejects.toThrow( 'Not a regular file or directory: /tmp/alink', ); @@ -252,7 +342,10 @@ describe('UploadQueue', () => { isCharacterDevice: () => false, isBlockDevice: () => false, } as Awaited>); - const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + const q = new UploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), + }); await expect(q.enqueueLocalPaths(['/weird'], parent)).rejects.toThrow('Not a regular file or directory'); }); @@ -264,9 +357,9 @@ describe('UploadQueue', () => { lstatMock.mockResolvedValueOnce(mockDirLstatResult(42)); readdirMock.mockResolvedValueOnce([dot, dotdot, child] as unknown as ReaddirDirents); lstatMock.mockResolvedValueOnce(mockFileLstatResult(42)); - const q = new SeededUploadQueue(getMockLogger(), { - onDirectory: jest.fn(), - startFile: jest.fn(), + const q = new SeededUploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), }); await q.enqueueLocalDirectoryChildren('/parent', parent); expect(readdirMock).toHaveBeenCalledWith(resolvedParent, { withFileTypes: true }); @@ -279,7 +372,10 @@ describe('UploadQueue', () => { lstatMock.mockResolvedValueOnce(mockDirLstatResult(1)); readdirMock.mockResolvedValueOnce([kid] as unknown as ReaddirDirents); lstatMock.mockResolvedValueOnce(mockDirLstatResult(2)); - const q = new UploadQueue(getMockLogger(), { onDirectory: jest.fn(), startFile: jest.fn() }); + const q = new UploadQueue(getMockLogger(), testSummary(), { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), + }); await expect(q.enqueueLocalDirectoryChildren('/parent', parent)).rejects.toThrow( 'Cannot traverse into a different file system (mount point)', ); @@ -291,9 +387,9 @@ describe('DownloadQueue', () => { const file = mockFileMaybe('remote.txt', 'rfile'); function createQueue(sdk: Pick) { - return new DownloadQueue(getMockLogger(), sdk as ProtonDriveClient, { - onDirectory: jest.fn(), - startFile: jest.fn(), + return new DownloadQueue(getMockLogger(), testSummary(), sdk as ProtonDriveClient, { + onDirectory: jest.fn(async () => true), + startFile: jest.fn(async () => 0), }); } diff --git a/js/cli/src/commands/fileSystem/transferQueue.ts b/js/cli/src/commands/fileSystem/transferQueue.ts index 0405e990..0ec3dcf3 100644 --- a/js/cli/src/commands/fileSystem/transferQueue.ts +++ b/js/cli/src/commands/fileSystem/transferQueue.ts @@ -7,6 +7,7 @@ import { Logger, NodeEntity, NodeType, ProtonDriveClient, ValidationError } from import { getName } from '../../cli'; import { sanitizePathSegmentForLocalFilesystem } from './downloadPathValidation'; import { resolveLocalPaths } from './localPath'; +import { TransferSummary } from './transferSummary'; export const MAX_CONCURRENT_ITEMS = 5; @@ -26,24 +27,36 @@ export type QueueItemFile = QueueItemBase & { export type QueueItem = QueueItemDirectory | QueueItemFile; type TransferQueueHandlers = { - onDirectory: (item: QueueItemDirectory) => Promise; - startFile: (item: QueueItemFile) => Promise; + onDirectory: (item: QueueItemDirectory) => Promise; + startFile: (item: QueueItemFile) => Promise; }; class TransferQueue { - protected queue: QueueItem[] = []; + private queue: QueueItem[] = []; private ongoingItems = new Set>(); constructor( private readonly logger: Logger, + private readonly summary: TransferSummary, private readonly handlers: TransferQueueHandlers, ) {} async processQueue(): Promise { while (this.queue.length > 0) { const item = this.queue.shift()!; + this.updateQueuedCount(); + if (item.kind === 'directory') { - await this.handlers.onDirectory(item); + try { + const completed = await this.handlers.onDirectory(item); + if (completed) { + this.recordSuccess(0); + } else { + this.recordSkip(); + } + } catch (error: unknown) { + this.recordFailure(item, error); + } continue; } @@ -51,14 +64,56 @@ class TransferQueue { this.logger.debug(`Waiting for ongoing items to finish`); await Promise.race(this.ongoingItems); } - const promise = this.handlers.startFile(item); + const promise = this.handlers + .startFile(item) + .then((result) => { + if (result === false) { + this.recordSkip(); + } else { + this.recordSuccess(result); + } + }) + .catch((error: unknown) => { + this.recordFailure(item, error); + }); this.ongoingItems.add(promise); + this.updateQueuedCount(); void promise.finally(() => { this.ongoingItems.delete(promise); + this.updateQueuedCount(); }); } await Promise.all(this.ongoingItems); } + + protected enqueueItem(item: QueueItem): void { + this.queue.push(item); + this.updateQueuedCount(); + } + + private updateQueuedCount(): void { + const queued = this.queue.length + this.ongoingItems.size; + this.summary.setQueuedCount(queued); + } + + private recordSuccess(bytes: number): void { + this.summary.recordSuccess(bytes); + } + + private recordSkip(): void { + this.summary.recordSkip(); + } + + private recordFailure(item: QueueItem, error: unknown): void { + this.summary.recordFailure(item.baseName, error, getItemUid(item)); + } +} + +function getItemUid(item: QueueItem): string | undefined { + if ('remoteNode' in item) { + return (item as QueueItem<{ remoteNode: NodeEntity }>).remoteNode.uid; + } + return undefined; } export class UploadQueue extends TransferQueue<{ parentNode: NodeEntity }> { @@ -95,9 +150,9 @@ export class UploadQueue extends TransferQueue<{ parentNode: NodeEntity }> { } const baseName = path.basename(absolutePath); if (stats.isDirectory()) { - this.queue.push({ kind: 'directory', localPath: absolutePath, parentNode, baseName }); + this.enqueueItem({ kind: 'directory', localPath: absolutePath, parentNode, baseName }); } else if (stats.isFile()) { - this.queue.push({ kind: 'file', localPath: absolutePath, parentNode, baseName }); + this.enqueueItem({ kind: 'file', localPath: absolutePath, parentNode, baseName }); } else { throw new ValidationError(`Not a regular file or directory: ${absolutePath}`); } @@ -113,10 +168,11 @@ function assertLocalPathIsUploadable(absolutePath: string, stats: Stats): void { export class DownloadQueue extends TransferQueue<{ remoteNode: NodeEntity }> { constructor( logger: Logger, + summary: TransferSummary, private readonly sdk: ProtonDriveClient, handlers: TransferQueueHandlers<{ remoteNode: NodeEntity }>, ) { - super(logger, handlers); + super(logger, summary, handlers); } async enqueueRemotePaths( @@ -145,9 +201,9 @@ export class DownloadQueue extends TransferQueue<{ remoteNode: NodeEntity }> { const absolutePath = path.resolve(localPath); const baseName = path.basename(absolutePath); if (node.type === NodeType.Folder) { - this.queue.push({ kind: 'directory', remoteNode: node, localPath: absolutePath, baseName }); + this.enqueueItem({ kind: 'directory', remoteNode: node, localPath: absolutePath, baseName }); } else if (node.type === NodeType.File) { - this.queue.push({ kind: 'file', remoteNode: node, localPath: absolutePath, baseName }); + this.enqueueItem({ kind: 'file', remoteNode: node, localPath: absolutePath, baseName }); } else { throw new ValidationError(`Unsupported node type for download: ${node.type}`); } diff --git a/js/cli/src/commands/fileSystem/transferSummary.test.ts b/js/cli/src/commands/fileSystem/transferSummary.test.ts new file mode 100644 index 00000000..9a28d7f2 --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferSummary.test.ts @@ -0,0 +1,58 @@ +import { TransferSummary } from './transferSummary'; + +function summaryAsJson(summary: TransferSummary) { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + summary.print({ json: true }); + const result = JSON.parse(logSpy.mock.calls[0]![0] as string); + logSpy.mockRestore(); + return result; +} + +describe('TransferSummary', () => { + it('records successes and failures', () => { + const summary = new TransferSummary('upload'); + summary.recordSuccess(1024); + summary.recordSuccess(2048); + summary.recordFailure('bad.txt', new Error('network error')); + summary.recordFailure('remote.txt', 'checksum mismatch', 'uid-1'); + + expect(summary.failureCount).toBe(2); + expect(summary.formatProgressLine()).toBe('Uploaded 2 | Failed 2 | Queued 0'); + expect(summaryAsJson(summary)).toEqual({ + transferredItems: 2, + transferredBytes: 3072, + skippedItems: 0, + failedItems: 2, + failures: [ + { name: 'bad.txt', error: 'Error: network error' }, + { name: 'remote.txt', nodeUid: 'uid-1', error: 'checksum mismatch' }, + ], + }); + }); + + it('formats progress line with and without failures', () => { + const downloadSummary = new TransferSummary('download'); + downloadSummary.setQueuedCount(3); + expect(downloadSummary.formatProgressLine()).toBe('Downloaded 0 | Queued 3'); + + const uploadSummary = new TransferSummary('upload'); + uploadSummary.recordSuccess(); + uploadSummary.recordFailure('bad.txt', new Error('network error')); + uploadSummary.setQueuedCount(1); + expect(uploadSummary.formatProgressLine()).toBe('Uploaded 1 | Failed 1 | Queued 1'); + }); + + it('includes skipped only when there are skipped items', () => { + const summary = new TransferSummary('upload'); + + expect(summary.formatProgressLine()).toBe('Uploaded 0 | Queued 0'); + expect(summaryAsJson(summary)).toMatchObject({ skippedItems: 0 }); + + summary.recordSkip(); + summary.recordSkip(); + summary.setQueuedCount(1); + + expect(summary.formatProgressLine()).toBe('Uploaded 0 | Skipped 2 | Queued 1'); + expect(summaryAsJson(summary)).toMatchObject({ skippedItems: 2 }); + }); +}); diff --git a/js/cli/src/commands/fileSystem/transferSummary.ts b/js/cli/src/commands/fileSystem/transferSummary.ts new file mode 100644 index 00000000..5dd1e9dc --- /dev/null +++ b/js/cli/src/commands/fileSystem/transferSummary.ts @@ -0,0 +1,86 @@ +import { formatSize, sanitizeTerminalText } from '../../cli/formatters'; + +type TransferFailure = { + name: string; + nodeUid?: string; + error: string; +}; + +export class TransferSummary { + private successCount = 0; + private transferredBytes = 0; + private skippedCount = 0; + private queuedCount = 0; + private readonly failures: TransferFailure[] = []; + + constructor(private readonly operation: 'upload' | 'download') {} + + get failureCount(): number { + return this.failures.length; + } + + setQueuedCount(count: number): void { + this.queuedCount = count; + } + + recordSuccess(bytes = 0): void { + this.successCount++; + this.transferredBytes += bytes; + } + + recordSkip(): void { + this.skippedCount++; + } + + recordFailure(name: string, error: unknown, nodeUid?: string): void { + const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + this.failures.push({ name, nodeUid, error: message }); + } + + formatProgressLine(): string { + const verb = this.operation === 'upload' ? 'Uploaded' : 'Downloaded'; + const parts = [`${verb} ${this.successCount}`]; + if (this.failures.length > 0) { + parts.push(`Failed ${this.failures.length}`); + } + if (this.skippedCount > 0) { + parts.push(`Skipped ${this.skippedCount}`); + } + parts.push(`Queued ${this.queuedCount}`); + return parts.join(' | '); + } + + print(options: { json: boolean }): void { + if (options.json) { + console.log( + JSON.stringify({ + transferredItems: this.successCount, + transferredBytes: this.transferredBytes, + skippedItems: this.skippedCount, + failedItems: this.failures.length, + failures: this.failures, + }), + ); + return; + } + + console.log('Transfer summary:'); + + const verb = this.operation === 'upload' ? 'Uploaded' : 'Downloaded'; + console.log(` ${verb}: ${this.successCount} items (${formatSize(this.transferredBytes, true)})`); + + if (this.skippedCount > 0) { + console.log(` Skipped: ${this.skippedCount} items`); + } + + if (this.failures.length > 0) { + console.log(` Failed: ${this.failures.length} items`); + for (const failure of this.failures) { + const uidPart = failure.nodeUid ? ` (${failure.nodeUid})` : ''; + console.log( + ` - ${sanitizeTerminalText(failure.name)}${uidPart}: ${sanitizeTerminalText(failure.error)}`, + ); + } + } + } +} diff --git a/js/cli/src/telemetry/metricHandler.ts b/js/cli/src/telemetry/metricHandler.ts index f92b7f24..4faaec07 100644 --- a/js/cli/src/telemetry/metricHandler.ts +++ b/js/cli/src/telemetry/metricHandler.ts @@ -121,7 +121,7 @@ class ConfiguredMetricHandler { } private onUpload(metric: MetricUploadEvent) { - if (metric.error !== 'network_error') { + if (metric.error !== 'network_error' && metric.error !== 'validation_error') { this.metrics.drive_sdk_upload_success_rate_total.increment({ volumeType: metric.volumeType, status: !metric.error ? 'success' : 'failure', @@ -147,6 +147,7 @@ class ConfiguredMetricHandler { if ( metric.error !== 'network_error' && + metric.error !== 'validation_error' && (!this.lastUploadError || this.lastUploadError.getTime() < Date.now() - REPORT_ERRORING_USERS_INTERVAL_MS) ) { this.metrics.drive_sdk_upload_erroring_users_total.increment({ @@ -171,7 +172,7 @@ class ConfiguredMetricHandler { } private onDownload(metric: MetricDownloadEvent) { - if (metric.error !== 'network_error') { + if (metric.error !== 'network_error' && metric.error !== 'validation_error') { this.metrics.drive_sdk_download_success_rate_total.increment({ volumeType: metric.volumeType, status: !metric.error ? 'success' : 'failure', @@ -197,6 +198,7 @@ class ConfiguredMetricHandler { if ( metric.error !== 'network_error' && + metric.error !== 'validation_error' && (!this.lastDownloadError || this.lastDownloadError.getTime() < Date.now() - REPORT_ERRORING_USERS_INTERVAL_MS) ) { From 846c25040e530cea5c0839d66e8f60f5f1b9b9cd Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Jun 2026 07:42:59 +0200 Subject: [PATCH 728/732] Add extra help to each command --- js/cli/src/cli/help.ts | 5 +++++ js/cli/src/cli/interface.ts | 1 + js/cli/src/cli/paths.ts | 8 ++++---- js/cli/src/commands/auth/commandAuthLogin.ts | 1 + js/cli/src/commands/auth/commandAuthLogout.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemCopy.ts | 1 + .../commands/fileSystem/commandFileSystemCreateFolder.ts | 2 ++ js/cli/src/commands/fileSystem/commandFileSystemDelete.ts | 8 +++++++- .../src/commands/fileSystem/commandFileSystemDownload.ts | 1 + .../commands/fileSystem/commandFileSystemEmptyTrash.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemInfo.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemList.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemMove.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemRename.ts | 1 + .../src/commands/fileSystem/commandFileSystemRestore.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemTrash.ts | 1 + js/cli/src/commands/fileSystem/commandFileSystemUpload.ts | 1 + js/cli/src/commands/sharing/commandInvitationAccept.ts | 1 + js/cli/src/commands/sharing/commandInvitationList.ts | 1 + js/cli/src/commands/sharing/commandInvitationReject.ts | 1 + js/cli/src/commands/sharing/commandSharingInvite.ts | 1 + js/cli/src/commands/sharing/commandSharingRemove.ts | 1 + js/cli/src/commands/sharing/commandSharingRemoveUrl.ts | 1 + js/cli/src/commands/sharing/commandSharingSetUrl.ts | 3 ++- js/cli/src/commands/sharing/commandSharingStatus.ts | 1 + 25 files changed, 40 insertions(+), 6 deletions(-) diff --git a/js/cli/src/cli/help.ts b/js/cli/src/cli/help.ts index 70773062..72e69d3f 100644 --- a/js/cli/src/cli/help.ts +++ b/js/cli/src/cli/help.ts @@ -52,6 +52,11 @@ export function printCommandUsage(command: Command) { console.log('Usage:'); printCommandSynopsisLine(command); + if (command.help) { + console.log(''); + wrapText(command.help, 80).forEach((line) => console.log(line)); + } + const opts = visibleOptions(command); if (opts.length) { console.log(''); diff --git a/js/cli/src/cli/interface.ts b/js/cli/src/cli/interface.ts index 10c4e2ed..20f2ea49 100644 --- a/js/cli/src/cli/interface.ts +++ b/js/cli/src/cli/interface.ts @@ -12,6 +12,7 @@ import { Paths } from './paths'; export interface Command { group: string; name: string; + help?: string; isAuthAction?: boolean; isPublicAction?: boolean; args?: string[]; diff --git a/js/cli/src/cli/paths.ts b/js/cli/src/cli/paths.ts index 0ef14842..6163ef0a 100644 --- a/js/cli/src/cli/paths.ts +++ b/js/cli/src/cli/paths.ts @@ -63,7 +63,7 @@ export class Paths { return nodes; } - getPaths(pathStrings: string[], supportedTypes?: PathType[]): Path[] { + getPaths(pathStrings: string[], supportedTypes?: PathType[], unsupportedTypeMessage?: string): Path[] { let pathType: PathType | undefined; if (pathStrings.length === 0) { @@ -72,7 +72,7 @@ export class Paths { const paths = []; for (const pathString of pathStrings) { - const path = this.getPath(pathString, supportedTypes); + const path = this.getPath(pathString, supportedTypes, unsupportedTypeMessage); if (pathType === undefined) { pathType = path.type; @@ -91,10 +91,10 @@ export class Paths { return await path.getNode(); } - getPath(path: string, supportedTypes?: PathType[]): Path { + getPath(path: string, supportedTypes?: PathType[], unsupportedTypeMessage?: string): Path { const p = new Path(this.sdk, this.photosSdk, path, this.eventsManager); if (supportedTypes && !supportedTypes.includes(p.type)) { - throw new ValidationError(`Path "${path}" is not supported`); + throw new ValidationError(unsupportedTypeMessage || `Path "${path}" is not supported`); } return p; } diff --git a/js/cli/src/commands/auth/commandAuthLogin.ts b/js/cli/src/commands/auth/commandAuthLogin.ts index 28c6e6e8..804a6b1c 100644 --- a/js/cli/src/commands/auth/commandAuthLogin.ts +++ b/js/cli/src/commands/auth/commandAuthLogin.ts @@ -4,6 +4,7 @@ import { type ActionArgs, type Command, openBrowserUrl, sanitizeTerminalText } f export class CommandAuthLogin implements Command { group = 'auth'; name = 'login'; + help = 'Opens a browser to sign in. Keep the terminal open until authentication completes.'; isAuthAction = true; async action({ auth, eventsManager, options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/auth/commandAuthLogout.ts b/js/cli/src/commands/auth/commandAuthLogout.ts index 8bc07666..1f3e8513 100644 --- a/js/cli/src/commands/auth/commandAuthLogout.ts +++ b/js/cli/src/commands/auth/commandAuthLogout.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command } from '../../cli'; export class CommandAuthLogout implements Command { group = 'auth'; name = 'logout'; + help = 'Signs out and clears local credentials and caches.'; isAuthAction = true; async action({ auth, clearCaches }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts index 0b2dced3..e4794d36 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCopy.ts @@ -16,6 +16,7 @@ const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.Share export class CommandFileSystemCopy implements Command { group = 'filesystem'; name = 'copy'; + help = 'Copies files and folders. You can copy across My files, devices and shared folders.'; args = ['sourcePath...', 'targetParentPath']; options: Options = { name: { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts index c06ff0a1..517c34ac 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemCreateFolder.ts @@ -3,6 +3,8 @@ import { type ActionArgs, type Command, PathType, printObject } from '../../cli' export class CommandFileSystemCreateFolder implements Command { group = 'filesystem'; name = 'create-folder'; + help = + 'You can create folders in your root folder (/my-files), devices (/devices) or in a shared folder (/shared-with-me).'; args = ['parentPath', 'name']; async action({ sdk, paths, args: [pathString, name], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts index 5154c2f9..39c04582 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDelete.ts @@ -5,10 +5,16 @@ const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; export class CommandFileSystemDelete implements Command { group = 'filesystem'; name = 'delete'; + help = + 'Permanently deletes trashed items only. First trash your files and then use this command to delete them permanently.'; args = ['path...']; async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { - const nodePaths = paths.getPaths(pathStrings, SUPPORTED_PATH_TYPES); + const nodePaths = paths.getPaths( + pathStrings, + SUPPORTED_PATH_TYPES, + `You can permanently delete items only from trash. Trash your files first.`, + ); const nodes = await paths.getNodes(pathStrings, SUPPORTED_PATH_TYPES); await printIterable(nodePaths[0].sdk.deleteNodes(nodes), json, (result) => { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts index 9e78c933..e7e3a586 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemDownload.ts @@ -46,6 +46,7 @@ type DownloadContext = { export class CommandFileSystemDownload implements Command { group = 'filesystem'; name = 'download'; + help = 'Downloads files and folders. It prompts for conflict resolution unless a strategy option is set.'; args = ['path...', 'localFolder']; options: Options = { 'conflict-strategy': { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts index 0d149f5d..ba1a73eb 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemEmptyTrash.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command } from '../../cli'; export class CommandFileSystemEmptyTrash implements Command { group = 'filesystem'; name = 'empty-trash'; + help = 'Permanently deletes all items in /trash. Does not affect /photos-trash. The operation is asynchronous.'; async action({ sdk, options: { json } }: ActionArgs) { await sdk.emptyTrash(); diff --git a/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts index 409d27cc..964788a5 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemInfo.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandFileSystemInfo implements Command { group = 'filesystem'; name = 'info'; + help = 'Shows full node metadata including latest revision details.'; args = ['path']; async action({ paths, args: [pathString], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemList.ts b/js/cli/src/commands/fileSystem/commandFileSystemList.ts index af95b956..a2b87de9 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemList.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemList.ts @@ -20,6 +20,7 @@ import { export class CommandFileSystemList implements Command { group = 'filesystem'; name = 'list'; + help = 'Use / to list top-level sections. Your root folder is /my-files.'; args = ['path']; options: Options = { type: { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts index 45ca65ac..cf28237a 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemMove.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemMove.ts @@ -7,6 +7,7 @@ const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices]; export class CommandFileSystemMove implements Command { group = 'filesystem'; name = 'move'; + help = 'Moves files and folders. You can move across My files and devices.'; args = ['sourcePath...', 'targetParentPath']; async action({ sdk, paths, args, options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRename.ts b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts index b14b0956..b8dc12d9 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRename.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRename.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandFileSystemRename implements Command { group = 'filesystem'; name = 'rename'; + help = 'Renames a node in place. Does not move it to a different folder.'; args = ['path', 'newName']; async action({ paths, args: [pathString, newName], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts index 23be2ae0..41395e99 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemRestore.ts @@ -5,6 +5,7 @@ const SUPPORTED_PATH_TYPES = [PathType.Trash, PathType.PhotosTrash]; export class CommandFileSystemRestore implements Command { group = 'filesystem'; name = 'restore'; + help = 'Restores trashed items.'; args = ['path...']; async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts index 00e30fdd..69001fbb 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemTrash.ts @@ -5,6 +5,7 @@ const SUPPORTED_PATH_TYPES = [PathType.MyFiles, PathType.Devices, PathType.Photo export class CommandFileSystemTrash implements Command { group = 'filesystem'; name = 'trash'; + help = 'Moves items to trash. Does not permanently delete; use delete or empty-trash for that.'; args = ['path...']; async action({ paths, args: pathStrings, options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts index 23ff9635..93b6ab36 100644 --- a/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts +++ b/js/cli/src/commands/fileSystem/commandFileSystemUpload.ts @@ -29,6 +29,7 @@ type UploadContext = { export class CommandFileSystemUpload implements Command { group = 'filesystem'; name = 'upload'; + help = 'Uploads files and folders. It prompts for conflict resolution unless a strategy option is set.'; args = ['localPath...', 'parentPath']; options: Options = { 'conflict-strategy': { diff --git a/js/cli/src/commands/sharing/commandInvitationAccept.ts b/js/cli/src/commands/sharing/commandInvitationAccept.ts index 16e238d6..df649497 100644 --- a/js/cli/src/commands/sharing/commandInvitationAccept.ts +++ b/js/cli/src/commands/sharing/commandInvitationAccept.ts @@ -4,6 +4,7 @@ import { parseInvitationUid } from './invitations'; export class CommandInvitationAccept implements Command { group = 'invitation'; name = 'accept'; + help = 'Accepts a pending invitation. Get the UID from the invitation list command.'; args = ['invitationUid']; async action({ sdk, photosSdk, args: [invitationUid], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/sharing/commandInvitationList.ts b/js/cli/src/commands/sharing/commandInvitationList.ts index 122391bf..475c430a 100644 --- a/js/cli/src/commands/sharing/commandInvitationList.ts +++ b/js/cli/src/commands/sharing/commandInvitationList.ts @@ -14,6 +14,7 @@ import { getInvitationUid } from './invitations'; export class CommandInvitationList implements Command { group = 'invitation'; name = 'list'; + help = 'Lists pending invitations from Drive and Photos.'; async action({ sdk, photosSdk, options: { json } }: ActionArgs) { await printIterable( diff --git a/js/cli/src/commands/sharing/commandInvitationReject.ts b/js/cli/src/commands/sharing/commandInvitationReject.ts index 6061ddfd..2b82f22d 100644 --- a/js/cli/src/commands/sharing/commandInvitationReject.ts +++ b/js/cli/src/commands/sharing/commandInvitationReject.ts @@ -4,6 +4,7 @@ import { parseInvitationUid } from './invitations'; export class CommandInvitationReject implements Command { group = 'invitation'; name = 'reject'; + help = 'Rejects a pending invitation. Get the UID from the invitation list command.'; args = ['invitationUid']; async action({ sdk, photosSdk, args: [invitationUid], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/sharing/commandSharingInvite.ts b/js/cli/src/commands/sharing/commandSharingInvite.ts index a9514954..641067da 100644 --- a/js/cli/src/commands/sharing/commandSharingInvite.ts +++ b/js/cli/src/commands/sharing/commandSharingInvite.ts @@ -5,6 +5,7 @@ import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingInvite implements Command { group = 'sharing'; name = 'invite'; + help = 'Invites users by email. Updates sharing settings if the node is already shared with given users.'; args = ['path']; options: Options = { user: { diff --git a/js/cli/src/commands/sharing/commandSharingRemove.ts b/js/cli/src/commands/sharing/commandSharingRemove.ts index d4449fe5..f6c04d9c 100644 --- a/js/cli/src/commands/sharing/commandSharingRemove.ts +++ b/js/cli/src/commands/sharing/commandSharingRemove.ts @@ -6,6 +6,7 @@ import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingRemove implements Command { group = 'sharing'; name = 'remove'; + help = 'Removes user access or pending invitations by email.'; args = ['path']; options: Options = { email: { diff --git a/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts b/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts index 9ff8adcd..e2ed8a82 100644 --- a/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts +++ b/js/cli/src/commands/sharing/commandSharingRemoveUrl.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandSharingRemoveUrl implements Command { group = 'sharing'; name = 'remove-url'; + help = 'Removes the public link. Direct member access is not affected.'; args = ['path']; async action({ paths, args: [pathString], options: { json } }: ActionArgs) { diff --git a/js/cli/src/commands/sharing/commandSharingSetUrl.ts b/js/cli/src/commands/sharing/commandSharingSetUrl.ts index 71c07567..94f8c54a 100644 --- a/js/cli/src/commands/sharing/commandSharingSetUrl.ts +++ b/js/cli/src/commands/sharing/commandSharingSetUrl.ts @@ -5,12 +5,13 @@ import { type ActionArgs, type Command, Options, printObject } from '../../cli'; export class CommandSharingSetUrl implements Command { group = 'sharing'; name = 'set-url'; + help = 'Creates or updates a public link for the node.'; args = ['path']; options: Options = { role: { type: 'string', default: 'viewer', - allowedValues: Object.values(MemberRole), + allowedValues: [MemberRole.Viewer, MemberRole.Editor], help: 'Role of the users to invite.', }, password: { diff --git a/js/cli/src/commands/sharing/commandSharingStatus.ts b/js/cli/src/commands/sharing/commandSharingStatus.ts index aa258f3a..a2badbb7 100644 --- a/js/cli/src/commands/sharing/commandSharingStatus.ts +++ b/js/cli/src/commands/sharing/commandSharingStatus.ts @@ -3,6 +3,7 @@ import { type ActionArgs, type Command, printObject } from '../../cli'; export class CommandSharingStatus implements Command { group = 'sharing'; name = 'status'; + help = 'Shows members, pending invitations, and public link settings for a node.'; args = ['path']; async action({ paths, args: [pathString], options: { json } }: ActionArgs) { From 74fce6f01dfb43d78554d13fd8dd8087826208a2 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Jun 2026 09:03:08 +0000 Subject: [PATCH 729/732] Update changelog for cli/v0.4.4 --- js/cli/CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/js/cli/CHANGELOG.md b/js/cli/CHANGELOG.md index 1d29321a..9389b754 100644 --- a/js/cli/CHANGELOG.md +++ b/js/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## cli/v0.4.4 (2026-06-12) + +* Add extra help to each command +* Add transfer summary +* Fix download to existing folder on Windows + +## cli/v0.4.3 (2026-06-10) + +* Do not log response data + ## cli/v0.4.2 (2026-06-09) -* No changes +* Do not log response data From 381bb4c2a34725498c8c24dafe729e55bcff8324 Mon Sep 17 00:00:00 2001 From: drive Date: Fri, 12 Jun 2026 15:25:02 +0200 Subject: [PATCH 730/732] Upgrade cryptography package --- cs/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props index cb9d9cf9..d04ff283 100644 --- a/cs/Directory.Packages.props +++ b/cs/Directory.Packages.props @@ -16,7 +16,7 @@ - + From 721053935bdefc74644f0514ba71f6adf1e978b0 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Fri, 12 Jun 2026 14:43:34 +0000 Subject: [PATCH 731/732] add --no-compile flag and build:bundle script allows building a plain js bundle without embedding the bun runtime, useful for distribution packaging where bun is a system dependency --- js/cli/package.json | 1 + js/cli/scripts/build-cli.mjs | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/js/cli/package.json b/js/cli/package.json index b06dc573..4ce7901c 100644 --- a/js/cli/package.json +++ b/js/cli/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "bun ./scripts/build-cli.mjs main", "build:internal": "bun ./scripts/build-cli.mjs internal", + "build:bundle": "bun ./scripts/build-cli.mjs main bun --no-compile", "check-types": "tsc --noEmit", "generate-types": "bunx openapi-typescript ../../api/openapi-core.json -o ./src/api/api-core-types.ts && bunx openapi-typescript ../../api/openapi-auth.json -o ./src/api/api-auth-types.ts", "generate-metric-schemas": "bun ./scripts/generate-metric-schemas.ts", diff --git a/js/cli/scripts/build-cli.mjs b/js/cli/scripts/build-cli.mjs index 68107e11..087433dd 100644 --- a/js/cli/scripts/build-cli.mjs +++ b/js/cli/scripts/build-cli.mjs @@ -8,20 +8,24 @@ const GIT_ROOT = path.resolve(import.meta.dir, '../../..'); const mode = process.argv[2] === 'internal' ? 'internal' : 'main'; const target = process.argv[3] || 'bun'; +const noCompile = process.argv.includes('--no-compile'); let outfileArchitecture = ''; if (target.startsWith('bun-')) { outfileArchitecture = '/' + target.slice(4); } const entry = mode === 'internal' ? 'src/internal-cli/proton-drive-internal.ts' : 'src/proton-drive.ts'; -const outfile = mode === 'internal' ? `release${outfileArchitecture}/proton-drive-internal` : `release${outfileArchitecture}/proton-drive`; +const outfileName = mode === 'internal' ? 'proton-drive-internal' : 'proton-drive'; +const outfile = `release${outfileArchitecture}/${outfileName}${noCompile ? '.js' : ''}`; const args = [ 'build', - '--compile', + ...(!noCompile ? [ + '--compile', + // Slower compile, bigger bundle size, faster execution. + '--bytecode', + ] : []), `--target=${target}`, - // Slower compile, bigger bundle size, faster execution. - '--bytecode', // Use modern ESM format to allow await syntax in the entry file. '--format=esm', // Reduce bundle size (not much, the biggest part is the embedded Bun itself). From 1ce0d158b8af59d34636c4e9c729134f492dd612 Mon Sep 17 00:00:00 2001 From: Ralph Torres Date: Fri, 12 Jun 2026 14:45:28 +0000 Subject: [PATCH 732/732] docs: document build:bundle in readme --- js/cli/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/js/cli/README.md b/js/cli/README.md index 50a4c34c..a13cec69 100644 --- a/js/cli/README.md +++ b/js/cli/README.md @@ -28,6 +28,15 @@ bun run build This produces a standalone executable at **`release/proton-drive`** with embedded Bun. Add that directory to your `PATH`, or invoke it with a full path. +To build a plain JS bundle using a system-installed Bun instead: + +```bash +cd ../sdk && bun install +cd ../cli && bun run build:bundle +``` + +This produces `release/proton-drive.js`. You are responsible for providing a wrapper to invoke it, e.g. `exec bun /path/to/proton-drive.js "$@"`. + ## Authentication Sign-in uses the browser (no password on the command line):