diff --git a/.gitignore b/.gitignore index 34bccf99..ea5bd5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,38 @@ +# macOS +.DS_Store + # Docs -public +__pycache__ +docs/build +/public +js/public +doc # JS node_modules .eslintcache tsconfig.tsbuildinfo +dist + +# JS CLI +js/cli/release +auth.txt +auth-session.json +cache*.sqlite +events.json +events.lock +clientUid.json +*.log +*.bun-build +config.json +*.map + +# IDEs +.vs +.vscode +.idea +*.swp + +# Tests +tests/storage +tests/test-results diff --git a/LICENSE.md b/LICENSE.md index 7b7ce730..8f8e23f2 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,3 +1,21 @@ -# License +The MIT License -TBD +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 1726f757..3fd5f131 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,102 @@ -# Drive SDK +# 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 the following languages: -TBD +- **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** — 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. -## Contributions +### Who this is for -Contributions are not accepted at the moment. +| 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 + +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**: + +- **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 requirements below. + +### Operational requirements + +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. + +| 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. | + +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). + +- **`{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`). + +Examples: + +- `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 + +The SDK provides functionality for Proton Drive business logic only. It does **not** include: + +- Authentication or login flows +- Session management +- User address provider + +**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-docs +``` + +## 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 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/.editorconfig b/cs/.editorconfig new file mode 100644 index 00000000..70312fa4 --- /dev/null +++ b/cs/.editorconfig @@ -0,0 +1,210 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +[**/obj/**.cs] +generated_code = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +insert_final_newline = true + +#### .NET Coding Conventions #### + +# Make build follow IDE severities +dotnet_analyzer_diagnostic.severity = default + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# Namespace declarations +csharp_style_namespace_declarations = file_scoped:warning + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = false +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +roslynator_max_line_length = 160 + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/cs/.gitignore b/cs/.gitignore new file mode 100644 index 00000000..875100c3 --- /dev/null +++ b/cs/.gitignore @@ -0,0 +1,362 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ar]tifacts/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +# *.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Artifacts folder +/artifacts + +# vcpkg +vcpkg_installed/ + +# Visual Studio project launch settings +**/Properties/launchSettings.json + +build/output +.vscode + +# DocFX +docfx/api/ +docfx/_site/ +.tools/ + +# macOS +*.DS_Store +.AppleDouble +.LSOverride diff --git a/cs/.globalconfig b/cs/.globalconfig new file mode 100644 index 00000000..cb95dd6e --- /dev/null +++ b/cs/.globalconfig @@ -0,0 +1,143 @@ +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 + +# 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 + +# 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 + +# 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 +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpecialRules.md + +dotnet_diagnostic.SA0001.severity = none + +# StyleCop - Spacing +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpacingRules.md + +dotnet_diagnostic.SA1009.severity = suggestion + +# StyleCop - Readability +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/ReadabilityRules.md + +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.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 + +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1602.severity = none +dotnet_diagnostic.SA1604.severity = none +dotnet_diagnostic.SA1605.severity = none +dotnet_diagnostic.SA1606.severity = none +dotnet_diagnostic.SA1607.severity = none +dotnet_diagnostic.SA1608.severity = none +dotnet_diagnostic.SA1610.severity = none +dotnet_diagnostic.SA1611.severity = none +dotnet_diagnostic.SA1612.severity = none +dotnet_diagnostic.SA1613.severity = none +dotnet_diagnostic.SA1614.severity = none +dotnet_diagnostic.SA1615.severity = none +dotnet_diagnostic.SA1616.severity = none +dotnet_diagnostic.SA1617.severity = none +dotnet_diagnostic.SA1618.severity = none +dotnet_diagnostic.SA1619.severity = none +dotnet_diagnostic.SA1620.severity = none +dotnet_diagnostic.SA1621.severity = none +dotnet_diagnostic.SA1622.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1624.severity = none +dotnet_diagnostic.SA1625.severity = none +dotnet_diagnostic.SA1626.severity = none +dotnet_diagnostic.SA1627.severity = none +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1634.severity = none +dotnet_diagnostic.SA1635.severity = none +dotnet_diagnostic.SA1636.severity = none +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1640.severity = none +dotnet_diagnostic.SA1641.severity = none +dotnet_diagnostic.SA1642.severity = none +dotnet_diagnostic.SA1643.severity = none +dotnet_diagnostic.SA1648.severity = none + +# StyleCop - Alternative +# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/AlternativeRules.md + +dotnet_diagnostic.SX1101.severity = warning +dotnet_diagnostic.SX1309.severity = warning + +# 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 + +# 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 + +# Sonar + +# 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 diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md new file mode 100644 index 00000000..32fd4ea7 --- /dev/null +++ b/cs/CHANGELOG.md @@ -0,0 +1,572 @@ +# 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 +* 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 +* Fix error mapping for decryption + +## 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 +* 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 +* Make cryptography time monotonic +* Optional AccountClientProtocol + interop nil handling + +## 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 +* 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 +* 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 +* Log error when volume type is unknown + +## 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 +* 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 +* 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 + +## 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 +* 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 +* 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 +* 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 + +## 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 + +## 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 +* Report checksum verification state to interop + +## 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 +* 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 +* 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 +* 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 +* 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 + +## 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 + +## 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 +* 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 +* 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 +* 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 +* 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 + +## cs/v0.7.0-alpha.6 (2026-02-10) + +* Add SHA1 upload verification + +## cs/v0.7.0-alpha.5 (2026-02-05) + +* 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 +* 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 +* 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 +* 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) + +* 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 +* Remove debug log with fatal level + +## cs/v0.6.0-test.2 (2025-12-04) + +* No changes + +## 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 +* 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 +* 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 +* 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) + +* 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 diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props new file mode 100644 index 00000000..2b73b042 --- /dev/null +++ b/cs/Directory.Build.props @@ -0,0 +1,92 @@ + + + + net10.0 + true + true + false + true + + + true + + Proton Drive + Proton AG + Proton AG + 0.0.1 + © 2025 Proton AG + Proton Drive + + enable + enable + en + true + true + + lib + + + + embedded + true + + + + false + + + + Exe + true + true + true + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets new file mode 100644 index 00000000..875a2046 --- /dev/null +++ b/cs/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + $(NETCoreSdkRuntimeIdentifier) + $(DefineConstants);WINDOWS + lib + + + diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props new file mode 100644 index 00000000..512b1897 --- /dev/null +++ b/cs/Directory.Packages.props @@ -0,0 +1,31 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cs/Proton.Drive.Sdk.slnx b/cs/Proton.Drive.Sdk.slnx new file mode 100644 index 00000000..dcdc2765 --- /dev/null +++ b/cs/Proton.Drive.Sdk.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cs/headers/module.modulemap b/cs/headers/module.modulemap new file mode 100644 index 00000000..345cd13c --- /dev/null +++ b/cs/headers/module.modulemap @@ -0,0 +1,4 @@ +module CProtonDriveSDK { + umbrella header "proton_drive_sdk.h" + export * +} diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h new file mode 100644 index 00000000..3af0c726 --- /dev/null +++ b/cs/headers/proton_drive_sdk.h @@ -0,0 +1,15 @@ +#ifndef PROTON_DRIVE_SDK_H +#define PROTON_DRIVE_SDK_H + +#include +#include + +#include "proton_sdk.h" + +void proton_drive_sdk_handle_request( + ByteArray request, + intptr_t bindings_handle, + array_action response_action +); + +#endif // PROTON_DRIVE_SDK_H diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h new file mode 100644 index 00000000..b2f55e7a --- /dev/null +++ b/cs/headers/proton_sdk.h @@ -0,0 +1,30 @@ +#ifndef PROTON_SDK_H +#define PROTON_SDK_H + +#include +#include + +typedef struct { + const uint8_t* pointer; + size_t length; +} ByteArray; + +typedef void array_action(intptr_t handle, ByteArray array); + +void override_native_library_name( + ByteArray library_name, + ByteArray overriding_library_name +); + +void proton_sdk_handle_request( + ByteArray request, + intptr_t bindings_handle, + array_action response_action +); + +void proton_sdk_handle_response( + intptr_t sdk_handle, + ByteArray response +); + +#endif // PROTON_SDK_H 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 @@ + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/.globalconfig b/cs/sdk/src/.globalconfig new file mode 100644 index 00000000..112c82f1 --- /dev/null +++ b/cs/sdk/src/.globalconfig @@ -0,0 +1,2 @@ +# For library projects, ConfigureAwait(false) is strongly recommended +dotnet_diagnostic.CA2007.severity = warning \ No newline at end of file diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs new file mode 100644 index 00000000..3f322143 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs @@ -0,0 +1,186 @@ +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; + +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) + { + IMessage? payload = metricEvent switch + { + UploadEvent me => GetUploadEventPayload(me), + DownloadEvent me => GetDownloadEventPayload(me), + DecryptionErrorEvent me => GetDecryptionErrorPayload(me), + BlockVerificationErrorEvent me => GetBlockVerificationErrorPayload(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, + ApproximateUploadedSize = me.ApproximateUploadedSize, + ExpectedSize = me.ExpectedSize, + ApproximateExpectedSize = me.ApproximateExpectedSize, + }; + + // Check if we should translate InteropErrorException when error is Unknown + var error = me is { Error: Telemetry.UploadError.Unknown, OriginalError: InteropErrorException interopError } + ? TranslateToUploadError(interopError) + : me.Error; + + if (error is not null) + { + payload.Error = (UploadError)error; + } + + if (me.OriginalError is not null) + { + payload.OriginalError = me.OriginalError.GetBaseException().ToString(); + } + + return payload; + } + + private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me) + { + var payload = new DownloadEventPayload + { + VolumeType = (VolumeType)me.VolumeType, + DownloadedSize = me.DownloadedSize, + ApproximateDownloadedSize = me.ApproximateDownloadedSize, + ClaimedFileSize = me.ClaimedFileSize ?? 0, + ApproximateClaimedFileSize = me.ApproximateClaimedFileSize ?? 0, + }; + + // Check if we should translate InteropErrorException when error is Unknown + var error = me is { Error: Telemetry.DownloadError.Unknown, OriginalError: InteropErrorException interopError } + ? TranslateToDownloadError(interopError) + : me.Error; + + if (error is not null) + { + payload.Error = (DownloadError)error; + } + + if (me.OriginalError is not null) + { + payload.OriginalError = me.OriginalError.GetBaseException().ToString(); + } + + 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 + { + VolumeType = (VolumeType)me.VolumeType, + Field = (EncryptedField)me.Field, + Uid = me.Uid.ToString(), + }; + + if (me.FromBefore2024.HasValue) + { + payload.FromBefore2024 = me.FromBefore2024.Value; + } + + if (me.Error is not null) + { + payload.Error = me.Error; + } + + return payload; + } + + private static Telemetry.UploadError? TranslateToUploadError(InteropErrorException exception) + { + if (exception.Error is null) + { + return Telemetry.UploadError.Unknown; + } + + var error = exception.Error; + return exception.Error.Domain switch + { + ErrorDomain.Api => TranslateApiErrorToUploadError(error.SecondaryCode), + 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, + }; + } + + private static Telemetry.UploadError TranslateApiErrorToUploadError(long statusCode) + { + return statusCode switch + { + (int)HttpStatusCode.TooManyRequests => Telemetry.UploadError.RateLimited, + >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode => Telemetry.UploadError.HttpClientSideError, + _ => Telemetry.UploadError.ServerError, + }; + } + + private static Telemetry.DownloadError? TranslateToDownloadError(InteropErrorException exception) + { + if (exception.Error is null) + { + return Telemetry.DownloadError.Unknown; + } + + var error = exception.Error; + return exception.Error.Domain switch + { + ErrorDomain.Api => TranslateApiErrorToDownloadError(error.SecondaryCode), + 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, + }; + } + + private static Telemetry.DownloadError TranslateApiErrorToDownloadError(long statusCode) + { + return statusCode switch + { + (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.CExports/InteropAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs new file mode 100644 index 00000000..3674e2c8 --- /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 bindingsHandle, InteropAction, nint> requestAction) : IAccountClient +{ + private readonly nint _bindingsHandle = bindingsHandle; + private readonly InteropAction, nint> _requestAction = requestAction; + + public async ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + var request = new AccountRequest { GetAddress = new GetAddressRequest { AddressId = addressId.ToString() } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false); + + return ConvertToAddress(response); + } + + public async ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + { + var response = await _requestAction.SendRequestAsync( + _bindingsHandle, + new AccountRequest { GetDefaultAddress = new GetDefaultAddressRequest() }).ConfigureAwait(false); + + return ConvertToAddress(response); + } + + public async ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + 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 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 AccountRequest { GetAddressPublicKeys = new GetAddressPublicKeysRequest { EmailAddress = emailAddress } }; + var response = await _requestAction.SendRequestAsync(_bindingsHandle, 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..13df84dd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs @@ -0,0 +1,27 @@ +using Google.Protobuf; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropActionExtensions +{ + public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long progress, long? total) + { + var progressUpdate = new ProgressUpdate + { + BytesCompleted = progress, + }; + + if (total is not null) + { + progressUpdate.BytesInTotal = total.Value; + } + + var requestBytes = progressUpdate.ToByteArray(); + + fixed (byte* requestBytesPointer = requestBytes) + { + interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length)); + } + } +} 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/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs new file mode 100644 index 00000000..26894c73 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs @@ -0,0 +1,59 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Sdk.CExports; + +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); + + await downloadController.Completion.ConfigureAwait(false); + + return null; + } + + public static IMessage? HandlePause(DownloadControllerPauseRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + downloadController.Pause(); + + return null; + } + + public static IMessage? HandleResume(DownloadControllerResumeRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + downloadController.Resume(); + + return null; + } + + public static IMessage? HandleIsDownloadCompleteWithVerificationIssue(DownloadControllerIsDownloadCompleteWithVerificationIssueRequest request) + { + var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle); + + return new BoolValue { Value = downloadController.GetIsDownloadCompleteWithVerificationIssue() }; + } + + public static async ValueTask HandleFree(DownloadControllerFreeRequest request) + { + var downloadController = Interop.FreeHandle(request.DownloadControllerHandle); + + await downloadController.DisposeAsync().ConfigureAwait(false); + + return null; + } +} 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..aa3bcd55 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs @@ -0,0 +1,192 @@ +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; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropDriveErrorConverter +{ + private const int UnknownDecryptionErrorPrimaryCode = 0; + 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) + { + 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: + case SessionKeyAndDataPacketMismatchException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode; + break; + + case DataIntegrityException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ManifestSignatureVerificationErrorPrimaryCode; + break; + + case MissingContentBlockIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; + + case ContentSizeMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; + + case ThumbnailCountMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; + + case ChecksumMismatchIntegrityException e: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + error.AdditionalData = Any.Pack(ToAdditionalData(e)); + break; + + case IntegrityException: + error.Domain = ErrorDomain.DataIntegrity; + error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode; + 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; + + default: + error.PrimaryCode = UnknownDecryptionErrorPrimaryCode; + InteropErrorConverter.SetDomainAndCodes(error, 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; + } + + 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.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/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs new file mode 100644 index 00000000..6f7bb904 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs @@ -0,0 +1,59 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Download; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropFileDownloader +{ + public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + 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, seekAction, cancelAction); + + 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(DownloadToFileRequest 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(FileDownloaderFreeRequest request) + { + 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 new file mode 100644 index 00000000..529a3b54 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Sdk.CExports; + +namespace Proton.Drive.Sdk.CExports; + +internal static class InteropFileUploader +{ + public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + 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 => + { + unsafe + { + var thumbnailType = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + 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, + forPhotos: false, + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; + } + + public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + var thumbnailType = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + 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, + forPhotos: false, + cancellationToken); + + return new Int64Value { Value = Interop.AllocHandle(uploadController) }; + } + + public static IMessage? HandleFree(FileUploaderFreeRequest request) + { + var fileUploader = Interop.FreeHandle(request.FileUploaderHandle); + + fileUploader.Dispose(); + + return null; + } + + internal static Func> CreateSha1Provider(nint bindingsHandle, long functionPointer) + { + return () => + { + 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.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs new file mode 100644 index 00000000..091b784b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs @@ -0,0 +1,207 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Google.Protobuf.WellKnownTypes; +using Proton.Sdk.CExports; + +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 bindingsHandle, InteropAction> responseAction) + { + try + { + var request = Request.Parser.ParseFrom(requestBytes.AsReadOnlySpan()); + + var response = request.PayloadCase switch + { + Request.PayloadOneofCase.DriveClientCreate + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate, bindingsHandle), + + Request.PayloadOneofCase.DriveClientCreateFromSession + => InteropProtonDriveClient.HandleCreate(request.DriveClientCreateFromSession), + + 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.DriveClientGetAvailableName + => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false), + + 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, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientEmptyTrash + => await InteropProtonDriveClient.HandleEmptyTrashAsync(request.DriveClientEmptyTrash).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientRename + => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientCreateFolder + => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientEnumerateThumbnails + => await InteropProtonDriveClient.HandleEnumerateThumbnailsAsync( + request.DriveClientEnumerateThumbnails, + bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DriveClientEnumerateFolderChildren + => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync( + request.DriveClientEnumerateFolderChildren, + bindingsHandle).ConfigureAwait(false), + + 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), + + Request.PayloadOneofCase.UploadFromFile + => InteropFileUploader.HandleUploadFromFile(request.UploadFromFile, bindingsHandle), + + Request.PayloadOneofCase.FileUploaderFree + => InteropFileUploader.HandleFree(request.FileUploaderFree), + + Request.PayloadOneofCase.UploadControllerIsPaused + => InteropUploadController.HandleIsPaused(request.UploadControllerIsPaused), + + 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.UploadControllerDispose + => await InteropUploadController.HandleDisposeAsync(request.UploadControllerDispose).ConfigureAwait(false), + + Request.PayloadOneofCase.UploadControllerFree + => InteropUploadController.HandleFree(request.UploadControllerFree), + + Request.PayloadOneofCase.DownloadToStream + => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, bindingsHandle), + + Request.PayloadOneofCase.DownloadToFile + => InteropFileDownloader.HandleDownloadToFile(request.DownloadToFile, bindingsHandle), + + Request.PayloadOneofCase.FileDownloaderFree + => InteropFileDownloader.HandleFree(request.FileDownloaderFree), + + Request.PayloadOneofCase.DownloadControllerIsPaused + => InteropDownloadController.HandleIsPaused(request.DownloadControllerIsPaused), + + Request.PayloadOneofCase.DownloadControllerIsDownloadCompleteWithVerificationIssue + => InteropDownloadController.HandleIsDownloadCompleteWithVerificationIssue( + request.DownloadControllerIsDownloadCompleteWithVerificationIssue), + + 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 + => await InteropDownloadController.HandleFree(request.DownloadControllerFree).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientCreate + => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreate, bindingsHandle), + + Request.PayloadOneofCase.DrivePhotosClientCreateFromSession + => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreateFromSession), + + Request.PayloadOneofCase.DrivePhotosClientFree + => InteropProtonPhotosClient.HandleFree(request.DrivePhotosClientFree), + + Request.PayloadOneofCase.DrivePhotosClientEnumerateThumbnails + => await InteropProtonPhotosClient.HandleEnumerateThumbnailsAsync( + request.DrivePhotosClientEnumerateThumbnails, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientEnumerateTimeline + => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync( + request.DrivePhotosClientEnumerateTimeline, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.DrivePhotosClientGetNode + => await InteropProtonPhotosClient.HandleGetNodeAsync(request.DrivePhotosClientGetNode).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.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.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)), + }; + + responseAction.InvokeWithMessage(bindingsHandle, response is not null ? new Response { Value = Any.Pack(response) } : new Response()); + } + catch (Exception e) + { + var error = e.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes); + + responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error }); + } + } +} 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..014a95e2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs @@ -0,0 +1,59 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Download; +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 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); + + 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/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs new file mode 100644 index 00000000..1a62f55a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs @@ -0,0 +1,81 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.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 InteropFunction, nint, nint>(request.ReadAction)); + + var thumbnails = request.Thumbnails.Select(t => + { + unsafe + { + var thumbnailType = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + var progressAction = new InteropAction>(request.ProgressAction); + + var expectedSha1Provider = request.HasSha1Function ? + InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + + var uploadController = uploader.UploadFromStream( + stream, + thumbnails, + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, + forPhotos: true, + 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 = (Nodes.ThumbnailType)t.Type; + return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray()); + } + }); + + var progressAction = new InteropAction>(request.ProgressAction); + var expectedSha1Provider = request.HasSha1Function ? + InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null; + + var uploader = Interop.GetFromHandle(request.UploaderHandle); + + var uploadController = uploader.UploadFromFile( + request.FilePath, + thumbnails, + (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total), + expectedSha1Provider, + forPhotos: true, + 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/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs new file mode 100644 index 00000000..3e3d90c0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs @@ -0,0 +1,373 @@ +using System.Text.Json; +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; +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.CExports; + +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 protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( + 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.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null); + + var httpClientFactory = new InteropHttpClientFactory( + bindingsHandle, + request.BaseUrl, + protonDriveClientOptions.BindingsLanguage, + new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), + new InteropFunction, nint, nint>(request.HttpClient.ResponseContentReadAction), + new InteropAction(request.HttpClient.CancellationAction)); + + var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction)); + + ICacheRepository entityCacheRepository = request.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.EntityCachePath) + : new InMemoryCacheRepository(); + + ICacheRepository secretCacheRepository = request.HasSecretCachePath + ? 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; + + var featureFlagProvider = request.HasFeatureEnabledFunction + ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction)) + : AlwaysDisabledFeatureFlagProvider.Instance; + + var client = new ProtonDriveClient( + httpClientFactory, + accountClient, + entityCacheRepository, + secretCacheRepository, + featureFlagProvider, + telemetry, + protonDriveClientOptions); + + return new Int64Value + { + Value = Interop.AllocHandle(client), + }; + } + + public static IMessage HandleCreate(DriveClientCreateFromSessionRequest request) + { + var session = Interop.GetFromHandle(request.SessionHandle); + + var client = new ProtonDriveClient(session); + + 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?.ToDateTimeFixed(), + cancellationToken).ConfigureAwait(false); + + return createdFolder.ToInterop(); + } + + public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var additionalMetadata = request.AdditionalMetadata is { Count: > 0 } + ? request.AdditionalMetadata.Select(x => + new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; + + var metadata = new FileUploadMetadata + { + LastModificationTime = request.LastModificationTime?.ToDateTimeFixed(), + AdditionalMetadata = additionalMetadata, + }; + + var client = Interop.GetFromHandle(request.ClientHandle); + + 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) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var additionalMetadata = request.AdditionalMetadata.Count > 0 + ? request.AdditionalMetadata.Select(x => + new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement)) + : null; + + 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, + metadata, + cancellationToken).ConfigureAwait(false); + } + + return new Int64Value { Value = fileUploader is null ? 0 : 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 HandleEnumerateThumbnailsAsync(DriveClientEnumerateThumbnailsRequest request, nint bindingsHandle) + { + var yieldFunction = new InteropAction>(request.YieldAction); + 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); + + 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 = error.ToInterop(); + } + + yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); + } + + return null; + } + + 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); + + await foreach (var x in client.EnumerateFolderChildrenAsync(NodeUid.Parse(request.FolderUid), cancellationToken).ConfigureAwait(false)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); + } + + return null; + } + + 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 folderNode.ToInterop(); + } + + 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); + + 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 = fileDownloader is null ? 0 : Interop.AllocHandle(fileDownloader) }; + } + + 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 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); + + return results.ToInterop(); + } + + 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 results.ToInterop(); + } + + 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 results.ToInterop(); + } + + public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request, nint bindingsHandle) + { + 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)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); + } + + return null; + } + + public static async ValueTask HandleGetNodeAsync(DriveClientGetNodeRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var node = await client.GetNodeAsync( + NodeUid.Parse(request.NodeUid), + cancellationToken).ConfigureAwait(false); + + return node?.ToInterop(); + } + + 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; + } +} 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..e58ed7a9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs @@ -0,0 +1,302 @@ +using System.Text.Json; +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; +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 protonDriveClientOptions = new Sdk.ProtonDriveClientOptions( + 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.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null); + + var httpClientFactory = new InteropHttpClientFactory( + bindingsHandle, + request.BaseUrl, + protonDriveClientOptions.BindingsLanguage, + new InteropFunction, nint, nint>(request.HttpClient.RequestFunction), + new InteropFunction, nint, 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, + protonDriveClientOptions); + + 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 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 results.ToInterop(); + } + + 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 results.ToInterop(); + } + + 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 results.ToInterop(); + } + + public static async ValueTask HandleEnumerateTrashAsync(DrivePhotosClientEnumerateTrashRequest request, nint bindingsHandle) + { + 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)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop()); + } + + 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); + + return null; + } + + public static async ValueTask HandleGetNodeAsync(DrivePhotosClientGetNodeRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + var client = Interop.GetFromHandle(request.ClientHandle); + + var node = await client.GetNodeAsync(NodeUid.Parse(request.NodeUid), cancellationToken).ConfigureAwait(false); + + return node?.ToInterop(); + } + + 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); + + await foreach (var x in client.EnumerateTimelineAsync(cancellationToken).ConfigureAwait(false)) + { + yieldFunction.InvokeWithMessage(bindingsHandle, new PhotosTimelineItem + { + NodeUid = x.Uid.ToString(), + CaptureTime = x.CaptureTime.ToUniversalTime().ToTimestamp(), + }); + } + + return null; + } + + public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhotosClientGetPhotoDownloaderRequest request) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var photoUid = NodeUid.Parse(request.PhotoUid); + + 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) + { + var yieldFunction = new InteropAction>(request.YieldAction); + 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); + + 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 = error.ToInterop(); + } + + yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail); + } + + return null; + } + + 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 => (Nodes.PhotoTag)t) + : null; + + 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 + { + 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 client = Interop.GetFromHandle(request.ClientHandle); + + 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 = uploader is null ? 0 : Interop.AllocHandle(uploader) }; + } + + public static async ValueTask HandleFindDuplicatesAsync(DrivePhotosClientFindDuplicatesRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var client = Interop.GetFromHandle(request.ClientHandle); + + var duplicates = await client.FindDuplicatesAsync(request.Name, GenerateSha1Action, cancellationToken).ConfigureAwait(false); + + var result = new ListValue(); + 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.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs new file mode 100644 index 00000000..5b505c3c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs @@ -0,0 +1,59 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Drive.Sdk.Nodes.Upload; +using Proton.Sdk.CExports; + +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); + + var uploadResult = await uploadController.Completion.ConfigureAwait(false); + + return new UploadResult { NodeUid = uploadResult.NodeUid.ToString(), RevisionUid = uploadResult.RevisionUid.ToString() }; + } + + public static IMessage? HandlePause(UploadControllerPauseRequest request) + { + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); + + uploadController.Pause(); + + return null; + } + + public static IMessage? HandleResume(UploadControllerResumeRequest request) + { + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); + + uploadController.Resume(); + + return null; + } + + public static async ValueTask HandleDisposeAsync(UploadControllerDisposeRequest request) + { + var uploadController = Interop.GetFromHandle(request.UploadControllerHandle); + + await uploadController.DisposeAsync().ConfigureAwait(false); + + return null; + } + + public static IMessage? HandleFree(UploadControllerFreeRequest request) + { + Interop.FreeHandle(request.UploadControllerHandle); + + return null; + } +} 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..1503485b --- /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()); + + LibraryNameMap[libraryName] = Encoding.UTF8.GetString(overridingLibraryNameBytes.AsReadOnlySpan()); + + AssemblyLoadContext.Default.ResolvingUnmanagedDll -= Resolve; + 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/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj new file mode 100644 index 00000000..7c293d6c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj @@ -0,0 +1,62 @@ + + + + $(NativeLibPrefix)proton_drive_sdk + true + true + false + proton_crypto + + $(NoWarn);Photos + + + $(NoWarn);IL2113 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + true + + + + Static + + + + + + + + + + + + + \ No newline at end of file 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); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs new file mode 100644 index 00000000..f624df44 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs @@ -0,0 +1,35 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk; + +internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountClient +{ + private readonly ProtonAccountClient _client = new(session); + + public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressAsync(addressId, cancellationToken); + } + + public ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken) + { + return _client.GetCurrentUserDefaultAddressAsync(cancellationToken); + } + + public ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressPrimaryPrivateKeyAsync(addressId, cancellationToken); + } + + public ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + return _client.GetAddressPrivateKeysAsync(addressId, cancellationToken); + } + + public ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return _client.GetAddressPublicKeysAsync(emailAddress, cancellationToken); + } +} 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/Api/BlockVerification/BlockVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs new file mode 100644 index 00000000..372b2385 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.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 BlockVerificationApiClient(HttpClient httpClient) : IBlockVerificationApiClient +{ + 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/BlockVerification/IBlockVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs new file mode 100644 index 00000000..93939ecd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs @@ -0,0 +1,14 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.BlockVerification; + +internal interface IBlockVerificationApiClient +{ + public ValueTask GetVerificationInputAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs new file mode 100644 index 00000000..55a21d17 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs @@ -0,0 +1,21 @@ +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; + +namespace Proton.Drive.Sdk.Api; + +internal sealed class DriveApiClients(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IDriveApiClients +{ + 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(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/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/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs new file mode 100644 index 00000000..486640fd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ActiveRevisionDto +{ + [JsonPropertyName("RevisionID")] + public required RevisionId Id { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("EncryptedSize")] + public required long StorageQuotaConsumption { get; init; } + + public bool? ChecksumVerified { get; init; } + + public PgpArmoredSignature? ManifestSignature { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { 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/BlockCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs new file mode 100644 index 00000000..060e18f1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockCreationRequest +{ + public required int Index { get; init; } + public required int Size { get; init; } + + [JsonPropertyName("EncSignature")] + public required PgpArmoredMessage EncryptedSignature { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("Verifier")] + public required BlockVerificationOutput VerificationOutput { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs new file mode 100644 index 00000000..e7667853 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs @@ -0,0 +1,18 @@ +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; } + + [JsonPropertyName("Hash")] + [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 new file mode 100644 index 00000000..e76fb66d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockListingRevisionDto : RevisionDto +{ + public required IReadOnlyList Blocks { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs new file mode 100644 index 00000000..2e100508 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockUploadPreparationRequest +{ + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + [JsonPropertyName("VolumeID")] + public required VolumeId VolumeId { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } + + [JsonPropertyName("RevisionID")] + public required RevisionId RevisionId { get; init; } + + [JsonPropertyName("BlockList")] + public required IReadOnlyList Blocks { get; init; } + + [JsonPropertyName("ThumbnailList")] + public required IReadOnlyList Thumbnails { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs new file mode 100644 index 00000000..e13089e0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockUploadPreparationResponse : ApiResponse +{ + [JsonPropertyName("UploadLinks")] + public required IReadOnlyList UploadTargets { get; set; } + + [JsonPropertyName("ThumbnailLinks")] + public required IReadOnlyList ThumbnailUploadTargets { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs new file mode 100644 index 00000000..1cefe921 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal class BlockUploadTarget +{ + [JsonPropertyName("BareURL")] + public required string BareUrl { get; set; } + + public required string Token { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs new file mode 100644 index 00000000..1a1a3b7f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class BlockUploadUrl +{ + public required string Token { get; init; } + + [JsonPropertyName("URL")] + public required string Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs new file mode 100644 index 00000000..a9532c68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed record BlockVerificationInputResponse +{ + public required ReadOnlyMemory VerificationCode { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs new file mode 100644 index 00000000..929e5037 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Files; + +public readonly struct BlockVerificationOutput +{ + public required ReadOnlyMemory Token { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs new file mode 100644 index 00000000..a3628e85 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs @@ -0,0 +1,17 @@ +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; } + + [JsonConverter(typeof(Iso8601DateTimeResultJsonConverter))] + public Result? ModificationTime { get; init; } + + public IReadOnlyList? BlockSizes { get; init; } + + public FileContentDigestsDto? Digests { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs new file mode 100644 index 00000000..08036fb4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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/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/FileCreationIdentifiers.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs new file mode 100644 index 00000000..da3a9545 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationIdentifiers +{ + [JsonPropertyName("ID")] + public required LinkId LinkId { get; init; } + + [JsonPropertyName("RevisionID")] + public required RevisionId RevisionId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs new file mode 100644 index 00000000..69700606 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationRequest : NodeCreationRequest +{ + [JsonPropertyName("MIMEType")] + public required string MediaType { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } + + [JsonPropertyName("ContentKeyPacketSignature")] + public required PgpArmoredSignature ContentKeySignature { get; init; } + + [JsonPropertyName("ClientUID")] + 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/FileCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs new file mode 100644 index 00000000..f2349b45 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class FileCreationResponse : ApiResponse +{ + [JsonPropertyName("File")] + public required FileCreationIdentifiers Identifiers { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs new file mode 100644 index 00000000..e67178b5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal class FileDto +{ + public required string MediaType { get; init; } + + [JsonPropertyName("TotalEncryptedSize")] + public required long TotalSizeOnStorage { get; init; } + + public required ReadOnlyMemory ContentKeyPacket { get; init; } + + [JsonPropertyName("ContentKeyPacketSignature")] + public PgpArmoredSignature? ContentKeySignature { get; init; } + + public ActiveRevisionDto? ActiveRevision { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs new file mode 100644 index 00000000..7bf8e324 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs @@ -0,0 +1,114 @@ +using System.Text; +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 FilesApiClient(HttpClient httpClient) : IFilesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse) + .PostAsync($"v2/volumes/{volumeId}/files", request, DriveApiSerializerContext.Default.FileCreationRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask CreateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionCreationRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse) + .PostAsync( + $"v2/volumes/{volumeId}/files/{linkId}/revisions", + request, + DriveApiSerializerContext.Default.RevisionCreationRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.BlockUploadPreparationResponse) + .PostAsync("blocks", request, DriveApiSerializerContext.Default.BlockUploadPreparationRequest, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask UpdateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + RevisionUpdateRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync( + $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", + request, + DriveApiSerializerContext.Default.RevisionUpdateRequest, + cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + 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(routeBuilder.ToString(), 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); + } + + 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 new file mode 100644 index 00000000..f85f3b88 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs @@ -0,0 +1,38 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal interface IFilesApiClient +{ + ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken); + + ValueTask CreateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionCreationRequest request, + CancellationToken cancellationToken); + + ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken); + + ValueTask UpdateRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + RevisionUpdateRequest request, + CancellationToken cancellationToken); + + ValueTask GetRevisionAsync( + VolumeId volumeId, + LinkId linkId, + RevisionId revisionId, + int? fromBlockIndex, + int? pageSize, + 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/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/RevisionConflict.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs new file mode 100644 index 00000000..0d9ce361 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs @@ -0,0 +1,29 @@ +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; + +internal sealed class RevisionConflict +{ + [JsonPropertyName("ConflictLinkID")] + public LinkId? LinkId { get; init; } + + [JsonPropertyName("ConflictRevisionID")] + public RevisionId? RevisionId { get; init; } + + [JsonPropertyName("ConflictDraftRevisionID")] + public RevisionId? DraftRevisionId { get; init; } + + [JsonPropertyName("ConflictDraftClientUID")] + public string? DraftClientUid { get; init; } + + 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/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..41b4bf8c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs @@ -0,0 +1,14 @@ +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; } + + public long? IntendedUploadSize { 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/Files/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs new file mode 100644 index 00000000..257c6161 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal class RevisionDto +{ + [JsonPropertyName("ID")] + public required RevisionId Id { get; init; } + + [JsonPropertyName("ClientUID")] + public string? ClientId { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + public required long Size { get; init; } + + public PgpArmoredSignature? ManifestSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } + + public required RevisionState State { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } + + public IReadOnlyList? Thumbnails { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs new file mode 100644 index 00000000..a97bb7b3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionErrorResponse : ApiResponse +{ + public JsonElement? Details { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs new file mode 100644 index 00000000..94b97259 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct RevisionId : IStrongId +{ + private readonly string? _value; + + internal RevisionId(string? value) + { + _value = value; + } + + public static explicit operator RevisionId(string? value) + { + return new RevisionId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs new file mode 100644 index 00000000..5367d4cc --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionResponse : ApiResponse +{ + public required BlockListingRevisionDto Revision { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs new file mode 100644 index 00000000..9191cb68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class RevisionUpdateRequest +{ + public required PgpArmoredSignature ManifestSignature { get; init; } + + [JsonPropertyName("SignatureAddress")] + public required string SignatureEmailAddress { get; init; } + + public required bool ChecksumVerified { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } + + [JsonPropertyName("Photo")] + public PhotosAttributesDto? PhotosAttributes { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs new file mode 100644 index 00000000..7216959e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlock +{ + [JsonPropertyName("ThumbnailID")] + public required string ThumbnailId { 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/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/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..4c0d5db7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs @@ -0,0 +1,13 @@ +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; } + + [JsonPropertyName("Errors")] + public required IReadOnlyList Errors { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs new file mode 100644 index 00000000..324cb0a3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailBlockUploadTarget : BlockUploadTarget +{ + [JsonPropertyName("ThumbnailType")] + public required ThumbnailType Type { get; set; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs new file mode 100644 index 00000000..aea7d6f0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailCreationRequest +{ + public required int Size { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs new file mode 100644 index 00000000..f3740959 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailDto +{ + [JsonPropertyName("ThumbnailID")] + public required string Id { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("Size")] + 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 new file mode 100644 index 00000000..dc54aae1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Files; + +internal sealed class ThumbnailDtoV2 +{ + [JsonPropertyName("ThumbnailID")] + public required string Id { get; init; } + + public required ThumbnailType Type { get; init; } + + [JsonPropertyName("Hash")] + public required ReadOnlyMemory HashDigest { get; init; } + + [JsonPropertyName("EncryptedSize")] + public required int StorageQuotaUsage { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs new file mode 100644 index 00000000..6841ce5b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Files; + +internal enum ThumbnailType +{ + Thumbnail = 1, + Preview = 2, +} 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..ae6b856f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs @@ -0,0 +1,69 @@ +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; +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 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, + 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/{volumeId}/trash", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs new file mode 100644 index 00000000..be7becd0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderChildrenResponse : ApiResponse +{ + [JsonPropertyName("LinkIDs")] + public required IReadOnlyList LinkIds { get; init; } + + [JsonPropertyName("AnchorID")] + public LinkId? AnchorId { get; init; } + + [JsonPropertyName("More")] + public required bool MoreResultsExist { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs new file mode 100644 index 00000000..64dff306 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderCreationRequest : NodeCreationRequest +{ + [JsonPropertyName("NodeHashKey")] + public required PgpArmoredMessage HashKey { get; init; } + + [JsonPropertyName("SignatureEmail")] + public required string SignatureEmailAddress { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs new file mode 100644 index 00000000..38e28b2f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderCreationResponse : ApiResponse +{ + [JsonPropertyName("Folder")] + public required FolderId FolderId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs new file mode 100644 index 00000000..c5b2f367 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FolderDto +{ + [JsonPropertyName("NodeHashKey")] + public required PgpArmoredMessage HashKey { get; init; } + + [JsonPropertyName("XAttr")] + public PgpArmoredMessage? ExtendedAttributes { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs new file mode 100644 index 00000000..b0265bba --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal readonly struct FolderId +{ + [JsonPropertyName("ID")] + public required LinkId Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs new file mode 100644 index 00000000..301483ba --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs @@ -0,0 +1,31 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal sealed class FoldersApiClient(HttpClient httpClient) : IFoldersApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken) + { + var query = anchorId is not null ? $"?AnchorID={anchorId}" : string.Empty; + + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FolderChildrenResponse) + .GetAsync($"v2/volumes/{volumeId}/folders/{linkId}/children{query}", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask CreateFolderAsync( + VolumeId volumeId, + FolderCreationRequest request, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.FolderCreationResponse) + .PostAsync($"v2/volumes/{volumeId}/folders", request, DriveApiSerializerContext.Default.FolderCreationRequest, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs new file mode 100644 index 00000000..403507e5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs @@ -0,0 +1,11 @@ +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Folders; + +internal interface IFoldersApiClient +{ + ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken); + + ValueTask CreateFolderAsync(VolumeId volumeId, FolderCreationRequest request, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs new file mode 100644 index 00000000..f1892029 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs @@ -0,0 +1,21 @@ +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; + +namespace Proton.Drive.Sdk.Api; + +internal interface IDriveApiClients +{ + IVolumesApiClient Volumes { get; } + ISharesApiClient Shares { get; } + ILinksApiClient Links { get; } + IFoldersApiClient Folders { get; } + IFilesApiClient Files { get; } + IStorageApiClient Storage { get; } + ITrashApiClient Trash { get; } + IPhotosApiClient Photos { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs new file mode 100644 index 00000000..870edc91 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal class ContextShareResponse : ApiResponse +{ + [JsonPropertyName("ContextShareID")] + public required ShareId ContextShareId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs new file mode 100644 index 00000000..1f96c569 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs @@ -0,0 +1,28 @@ +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal interface ILinksApiClient +{ + ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken); + + ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken); + + ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken); + + ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken); + + ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken); + + ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + IEnumerable linkIds, + 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 new file mode 100644 index 00000000..1071b617 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs @@ -0,0 +1,27 @@ +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, + 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/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs new file mode 100644 index 00000000..d639aaaf --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs @@ -0,0 +1,34 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Photos; + +namespace Proton.Drive.Sdk.Api.Links; + +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 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/Links/LinkDetailsRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs new file mode 100644 index 00000000..f147243c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct LinkDetailsRequest(IEnumerable linkIds) +{ + [JsonPropertyName("LinkIDs")] + public IEnumerable LinkIds { get; } = linkIds; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs new file mode 100644 index 00000000..75bdb279 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkDetailsResponse : ApiResponse +{ + public required IReadOnlyList Links { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs new file mode 100644 index 00000000..57c1a304 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinkDto +{ + [JsonPropertyName("LinkID")] + public LinkId Id { get; init; } + + public LinkType Type { get; init; } + + [JsonPropertyName("ParentLinkID")] + public LinkId? ParentId { get; init; } + + public required LinkState State { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime ModificationTime { get; init; } + + [JsonPropertyName("Trashed")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? TrashTime { get; init; } + + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NameHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("NodeKey")] + public required PgpArmoredPrivateKey Key { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + public PgpArmoredSignature? PassphraseSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + public string? SignatureEmailAddress { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public string? NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("OwnedBy")] + public OwnedByDto? OwnedBy { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs new file mode 100644 index 00000000..e666cdd7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct LinkId : IStrongId +{ + private readonly string? _value; + + internal LinkId(string? value) + { + _value = value; + } + + public static explicit operator LinkId(string? value) + { + return new LinkId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs new file mode 100644 index 00000000..9ba8cd62 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly record struct LinkIdResponsePair([property: JsonPropertyName("LinkID")] LinkId LinkId, ApiResponse Response); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/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/Links/LinkState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs new file mode 100644 index 00000000..10cb2935 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs @@ -0,0 +1,32 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal enum LinkState +{ + /// + /// File is created, waiting for the revision to be committed. + /// Automatically garbage collected if no blocks uploaded within last 3 hours. + /// + Draft = 0, + + /// + /// Active + /// + Active = 1, + + /// + /// Trashed + /// + Trashed = 2, + + /// + /// Permanently deleted, waiting for the garbage collection. + /// Should not appear in API responses. + /// + Deleted = 3, + + /// + /// Hidden, being restored from old volume. + /// Should not appear in API responses. + /// + Restoring = 4, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs new file mode 100644 index 00000000..76c0a2db --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Api.Links; + +internal enum LinkType +{ + Folder = 1, + File = 2, + Album = 3, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs new file mode 100644 index 00000000..406e8363 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs @@ -0,0 +1,83 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class LinksApiClient(HttpClient httpClient) : ILinksApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse) + .PostAsync($"v2/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken) + .ConfigureAwait(false); + } + + // FIXME use recursive lookup instead, remove this + public async ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ContextShareResponse) + .GetAsync($"volumes/{volumeId}/links/{linkId}/context", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/move", request, DriveApiSerializerContext.Default.MoveSingleLinkRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"volumes/{volumeId}/links/move-multiple", request, DriveApiSerializerContext.Default.MoveMultipleLinksRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/rename", request, DriveApiSerializerContext.Default.RenameLinkRequest, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask> DeleteMultipleAsync( + VolumeId volumeId, + IEnumerable linkIds, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair) + .PostAsync( + $"v2/volumes/{volumeId}/delete_multiple", + new MultipleLinksNullaryRequest { LinkIds = linkIds }, + DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, + 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/MoveMultipleLinksItem.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs new file mode 100644 index 00000000..8806bb72 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveMultipleLinksItem +{ + [JsonPropertyName("LinkID")] + public required LinkId LinkId { get; init; } + + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required PgpArmoredSignature? PassphraseSignature { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs new file mode 100644 index 00000000..38c4cbd7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveMultipleLinksRequest +{ + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("Links")] + public required IReadOnlyList Batch { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("SignatureEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs new file mode 100644 index 00000000..96114135 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class MoveSingleLinkRequest +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required PgpArmoredSignature? PassphraseSignature { get; init; } + + [JsonPropertyName("SignatureEmail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? SignatureEmailAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs new file mode 100644 index 00000000..88e8f7d5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MultipleLinksNullaryRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal readonly struct MultipleLinksNullaryRequest +{ + [JsonPropertyName("LinkIDs")] + public IEnumerable LinkIds { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/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/NodeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs new file mode 100644 index 00000000..1b271203 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/NodeCreationRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal abstract class NodeCreationRequest +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("ParentLinkID")] + public required LinkId ParentLinkId { get; init; } + + [JsonPropertyName("NodePassphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("NodePassphraseSignature")] + public required PgpArmoredSignature PassphraseSignature { get; init; } + + [JsonPropertyName("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..a4fa4b3c --- /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/Links/OwnedByDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/OwnedByDto.cs new file mode 100644 index 00000000..0d5a9330 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/OwnedByDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class OwnedByDto +{ + [JsonPropertyName("Email")] + public string? Email { get; init; } + + [JsonPropertyName("Organization")] + public string? Organization { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs new file mode 100644 index 00000000..1a3908e4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/RenameLinkRequest.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class RenameLinkRequest +{ + public required PgpArmoredMessage Name { get; init; } + + [JsonPropertyName("Hash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory NameHashDigest { get; init; } + + [JsonPropertyName("NameSignatureEmail")] + public required string NameSignatureEmailAddress { get; init; } + + [JsonPropertyName("MIMEType")] + public required string? MediaType { get; set; } + + [JsonPropertyName("OriginalHash")] + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory OriginalNameHashDigest { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs new file mode 100644 index 00000000..916020e5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ShareMembershipSummaryDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Links; + +internal sealed class ShareMembershipSummaryDto +{ + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } + + [JsonPropertyName("MembershipID")] + public required ShareMembershipId MembershipId { get; init; } + + public required ShareMemberPermissions Permissions { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs new file mode 100644 index 00000000..abaa925e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/IPhotosApiClient.cs @@ -0,0 +1,17 @@ +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; + +internal interface IPhotosApiClient +{ + ValueTask CreateVolumeAsync(PhotosVolumeCreationRequest request, CancellationToken cancellationToken); + + 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 new file mode 100644 index 00000000..ff3fe974 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoAlbumInclusionDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Photos; + +internal sealed class PhotoAlbumInclusionDto +{ + [JsonPropertyName("AlbumLinkID")] + public required LinkId Id { get; init; } + + [JsonPropertyName("Hash")] + public required string NameHash { get; init; } + + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public required ReadOnlyMemory ContentHash { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + [JsonPropertyName("AddedTime")] + public required DateTime CreationTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs new file mode 100644 index 00000000..3c3c9871 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotoDto.cs @@ -0,0 +1,33 @@ +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; + +internal sealed class PhotoDto : FileDto +{ + [JsonPropertyName("LinkID")] + public LinkId? Id { get; init; } + + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CaptureTime { get; init; } + + [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))] + public ReadOnlyMemory? ContentHash { get; init; } + + [JsonPropertyName("Hash")] + public string? NameHash { get; init; } + + [JsonPropertyName("MainPhotoLinkID")] + public string? MainPhotoLinkId { get; init; } + + [JsonPropertyName("RelatedPhotosLinkIDs")] + public required IReadOnlyList RelatedPhotosLinkIds { get; init; } = []; + + public required IReadOnlyList Tags { get; init; } = []; + + [JsonPropertyName("Albums")] + public required IReadOnlyList AlbumInclusions { get; init; } = []; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs new file mode 100644 index 00000000..b6ec5e66 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosApiClient.cs @@ -0,0 +1,48 @@ +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.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Photos; + +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); + } + + 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.TimelinePhotoListResponse) + .GetAsync($"volumes/{request.VolumeId}/photos{query}", cancellationToken).ConfigureAwait(false); + } + + 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); + } +} 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/Api/Photos/PhotosVolumeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs new file mode 100644 index 00000000..4f21b45d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeCreationRequest.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.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.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs new file mode 100644 index 00000000..8b7bda71 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeLinkCreationParameters.cs @@ -0,0 +1,16 @@ +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.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.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs new file mode 100644 index 00000000..e1e45c92 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/PhotosVolumeShareCreationParameters.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.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.Drive.Sdk/Api/Photos/RelatedPhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/RelatedPhotoDto.cs new file mode 100644 index 00000000..5d81280e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.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.Drive.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.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs new file mode 100644 index 00000000..3dbf20a9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoDto.cs @@ -0,0 +1,24 @@ +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; + +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.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs new file mode 100644 index 00000000..3d137e8b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Photos; + +internal sealed class TimelinePhotoListRequest +{ + public required VolumeId VolumeId { get; init; } + + [JsonPropertyName("PreviousPageLastLinkID")] + public LinkId? PreviousPageLastLinkId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs new file mode 100644 index 00000000..b8ffb832 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Photos/TimelinePhotoListResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Api.Photos; + +internal sealed class TimelinePhotoListResponse +{ + public required IReadOnlyList Photos { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs new file mode 100644 index 00000000..1be8cafe --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ISharesApiClient.cs @@ -0,0 +1,10 @@ +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/ShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs new file mode 100644 index 00000000..e08d01aa --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareDto.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareDto +{ + [JsonPropertyName("ShareID")] + public required ShareId Id { get; init; } + + [JsonPropertyName("CreatorEmail")] + public required string CreatorEmailAddress { get; init; } + + public required PgpArmoredPrivateKey Key { get; init; } + + public required PgpArmoredMessage Passphrase { get; init; } + + public required PgpArmoredSignature PassphraseSignature { get; init; } + + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs new file mode 100644 index 00000000..91394c46 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareId.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct ShareId : IStrongId +{ + private readonly string? _value; + + internal ShareId(string? value) + { + _value = value; + } + + public static explicit operator ShareId(string? value) => new(value); + + public override string ToString() + { + return _value ?? string.Empty; + } +} 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/ShareMemberPermissions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMemberPermissions.cs new file mode 100644 index 00000000..2643d641 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMemberPermissions.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +[Flags] +public enum ShareMemberPermissions +{ + None = 0, + Write = 2, + Read = 4, + Admin = 16, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs new file mode 100644 index 00000000..c66571da --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipDto.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareMembershipDto +{ + [JsonPropertyName("MemberID")] + public required ShareMembershipId Id { get; init; } + + [JsonPropertyName("ShareID")] + public required ShareId ShareId { get; init; } + + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + [JsonPropertyName("AddressKeyID")] + public required AddressKeyId AddressKeyId { get; init; } + + [JsonPropertyName("Inviter")] + public required string InviterEmailAddress { get; init; } + + public required ShareMemberPermissions Permissions { get; init; } + + public required ReadOnlyMemory KeyPacket { get; init; } + + public PgpArmoredSignature? KeyPacketSignature { get; init; } + + public PgpArmoredSignature? SessionKeySignature { get; init; } + + public required ShareMembershipState State { get; init; } + + [JsonPropertyName("Unlockable")] + public bool? CanBeUnlocked { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public required DateTime ModificationTime { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs new file mode 100644 index 00000000..81e88a0b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipId.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct ShareMembershipId : IStrongId +{ + private readonly string? _value; + + internal ShareMembershipId(string? value) + { + _value = value; + } + + public static explicit operator ShareMembershipId(string? value) => new(value); + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs new file mode 100644 index 00000000..0fb516a1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareMembershipState.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +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 new file mode 100644 index 00000000..9dc9583a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponse.cs @@ -0,0 +1,56 @@ +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; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareResponse : ApiResponse +{ + [JsonPropertyName("ShareID")] + public required ShareId Id { get; init; } + + [JsonPropertyName("VolumeID")] + public required VolumeId VolumeId { get; init; } + + public required ShareType Type { get; init; } + + public required ShareState State { get; init; } + + [JsonPropertyName("Creator")] + public required string CreatorEmailAddress { get; init; } + + [JsonPropertyName("Locked")] + public bool? IsLocked { get; init; } + + [JsonPropertyName("CreateTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? CreationTime { get; init; } + + [JsonPropertyName("ModifyTime")] + [JsonConverter(typeof(EpochSecondsJsonConverter))] + public DateTime? ModificationTime { get; init; } + + [JsonPropertyName("LinkID")] + public required LinkId RootLinkId { get; init; } + + [JsonPropertyName("LinkType")] + public required LinkType RootLinkType { get; init; } + + public required PgpArmoredPrivateKey Key { get; init; } + + [JsonPropertyName("Passphrase")] + public required PgpArmoredMessage Passphrase { get; init; } + + [JsonPropertyName("PassphraseSignature")] + public required PgpArmoredSignature PassphraseSignature { get; init; } + + [JsonPropertyName("AddressID")] + public required AddressId AddressId { get; init; } + + public required IReadOnlyList Memberships { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs new file mode 100644 index 00000000..de4523e2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareResponseV2.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareResponseV2 : ApiResponse +{ + public required ShareVolumeDto Volume { get; init; } + + public required ShareDto Share { get; init; } + + [JsonPropertyName("Link")] + public required LinkDetailsDto LinkDetails { get; init; } + + public void Deconstruct(out ShareVolumeDto volume, out ShareDto share, out LinkDetailsDto linkDetails) + { + volume = Volume; + share = Share; + linkDetails = LinkDetails; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs new file mode 100644 index 00000000..22782807 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareState.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Api.Shares; + +public enum ShareState +{ + Active = 1, + Deleted = 2, + Restored = 3, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs new file mode 100644 index 00000000..907fab7f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/ShareVolumeDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class ShareVolumeDto +{ + [JsonPropertyName("VolumeID")] + public required VolumeId Id { get; init; } + + public required long UsedSpace { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs new file mode 100644 index 00000000..c07d45b0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Shares/SharesApiClient.cs @@ -0,0 +1,33 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Shares; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Shares; + +internal sealed class SharesApiClient(HttpClient httpClient) : ISharesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask GetMyFilesShareAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareResponseV2) + .GetAsync("v2/shares/my-files", cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetShareAsync(ShareId id, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.ShareResponse) + .GetAsync($"shares/{id}", cancellationToken).ConfigureAwait(false); + } + + 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/Api/Storage/IStorageApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs new file mode 100644 index 00000000..0c4d3a28 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/IStorageApiClient.cs @@ -0,0 +1,9 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Storage; + +internal interface IStorageApiClient +{ + ValueTask UploadBlobAsync(string baseUrl, string token, Stream stream, 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 new file mode 100644 index 00000000..da5253fb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Storage/StorageApiClient.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Headers; +using System.Net.Mime; +using Proton.Sdk.Api; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Api.Storage; + +internal sealed class StorageApiClient(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IStorageApiClient +{ + private readonly HttpClient _defaultHttpClient = defaultHttpClient; + private readonly HttpClient _storageHttpClient = storageHttpClient; + + public async ValueTask UploadBlobAsync( + string baseUrl, + string token, + Stream stream, + CancellationToken cancellationToken) + { + using var blobContent = new StreamContent(stream); + blobContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "Block", FileName = "blob" }; + blobContent.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); + + using var multipartContent = new MultipartFormDataContent("-----------------------------" + Guid.NewGuid().ToString("N")) + { + blobContent, + }; + + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, baseUrl, multipartContent); + requestMessage.Headers.Add("pm-storage-token", token); + 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 _storageHttpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + 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.SetRequestType(HttpRequestType.StorageDownload); + + 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); + + return await blobResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The operation has timed out.", e); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs new file mode 100644 index 00000000..3b9a4057 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/IVolumesApiClient.cs @@ -0,0 +1,10 @@ +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal interface IVolumesApiClient +{ + ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken); + + ValueTask GetVolumeAsync(VolumeId volumeId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs new file mode 100644 index 00000000..397bec8b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/ShareTrashDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal readonly record struct ShareTrashDto( + [property: JsonPropertyName("ShareID")] + ShareId ShareId, + [property: JsonPropertyName("LinkIDs")] + IReadOnlyList LinkIds, + [property: JsonPropertyName("ParentIDs")] + IReadOnlyList ParentIds); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs new file mode 100644 index 00000000..331db7f1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationRequest.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Api.Volumes; + +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; } + + public required PgpArmoredSignature SharePassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderName { get; init; } + + public required PgpArmoredPrivateKey FolderKey { get; init; } + + public required PgpArmoredMessage FolderPassphrase { get; init; } + + public required PgpArmoredSignature FolderPassphraseSignature { get; init; } + + public required PgpArmoredMessage FolderHashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs new file mode 100644 index 00000000..101282a0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeCreationResponse.cs @@ -0,0 +1,8 @@ +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeCreationResponse : ApiResponse +{ + public required VolumeDto Volume { get; init; } +} 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/VolumeDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs new file mode 100644 index 00000000..d0da9dfa --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeDto +{ + [JsonPropertyName("VolumeID")] + public required VolumeId Id { get; set; } + + public long? MaxSpace { get; init; } + + public required long UsedSpace { get; init; } + + public required VolumeState State { get; init; } + + public required VolumeType Type { get; init; } + + [JsonPropertyName("Share")] + public required VolumeRootDto Root { 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/VolumeRootDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeRootDto.cs new file mode 100644 index 00000000..f39bd539 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeRootDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeRootDto +{ + [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/VolumeShareDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs new file mode 100644 index 00000000..24dcd8c5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeShareDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; + +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/VolumeTrashResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs new file mode 100644 index 00000000..373d587a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumeTrashResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumeTrashResponse : ApiResponse +{ + [JsonPropertyName("Trash")] + public required IReadOnlyList TrashByShare { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs new file mode 100644 index 00000000..c4c157f4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Volumes/VolumesApiClient.cs @@ -0,0 +1,24 @@ +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Api.Volumes; + +internal sealed class VolumesApiClient(HttpClient httpClient) : IVolumesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async ValueTask CreateVolumeAsync(VolumeCreationRequest request, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(DriveApiSerializerContext.Default.VolumeCreationResponse) + .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); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Author.cs b/cs/sdk/src/Proton.Drive.Sdk/Author.cs new file mode 100644 index 00000000..1f14f534 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Author.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk; + +public readonly record struct Author +{ + public static readonly Author Anonymous = default; + + public string? EmailAddress { get; init; } + + public bool TryGetIdentity([MaybeNullWhen(false)] out string emailAddress) + { + if (EmailAddress is null) + { + emailAddress = null; + return false; + } + + emailAddress = EmailAddress; + return true; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs new file mode 100644 index 00000000..4dfbad19 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/BatchLoaderBase.cs @@ -0,0 +1,69 @@ +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace Proton.Drive.Sdk; + +internal abstract class BatchLoaderBase +{ + private const int DefaultBatchSize = 50; + + private readonly ArrayBufferWriter _queueWriter; + + protected BatchLoaderBase(int batchSize = DefaultBatchSize) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); + + _queueWriter = new ArrayBufferWriter(batchSize); + } + + /// + /// 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 IAsyncEnumerable QueueAndTryLoadBatchAsync(TId id, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _queueWriter.Write(new ReadOnlySpan(ref id)); + + if (_queueWriter.FreeCapacity > 0) + { + yield break; + } + + await foreach (var value in EnumerateQueuedBatchAsync(cancellationToken).ConfigureAwait(false)) + { + yield return value; + } + } + + /// + /// Loads the remaining items in the queue if any, regardless of batch size. + /// Otherwise, returns an empty enumerable. + /// + /// + /// Call this after no more items are expected to be queued. + /// + public async IAsyncEnumerable LoadRemainingAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (_queueWriter.WrittenCount == 0) + { + yield break; + } + + await foreach (var value in EnumerateQueuedBatchAsync(cancellationToken).ConfigureAwait(false)) + { + yield return value; + } + } + + protected abstract IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, CancellationToken cancellationToken); + + private async IAsyncEnumerable EnumerateQueuedBatchAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var value in LoadBatchAsync(_queueWriter.WrittenMemory, cancellationToken).ConfigureAwait(false)) + { + yield return value; + } + + _queueWriter.Clear(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs new file mode 100644 index 00000000..b460c530 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/CachedNodeInfo.cs @@ -0,0 +1,9 @@ +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/DriveClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs new file mode 100644 index 00000000..805b2ad1 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveClientCache.cs @@ -0,0 +1,11 @@ +using Proton.Sdk.Caching; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveClientCache( + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository) : IDriveClientCache +{ + public IDriveEntityCache Entities { get; } = new DriveEntityCache(entityCacheRepository); + public IDriveSecretCache Secrets { get; } = new DriveSecretCache(secretCacheRepository); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs new file mode 100644 index 00000000..89e32b78 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveEntityCache.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk.Caching; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveEntityCache(ICacheRepository repository) : IDriveEntityCache +{ + 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; + + public ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken) + { + return _repository.SetAsync(ClientUidKey, clientUid, cancellationToken); + } + + public ValueTask TryGetClientUidAsync(CancellationToken cancellationToken) + { + return _repository.TryGetAsync(ClientUidKey, cancellationToken); + } + + public ValueTask SetMainVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(volumeId, DriveEntitiesSerializerContext.Default.NullableVolumeId); + + return _repository.SetAsync(MainVolumeIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetMainVolumeIdAsync(CancellationToken cancellationToken) + { + return await _repository.TryGetDeserializedValueAsync( + MainVolumeIdCacheKey, + DriveEntitiesSerializerContext.Default.NullableVolumeId, + cancellationToken).ConfigureAwait(false); + } + + public ValueTask SetPhotosVolumeIdAsync(VolumeId? volumeId, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(volumeId, DriveEntitiesSerializerContext.Default.NullableVolumeId); + + return _repository.SetAsync(PhotosVolumeIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask<(bool Exists, VolumeId? VolumeId)> TryGetPhotosVolumeIdAsync(CancellationToken cancellationToken) + { + return await _repository.TryGetDeserializedValueAsync( + PhotosVolumeIdCacheKey, + DriveEntitiesSerializerContext.Default.NullableVolumeId, + cancellationToken).ConfigureAwait(false); + } + + public ValueTask SetMyFilesShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(shareId, DriveEntitiesSerializerContext.Default.ShareId); + + return _repository.SetAsync(MyFilesShareIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask TryGetMyFilesShareIdAsync(CancellationToken cancellationToken) + { + var (exists, value) = await _repository.TryGetDeserializedValueAsync( + MyFilesShareIdCacheKey, + DriveEntitiesSerializerContext.Default.ShareId, + cancellationToken).ConfigureAwait(false); + + return exists ? value : null; + } + + public ValueTask SetPhotosShareIdAsync(ShareId shareId, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(shareId, DriveEntitiesSerializerContext.Default.ShareId); + + return _repository.SetAsync(PhotosShareIdCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask TryGetPhotosShareIdAsync(CancellationToken cancellationToken) + { + var (exists, value) = await _repository.TryGetDeserializedValueAsync( + PhotosShareIdCacheKey, + DriveEntitiesSerializerContext.Default.ShareId, + cancellationToken).ConfigureAwait(false); + + return exists ? value : null; + } + + public ValueTask SetShareAsync(Share share, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(share, DriveEntitiesSerializerContext.Default.Share); + + return _repository.SetAsync(GetShareCacheKey(share.Id), serializedValue, cancellationToken); + } + + public async ValueTask TryGetShareAsync(ShareId shareId, CancellationToken cancellationToken) + { + var (exists, share) = await _repository.TryGetDeserializedValueAsync( + GetShareCacheKey(shareId), + DriveEntitiesSerializerContext.Default.Share, + cancellationToken).ConfigureAwait(false); + + return exists ? share : null; + } + + public ValueTask SetNodeAsync( + NodeUid nodeId, + Node node, + ShareId? membershipShareId, + ReadOnlyMemory nameHashDigest, + CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize( + new CachedNodeInfo(node, membershipShareId, nameHashDigest), + DriveEntitiesSerializerContext.Default.CachedNodeInfo); + + return _repository.SetAsync(GetNodeCacheKey(nodeId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetNodeAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var (exists, node) = await _repository.TryGetDeserializedValueAsync( + GetNodeCacheKey(nodeId), + DriveEntitiesSerializerContext.Default.CachedNodeInfo, + cancellationToken).ConfigureAwait(false); + + return exists ? node : null; + } + + public async ValueTask RemoveNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + await _repository.RemoveAsync(GetNodeCacheKey(nodeUid), cancellationToken).ConfigureAwait(false); + } + + public ValueTask ClearAsync() + { + return _repository.ClearAsync(); + } + + private static string GetShareCacheKey(ShareId shareId) + { + return $"share:{shareId}"; + } + + private static string GetNodeCacheKey(NodeUid nodeId) + { + return $"node:{nodeId}"; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs new file mode 100644 index 00000000..d5ed825d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/DriveSecretCache.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk.Caching; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Caching; + +internal sealed class DriveSecretCache(ICacheRepository repository) : IDriveSecretCache +{ + private readonly ICacheRepository _repository = repository; + + public ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(shareKey, SecretsSerializerContext.Default.PgpPrivateKey); + + return _repository.SetAsync(GetShareKeyCacheKey(shareId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken) + { + var (exists, shareKey) = await _repository.TryGetDeserializedValueAsync( + GetShareKeyCacheKey(shareId), + SecretsSerializerContext.Default.PgpPrivateKey, + cancellationToken).ConfigureAwait(false); + + return exists ? shareKey : null; + } + + public ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets secrets, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(secrets, DriveSecretsSerializerContext.Default.FolderSecrets); + + return _repository.SetAsync(GetFolderSecretsCacheKey(nodeId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var (exists, folderSecrets) = await _repository.TryGetDeserializedValueAsync( + GetFolderSecretsCacheKey(nodeId), + DriveSecretsSerializerContext.Default.FolderSecrets, + cancellationToken).ConfigureAwait(false); + + return exists ? folderSecrets : null; + } + + public ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets secrets, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(secrets, DriveSecretsSerializerContext.Default.FileSecrets); + + return _repository.SetAsync(GetFileSecretsCacheKey(nodeId), serializedValue, cancellationToken); + } + + public async ValueTask TryGetFileSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken) + { + var (exists, fileSecrets) = await _repository.TryGetDeserializedValueAsync( + GetFileSecretsCacheKey(nodeId), + DriveSecretsSerializerContext.Default.FileSecrets, + cancellationToken).ConfigureAwait(false); + + return exists ? fileSecrets : null; + } + + 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/Caching/IDriveClientCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs new file mode 100644 index 00000000..363b1bc7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveClientCache.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveClientCache +{ + IDriveEntityCache Entities { get; } + IDriveSecretCache Secrets { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs new file mode 100644 index 00000000..1bca1f32 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveEntityCache.cs @@ -0,0 +1,29 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveEntityCache : IEntityCache +{ + ValueTask SetClientUidAsync(string clientUid, CancellationToken cancellationToken); + ValueTask TryGetClientUidAsync(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); + + 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 new file mode 100644 index 00000000..87855553 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IDriveSecretCache.cs @@ -0,0 +1,22 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IDriveSecretCache +{ + ValueTask SetShareKeyAsync(ShareId shareId, PgpPrivateKey shareKey, CancellationToken cancellationToken); + + ValueTask TryGetShareKeyAsync(ShareId shareId, CancellationToken cancellationToken); + + ValueTask SetFolderSecretsAsync(NodeUid nodeId, FolderSecrets secrets, CancellationToken cancellationToken); + + ValueTask TryGetFolderSecretsAsync(NodeUid nodeId, CancellationToken cancellationToken); + + ValueTask SetFileSecretsAsync(NodeUid nodeId, FileSecrets secrets, 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 new file mode 100644 index 00000000..3453567f --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Caching/IEntityCache.cs @@ -0,0 +1,11 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Caching; + +internal interface IEntityCache +{ + 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/Cryptography/CryptoGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs new file mode 100644 index 00000000..be896d20 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/CryptoGenerator.cs @@ -0,0 +1,37 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Cryptography; + +internal static class CryptoGenerator +{ + private const int PassphraseMaxUtf8Length = ((PassphraseRandomBytesLength + 2) / 3) * 4; + private const int PassphraseRandomBytesLength = 32; + private const int FolderHashKeyLength = 32; + + public static int PassphraseBufferRequiredLength => PassphraseMaxUtf8Length; + + public static ReadOnlySpan GeneratePassphrase(Span buffer) + { + var randomBytes = buffer[..PassphraseRandomBytesLength]; + RandomNumberGenerator.Fill(randomBytes); + Base64.EncodeToUtf8InPlace(buffer, PassphraseRandomBytesLength, out var length); + return buffer[..length]; + } + + public static PgpPrivateKey GeneratePrivateKey() + { + return PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default); + } + + public static byte[] GenerateFolderHashKey() + { + return RandomNumberGenerator.GetBytes(FolderHashKeyLength); + } + + public static PgpSessionKey GenerateSessionKey() + { + return PgpSessionKey.Generate(); + } +} 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..7bbea784 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Cryptography/HashingReadStream.cs @@ -0,0 +1,80 @@ +using System.Security.Cryptography; + +namespace Proton.Drive.Sdk.Cryptography; + +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; + + 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 + { + 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/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/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/ExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs new file mode 100644 index 00000000..9b8f0fb2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ExceptionExtensions.cs @@ -0,0 +1,147 @@ +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 ProtonDriveError ToProtonDriveError(this Exception exception) + { + return new ProtonDriveError(exception.Message, exception.InnerException?.ToProtonDriveError()); + } + + // TODO: Find a way to share the share this logic with ProtonDriveErrorExtensions.FlattenMessage + 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; + } + + private 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/FifoFlexibleSemaphore.cs b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs new file mode 100644 index 00000000..fe5fea92 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/FifoFlexibleSemaphore.cs @@ -0,0 +1,107 @@ +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 class FifoFlexibleSemaphore +{ + private readonly Queue<(int Increment, TaskCompletionSource TaskCompletionSource)> _waitingQueue = new(); + + public FifoFlexibleSemaphore(int maximumCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); + + MaximumCount = maximumCount; + CurrentCount = 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); + + TaskCompletionSource tcs; + lock (_waitingQueue) + { + if (CurrentCount > 0) + { + CurrentCount -= count; + return; + } + + tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _waitingQueue.Enqueue((count, tcs)); + } + + var cancellationTokenRegistration = cancellationToken.Register(() => tcs.TrySetCanceled()); + + if (cancellationToken.IsCancellationRequested) + { + await cancellationTokenRegistration.DisposeAsync().ConfigureAwait(false); + return; + } + + await WaitAsync().ConfigureAwait(false); + + return; + + async ValueTask WaitAsync() + { + await using (cancellationTokenRegistration.ConfigureAwait(false)) + { + await tcs.Task.ConfigureAwait(false); + } + } + } + + public void DecreaseCount(int count) + { + lock (_waitingQueue) + { + CurrentCount -= count; + } + } + + public void Release(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + lock (_waitingQueue) + { + if (CurrentCount + count > 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; + + if (taskCompletionSource.TrySetResult()) + { + CurrentCount -= countToDecrement; + } + } + } + } +} 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..5379fea8 --- /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, double timeoutSeconds) + { + var client = httpClientFactory.CreateClient(); + + client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + + return client; + } +} 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/IAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs new file mode 100644 index 00000000..3ae20b61 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/IAccountClient.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk; + +public interface IAccountClient +{ + ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask
GetDefaultAddressAsync(CancellationToken cancellationToken); + ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken); + ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs new file mode 100644 index 00000000..a6da5214 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/IntegerExtensions.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk; + +internal static class IntegerExtensions +{ + internal static long DivideAndRoundUp(this long dividend, long divisor) + { + return (dividend + divisor - 1) / divisor; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs new file mode 100644 index 00000000..86fea81e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/JsonExceptionExtensions.cs @@ -0,0 +1,60 @@ +using System.Text.Json; + +namespace Proton.Drive.Sdk; + +internal static class JsonExceptionExtensions +{ + internal static ProtonDriveError ToEnrichedProtonDriveError(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 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(); + } + } + + 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/NodeWithSameNameExistsException.cs b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs new file mode 100644 index 00000000..548a49ff --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/NodeWithSameNameExistsException.cs @@ -0,0 +1,59 @@ +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 : ValidationException +{ + 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; + } + + Code = response.Code; + + var conflict = RevisionConflict.FromErrorResponse(response); + + ConflictingNodeIsFileDraft = conflict is { RevisionId: null, DraftRevisionId: not null }; + + if (conflict is { LinkId: { } linkId }) + { + var conflictingNodeUid = new NodeUid(volumeId, linkId); + + ConflictingNodeUid = conflictingNodeUid; + + if (conflict.RevisionId is { } revisionId) + { + ConflictingRevisionUid = new RevisionUid(conflictingNodeUid, revisionId); + } + else if (conflict.DraftRevisionId is { } draftRevisionId) + { + ConflictingRevisionUid = new RevisionUid(conflictingNodeUid, draftRevisionId); + ConflictingNodeIsFileDraft = true; + } + } + } + + public bool? ConflictingNodeIsFileDraft { get; } + public NodeUid? ConflictingNodeUid { get; } + public RevisionUid? ConflictingRevisionUid { get; } +} 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/AuthorshipClaim.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs new file mode 100644 index 00000000..42c1fd11 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaim.cs @@ -0,0 +1,39 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct AuthorshipClaim(Author author, IReadOnlyList keys, ProtonDriveError? keyRetrievalError = null) +{ + public readonly IReadOnlyList Keys { get; } = keys; + + public Author Author { get; } = author; + + public ProtonDriveError? KeyRetrievalError { get; } = keyRetrievalError; + + public static async ValueTask CreateAsync( + IAccountClient accountClient, + string? claimedAuthorEmailAddress, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(claimedAuthorEmailAddress)) + { + return new AuthorshipClaim(Author.Anonymous, []); + } + + try + { + var keys = await accountClient.GetAddressPublicKeysAsync(claimedAuthorEmailAddress, cancellationToken).ConfigureAwait(false); + + return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, keys); + } + catch (Exception e) + { + return new AuthorshipClaim(new Author { EmailAddress = claimedAuthorEmailAddress }, [], e.ToProtonDriveError()); + } + } + + public PgpKeyRing GetKeyRing(PgpPrivateKey anonymousFallbackKey) + { + return Author != Author.Anonymous ? new PgpKeyRing(Keys) : anonymousFallbackKey; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs new file mode 100644 index 00000000..4137189d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/AuthorshipClaimExtensions.cs @@ -0,0 +1,21 @@ +using Proton.Drive.Sdk.Nodes.Cryptography; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class AuthorshipClaimExtensions +{ + public static Result ToAuthorshipResult( + this AuthorshipClaim authorshipClaim, + AuthorshipVerificationFailure? verificationFailure) + { + if (verificationFailure is not null) + { + var error = authorshipClaim.KeyRetrievalError ?? verificationFailure.Value.Error; + + 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/DecryptionError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs new file mode 100644 index 00000000..671e0b9e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionError.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class DecryptionError(string message, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) +{ +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs new file mode 100644 index 00000000..fb26bd15 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/DecryptionOutput.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal readonly record struct DecryptionOutput(TData Data, AuthorshipVerificationFailure? AuthorshipVerificationFailure = null); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs new file mode 100644 index 00000000..95d53d2b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FileDecryptionResult.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class FileDecryptionResult +{ + public required LinkDecryptionResult Link { 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 new file mode 100644 index 00000000..49b3f4b8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/FolderDecryptionResult.cs @@ -0,0 +1,9 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class FolderDecryptionResult +{ + public required LinkDecryptionResult Link { get; init; } + public required 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 new file mode 100644 index 00000000..b8c52848 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/LinkDecryptionResult.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal sealed class LinkDecryptionResult +{ + public required Result>, ProtonDriveError> Passphrase { get; init; } + public required AuthorshipClaim NodeAuthorshipClaim { get; init; } + public required Result, ProtonDriveError> Name { get; init; } + public required AuthorshipClaim NameAuthorshipClaim { 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 new file mode 100644 index 00000000..70a9c7e3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/NodeCrypto.cs @@ -0,0 +1,321 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal static class NodeCrypto +{ + public static async ValueTask DecryptFolderAsync( + IAccountClient accountClient, + LinkDto link, + PgpArmoredMessage folderHashKey, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var linkDecryptionResult = await DecryptLinkAsync(accountClient, link, parentKey, cancellationToken).ConfigureAwait(false); + + var hashKeyResult = DecryptHashKey(folderHashKey, linkDecryptionResult.NodeKey, linkDecryptionResult.NodeAuthorshipClaim); + + return new FolderDecryptionResult + { + Link = linkDecryptionResult, + HashKey = hashKeyResult, + }; + } + + public static async ValueTask DecryptFileAsync( + IAccountClient accountClient, + LinkDto linkDto, + FileDto fileDto, + ActiveRevisionDto activeRevisionDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var contentAuthorshipClaim = + await AuthorshipClaim.CreateAsync(accountClient, activeRevisionDto.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + var linkDecryptionResult = await DecryptLinkAsync(accountClient, linkDto, parentKey, cancellationToken).ConfigureAwait(false); + + var contentKeyDecryptionResult = DecryptContentKey( + linkDecryptionResult.NodeKey, + fileDto.ContentKeyPacket, + fileDto.ContentKeySignature, + linkDecryptionResult.NodeAuthorshipClaim); + + var extendedAttributesResult = DecryptExtendedAttributes( + activeRevisionDto.ExtendedAttributes, + linkDecryptionResult.NodeKey, + contentAuthorshipClaim); + + return new FileDecryptionResult + { + Link = linkDecryptionResult, + ContentKey = contentKeyDecryptionResult, + ExtendedAttributes = extendedAttributesResult, + ContentAuthorshipClaim = contentAuthorshipClaim, + }; + } + + public static byte[] HashNodeName(string name, ReadOnlySpan parentFolderHashKey) + { + var maxNameByteLength = Encoding.UTF8.GetMaxByteCount(name.Length); + var nameBytes = MemoryPolicy.GetRentedHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; + + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; + + return HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } + } + + 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.ToEnrichedProtonDriveError(serializedExtendedAttributes)); + } + catch (Exception e) + { + return new ProtonDriveError("Unknown error while deserializing extended attributes", e.ToProtonDriveError()); + } + } + + private static async ValueTask DecryptLinkAsync( + IAccountClient accountClient, + LinkDto link, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var nodeAuthorshipClaim = await AuthorshipClaim.CreateAsync(accountClient, link.SignatureEmailAddress, cancellationToken).ConfigureAwait(false); + + var nameAuthorshipClaim = link.NameSignatureEmailAddress != link.SignatureEmailAddress + ? await AuthorshipClaim.CreateAsync(accountClient, link.NameSignatureEmailAddress, cancellationToken).ConfigureAwait(false) + : nodeAuthorshipClaim; + + var nameResult = DecryptName(link.Name, parentKey, nameAuthorshipClaim); + var passphraseResult = DecryptPassphrase(parentKey, link.Passphrase, link.PassphraseSignature, nodeAuthorshipClaim); + + var nodeKeyResult = UnlockNodeKey(link.Key, passphraseResult); + + return new LinkDecryptionResult + { + Passphrase = passphraseResult, + NodeAuthorshipClaim = nodeAuthorshipClaim, + Name = nameResult, + NameAuthorshipClaim = nameAuthorshipClaim, + NodeKey = nodeKeyResult, + }; + } + + private static Result>, ProtonDriveError> DecryptPassphrase( + PgpPrivateKey parentNodeKey, + PgpArmoredMessage encryptedPassphrase, + PgpArmoredSignature? signature, + AuthorshipClaim authorshipClaim) + { + try + { + var passphrase = DecryptMessage( + encryptedPassphrase, + signature, + parentNodeKey, + authorshipClaim.GetKeyRing(parentNodeKey), + out var sessionKey, + out var author); + + return new PhasedDecryptionOutput>(sessionKey, passphrase, author); + } + catch (Exception e) + { + return new ProtonDriveError("Failed to decrypt passphrase", e.ToProtonDriveError()); + } + } + + private static Result UnlockNodeKey( + PgpArmoredPrivateKey lockedKey, + Result>, ProtonDriveError> passphraseResult) + { + if (!passphraseResult.TryGetValueElseError(out var passphrase, out var error)) + { + return new ProtonDriveError("Cannot get passphrase", error); + } + + try + { + return PgpPrivateKey.ImportAndUnlock(lockedKey, passphrase.Data.Span); + } + catch (Exception e) + { + return new ProtonDriveError("Failed to import and unlock passphrase", e.ToProtonDriveError()); + } + } + + private static Result, ProtonDriveError> DecryptName( + PgpArmoredMessage encryptedName, + PgpPrivateKey parentNodeKey, + AuthorshipClaim authorshipClaim) + { + try + { + var nameUtf8Bytes = DecryptMessage( + encryptedName, + detachedSignature: null, + parentNodeKey, + authorshipClaim.GetKeyRing(parentNodeKey), + out var sessionKey, + out var author); + + var name = Encoding.UTF8.GetString(nameUtf8Bytes); + + return new PhasedDecryptionOutput(sessionKey, name, author); + } + catch (Exception e) + { + return new ProtonDriveError("Failed to decrypt name", e.ToProtonDriveError()); + } + } + + private static Result>, ProtonDriveError> DecryptHashKey( + PgpArmoredMessage encryptedHashKey, + Result nodeKeyResult, + AuthorshipClaim authorshipClaim) + { + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) + { + return new ProtonDriveError("Cannot decrypt hash key without node key", error); + } + + try + { + 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 new ProtonDriveError("Failed to decrypt hash key", e.ToProtonDriveError()); + } + } + + 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, ProtonDriveError> DecryptContentKey( + Result nodeKeyResult, + ReadOnlyMemory contentKeyPacket, + PgpArmoredSignature? contentKeySignature, + AuthorshipClaim nodeAuthorshipClaim) + { + if (!nodeKeyResult.TryGetValueElseError(out var nodeKey, out var error)) + { + return new ProtonDriveError("Cannot get node key", error); + } + + PgpSessionKey contentKey; + try + { + contentKey = nodeKey.DecryptSessionKey(contentKeyPacket.Span); + } + catch (Exception e) + { + return new ProtonDriveError("Cannot decrypt session key", e.ToProtonDriveError()); + } + + var verificationKeyRing = GetContentKeyAndHashKeyVerificationKeyRing(nodeKey, nodeAuthorshipClaim); + + AuthorshipVerificationFailure? verificationFailure; + try + { + var verificationStatus = contentKeySignature is not null + ? verificationKeyRing.Verify(contentKey.Export(), contentKeySignature.Value).Status + : PgpVerificationStatus.NotSigned; + + verificationFailure = verificationStatus is not PgpVerificationStatus.Ok + ? new AuthorshipVerificationFailure(verificationStatus) + : null; + } + catch (Exception e) + { + verificationFailure = new AuthorshipVerificationFailure(PgpVerificationStatus.Failed, e.ToProtonDriveError()); + } + + return new DecryptionOutput(contentKey, verificationFailure); + } + + private static ArraySegment DecryptMessage( + PgpArmoredMessage encryptedMessage, + PgpArmoredSignature? detachedSignature, + PgpPrivateKey decryptionKey, + PgpKeyRing verificationKeyRing, + out PgpSessionKey sessionKey, + out AuthorshipVerificationFailure? authorshipVerificationFailure) + { + sessionKey = decryptionKey.DecryptSessionKey(encryptedMessage); + + var plaintext = detachedSignature is not null + ? sessionKey.DecryptAndVerify(encryptedMessage.Bytes.Span, detachedSignature.Value.Bytes.Span, verificationKeyRing, out var verificationResult) + : sessionKey.DecryptAndVerify(encryptedMessage, verificationKeyRing, out verificationResult); + + authorshipVerificationFailure = verificationResult.Status is not PgpVerificationStatus.Ok + ? new AuthorshipVerificationFailure(verificationResult.Status) + : null; + + return plaintext; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs new file mode 100644 index 00000000..ae5199ee --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Cryptography/PhasedDecryptionOutput.cs @@ -0,0 +1,10 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes.Cryptography; + +internal readonly record struct PhasedDecryptionOutput( + PgpSessionKey SessionKey, + TData Data, + AuthorshipVerificationFailure? AuthorshipVerificationFailure = null); + +internal readonly record struct AuthorshipVerificationFailure(PgpVerificationStatus Status, ProtonDriveError? Error = 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 new file mode 100644 index 00000000..95bcd8f5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/BlockDownloader.cs @@ -0,0 +1,104 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Polly; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Resilience; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes.Download; + +internal sealed partial class BlockDownloader +{ + private readonly ProtonDriveClient _client; + private readonly ILogger _logger; + + internal BlockDownloader(ProtonDriveClient client) + { + _client = client; + _logger = client.Telemetry.GetLogger("Block downloader"); + } + + public async ValueTask> DownloadAsync( + RevisionUid revisionUid, + int index, + string bareUrl, + string token, + PgpSessionKey contentKey, + Stream outputStream, + CancellationToken cancellationToken) + { + return await Policy + .Handle(ex => !cancellationToken.IsCancellationRequested && IsExceptionRetriable(ex)) + .WaitAndRetryAsync( + retryCount: 4, + sleepDurationProvider: RetryPolicy.GetAttemptDelay, + 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 + { + 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); + + await using (hashingStream.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); + } + } + } + + 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); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs new file mode 100644 index 00000000..f4fc4652 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/CompletedDownloadManifestVerificationException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +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 new file mode 100644 index 00000000..8d1fab6e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DataIntegrityException.cs @@ -0,0 +1,18 @@ +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/Download/DownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs new file mode 100644 index 00000000..142f90f2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadController.cs @@ -0,0 +1,177 @@ +using Proton.Sdk.Threading; + +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class DownloadController : IAsyncDisposable +{ + private readonly Task _downloadStateTask; + private readonly Func _resumeFunction; + private readonly ITaskControl _taskControl; + private readonly Stream? _outputStreamToDispose; + private readonly Func? _onFailedAsync; + private readonly Func? _onSucceededAsync; + + private bool _isDownloadCompleteWithVerificationIssue; + + internal DownloadController( + Task downloadStateTask, + Task downloadTask, + Func resumeFunction, + Stream? outputStreamToDispose, + ITaskControl taskControl, + Func? onFailedAsync = null, + Func? onSucceededAsync = null) + { + _downloadStateTask = downloadStateTask; + _resumeFunction = resumeFunction; + _taskControl = taskControl; + _outputStreamToDispose = outputStreamToDispose; + _onFailedAsync = onFailedAsync; + _onSucceededAsync = onSucceededAsync; + + Completion = PauseOnResumableErrorAsync(downloadTask, taskControl.Attempt); + } + + public bool IsPaused => _taskControl.IsPaused; + + public Task Completion { get; private set; } + + public bool GetIsDownloadCompleteWithVerificationIssue() + { + return _isDownloadCompleteWithVerificationIssue; + } + + public void Pause() + { + _taskControl.Pause(); + } + + public void Resume() + { + if (!_taskControl.TryResume()) + { + return; + } + + var previousCompletion = Completion; + Completion = ResumeAfterPreviousCompletionAsync(previousCompletion, _taskControl.Attempt); + } + + public async ValueTask DisposeAsync() + { + try + { + try + { + Exception? exception = null; + try + { + await Completion.ConfigureAwait(false); + } + catch (Exception ex) + { + exception = ex; + } + + var downloadState = _downloadStateTask.GetResultIfCompletedSuccessfully(); + + try + { + if (exception is not null and not OperationCanceledException && _onFailedAsync is not null) + { + await _onFailedAsync.Invoke( + exception, + downloadState?.ClaimedSize, + downloadState?.GetNumberOfBytesWritten() ?? 0).ConfigureAwait(false); + } + } + finally + { + if (downloadState is not null) + { + await downloadState.DisposeAsync().ConfigureAwait(false); + } + } + } + finally + { + _taskControl.Dispose(); + } + } + finally + { + if (_outputStreamToDispose is not null) + { + await _outputStreamToDispose.DisposeAsync().ConfigureAwait(false); + } + } + } + + 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 + { + await downloadTask.ConfigureAwait(false); + + await FinalizeDownloadAsync().ConfigureAwait(false); + } + catch (CompletedDownloadManifestVerificationException error) + { + _isDownloadCompleteWithVerificationIssue = true; + throw new DataIntegrityException(error.Message, error); + } + catch (Exception) when (IsResumable()) + { + if (_taskControl.Attempt == attempt && !_taskControl.IsPaused) + { + _taskControl.Pause(); + } + + throw; + } + catch + { + if (_taskControl.IsPaused) + { + _taskControl.AbortPause(); + } + + throw; + } + } + + private async ValueTask FinalizeDownloadAsync() + { + if (_outputStreamToDispose is not null) + { + await _outputStreamToDispose.FlushAsync().ConfigureAwait(false); + } + + var onSucceededHandler = _onSucceededAsync; + if (onSucceededHandler is null) + { + return; + } + + var downloadState = await _downloadStateTask.ConfigureAwait(false); + + await onSucceededHandler.Invoke( + downloadState.ClaimedSize, + 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 new file mode 100644 index 00000000..b817da1a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/DownloadState.cs @@ -0,0 +1,85 @@ +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, + long? claimedSize, + long queueToken, + 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 long? ClaimedSize { get; } = claimedSize; + public long QueueToken { get; } = queueToken; + public PgpPrivateKey NodeKey { get; } = nodeKey; + public PgpSessionKey ContentKey { get; } = contentKey; + public bool IsResumable { get; set; } = true; + + 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/FileContentsDecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs new file mode 100644 index 00000000..c778a8e4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileContentsDecryptionException.cs @@ -0,0 +1,23 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class FileContentsDecryptionException : ProtonDriveException +{ + public FileContentsDecryptionException() + { + } + + public FileContentsDecryptionException(string message) + : base(message) + { + } + + public FileContentsDecryptionException(string message, Exception innerException) + : base(message, innerException) + { + } + + public FileContentsDecryptionException(Exception innerException) + : this("Failed to decrypt file contents", innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs new file mode 100644 index 00000000..7ae74c54 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/FileDownloader.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.Threading; + +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 FileDownloader(ProtonDriveClient client, long queueToken, RevisionUid revisionUid, ILogger logger) + { + _client = client; + _queueToken = queueToken; + _revisionUid = revisionUid; + _logger = logger; + } + + 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) + { + var contentOutputStream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + + return BuildDownloadController(contentOutputStream, ownsOutputStream: true, onProgress, cancellationToken); + } + + public void Dispose() + { + _client.DownloadQueue.RemoveFileFromQueue(_queueToken); + } + + internal static FileDownloader? TryCreate(ProtonDriveClient client, RevisionUid revisionUid) + { + const int initialEstimatedNumberOfBlocks = 1; + + if (client.DownloadQueue.TryEnqueueFile(initialEstimatedNumberOfBlocks) is not { } queueToken) + { + return null; + } + + return new FileDownloader( + client, + queueToken, + revisionUid, + client.Telemetry.GetLogger("File downloader")); + } + + internal static async ValueTask CreateAsync(ProtonDriveClient client, RevisionUid revisionUid, CancellationToken cancellationToken) + { + const int initialEstimatedNumberOfBlocks = 1; + + var queueToken = await client.DownloadQueue.EnqueueFileAsync(initialEstimatedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + + 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); + + 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, + queueToken, + forPhotos: false, + cancellationToken).ConfigureAwait(false); + + downloadStateTaskCompletionSource.SetResult(downloadState); + } + + var revisionReader = RevisionOperations.OpenForReading(_client, downloadState); + + await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + + private DownloadController BuildDownloadController( + Stream contentOutputStream, + bool ownsOutputStream, + Action onProgress, + CancellationToken cancellationToken) + { + var taskControl = new TaskControl(cancellationToken); + + var downloadStateTaskCompletionSource = new TaskCompletionSource(); + + var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( + contentOutputStream, + onProgress, + downloadStateTaskCompletionSource, + _queueToken, + ct); + + return new DownloadController( + downloadStateTaskCompletionSource.Task, + downloadFunction.Invoke(taskControl.PauseOrCancellationToken), + downloadFunction, + ownsOutputStream ? contentOutputStream : null, + taskControl, + OnFailedAsync, + OnSucceededAsync); + + async ValueTask OnFailedAsync(Exception ex, long? claimedFileSize, long downloadedByteCount) + { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + 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); + } + + async ValueTask OnSucceededAsync(long? claimedFileSize, long downloadedByteCount) + { + var downloadEvent = await TelemetryEventFactory.CreateDownloadEventAsync(_client, _revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + + downloadEvent.ClaimedFileSize = claimedFileSize; + downloadEvent.ApproximateClaimedFileSize = Privacy.ReduceSizePrecision(claimedFileSize); + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + + RaiseTelemetryEvent(downloadEvent); + } + } + + private void RaiseTelemetryEvent(DownloadEvent downloadEvent) + { + try + { + _client.Telemetry.RecordMetric(downloadEvent); + } + catch (Exception ex) + { + LogTelemetryEventFailed(_logger, ex); + } + } +} 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..739f8875 --- /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/Download/NodeMetadataDecryptionException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs new file mode 100644 index 00000000..e66ba0dd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataDecryptionException.cs @@ -0,0 +1,26 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed class NodeMetadataDecryptionException : Exception +{ + public NodeMetadataDecryptionException() + { + } + + public NodeMetadataDecryptionException(string message) + : base(message) + { + } + + public NodeMetadataDecryptionException(string message, Exception innerException) + : base(message, innerException) + { + } + + internal NodeMetadataDecryptionException(NodeMetadataPart part, Exception innerException) + : base($"Failed to decrypt node metadata: {part.ToString()}", innerException) + { + Part = part; + } + + internal NodeMetadataPart Part { get; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs new file mode 100644 index 00000000..5fad1ebb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/NodeMetadataPart.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Nodes.Download; + +internal enum NodeMetadataPart +{ + Key = 0, + Passphrase = 1, + Name = 2, + ExtendedAttributes = 3, + ContentKey = 4, + HashKey = 5, + BlockSignature = 6, + Thumbnail = 7, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs new file mode 100644 index 00000000..ee0bac52 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/PhotosFileDownloader.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Nodes.Download; + +public sealed partial class PhotosFileDownloader : IFileDownloader +{ + private readonly ProtonPhotosClient _client; + private readonly NodeUid _photoUid; + private readonly long _queueToken; + private readonly ILogger _logger; + + private PhotosFileDownloader(ProtonPhotosClient client, NodeUid photoUid, long queueToken, ILogger logger) + { + _client = client; + _photoUid = photoUid; + _queueToken = queueToken; + _logger = logger; + } + + 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) + { + var stream = File.Open(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + + return DownloadToStream(stream, ownsOutputStream: true, onProgress, cancellationToken); + } + + public void Dispose() + { + _client.DriveClient.DownloadQueue.RemoveFileFromQueue(_queueToken); + } + + internal static PhotosFileDownloader? TryCreate(ProtonPhotosClient client, NodeUid photoUid) + { + const int initialEstimatedNumberOfBlocks = 1; + + if (client.DriveClient.DownloadQueue.TryEnqueueFile(initialEstimatedNumberOfBlocks) is not { } queueToken) + { + return null; + } + + return new PhotosFileDownloader( + client, + photoUid, + queueToken, + client.DriveClient.Telemetry.GetLogger("Photos file downloader")); + } + + internal static async ValueTask CreateAsync(ProtonPhotosClient client, NodeUid photoUid, CancellationToken cancellationToken) + { + const int initialEstimatedNumberOfBlocks = 1; + + var queuePosition = await client.DriveClient.DownloadQueue.EnqueueFileAsync(initialEstimatedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + + 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); + + private async Task DownloadToStreamAsync( + Stream contentOutputStream, + Action onProgress, + TaskCompletionSource downloadStateTaskCompletionSource, + CancellationToken cancellationToken) + { + var result = await _client.GetNodeAsync(_photoUid, cancellationToken).ConfigureAwait(false); + + if (result is not FileNode fileNode) + { + throw new NodeNotFoundException(_photoUid); + } + + if (!downloadStateTaskCompletionSource.Task.IsCompletedSuccessfully) + { + var state = await RevisionOperations.CreateDownloadStateAsync( + _client.DriveClient, + fileNode.ActiveRevision.Uid, + _queueToken, + forPhotos: true, + cancellationToken).ConfigureAwait(false); + + downloadStateTaskCompletionSource.SetResult(state); + } + + var downloadState = await downloadStateTaskCompletionSource.Task.ConfigureAwait(false); + + var revisionReader = RevisionOperations.OpenForReading(_client.DriveClient, downloadState); + + await revisionReader.ReadAsync(contentOutputStream, onProgress, cancellationToken).ConfigureAwait(false); + } + + private DownloadController DownloadToStream( + Stream contentOutputStream, + bool ownsOutputStream, + Action onProgress, + CancellationToken cancellationToken) + { + var taskControl = new TaskControl(cancellationToken); + + var downloadStateTaskCompletionSource = new TaskCompletionSource(); + + var downloadFunction = (CancellationToken ct) => DownloadToStreamAsync( + contentOutputStream, + onProgress, + downloadStateTaskCompletionSource, + ct); + + return new DownloadController( + downloadStateTaskCompletionSource.Task, + downloadFunction.Invoke(taskControl.PauseOrCancellationToken), + downloadFunction, + ownsOutputStream ? contentOutputStream : null, + taskControl, + OnFailedAsync, + OnSucceededAsync); + + 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); + downloadEvent.DownloadedSize = downloadedByteCount; + downloadEvent.ApproximateDownloadedSize = Privacy.ReduceSizePrecision(downloadedByteCount); + downloadEvent.Error = TelemetryErrorResolver.GetDownloadErrorFromException(ex); + downloadEvent.OriginalError = ex; + RaiseTelemetryEvent(downloadEvent); + } + + 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); + } + } + + private void RaiseTelemetryEvent(DownloadEvent downloadEvent) + { + try + { + _client.DriveClient.Telemetry.RecordMetric(downloadEvent); + } + catch (Exception ex) + { + 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 new file mode 100644 index 00000000..71e44584 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Download/RevisionReader.cs @@ -0,0 +1,369 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Microsoft.IO; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Nodes.Download; + +internal sealed partial class RevisionReader +{ + 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; + private readonly int _blockPageSize; + private readonly ILogger _logger; + + internal RevisionReader( + ProtonDriveClient client, + DownloadState state, + int blockPageSize = DefaultBlockPageSize) + { + _client = client; + _state = state; + _blockPageSize = blockPageSize; + _logger = client.Telemetry.GetLogger("Revision reader"); + } + + public async ValueTask ReadAsync(Stream contentOutputStream, Action onProgress, CancellationToken cancellationToken) + { + try + { + var revisionDto = _state.RevisionDto; + var downloadedBlockDigests = _state.GetDownloadedBlockDigests(); + + var manifestStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (manifestStream) + { + if (revisionDto.Thumbnails is { } thumbnails) + { + 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); + } + + await DownloadBlocks( + contentOutputStream, + downloaded => onProgress(downloaded, _state.ClaimedSize), + manifestStream, + cancellationToken).ConfigureAwait(false); + + 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(); + } + } + catch (Exception ex) when (!IsResumableError(ex)) + { + _state.IsResumable = false; + throw; + } + } + + private static bool IsResumableError(Exception ex) + { + return ex is not DataIntegrityException + and not ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } + 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, + Stream manifestStream, + Action onProgress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var downloadTask = downloadTasks.Dequeue(); + + 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 + { + var (plaintextStream, blockDigest) = await downloadTask.ConfigureAwait(false); + + try + { + 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); + + _client.DownloadQueue.DecreaseFileRemainingBlockCount(_state.QueueToken, 1); + + onProgress(_state.GetNumberOfBytesWritten()); + } + finally + { + await plaintextStream.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + _client.DownloadQueue.DequeueBlocks(1); + } + } + } + + private bool TrySeekOutputStream(Stream stream, long position) + { + if (!stream.CanSeek) + { + return false; + } + + try + { + stream.Seek(position, SeekOrigin.Begin); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Seeking output stream failed"); + return false; + } + } + + private async Task DownloadBlockAsync(BlockDto block, CancellationToken cancellationToken) + { + var blockOutputStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + var hashDigest = await _client.BlockDownloader.DownloadAsync( + _state.Uid, + block.Index, + block.BareUrl, + block.Token, + _state.ContentKey, + blockOutputStream, + cancellationToken).ConfigureAwait(false); + + return new BlockDownloadResult(blockOutputStream, hashDigest); + } + + private async IAsyncEnumerable<(BlockDto Value, bool IsLast)> GetBlocksAsync( + int startBlockIndex, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + 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; + + // The first block is already in the queue, so we subtract it from the first page of block results + var initialQueueCountToSubtract = 1; + + while (mustTryNextPageOfBlocks) + { + currentPageBlocks.Clear(); + + cancellationToken.ThrowIfCancellationRequested(); + + if (revisionDto.Blocks.Count == 0) + { + break; + } + + mustTryNextPageOfBlocks = revisionDto.Blocks.Count >= _blockPageSize; + + currentPageBlocks.AddRange(revisionDto.Blocks); + currentPageBlocks.Sort((a, b) => a.Index.CompareTo(b.Index)); + + _client.DownloadQueue.IncreaseFileBlockCount(_state.QueueToken, currentPageBlocks.Count - initialQueueCountToSubtract); + initialQueueCountToSubtract = 0; + + var blocksExceptLast = currentPageBlocks.Take(currentPageBlocks.Count - 1); + var blocksToReturn = outstandingBlock is not null ? blocksExceptLast.Prepend(outstandingBlock) : blocksExceptLast; + + outstandingBlock = currentPageBlocks[^1]; + var lastKnownIndex = outstandingBlock.Index; + + foreach (var block in blocksToReturn) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (block.Index != nextExpectedIndex) + { + LogMissingBlock(block.Index, _state.Uid); + + throw new ProtonDriveException("File contents are incomplete"); + } + + ++nextExpectedIndex; + + yield return (block, false); + } + + if (mustTryNextPageOfBlocks) + { + 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; + } + } + + if (outstandingBlock is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + + yield return (outstandingBlock, true); + } + } + + private async Task VerifyManifestAsync(Stream manifestStream, CancellationToken cancellationToken) + { + if (_state.RevisionDto.ManifestSignature is null) + { + return PgpVerificationStatus.NotSigned; + } + + 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, _state.RevisionDto.ManifestSignature.Value); + + return verificationResult.Status; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Missing block #{BlockIndex} on revision \"{RevisionUid}\"")] + private partial void LogMissingBlock(int blockIndex, RevisionUid revisionUid); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Manifest verification failed for revision \"{RevisionUid}\": {VerificationStatus}")] + private partial void LogFailedManifestVerification(RevisionUid revisionUid, PgpVerificationStatus verificationStatus); + + private readonly record struct BlockDownloadResult(Stream Stream, ReadOnlyMemory Sha256Digest); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs new file mode 100644 index 00000000..ee121ad9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/DtoToMetadataConverter.cs @@ -0,0 +1,738 @@ +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; +using Proton.Drive.Sdk.Api.Photos; +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.Telemetry; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class DtoToMetadataConverter +{ + public static async Task ConvertDtoToNodeMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + ShareAndKey? knownShareAndKey, + CancellationToken cancellationToken) + { + var entryPointKey = linkDetailsDto.Link.ParentId is not null || linkDetailsDto.Photo is not { AlbumInclusions: { Count: > 0 } albumInclusions } + ? await GetEntryPointKeyOrThrowAsync( + client, + volumeId, + linkDetailsDto.Link.ParentId, + knownShareAndKey, + linkDetailsDto.Sharing?.ShareId, + forPhotos: false, + cancellationToken).ConfigureAwait(false) + : await GetAlbumEntryPointKeyOrThrowAsync(client, volumeId, linkDetailsDto, knownShareAndKey, albumInclusions, cancellationToken) + .ConfigureAwait(false); + + return await ConvertDtoToNodeMetadataAsync( + client, + volumeId, + linkDetailsDto, + entryPointKey, + cancellationToken).ConfigureAwait(false); + } + + public static async Task ConvertDtoToNodeMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var linkType = linkDetailsDto.Link.Type; + + var nodeMetadata = linkType switch + { + LinkType.Folder => + NodeMetadata.FromFolder(await ConvertDtoToFolderMetadataAsync( + client, + volumeId, + linkDetailsDto, + parentKey, + cancellationToken).ConfigureAwait(false)), + + LinkType.File => + NodeMetadata.FromFile(await ConvertDtoToFileMetadataAsync( + client, + volumeId, + linkDetailsDto, + parentKey, + cancellationToken).ConfigureAwait(false)), + + LinkType.Album => + NodeMetadata.FromFolder(await ConvertDtoToAlbumMetadataAsync( + client, + volumeId, + linkDetailsDto, + parentKey, + 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."), + }; + + return nodeMetadata; + } + + public static async ValueTask ConvertDtoToFolderMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + if (linkDetailsDto.Folder is null) + { + throw new InvalidOperationException("Node is a folder, but folder properties are missing"); + } + + return await ConvertDtoToFolderMetadataAsync( + client, + volumeId, + linkDetailsDto, + linkDetailsDto.Folder, + parentKey, + cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask ConvertDtoToAlbumMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + if (linkDetailsDto.Album is null) + { + throw new InvalidOperationException("Node is an album, but album properties are missing"); + } + + return await ConvertDtoToFolderMetadataAsync( + client, + volumeId, + linkDetailsDto, + linkDetailsDto.Album, + parentKey, + cancellationToken).ConfigureAwait(false); + } + + public static async Task ConvertDtoToFileMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var linkDto = linkDetailsDto.Link; + var fileDto = linkDetailsDto.File ?? linkDetailsDto.Photo; + var membershipDto = linkDetailsDto.Membership; + + if (fileDto is null) + { + // FIXME: handle missing file information with degraded node + throw new InvalidOperationException("Node is a file, but file properties are missing"); + } + + if (linkDto.State is LinkState.Draft) + { + // We don't currently expect draft nodes + throw new NotSupportedException("Draft nodes are not supported"); + } + + if (fileDto.ActiveRevision is not { } activeRevisionDto) + { + // FIXME: handle missing revision information with degraded node + throw new InvalidOperationException("Node is a non-draft file, but active revision properties are missing"); + } + + var uid = new NodeUid(volumeId, linkDto.Id); + var parentUid = linkDto.ParentId is not null ? (NodeUid?)new NodeUid(uid.VolumeId, linkDto.ParentId.Value) : null; + + var decryptionResult = await NodeCrypto.DecryptFileAsync(client.Account, linkDto, fileDto, activeRevisionDto, parentKey, cancellationToken) + .ConfigureAwait(false); + + 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 thumbnails = activeRevisionDto.Thumbnails.Select(dto => new ThumbnailHeader(dto.Id, (ThumbnailType)dto.Type)).ToList().AsReadOnly(); + + 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 + || !modificationTimeIsValid) + { + var (partialFileMetadata, failedDecryptionFields) = CreatePartialFileMetadata( + linkDetailsDto, + decryptionResult, + nameResult, + uid, + activeRevisionDto, + extendedAttributes, + modificationTimeResult, + thumbnails, + additionalMetadata, + parentUid, + linkDto, + fileDto, + nameSessionKey, + membershipDto); + + await client.Cache.Secrets.SetFileSecretsAsync(uid, partialFileMetadata.Secrets, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetNodeAsync(uid, partialFileMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + .ConfigureAwait(false); + + await TelemetryRecorder.TryRecordDecryptionErrorAsync( + client, + partialFileMetadata.Node, + failedDecryptionFields, + cancellationToken).ConfigureAwait(false); + + return partialFileMetadata; + } + + var secrets = new FileSecrets + { + Key = nodeKey, + PassphraseSessionKey = passphraseOutput.SessionKey, + NameSessionKey = nameSessionKey.Value, + ContentKey = contentKeyOutput.Data, + PassphraseForAnonymousMove = decryptionResult.Link.NodeAuthorshipClaim.Author == Author.Anonymous + ? passphraseOutput.Data + : (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), + CreationTime = activeRevisionDto.CreationTime, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = modificationTimeResult?.GetValueOrDefault(), + ClaimedDigests = + new FileContentDigests + { + Sha1 = extendedAttributes?.Common?.Digests?.Sha1, + Sha1Verified = fileDto.ActiveRevision.ChecksumVerified ?? false, + }, + Thumbnails = thumbnails.AsReadOnly(), + AdditionalClaimedMetadata = additionalMetadata, + ContentAuthor = contentAuthor, + }; + + var ownedBy = MapOwnedBy(linkDto.OwnedBy); + 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, + AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), + OwnedBy = ownedBy, + Errors = [], + } + : 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, + OwnedBy = ownedBy, + Errors = [], + }; + + 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); + } + + private static (FileMetadata Metadata, Dictionary FailedDecryptionFields) CreatePartialFileMetadata( + LinkDetailsDto linkDetailsDto, + FileDecryptionResult decryptionResult, + Result nameResult, + NodeUid uid, + ActiveRevisionDto activeRevisionDto, + ExtendedAttributes? extendedAttributes, + Result? modificationTimeResult, + ReadOnlyCollection thumbnails, + ReadOnlyCollection? additionalMetadata, + NodeUid? parentUid, + LinkDto linkDto, + FileDto fileDto, + PgpSessionKey? nameSessionKey, + ShareMembershipSummaryDto? membershipDto) + { + Dictionary failedDecryptionFields = []; + List nodeErrors = []; + + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + nodeErrors.Add(passphraseError); + + if (passphraseError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + } + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + nodeErrors.Add(nodeKeyError); + + if (nodeKeyError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + } + } + else if (decryptionResult.ContentKey.TryGetError(out var contentKeyError)) + { + failedDecryptionFields.Add(EncryptedField.NodeContentKey, contentKeyError); + } + + if (nameResult.TryGetError(out var nameError)) + { + failedDecryptionFields.Add(EncryptedField.NodeName, nameError); + } + + if (modificationTimeResult?.TryGetError(out var modificationTimeError) == true) + { + nodeErrors.Add(new ExtendedAttributesDeserializationError("Failed to deserialize modification time", modificationTimeError)); + } + + if (decryptionResult.ExtendedAttributes.TryGetError(out var extendedAttributesError)) + { + nodeErrors.Add(extendedAttributesError); + + if (extendedAttributesError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeExtendedAttributes, extendedAttributesError); + } + } + + var nodeAuthor = decryptionResult.Link.Passphrase.Merge( + x => decryptionResult.Link.NodeAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + error => new SignatureVerificationError(decryptionResult.Link.NodeAuthorshipClaim.Author, "Passphrase decryption failed", error)); + + var nameAuthor = decryptionResult.Link.Name.Merge( + x => decryptionResult.Link.NameAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + error => new SignatureVerificationError(decryptionResult.Link.NameAuthorshipClaim.Author, "Name decryption failed", error)); + + var contentAuthor = decryptionResult.ContentKey.Merge( + x => decryptionResult.ContentAuthorshipClaim.ToAuthorshipResult(x.AuthorshipVerificationFailure), + error => new SignatureVerificationError(decryptionResult.ContentAuthorshipClaim.Author, "Content key decryption failed", error)); + + var partialRevision = new Revision + { + Uid = new RevisionUid(uid, activeRevisionDto.Id), + CreationTime = activeRevisionDto.CreationTime, + SizeOnCloudStorage = activeRevisionDto.StorageQuotaConsumption, + ClaimedSize = extendedAttributes?.Common?.Size, + ClaimedModificationTime = modificationTimeResult?.GetValueOrDefault(), + ClaimedDigests = new FileContentDigests { Sha1 = extendedAttributes?.Common?.Digests?.Sha1 }, + Thumbnails = thumbnails.AsReadOnly(), + AdditionalClaimedMetadata = additionalMetadata, + ContentAuthor = contentAuthor, + }; + + var ownedBy = MapOwnedBy(linkDto.OwnedBy); + var partialNode = linkDetailsDto.Photo is not null + ? new PhotoNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = partialRevision, + TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, + Errors = nodeErrors, + CaptureTime = linkDetailsDto.Photo.CaptureTime, + AlbumUids = linkDetailsDto.Photo.AlbumInclusions.Select(a => new NodeUid(uid.VolumeId, a.Id)).ToList(), + OwnedBy = ownedBy, + } + : new FileNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + MediaType = fileDto.MediaType, + ActiveRevision = partialRevision, + TotalSizeOnCloudStorage = fileDto.TotalSizeOnStorage, + Errors = nodeErrors, + OwnedBy = ownedBy, + }; + + var partialSecrets = new FileSecrets + { + 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), + }; + + return (new FileMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + } + + private static async ValueTask ConvertDtoToFolderMetadataAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkDetailsDto linkDetailsDto, + FolderDto folderDto, + PgpPrivateKey parentKey, + CancellationToken cancellationToken) + { + var linkDto = linkDetailsDto.Link; + var membershipDto = linkDetailsDto.Membership; + + 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, parentKey, 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 (partialFolderMetadata, failedDecryptionFields) = CreatePartialFolderMetadata( + decryptionResult, + nameResult, + uid, + parentUid, + linkDto, + nameSessionKey, + membershipDto); + + await client.Cache.Secrets.SetFolderSecretsAsync(uid, partialFolderMetadata.Secrets, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetNodeAsync(uid, partialFolderMetadata.Node, membershipDto?.ShareId, linkDto.NameHashDigest, cancellationToken) + .ConfigureAwait(false); + + await TelemetryRecorder.TryRecordDecryptionErrorAsync( + client, + partialFolderMetadata.Node, + failedDecryptionFields, + cancellationToken).ConfigureAwait(false); + + return partialFolderMetadata; + } + + 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, + }; + + 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 + { + Uid = uid, + ParentUid = parentUid, + Name = nameOutput.Value.Data, + NameAuthor = nameAuthor, + Author = nodeAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + OwnedBy = MapOwnedBy(linkDto.OwnedBy), + Errors = [], + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(uid, secrets, 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); + } + + private static (FolderMetadata Metadata, Dictionary FailedDecryptionFields) CreatePartialFolderMetadata( + FolderDecryptionResult decryptionResult, + Result nameResult, + NodeUid uid, + NodeUid? parentUid, + LinkDto linkDto, + PgpSessionKey? nameSessionKey, + ShareMembershipSummaryDto? membershipDto) + { + Dictionary failedDecryptionFields = []; + List nodeKeyAndHashKeyErrors = []; + + if (decryptionResult.Link.Passphrase.TryGetError(out var passphraseError)) + { + nodeKeyAndHashKeyErrors.Add(passphraseError); + + if (passphraseError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, passphraseError); + } + } + else if (decryptionResult.Link.NodeKey.TryGetError(out var nodeKeyError)) + { + nodeKeyAndHashKeyErrors.Add(nodeKeyError); + + if (nodeKeyError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeKey, nodeKeyError); + } + } + else if (decryptionResult.HashKey.TryGetError(out var hashKeyError)) + { + nodeKeyAndHashKeyErrors.Add(hashKeyError); + + failedDecryptionFields.Add(EncryptedField.NodeHashKey, hashKeyError); + } + + if (nameResult.TryGetError(out var nameError) && nameError is DecryptionError) + { + failedDecryptionFields.Add(EncryptedField.NodeName, nameError); + } + + 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 partialNode = new FolderNode + { + Uid = uid, + ParentUid = parentUid, + Name = nameResult, + NameAuthor = nameAuthor, + CreationTime = linkDto.CreationTime, + TrashTime = linkDto.TrashTime, + Author = nodeAuthor, + Errors = nodeKeyAndHashKeyErrors, + OwnedBy = MapOwnedBy(linkDto.OwnedBy), + }; + + var partialSecrets = new FolderSecrets + { + 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), + }; + + return (new FolderMetadata(partialNode, partialSecrets, membershipDto?.ShareId, linkDto.NameHashDigest), failedDecryptionFields); + } + + private static async ValueTask GetEntryPointKeyOrThrowAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? shareId, + IDriveSecretCache secretCache, + Func> getLinkDetails, + CancellationToken cancellationToken) + { + if (shareId is not null && shareId == shareAndKeyToUse?.Share.Id) + { + return shareAndKeyToUse.Value.Key; + } + + var currentId = parentId; + var currentShareId = shareId; + + var linkAncestry = new Stack(8); + + PgpPrivateKey? lastKey = 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) + { + lastKey = shareKeyToUse; + break; + } + + var nodeUid = new NodeUid(volumeId, currentId.Value); + + var folderSecrets = await secretCache.TryGetFolderSecretsAsync(nodeUid, cancellationToken).ConfigureAwait(false); + + var folderKey = folderSecrets?.Key; + + if (folderKey is not null) + { + lastKey = folderKey.Value; + break; + } + + var linkDetails = await getLinkDetails.Invoke(currentId.Value, cancellationToken).ConfigureAwait(false); + + linkAncestry.Push(linkDetails); + + currentShareId = linkDetails.Sharing?.ShareId; + + currentId = linkDetails.Link.ParentId; + } + + if (lastKey is not { } currentParentKey) + { + if (shareAndKeyToUse is not null) + { + currentParentKey = shareAndKeyToUse.Value.Key; + } + else + { + if (currentShareId is null) + { + throw new ProtonDriveException("No share available to access node"); + } + + (_, currentParentKey) = await ShareOperations.GetShareAsync(client, currentShareId.Value, useCacheOnly: false, cancellationToken) + .ConfigureAwait(false); + } + } + + while (linkAncestry.TryPop(out var ancestorLinkDetails)) + { + var decryptionResult = await ConvertDtoToNodeMetadataAsync( + client, + volumeId, + ancestorLinkDetails, + currentParentKey, + cancellationToken).ConfigureAwait(false); + + currentParentKey = decryptionResult.GetFolderKeyOrThrow(); + } + + return currentParentKey; + } + + private static async ValueTask GetEntryPointKeyOrThrowAsync( + ProtonDriveClient client, + VolumeId volumeId, + LinkId? parentId, + ShareAndKey? shareAndKeyToUse, + ShareId? shareId, + bool forPhotos, + CancellationToken cancellationToken) + { + 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.GetLinkDetailsAsync(volumeId, [linkId], forPhotos, ct).ConfigureAwait(false); + + return response.Links is { Count: > 0 } links + ? links[0] + : throw new NodeNotFoundException(new NodeUid(volumeId, linkId)); + } + } + + 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, + forPhotos: true, + 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/ExtendedAttributesDeserializationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributesDeserializationError.cs new file mode 100644 index 00000000..efed8fbb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ExtendedAttributesDeserializationError.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Nodes; + +[method: JsonConstructor] +public sealed class ExtendedAttributesDeserializationError(string? message, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) +{ + public ExtendedAttributesDeserializationError(ProtonDriveError? innerError = null) + : this("Failed to deserialize extended attributes", innerError) + { + } +} 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..08648b68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileContentDigests.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public readonly struct FileContentDigests +{ + public ReadOnlyMemory? Sha1 { get; init; } + public bool Sha1Verified { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs new file mode 100644 index 00000000..6627df15 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileDraftNode.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed record FileDraftNode : FileOrFileDraftNode; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs new file mode 100644 index 00000000..0e09ad8d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileMetadata.cs @@ -0,0 +1,5 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct FileMetadata(FileNode Node, FileSecrets Secrets, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs new file mode 100644 index 00000000..15193866 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileNode.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +public record FileNode : FileOrFileDraftNode +{ + public required Revision ActiveRevision { get; init; } + + public required long TotalSizeOnCloudStorage { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs new file mode 100644 index 00000000..9a713608 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOperations.cs @@ -0,0 +1,193 @@ +using System.Runtime.CompilerServices; +using Proton.Drive.Sdk.Api.Files; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class FileOperations +{ + private const int MaxThumbnailIdsPerRequest = 30; + + 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, forPhotos, cancellationToken) + .ConfigureAwait(false); + + fileSecrets = metadata.GetFileSecretsOrThrow(); + } + + return fileSecrets; + } + + 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 + foreach (var volumeLinkIdGroup in fileUids.GroupBy(uid => uid.VolumeId, uid => uid.LinkId)) + { + var volumeId = volumeLinkIdGroup.Key; + + var unprocessedLinkIds = volumeLinkIdGroup.ToHashSet(); + + var nodeResults = NodeOperations.EnumerateNodesAsync(client, volumeId, unprocessedLinkIds, forPhotos, cancellationToken); + + var errors = new List(); + + var thumbnailIds = await nodeResults + .Select(FileNodeInfo? (node) => + { + unprocessedLinkIds.Remove(node.Uid.LinkId); + + if (!node.TryGetFileElseFolder(out var fileNode, out _)) + { + errors.Add(new FileThumbnail(node.Uid, new ProtonDriveError("Node is not a file"))); + return null; + } + + var revision = fileNode.ActiveRevision; + + 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.All(thumbnail => thumbnail.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 + .Where(thumbnail => thumbnail.Type == thumbnailType) + .Select(thumbnail => (thumbnail.Id, Info: fileNodeInfo)) + .ToAsyncEnumerable(); + }) + .ToDictionaryAsync(thumbnail => thumbnail.Id, thumbnail => thumbnail.Info, cancellationToken: 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; + } + + // 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)) + { + var response = await client.Api.Files.GetThumbnailBlocksAsync(volumeId, thumbnailIdBatch, cancellationToken).ConfigureAwait(false); + + var tasks = new Queue>(); + var processedThumbnailIds = new HashSet(); + foreach (var block in response.Blocks) + { + processedThumbnailIds.Add(block.ThumbnailId); + var nodeInfo = thumbnailIds[block.ThumbnailId]; + + if (!client.ThumbnailDownloadQueue.TryEnqueueBlock()) + { + if (tasks.Count > 0) + { + yield return await tasks.Dequeue().ConfigureAwait(false); + } + + await client.ThumbnailDownloadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); + } + + tasks.Enqueue(DownloadThumbnailAsync(client, nodeInfo.ActiveRevisionUid, block, forPhotos, 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)) + { + 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")); + } + } + } + } + } + + private static async Task DownloadThumbnailAsync( + ProtonDriveClient client, + RevisionUid revisionUid, + ThumbnailBlock block, + bool forPhotos, + CancellationToken cancellationToken) + { + const int initialBufferLength = 64 * 1024; + + try + { + var outputStream = new MemoryStream(initialBufferLength); + await using (outputStream.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}"); + + await client.ThumbnailBlockDownloader.DownloadAsync( + revisionUid, + index: 0, + block.BareUrl, + block.Token, + contentKey, + outputStream, + cancellationToken).ConfigureAwait(false); + var thumbnailData = outputStream.TryGetBuffer(out var outputBuffer) ? outputBuffer : outputStream.ToArray(); + + return new FileThumbnail(revisionUid.NodeUid, (ReadOnlyMemory)thumbnailData); + } + } + catch (Exception ex) + { + return new FileThumbnail(revisionUid.NodeUid, ex.ToProtonDriveError()); + } + finally + { + client.ThumbnailDownloadQueue.DequeueBlocks(1); + } + } + + private readonly record struct FileNodeInfo(NodeUid Uid, RevisionUid ActiveRevisionUid, IReadOnlyList Thumbnails); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs new file mode 100644 index 00000000..771c12f5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileOrFileDraftNode.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +public abstract record FileOrFileDraftNode : Node +{ + public required string MediaType { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs new file mode 100644 index 00000000..6946793c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileSecrets.cs @@ -0,0 +1,8 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FileSecrets : NodeSecrets +{ + public required PgpSessionKey? ContentKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs new file mode 100644 index 00000000..b22edfbf --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileThumbnail.cs @@ -0,0 +1,7 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed record FileThumbnail( + NodeUid FileUid, + Result, ProtonDriveError> 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..33e2717b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FileUploadMetadata.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public class FileUploadMetadata +{ + public DateTimeOffset? LastModificationTime { get; init; } + public IEnumerable? AdditionalMetadata { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs new file mode 100644 index 00000000..eaf1c24e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderChildrenBatchLoader.cs @@ -0,0 +1,32 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FolderChildrenBatchLoader(ProtonDriveClient client, VolumeId volumeId, PgpPrivateKey parentKey) + : BatchLoaderBase +{ + private readonly ProtonDriveClient _client = client; + private readonly VolumeId _volumeId = volumeId; + private readonly PgpPrivateKey _parentKey = parentKey; + + protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) + { + 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, + _parentKey, + cancellationToken).ConfigureAwait(false); + + yield return nodeMetadataResult.Node; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs new file mode 100644 index 00000000..f5ece066 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderMetadata.cs @@ -0,0 +1,5 @@ +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct FolderMetadata(FolderNode Node, FolderSecrets Secrets, ShareId? MembershipShareId, ReadOnlyMemory NameHashDigest); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs new file mode 100644 index 00000000..cbe1a0e6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderNode.cs @@ -0,0 +1,5 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed record FolderNode : Node +{ +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs new file mode 100644 index 00000000..a6b9a6b7 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderOperations.cs @@ -0,0 +1,190 @@ +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; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class FolderOperations +{ + public static async IAsyncEnumerable EnumerateChildrenAsync( + ProtonDriveClient client, + NodeUid folderUid, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var anchorLinkId = default(LinkId?); + var mustTryMoreResults = true; + + 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}"); + + var batchLoader = new FolderChildrenBatchLoader(client, folderUid.VolumeId, folderKey); + + while (mustTryMoreResults) + { + var response = await client.Api.Folders.GetChildrenAsync(folderUid.VolumeId, folderUid.LinkId, anchorLinkId, cancellationToken) + .ConfigureAwait(false); + + mustTryMoreResults = response.MoreResultsExist; + anchorLinkId = response.AnchorId; + + foreach (var childLinkId in response.LinkIds) + { + var childUid = new NodeUid(folderUid.VolumeId, childLinkId); + + var cachedChildNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(childUid, cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfoOrNull is not { } cachedChildNodeInfo) + { + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(childLinkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return cachedChildNodeInfo.Node; + } + } + } + + await foreach (var node in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return node; + } + } + + public static async ValueTask CreateAsync( + ProtonDriveClient client, + NodeUid parentUid, + string name, + 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.OwnedBy; + + var (parentKey, parentHashKey) = await GetKeyAndHashKeyAsync(client, parentUid, forPhotos: false, 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 hashKey = CryptoGenerator.GenerateFolderHashKey(); + + NodeOperations.GetCommonCreationParameters( + name, + parentKey, + parentHashKey.Span, + signingKey, + PgpProfile.Proton, + out var key, + out var nameSessionKey, + out var passphraseSessionKey, + out var encryptedName, + out var nameHashDigest, + out var encryptedKeyPassphrase, + out var keyPassphraseSignature, + out var armoredKey); + + var 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, + NameHashDigest = nameHashDigest, + ParentLinkId = parentUid.LinkId, + Passphrase = encryptedKeyPassphrase, + PassphraseSignature = keyPassphraseSignature, + 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); + + var folderUid = new NodeUid(parentUid.VolumeId, response.FolderId.Value); + + var folderSecrets = new FolderSecrets + { + Key = key, + PassphraseSessionKey = passphraseSessionKey, + NameSessionKey = nameSessionKey, + HashKey = hashKey, + }; + + await client.Cache.Secrets.SetFolderSecretsAsync(folderUid, folderSecrets, cancellationToken).ConfigureAwait(false); + + var author = new Author { EmailAddress = membershipAddress.EmailAddress }; + + var folderNode = new FolderNode + { + Uid = folderUid, + ParentUid = parentUid, + Name = name, + NameAuthor = author, + Author = author, + CreationTime = DateTime.UtcNow, + OwnedBy = parentOwnedBy, + Errors = [], + }; + + await client.Cache.Entities.SetNodeAsync(folderUid, folderNode, membershipShareId: null, nameHashDigest, cancellationToken).ConfigureAwait(false); + + return folderNode; + } + + 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, forPhotos, cancellationToken) + .ConfigureAwait(false); + + result = nodeMetadata.GetFolderSecretsOrThrow(); + } + + return result; + } + + public static async ValueTask<(PgpPrivateKey Key, ReadOnlyMemory HashKey)> GetKeyAndHashKeyAsync( + ProtonDriveClient client, + NodeUid folderUid, + bool forPhotos, + CancellationToken cancellationToken) + { + 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}"); + + return (key, hashKey); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs new file mode 100644 index 00000000..eb363110 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/FolderSecrets.cs @@ -0,0 +1,6 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class FolderSecrets : NodeSecrets +{ + public required ReadOnlyMemory? HashKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs new file mode 100644 index 00000000..83734d29 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ITaskControl.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal interface ITaskControl : IDisposable +{ + bool IsPaused { get; } + bool IsCanceled { get; } + CancellationToken CancellationToken { get; } + int Attempt { get; } + CancellationToken PauseOrCancellationToken { get; } + void Pause(); + bool TryResume(); + void AbortPause(); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs new file mode 100644 index 00000000..1cc72329 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNameError.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class InvalidNameError(string name, string message) + : ProtonDriveError(message) +{ + public string Name { get; } = name; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs new file mode 100644 index 00000000..03a01520 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/InvalidNodeTypeException.cs @@ -0,0 +1,33 @@ +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed class InvalidNodeTypeException : ProtonDriveException +{ + public InvalidNodeTypeException(string message) + : base(message) + { + } + + public InvalidNodeTypeException(string message, Exception innerException) + : base(message, innerException) + { + } + + public InvalidNodeTypeException() + { + } + + internal InvalidNodeTypeException(NodeUid nodeId, LinkType actualType) + : this(GetMessage(nodeId, actualType)) + { + } + + internal static string GetMessage(NodeUid nodeId, LinkType actualType) + { + return $"Expected node \"{nodeId}\" to be of type {GetExpectedTypeString(actualType)}, but is of type {GetActualTypeString(actualType)} instead."; + } + + private static string GetActualTypeString(LinkType actualType) => actualType is LinkType.File ? "file" : "folder"; + private static string GetExpectedTypeString(LinkType actualType) => actualType is LinkType.File ? "folder" : "file"; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs new file mode 100644 index 00000000..ed51e580 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Node.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[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; } + + public required NodeUid? ParentUid { get; init; } + + [JsonIgnore] + public string TreeEventScopeId => Uid.VolumeId.ToString(); + + public required Result Name { get; init; } + + public required DateTime CreationTime { get; init; } + + public DateTime? TrashTime { get; init; } + + public required Result NameAuthor { 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/NodeBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs new file mode 100644 index 00000000..fe32c910 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeBatchLoader.cs @@ -0,0 +1,30 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Proton.Drive.Sdk.Api; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class NodeBatchLoader(ProtonDriveClient client, VolumeId volumeId, bool forPhotos) : BatchLoaderBase +{ + private readonly ProtonDriveClient _client = client; + private readonly bool _forPhotos = forPhotos; + + protected override async IAsyncEnumerable LoadBatchAsync(ReadOnlyMemory ids, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var response = await _client.Api.GetLinkDetailsAsync(volumeId, MemoryMarshal.ToEnumerable(ids), _forPhotos, cancellationToken).ConfigureAwait(false); + + foreach (var linkDetails in response.Links) + { + var (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + volumeId, + linkDetails, + knownShareAndKey: null, + cancellationToken).ConfigureAwait(false); + + yield return node; + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs new file mode 100644 index 00000000..051e7018 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadata.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Drive.Sdk.Api.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly struct NodeMetadata +{ + private readonly (FileNode Node, FileSecrets Secrets)? _fileAndSecrets; + private readonly (FolderNode Node, FolderSecrets Secrets)? _folderAndSecrets; + + public NodeMetadata(FileNode node, FileSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _fileAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public NodeMetadata(FolderNode node, FolderSecrets secrets, ShareId? membershipShareId, ReadOnlyMemory nameHashDigest) + { + _folderAndSecrets = (node, secrets); + MembershipShareId = membershipShareId; + NameHashDigest = nameHashDigest; + } + + public Node Node => _fileAndSecrets?.Node ?? (Node)_folderAndSecrets!.Value.Node; + public NodeSecrets Secrets => _fileAndSecrets?.Secrets ?? (NodeSecrets)_folderAndSecrets!.Value.Secrets; + public ShareId? MembershipShareId { get; } + public ReadOnlyMemory NameHashDigest { get; } + + public static NodeMetadata FromFile(FileMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + public static NodeMetadata FromFolder(FolderMetadata m) => new(m.Node, m.Secrets, m.MembershipShareId, m.NameHashDigest); + + public bool TryGetFileElseFolder( + [MaybeNullWhen(false)] out FileNode fileNode, + [MaybeNullWhen(false)] out FileSecrets fileSecrets, + [MaybeNullWhen(true)] out FolderNode folderNode, + [MaybeNullWhen(true)] out FolderSecrets folderSecrets) + { + if (_fileAndSecrets is null) + { + (folderNode, folderSecrets) = _folderAndSecrets!.Value; + fileNode = null; + fileSecrets = null; + return false; + } + + (fileNode, fileSecrets) = _fileAndSecrets.Value; + folderNode = null; + folderSecrets = null; + return true; + } + + public void Deconstruct(out Node node, out NodeSecrets secrets, out ShareId? membershipShareId, out ReadOnlyMemory nameHashDigest) + { + node = Node; + secrets = Secrets; + membershipShareId = MembershipShareId; + nameHashDigest = NameHashDigest; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs new file mode 100644 index 00000000..2e885672 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeMetadataResultExtensions.cs @@ -0,0 +1,46 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Links; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeMetadataResultExtensions +{ + extension(NodeMetadata metadata) + { + public Node GetNodeOrThrow() + { + return metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) ? fileNode : folderNode; + } + + public FolderNode GetFolderNodeOrThrow() + { + return !metadata.TryGetFileElseFolder(out var fileNode, out _, out var folderNode, out _) + ? folderNode + : throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + } + + public FolderSecrets GetFolderSecretsOrThrow() + { + return !metadata.TryGetFileElseFolder(out var fileNode, out _, out _, out var folderSecrets) + ? folderSecrets + : throw new InvalidNodeTypeException(fileNode.Uid, LinkType.File); + } + + public FileSecrets GetFileSecretsOrThrow() + { + return metadata.TryGetFileElseFolder(out _, out var fileSecrets, out var folderNode, out _) + ? fileSecrets + : throw new InvalidNodeTypeException(folderNode.Uid, LinkType.Folder); + } + + public PgpPrivateKey GetFolderKeyOrThrow() + { + if (metadata.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]}"); + } + } +} 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 new file mode 100644 index 00000000..5a5a0929 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeOperations.cs @@ -0,0 +1,720 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +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; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; +using Proton.Sdk.Addresses; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class NodeOperations +{ + private const int MaximumBatchCount = 150; + private const int MaxNodeNameLength = 255; + + 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) + { + 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, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + + var metadata = await GetNodeMetadataAsync(client, shareAndKey.Share.RootFolderId, shareAndKey, useCacheOnly: false, forPhotos: false, cancellationToken) + .ConfigureAwait(false); + + return metadata.GetFolderNodeOrThrow(); + } + + public static async ValueTask GetNodeMetadataAsync( + ProtonDriveClient client, + NodeUid uid, + ShareAndKey? knownShareAndKey, + bool useCacheOnly, + bool forPhotos, + CancellationToken cancellationToken) + { + var metadataResult = await TryGetNodeMetadataFromCacheAsync(client, uid, cancellationToken).ConfigureAwait(false); + + if (metadataResult is null) + { + if (useCacheOnly) + { + throw new NodeNotFoundException(uid); + } + + metadataResult = await GetFreshNodeMetadataAsync(client, uid, knownShareAndKey, forPhotos, cancellationToken).ConfigureAwait(false); + } + + return metadataResult.Value; + } + + 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, 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, forPhotos); + + foreach (var linkId in linkIds) + { + var cachedChildNodeInfo = await client.Cache.Entities.TryGetNodeAsync(new NodeUid(volumeId, linkId), cancellationToken).ConfigureAwait(false); + + if (cachedChildNodeInfo is not { Node: { } node }) + { + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + + continue; + } + + yield return node; + } + + await foreach (var nodeResult in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + + public static void GetCommonCreationParameters( + string name, + PgpPrivateKey parentFolderKey, + ReadOnlySpan parentFolderHashKey, + PgpPrivateKey signingKey, + PgpProfile pgpProfile, + out PgpPrivateKey key, + out PgpSessionKey nameSessionKey, + out PgpSessionKey passphraseSessionKey, + out ArraySegment encryptedName, + out ArraySegment nameHashDigest, + out ArraySegment encryptedKeyPassphrase, + out ArraySegment passphraseSignature, + out ArraySegment lockedKeyBytes) + { + key = PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", KeyGenerationAlgorithm.Default, profile: pgpProfile); + nameSessionKey = PgpSessionKey.Generate(); + + Span passphraseBuffer = stackalloc byte[CryptoGenerator.PassphraseBufferRequiredLength]; + var passphrase = CryptoGenerator.GeneratePassphrase(passphraseBuffer); + + passphraseSessionKey = PgpSessionKey.Generate(); + var passphraseEncryptionSecrets = new EncryptionSecrets(parentFolderKey, passphraseSessionKey); + + encryptedKeyPassphrase = PgpEncrypter.EncryptAndSign(passphrase, passphraseEncryptionSecrets, signingKey, out passphraseSignature); + + using var lockedKey = key.Lock(passphrase); + lockedKeyBytes = lockedKey.ToBytes(); + + GetNameParameters(name, parentFolderKey, parentFolderHashKey, nameSessionKey, signingKey, out encryptedName, out nameHashDigest); + } + + public static async ValueTask GetFreshNodeMetadataAsync( + ProtonDriveClient client, + NodeUid uid, + ShareAndKey? knownShareAndKey, + bool forPhotos, + CancellationToken cancellationToken) + { + var response = await client.Api.GetLinkDetailsAsync(uid.VolumeId, [uid.LinkId], forPhotos, cancellationToken).ConfigureAwait(false); + + return await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + client, + uid.VolumeId, + response.Links is { Count: > 0 } links ? links[0] : throw new NodeNotFoundException(uid), + knownShareAndKey, + cancellationToken) + .ConfigureAwait(false); + } + + public static async ValueTask MoveSingleAsync( + ProtonDriveClient client, + NodeUid uid, + NodeUid newParentUid, + string? newName, + CancellationToken cancellationToken) + { + // FIXME: try to get the information from cache first + var membershipAddress = await GetMembershipAddressAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + + using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, 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}"); + + var destinationHashKey = destinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); + + if (uid == newParentUid) + { + throw new InvalidOperationException($"Node {uid} cannot be moved onto itself"); + } + + if (uid.VolumeId != newParentUid.VolumeId) + { + 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, forPhotos: 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 ?? originName, // FIXME: validate name + destinationKey, + destinationHashKey.Span, + originNameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); + + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originPassphraseSessionKey); + + ReadOnlyMemory? passphraseSignature = null; + string? signatureEmailAddress = null; + + if (originSecrets.PassphraseForAnonymousMove is not null) + { + passphraseSignature = signingKey.Sign(originSecrets.PassphraseForAnonymousMove.Value.Span); + signatureEmailAddress = membershipAddress.EmailAddress; + } + + var request = new MoveSingleLinkRequest + { + Name = encryptedName, + Passphrase = passphraseKeyPacket, + NameHashDigest = nameHashDigest, + ParentLinkId = newParentUid.LinkId, + OriginalNameHashDigest = originNameHashDigest, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + PassphraseSignature = passphraseSignature, + SignatureEmailAddress = signatureEmailAddress, + }; + + await client.Api.Links.MoveAsync(newParentUid.VolumeId, uid.LinkId, request, cancellationToken).ConfigureAwait(false); + + var newNode = originNode with { ParentUid = newParentUid, Name = newName ?? originName }; + + await client.Cache.Entities.SetNodeAsync(uid, newNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); + } + + // For future use + public static async Task MoveMultipleAsync( + ProtonDriveClient client, + IEnumerable uids, + NodeUid newParentUid, + string? newName, + CancellationToken cancellationToken) + { + // FIXME: try to get the information from cache first + var membershipAddress = await GetMembershipAddressAsync(client, newParentUid, cancellationToken).ConfigureAwait(false); + + using var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, 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}"); + + var destinationHashKey = destinationFolderSecrets.HashKey + ?? throw new ProtonDriveException($"Destination folder hash key not available for {newParentUid}"); + + var batch = new List(); + + foreach (var uid in uids) + { + if (uid.VolumeId != newParentUid.VolumeId) + { + 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, _, originNameHashDigest) = + await GetNodeMetadataAsync(client, uid, null, useCacheOnly: false, forPhotos: 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 ?? originName, // FIXME: validate name + destinationKey, + destinationHashKey.Span, + originNameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); + + var passphraseKeyPacket = destinationKey.EncryptSessionKey(originPassphraseSessionKey); + + var itemRequest = new MoveMultipleLinksItem + { + LinkId = uid.LinkId, + Passphrase = passphraseKeyPacket, + Name = encryptedName, + NameHashDigest = nameHashDigest, + OriginalNameHashDigest = originNameHashDigest, + PassphraseSignature = null, // FIXME: sign with parent node key if anonymously-uploaded file + }; + + batch.Add(itemRequest); + } + + var batchRequest = new MoveMultipleLinksRequest + { + ParentLinkId = newParentUid.LinkId, + Batch = batch, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + SignatureEmailAddress = null, // FIXME: specify for anonymously-uploaded files + }; + + await client.Api.Links.MoveMultipleAsync(newParentUid.VolumeId, batchRequest, cancellationToken).ConfigureAwait(false); + + // FIXME: update cache + } + + public static async ValueTask RenameAsync( + ProtonDriveClient client, + NodeUid uid, + string newName, + string? newMediaType, + CancellationToken cancellationToken) + { + // 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, forPhotos: false, cancellationToken).ConfigureAwait(false); + + if (node.ParentUid is not { } parentUid) + { + throw new InvalidOperationException("Cannot rename root node"); + } + + var membershipAddress = await GetMembershipAddressAsync(client, uid, cancellationToken).ConfigureAwait(false); + + var signingKey = await client.Account.GetAddressPrimaryPrivateKeyAsync(membershipAddress.Id, cancellationToken).ConfigureAwait(false); + + var (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}"); + + GetNameParameters( + newName, // FIXME: validate name + parentKey, + parentHashKey.Span, + nameSessionKey, + signingKey, + out var encryptedName, + out var nameHashDigest); + + var parameters = new RenameLinkRequest + { + Name = encryptedName, + NameHashDigest = nameHashDigest, + NameSignatureEmailAddress = membershipAddress.EmailAddress, + MediaType = newMediaType, + OriginalNameHashDigest = originalNameHashDigest, + }; + + await client.Api.Links.RenameAsync(uid.VolumeId, uid.LinkId, parameters, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetNodeAsync(uid, node with { Name = newName }, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask>> 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, + 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.Trash.TrashMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var cachedNodeInfo = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfo is var (node, membershipShareId, nameHashDigest)) + { + // TODO: have the back-end return the trash time so that the cached value be exactly the same + 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); + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + return results; + } + + public static async ValueTask>> DeleteFromTrashAsync( + 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.Trash.DeleteMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); + + results.TryAdd(uid, result); + } + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + return results; + } + + public static async ValueTask>> RestoreFromTrashAsync( + 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.Trash.RestoreMultipleAsync(uidGroup.Key, request, cancellationToken).ConfigureAwait(false); + + foreach (var (linkId, response) in aggregateResponse.Responses) + { + var uid = new NodeUid(uidGroup.Key, linkId); + + var result = response.IsSuccess ? Result.Success : new ProtonApiException(response); + + results.TryAdd(uid, result); + } + } + }); + + 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, forPhotos: false, cancellationToken).ConfigureAwait(false); + + var folderHashKey = folderSecrets.HashKey ?? throw new ProtonDriveException($"Folder hash key not available for {parentUid}"); + + 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, folderHashKey.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 + var response = await client.Api.Links.GetContextShareAsync(nodeUid.VolumeId, nodeUid.LinkId, 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); + } + + public static bool ValidateName( + Result, ProtonDriveError> decryptionResult, + [NotNullWhen(true)] out PhasedDecryptionOutput? nameOutput, + out Result nameResult, + [NotNullWhen(true)] out PgpSessionKey? sessionKey) + { + if (!decryptionResult.TryGetValueElseError(out var nameOutputValue, out var decryptionError)) + { + nameOutput = null; + nameResult = new DecryptionError("Name decryption failed", decryptionError); + sessionKey = null; + return false; + } + + nameOutput = nameOutputValue; + sessionKey = nameOutputValue.SessionKey; + + var name = nameOutputValue.Data; + + if (string.IsNullOrEmpty(name)) + { + nameResult = new InvalidNameError(name, "Name must not be empty"); + return false; + } + + if (name.Length > MaxNodeNameLength) + { + nameResult = new InvalidNameError(name, $"Name must be {MaxNodeNameLength} characters long at most"); + return false; + } + + nameResult = name; + return true; + } + + public static async Task> GetParentFolderHashKeyAsync( + ProtonDriveClient client, NodeUid uid, bool forPhotos, CancellationToken cancellationToken) + { + 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, forPhotos, cancellationToken).ConfigureAwait(false); + + return hashKey; + } + + private static async ValueTask GetFreshExistingMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + 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); + + var (share, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareDto.Id, + shareDto.Key, + 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 (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToFolderMetadataAsync( + client, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken).ConfigureAwait(false); + + return node; + } + + private static void GetNameParameters( + string name, + PgpPrivateKey parentFolderKey, + ReadOnlySpan parentFolderHashKey, + PgpSessionKey nameSessionKey, + PgpPrivateKey signingKey, + out ArraySegment encryptedName, + out ArraySegment nameHashDigest) + { + var maxNameByteLength = Encoding.UTF8.GetMaxByteCount(name.Length); + var nameBytes = MemoryPolicy.GetRentedHeapMemoryIfTooLargeForStack(maxNameByteLength, out var nameHeapMemoryOwner) + ? nameHeapMemoryOwner.Memory.Span + : stackalloc byte[maxNameByteLength]; + + using (nameHeapMemoryOwner) + { + var nameByteLength = Encoding.UTF8.GetBytes(name, nameBytes); + nameBytes = nameBytes[..nameByteLength]; + + encryptedName = PgpEncrypter.EncryptAndSignText(name, new EncryptionSecrets(parentFolderKey, nameSessionKey), signingKey); + + nameHashDigest = HMACSHA256.HashData(parentFolderHashKey, nameBytes); + } + } + + private static async ValueTask TryGetNodeMetadataFromCacheAsync( + ProtonDriveClient client, + NodeUid uid, + CancellationToken cancellationToken) + { + var cachedNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + if (cachedNodeInfoOrNull is not var (node, membershipShareId, nameHashDigest)) + { + return null; + } + + return node switch + { + FolderNode folderNode => await client.Cache.Secrets.TryGetFolderSecretsAsync(uid, cancellationToken).ConfigureAwait(false) is { } folderSecrets + ? new NodeMetadata(folderNode, folderSecrets, membershipShareId, nameHashDigest) + : null, + + FileNode fileNode => await client.Cache.Secrets.TryGetFileSecretsAsync(uid, cancellationToken).ConfigureAwait(false) is { } fileSecrets + ? new NodeMetadata(fileNode, fileSecrets, membershipShareId, nameHashDigest) + : null, + + _ => throw new InvalidOperationException($"Node type \"{node.GetType().Name}\" is not supported"), + }; + } + + private static async ValueTask CreateMyFilesFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var (_, _, folderNode) = await VolumeOperations.CreateVolumeAsync(client, cancellationToken).ConfigureAwait(false); + + return folderNode; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs new file mode 100644 index 00000000..11850a97 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeResultExtensions.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk.Nodes; + +public static class NodeResultExtensions +{ + public static bool TryGetFileElseFolder( + this Node node, + [NotNullWhen(true)] out FileNode? fileNode, + [NotNullWhen(false)] out FolderNode? folderNode) + { + if (node is FolderNode folder) + { + fileNode = null; + folderNode = folder; + return false; + } + + 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 new file mode 100644 index 00000000..85cd2439 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeSecrets.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +internal class NodeSecrets +{ + public required PgpPrivateKey? Key { get; init; } + public required PgpSessionKey? PassphraseSessionKey { get; init; } + public required PgpSessionKey? NameSessionKey { get; init; } + + [JsonPropertyName("passphrase")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReadOnlyMemory? PassphraseForAnonymousMove { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs new file mode 100644 index 00000000..669ecbd6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/NodeUid.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Serialization; +using Proton.Drive.Sdk.Volumes; + +namespace Proton.Drive.Sdk.Nodes; + +[JsonConverter(typeof(UidJsonConverter))] +public readonly record struct NodeUid : ICompositeUid +{ + internal NodeUid(VolumeId volumeId, LinkId linkId) + { + VolumeId = volumeId; + LinkId = linkId; + } + + internal VolumeId VolumeId { get; } + internal LinkId LinkId { get; } + + public override string ToString() + { + return $"{VolumeId}~{LinkId}"; + } + + public static bool TryParse(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)); + return true; + } + + internal void Deconstruct(out VolumeId volumeId, out LinkId linkId) + { + volumeId = VolumeId; + linkId = LinkId; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/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/Nodes/PhotoNode.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs new file mode 100644 index 00000000..369a16df --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotoNode.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +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/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 new file mode 100644 index 00000000..17a03aa6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosFileUploadMetadata.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed class PhotosFileUploadMetadata : FileUploadMetadata +{ + public DateTime? CaptureTime { get; init; } + public NodeUid? MainPhotoUid { get; init; } + public IEnumerable? Tags { get; init; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs new file mode 100644 index 00000000..c8c6b883 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosNodeOperations.cs @@ -0,0 +1,115 @@ +using System.Runtime.CompilerServices; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Photos; +using Proton.Drive.Sdk.Shares; +using Proton.Drive.Sdk.Volumes; +using Proton.Sdk; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class PhotosNodeOperations +{ + private const int TimelinePageSize = 500; + + 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) + { + 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, shareId.Value, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + + var metadata = await NodeOperations.GetNodeMetadataAsync( + client, + shareAndKey.Share.RootFolderId, + shareAndKey, + useCacheOnly: false, + forPhotos: true, + cancellationToken).ConfigureAwait(false); + + return metadata.GetFolderNodeOrThrow(); + } + + public static async IAsyncEnumerable EnumeratePhotosTimelineAsync( + ProtonDriveClient client, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var anchorLinkId = default(LinkId?); + + do + { + 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.Api.Photos.GetTimelinePhotosAsync(request, cancellationToken).ConfigureAwait(false); + + anchorLinkId = response.Photos.Count == TimelinePageSize ? response.Photos[^1].Id : null; + + foreach (var photo in response.Photos) + { + var photoUid = new NodeUid(photosVolumeId, photo.Id); + + yield return new PhotosTimelineItem(photoUid, photo.CaptureTime); + } + } while (anchorLinkId is not null); + } + + private static async ValueTask GetFreshExistingPhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + 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); + + var (share, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareDto.Id, + shareDto.Key, + shareDto.Passphrase, + shareDto.AddressId, + nodeUid, + ShareType.Photos, + 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, + volumeDto.Id, + linkDetailsDto, + shareKey, + cancellationToken).ConfigureAwait(false); + + return metadataResult.Node; + } + + private static async ValueTask CreatePhotosFolderAsync(ProtonDriveClient client, CancellationToken cancellationToken) + { + var (_, _, folderNode) = await VolumeOperations.CreatePhotosVolumeAsync(client, cancellationToken).ConfigureAwait(false); + + return folderNode; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs new file mode 100644 index 00000000..de0c9490 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/PhotosTimelineItem.cs @@ -0,0 +1,3 @@ +namespace Proton.Drive.Sdk.Nodes; + +public sealed record PhotosTimelineItem(NodeUid Uid, DateTime CaptureTime); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs new file mode 100644 index 00000000..d433ac72 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Revision.cs @@ -0,0 +1,16 @@ +using Proton.Sdk; + +namespace Proton.Drive.Sdk.Nodes; + +public sealed record Revision +{ + 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; } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs new file mode 100644 index 00000000..d3de2b4b --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionOperations.cs @@ -0,0 +1,82 @@ +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; + +namespace Proton.Drive.Sdk.Nodes; + +internal static class RevisionOperations +{ + public static RevisionWriter OpenForWriting( + ProtonDriveClient client, + RevisionDraft draft, + long queueToken) + { + return new RevisionWriter(client, draft, queueToken, client.TargetBlockSize); + } + + internal static async ValueTask CreateDownloadStateAsync( + ProtonDriveClient client, + RevisionUid revisionUid, + long queueToken, + bool forPhotos, + CancellationToken cancellationToken) + { + var (fileUid, revisionId) = revisionUid; + + var secretsTask = FileOperations.GetSecretsAsync( + client, + revisionUid.NodeUid, + forPhotos, + cancellationToken).AsTask(); + + var revisionTask = client.Api.Files.GetRevisionAsync( + fileUid.VolumeId, + fileUid.LinkId, + revisionId, + RevisionReader.MinBlockIndex, + RevisionReader.DefaultBlockPageSize, + withoutBlockUrls: false, + cancellationToken).AsTask(); + + await Task.WhenAll(secretsTask, revisionTask).ConfigureAwait(false); + + var fileSecrets = await secretsTask.ConfigureAwait(false); + var revisionResponse = await revisionTask.ConfigureAwait(false); + + 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")); + } + + internal static RevisionReader OpenForReading(ProtonDriveClient client, DownloadState downloadState) + { + 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/Nodes/RevisionState.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs new file mode 100644 index 00000000..f5d66187 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionState.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk.Nodes; + +public enum RevisionState +{ + Draft = 0, + Active = 1, + Superseded = 2, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs new file mode 100644 index 00000000..d779bc50 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/RevisionUid.cs @@ -0,0 +1,62 @@ +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; + +[JsonConverter(typeof(UidJsonConverter))] +public readonly record struct RevisionUid : ICompositeUid +{ + internal RevisionUid(NodeUid nodeUid, RevisionId revisionId) + { + NodeUid = nodeUid; + 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; } + + public override string ToString() + { + return $"{NodeUid}~{RevisionId}"; + } + + public static bool TryParse(string s, [NotNullWhen(true)] out RevisionUid? result) + { + 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)) + { + uid = null; + return false; + } + + uid = new RevisionUid(nodeUid.Value, new RevisionId(relativeIdString)); + return true; + } + + internal void Deconstruct(out NodeUid nodeUid, out RevisionId revisionId) + { + nodeUid = NodeUid; + revisionId = RevisionId; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs new file mode 100644 index 00000000..d1b40d6e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ShareAndKey.cs @@ -0,0 +1,6 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Shares; + +namespace Proton.Drive.Sdk.Nodes; + +internal readonly record struct ShareAndKey(Share Share, PgpPrivateKey Key); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs new file mode 100644 index 00000000..52b16e89 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/SignatureVerificationError.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes; + +[method: JsonConstructor] +public sealed class SignatureVerificationError(Author claimedAuthor, string? message = null, ProtonDriveError? innerError = null) + : ProtonDriveError(message, innerError) +{ + public SignatureVerificationError( + Author claimedAuthor, + PgpVerificationStatus? verificationStatus = null, + string? message = null, + ProtonDriveError? innerError = null) + : this(claimedAuthor, GetMessage(verificationStatus, message), innerError) + { + } + + public Author ClaimedAuthor { get; } = claimedAuthor; + + private static string GetMessage(PgpVerificationStatus? verificationStatus, string? message) + { + if (!string.IsNullOrEmpty(message)) + { + return message; + } + + return verificationStatus is not null + ? $"Verification resulted in unsuccessful status: {verificationStatus}" + : "Authorship could not be verified"; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs new file mode 100644 index 00000000..71830a6e --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TaskControl.cs @@ -0,0 +1,93 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal sealed class TaskControl(CancellationToken cancellationToken) : ITaskControl +{ + private readonly Lock _pauseLock = new(); + + 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; + + public CancellationToken CancellationToken { get; } = cancellationToken; + public CancellationToken PauseOrCancellationToken => _pauseCancellationTokenSource.Token; + + public void Pause() + { + if (IsPaused) + { + return; + } + + lock (_pauseLock) + { + if (IsPaused) + { + return; + } + + _resumeSignalSource = new TaskCompletionSource(); + + _pauseCancellationTokenSource.Cancel(); + } + } + + public bool TryResume() + { + if (!IsPaused) + { + return false; + } + + lock (_pauseLock) + { + if (!IsPaused) + { + return false; + } + + _pauseCancellationTokenSource.Dispose(); + _pauseCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); + + Interlocked.Increment(ref _attemptCount); + + var resumeSignalSource = _resumeSignalSource; + _resumeSignalSource = null; + + resumeSignalSource?.SetResult(); + } + + return true; + } + + public void AbortPause() + { + TaskCompletionSource? resumeSignalSource; + + lock (_pauseLock) + { + resumeSignalSource = _resumeSignalSource; + _resumeSignalSource = null; + } + + resumeSignalSource?.TrySetCanceled(); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _resumeSignalSource?.TrySetCanceled(); + + _pauseCancellationTokenSource.Dispose(); + + _isDisposed = true; + } +} 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..b7e78621 --- /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, ReadOnlyMemory content) +{ + public ThumbnailType Type { get; } = type; + public ReadOnlyMemory Content { get; } = content; +} 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/ThumbnailType.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailType.cs new file mode 100644 index 00000000..10416278 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/ThumbnailType.cs @@ -0,0 +1,7 @@ +namespace Proton.Drive.Sdk.Nodes; + +public enum ThumbnailType +{ + Thumbnail = 1, + Preview = 2, +} 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..f1b2e853 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TransferQueue.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Logging; + +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(); + + 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 long? TryEnqueueFile(int initialBlockCount) + { + 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, initialBlockCount)); + } + + return queuePosition; + } + + public async ValueTask EnqueueFileAsync(int initialBlockCount, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(initialBlockCount); + + LogAcquiringFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + + await FileQueueSemaphore.EnterAsync(initialBlockCount, cancellationToken).ConfigureAwait(false); + + LogAcquiredFileQueueSemaphore(FileQueueSemaphore.CurrentCount); + + var queuePosition = Interlocked.Increment(ref _lastEntryId); + + lock (_fileBlocksLock) + { + _fileBlocks.Add(queuePosition, (initialBlockCount, initialBlockCount)); + } + + return queuePosition; + } + + /// + /// 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) + { + 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) + { + return; + } + + FileQueueSemaphore.DecreaseCount(delta); + + LogDecreasedFileQueueSemaphoreCount(delta, FileQueueSemaphore.CurrentCount); + + _fileBlocks[queueToken] = (currentRemaining + delta, total); + } + } + + 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.Remaining + amount, currentBlockCount.Total + amount); + } + } + + public void DecreaseFileRemainingBlockCount(long queueToken, int amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + lock (_fileBlocksLock) + { + if (!_fileBlocks.TryGetValue(queueToken, out var currentBlockCount)) + { + throw new InvalidOperationException($"Queue token {queueToken} not found in transfer queue."); + } + + RemoveBlocksFromFileQueue(amount); + + _fileBlocks[queueToken] = (currentBlockCount.Remaining - amount, currentBlockCount.Total); + } + } + + 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.Remaining); + } + } + + 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) + { + LogAcquiredBlockTransferSemaphore(BlockTransferSemaphore.CurrentCount); + } + + return result; + } + + 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) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + BlockTransferSemaphore.Release(count); + + LogReleasedBlockTransferSemaphore(count, BlockTransferSemaphore.CurrentCount); + } + + private void RemoveBlocksFromFileQueue(int blockCount) + { + FileQueueSemaphore.Release(blockCount); + + LogReleasedFileQueueSemaphore(blockCount, FileQueueSemaphore.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 block queueing semaphore, current count is {CurrentCount}")] + private partial void LogAcquiredBlockQueueingSemaphore(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 = "Waiting to acquire block transfer semaphore, current count is {CurrentCount}")] + private partial void LogAcquiringBlockTransferSemaphore(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 transfer semaphore, current count is {CurrentCount}")] + private partial void LogReleasedBlockTransferSemaphore(int count, int currentCount); +} 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..dd7f97ea --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/TraversalOperations.cs @@ -0,0 +1,53 @@ +namespace Proton.Drive.Sdk.Nodes; + +internal static class TraversalOperations +{ + public static async ValueTask FindRootForNode( + ProtonDriveClient client, + NodeMetadata nodeMetadata, + bool useCacheOnly, + CancellationToken cancellationToken) + { + var currentMetadata = nodeMetadata; + var forPhotos = nodeMetadata.Node is PhotoNode; + var (entryPointUid, nextForPhotos) = GetNextEntryPoint(currentMetadata); + forPhotos |= nextForPhotos; + + HashSet visitedNodes = []; + + while (entryPointUid is not null) + { + if (!visitedNodes.Add((NodeUid)entryPointUid)) + { + throw new ProtonDriveException("Folder structure loop detected"); + } + + currentMetadata = await NodeOperations.GetNodeMetadataAsync( + client, + (NodeUid)entryPointUid, + knownShareAndKey: null, + useCacheOnly, + forPhotos, + cancellationToken).ConfigureAwait(false); + + (entryPointUid, nextForPhotos) = GetNextEntryPoint(currentMetadata); + forPhotos |= nextForPhotos; + } + + return currentMetadata; + } + + private static (NodeUid? Uid, bool ForPhotos) GetNextEntryPoint(NodeMetadata nodeMetadata) + { + 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/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 new file mode 100644 index 00000000..66ba0f78 --- /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); diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs new file mode 100644 index 00000000..8b72d177 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/BlockUploader.cs @@ -0,0 +1,311 @@ +using System.Diagnostics; +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.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; + + internal BlockUploader(ProtonDriveClient client) + { + _client = client; + _logger = client.Telemetry.GetLogger("Block uploader"); + } + + public async ValueTask UploadContentAsync( + RevisionDraft draft, + int blockNumber, + BlockUploadPlainData plainData, + Action? onBlockProgress, + CancellationToken cancellationToken) + { + using (_logger.BeginScope("Content block #{BlockNumber} of revision #{RevisionUid}", draft.Uid, blockNumber)) + { + var plainDataLength = plainData.Stream.Length; + var integrityErrorEncountered = false; + var attempt = 0; + + while (true) + { + attempt++; + plainData.Stream.Seek(0, SeekOrigin.Begin); + + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) + { + var signatureStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + + await using (signatureStream.ConfigureAwait(false)) + { + using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + var hashingStream = new HashingWriteStream(dataPacketStream, sha256, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) + { + var signatureEncryptingStream = draft.FileKey.OpenEncryptingStream(signatureStream); + + await using (signatureEncryptingStream.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)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]; + + 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); + + try + { + var verificationToken = draft.BlockVerifier.VerifyBlock( + dataPacketStream.GetFirstBytes(AeadChunkSize), + plainData.PrefixForVerification.AsSpan()[..plainDataPrefixLength]); + + if (integrityErrorEncountered) + { + await RecordBlockVerificationErrorAsync(draft, retryHelped: true, cancellationToken).ConfigureAwait(false); + } + + 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; + } + } + } + } + } + } + + public async ValueTask UploadThumbnailAsync(RevisionDraft draft, Thumbnail thumbnail, CancellationToken cancellationToken) + { + using (_logger.BeginScope("{ThumbnailType} block of revision #{RevisionUid}", thumbnail.Type, draft.Uid)) + { + var dataPacketStream = ProtonDriveClient.MemoryStreamManager.GetStream(); + await using (dataPacketStream.ConfigureAwait(false)) + { + using var sha256 = SHA256.Create(); + + var hashingStream = new CryptoStream(dataPacketStream, sha256, CryptoStreamMode.Write, leaveOpen: true); + + await using (hashingStream.ConfigureAwait(false)) + { + var 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 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); + + LogBlobUploaded(); + + return new BlockUploadResult(0, sha256Digest); + } + } + } + + private async ValueTask UploadBlobAsync( + BlockUploadPreparationRequest request, + RecyclableMemoryStream dataPacketStream, + CancellationToken cancellationToken) + { +#pragma warning disable S3236 // FP: https://community.sonarsource.com/t/false-positive-on-s3236-when-calling-debug-assert-with-message/138761/6 + Debug.Assert(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 nonDisposableDataPacketStream = new NonDisposingStreamWrapper(dataPacketStream); + await using (nonDisposableDataPacketStream.ConfigureAwait(false)) + { + await Policy + .Handle(ex => !cancellationToken.IsCancellationRequested && ExceptionIsRetriable(ex)) + .WaitAndRetryAsync( + retryCount: 1, + sleepDurationProvider: RetryPolicy.GetAttemptDelay, + onRetryAsync: async (exception, _, retryNumber, _) => + { + await WaitOnRetryAfterIfNeededAsync(exception, cancellationToken).ConfigureAwait(false); + + LogBlobUploadRetry(retryNumber, exception.FlattenMessage()); + }) + .ExecuteAsync(ExecuteUploadAsync).ConfigureAwait(false); + } + + return; + + static bool ExceptionIsRetriable(Exception ex) + { + return ex is not FileContentsDecryptionException; + } + + async Task ExecuteUploadAsync() + { + // FIXME: request multiple blocks at once + var uploadRequestResponse = await _client.Api.Files.PrepareBlockUploadAsync(request, cancellationToken).ConfigureAwait(false); + + var uploadTarget = request.Thumbnails.Count == 0 ? uploadRequestResponse.UploadTargets[0] : uploadRequestResponse.ThumbnailUploadTargets[0]; + + nonDisposableDataPacketStream.Seek(0, SeekOrigin.Begin); + + await _client.Api.Storage.UploadBlobAsync(uploadTarget.BareUrl, uploadTarget.Token, nonDisposableDataPacketStream, cancellationToken) + .ConfigureAwait(false); + } + } + + 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); + } + } + } + + 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}")] + private partial void LogBlobUploadRetry(int retryNumber, string errorMessage); + + [LoggerMessage( + Level = LogLevel.Information, + 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/ChecksumMismatchIntegrityException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/ChecksumMismatchIntegrityException.cs new file mode 100644 index 00000000..7fff16b7 --- /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/FileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs new file mode 100644 index 00000000..5f962f52 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/FileUploader.cs @@ -0,0 +1,281 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Telemetry; +using Proton.Sdk.Threading; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +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 bool _isDisposed; + + private FileUploader( + ProtonDriveClient client, + long queueToken, + IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, + long size, + FileUploadMetadata metadata, + ILogger logger) + { + _client = client; + _queueToken = queueToken; + _revisionDraftProvider = revisionDraftProvider; + _telemetryContextNodeUid = telemetryContextNodeUid; + FileSize = size; + _metadata = metadata; + _logger = logger; + } + + internal long FileSize { get; } + + public UploadController UploadFromStream( + Stream contentStream, + IEnumerable thumbnails, + Action? onProgress, + Func>? expectedSha1Provider, + bool forPhotos, + CancellationToken cancellationToken) + { + return UploadFromStream( + contentStream, + ownsContentStream: false, + thumbnails, + onProgress, + expectedSha1Provider, + forPhotos, + cancellationToken); + } + + public UploadController UploadFromFile( + string filePath, + IEnumerable thumbnails, + Action? onProgress, + Func>? expectedSha1Provider, + bool forPhotos, + CancellationToken cancellationToken) + { + var contentStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + return UploadFromStream( + contentStream, + ownsContentStream: true, + thumbnails, + onProgress, + expectedSha1Provider, + forPhotos, + cancellationToken); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + try + { + _client.UploadQueue.RemoveFileFromQueue(_queueToken); + } + finally + { + _isDisposed = true; + } + } + + internal static FileUploader? TryCreate( + ProtonDriveClient client, + IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, + long size, + FileUploadMetadata metadata) + { + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(client.TargetBlockSize); + + if (client.UploadQueue.TryEnqueueFile(expectedNumberOfBlocks) is not { } queueToken) + { + return null; + } + + return new FileUploader( + client, + queueToken, + revisionDraftProvider, + telemetryContextNodeUid, + size, + metadata, + client.Telemetry.GetLogger("File uploader")); + } + + internal static async ValueTask CreateAsync( + ProtonDriveClient client, + IRevisionDraftProvider revisionDraftProvider, + NodeUid telemetryContextNodeUid, + long size, + FileUploadMetadata metadata, + CancellationToken cancellationToken) + { + var expectedNumberOfBlocks = (int)size.DivideAndRoundUp(client.TargetBlockSize); + + var queueToken = await client.UploadQueue.EnqueueFileAsync(expectedNumberOfBlocks, cancellationToken).ConfigureAwait(false); + + return new FileUploader( + client, + queueToken, + revisionDraftProvider, + telemetryContextNodeUid, + size, + metadata, + client.Telemetry.GetLogger("File uploader")); + } + + private UploadController UploadFromStream( + Stream contentStream, + bool ownsContentStream, + IEnumerable thumbnails, + Action? onProgress, + Func>? expectedSha1Provider, + bool forPhotos, + CancellationToken cancellationToken) + { + var taskControl = new TaskControl(cancellationToken); + + 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), + expectedSha1, + revisionDraftTaskCompletionSource, + forPhotos, + ct); + + return new UploadController( + revisionDraftTaskCompletionSource.Task, + uploadFunction.Invoke(taskControl.PauseOrCancellationToken), + uploadFunction, + ownsContentStream ? contentStream : null, + taskControl, + OnFailedAsync, + OnSucceededAsync); + + async ValueTask OnFailedAsync(Exception ex, long uploadedByteCount) + { + 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); + uploadEvent.OriginalError = ex; + + RaiseTelemetryEvent(uploadEvent); + } + + 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); + } + } + + private async Task UploadFromStreamAsync( + Stream contentStream, + IEnumerable thumbnails, + Action? onProgress, + Lazy>? expectedSha1, + TaskCompletionSource revisionDraftTaskCompletionSource, + bool forPhotos, + CancellationToken cancellationToken) + { + var revisionDraft = revisionDraftTaskCompletionSource.Task.GetResultIfCompletedSuccessfully(); + if (revisionDraft is null) + { + revisionDraft = await _revisionDraftProvider.GetDraftAsync(FileSize, forPhotos, cancellationToken).ConfigureAwait(false); + revisionDraftTaskCompletionSource.SetResult(revisionDraft); + } + + await UploadAsync( + revisionDraft, + contentStream, + thumbnails, + onProgress, + expectedSha1, + cancellationToken).ConfigureAwait(false); + + await UpdateActiveRevisionInCacheAsync(revisionDraft.Uid, contentStream.Length, cancellationToken).ConfigureAwait(false); + + return new UploadResult(revisionDraft.Uid.NodeUid, revisionDraft.Uid); + } + + 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 (FileNode fileNode, var membershipShareId, var nameHashDigest)) + { + await _client.Cache.Entities.RemoveNodeAsync(revisionUid.NodeUid, cancellationToken).ConfigureAwait(false); + return; + } + + fileNode = fileNode with + { + ActiveRevision = fileNode.ActiveRevision with + { + Uid = revisionUid, + ClaimedSize = size, + 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 + }, + }; + + await _client.Cache.Entities.SetNodeAsync(fileNode.Uid, fileNode, membershipShareId, nameHashDigest, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask UploadAsync( + RevisionDraft revisionDraft, + Stream contentStream, + IEnumerable thumbnails, + Action? onProgress, + Lazy>? expectedSha1, + CancellationToken cancellationToken) + { + var revisionWriter = RevisionOperations.OpenForWriting(_client, revisionDraft, _queueToken); + + await revisionWriter.WriteAsync( + contentStream, + expectedSha1, + thumbnails, + _metadata, + onProgress, + cancellationToken).ConfigureAwait(false); + } + + 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/IRevisionDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IRevisionDraftProvider.cs new file mode 100644 index 00000000..67f76b17 --- /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(long intendedUploadSize, bool forPhotos, 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..905a2c03 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/IntegrityException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk.Nodes.Upload; + +public class IntegrityException : ProtonDriveException +{ + 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/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/NewFileDraftProvider.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs new file mode 100644 index 00000000..9e8b7d08 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewFileDraftProvider.cs @@ -0,0 +1,201 @@ +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 : 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 GetDraftAsync(long intendedUploadSize, bool forPhotos, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); + + var (parentKey, parentHashKey) = await FolderOperations.GetKeyAndHashKeyAsync(_client, _parentUid, forPhotos, 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 (revisionUid, key, contentKey) = await CreateDraftAsync( + intendedUploadSize, + parentKey, + parentHashKey, + signingKey, + membershipAddress.EmailAddress, + cancellationToken).ConfigureAwait(false); + + var blockVerifier = await _client.BlockVerifierFactory.CreateAsync(revisionUid, key, cancellationToken).ConfigureAwait(false); + + return new RevisionDraft( + revisionUid, + key, + contentKey, + signingKey, + parentHashKey, + membershipAddress, + blockVerifier, + intendedUploadSize, + ct => DeleteDraftAsync(revisionUid, ct), + _client.Telemetry.GetLogger("New file draft")); + } + + private static FileCreationRequest GetFileCreationRequest( + long intendedUploadSize, + string clientUid, + NodeUid parentUid, + string name, + string mediaType, + PgpPrivateKey parentKey, + ReadOnlyMemory parentHashKey, + PgpPrivateKey signingKey, + string membershipEmailAddress, + bool useAeadFeatureFlag, + out PgpPrivateKey nodeKey, + out PgpSessionKey passphraseSessionKey, + out PgpSessionKey nameSessionKey, + out PgpSessionKey contentKey) + { + var pgpProfile = useAeadFeatureFlag ? PgpProfile.ProtonAead : PgpProfile.Proton; + + NodeOperations.GetCommonCreationParameters( + name, + parentKey, + parentHashKey.Span, + signingKey, + pgpProfile, + out nodeKey, + out nameSessionKey, + out passphraseSessionKey, + out var encryptedName, + out var nameHashDigest, + out var encryptedKeyPassphrase, + out var passphraseSignature, + out var lockedKeyBytes); + + contentKey = useAeadFeatureFlag ? PgpSessionKey.GenerateForAead() : PgpSessionKey.Generate(); + + 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), + ContentKeySignature = nodeKey.Sign(contentKey.Export()), + IntendedUploadSize = intendedUploadSize, + }; + } + + private async ValueTask<(RevisionUid RevisionUid, PgpPrivateKey Key, PgpSessionKey ContentKey)> CreateDraftAsync( + long intendedUploadSize, + PgpPrivateKey parentKey, + ReadOnlyMemory parentHashKey, + PgpPrivateKey signingKey, + string membershipEmailAddress, + CancellationToken cancellationToken) + { + var remainingNumberOfAttempts = MaxNumberOfDraftCreationAttempts; + + (RevisionUid RevisionUid, PgpPrivateKey Key, PgpSessionKey ContentKey)? result = null; + + var useAeadFeatureFlag = await _client.FeatureFlagProvider.IsEnabledAsync(FeatureFlags.DriveCryptoEncryptBlocksWithPgpAead, cancellationToken) + .ConfigureAwait(false); + + while (result is null) + { + var request = GetFileCreationRequest( + intendedUploadSize, + _client.Uid, + _parentUid, + _name, + _mediaType, + parentKey, + parentHashKey, + signingKey, + membershipEmailAddress, + useAeadFeatureFlag, + 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, + }; + + 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 + && (conflict.DraftClientUid == _client.Uid || _overrideExistingDraftByOtherClient) + && remainingNumberOfAttempts-- > 0) + { + var conflictingNodeUid = new NodeUid(_parentUid.VolumeId, conflictingLinkId); + + var deletionResults = await NodeOperations.DeleteDraftAsync(_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; + } + } + catch (ProtonApiException e) when (e.Code is ResponseCode.AlreadyExists) + { + throw new NodeWithSameNameExistsException(_parentUid.VolumeId, e); + } + } + + 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 new file mode 100644 index 00000000..9edb55be --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/NewRevisionDraftProvider.cs @@ -0,0 +1,94 @@ +using Proton.Drive.Sdk.Api.Files; +using Proton.Sdk; +using Proton.Sdk.Api; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +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 GetDraftAsync(long intendedUploadSize, bool forPhotos, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(intendedUploadSize); + + var parameters = new RevisionCreationRequest + { + CurrentRevisionId = _lastKnownRevisionId, + ClientId = _client.Uid, + IntendedUploadSize = intendedUploadSize, + }; + + var fileSecrets = await FileOperations.GetSecretsAsync(_client, _fileUid, forPhotos, cancellationToken).ConfigureAwait(false); + + if (fileSecrets is not { Key: { } nodeKey, ContentKey: { } contentKey }) + { + throw new InvalidOperationException($"Cannot create draft for file {_fileUid} with missing 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 (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); + } + catch (ProtonApiException e) when (e.Code is ResponseCode.AlreadyExists) + { + throw new RevisionDraftConflictException("Cannot create revision", e); + } + } + + 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, nodeKey, cancellationToken).ConfigureAwait(false); + + return new RevisionDraft( + draftRevisionUid, + nodeKey, + contentKey, + signingKey, + parentHashKey: null, + membershipAddress, + blockVerifier, + intendedUploadSize, + ct => DeleteDraftAsync(draftRevisionUid, ct), + _client.Telemetry.GetLogger("New file draft")); + } + + private async ValueTask DeleteDraftAsync(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/RevisionDraft.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs new file mode 100644 index 00000000..83b932c8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionDraft.cs @@ -0,0 +1,149 @@ +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, + ReadOnlyMemory? parentHashKey, + Address membershipAddress, + IBlockVerifier blockVerifier, + long intendedUploadSize, + Func deleteDraftFunction, + ILogger logger) : IAsyncDisposable +{ + private readonly SortedDictionary _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 ReadOnlyMemory? ParentHashKey { get; } = parentHashKey; + public Address MembershipAddress { get; } = membershipAddress; + public IBlockVerifier BlockVerifier { get; } = blockVerifier; + + public IncrementalHash Sha1 { get; } = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + + public IReadOnlyCollection OrderedThumbnailUploadResults => _thumbnailUploadResults.Values; + public IReadOnlyList> OrderedContentBlockStates => _contentBlockStates; + + public bool IsCompleted { get; set; } + 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) + { + 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 GetNewContentBlockNumber() + { + return OrderedContentBlockStates.Count + 1; + } + + public bool TryGetNextContentBlockPlainData( + int? currentBlockNumber, + [NotNullWhen(true)] out (int BlockNumber, BlockUploadPlainData PlainData)? result) + { + lock (_blockUploadStatesLock) + { + var offset = currentBlockNumber ?? 0; + + 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() + { + Sha1.Dispose(); + + var dataItemsToDispose = OrderedContentBlockStates + .Select(x => x.TryGetFirst(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.Warning, 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 new file mode 100644 index 00000000..2934c1a8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/RevisionWriter.cs @@ -0,0 +1,513 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Serialization; +using Proton.Sdk; +using Proton.Sdk.Api; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +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 long _queueToken; + private readonly ILogger _logger; + + private readonly int _targetBlockSize; + + internal RevisionWriter( + ProtonDriveClient client, + RevisionDraft draft, + long queueToken, + int targetBlockSize = DefaultBlockSize) + { + _client = client; + _draft = draft; + _queueToken = queueToken; + _targetBlockSize = targetBlockSize; + _logger = client.Telemetry.GetLogger("Revision writer"); + } + + public async ValueTask WriteAsync( + Stream contentStream, + Lazy>? expectedSha1, + IEnumerable thumbnails, + FileUploadMetadata metadata, + Action? onProgress, + CancellationToken cancellationToken) + { + try + { + var signingEmailAddress = _draft.MembershipAddress.EmailAddress; + + var expectedThumbnailBlockCount = await UploadBlocksAsync(contentStream, thumbnails, onProgress, cancellationToken).ConfigureAwait(false); + + var sha1Digest = _draft.Sha1.GetCurrentHash(); + + RevisionUpdateRequest request; + + if (metadata is PhotosFileUploadMetadata photoMetadata) + { + var hashKey = _draft.ParentHashKey + ?? await NodeOperations.GetParentFolderHashKeyAsync(_client, _draft.Uid.NodeUid, forPhotos: true, cancellationToken).ConfigureAwait(false); + + request = CreatePhotosRevisionUpdateRequest( + photoMetadata, + expectedThumbnailBlockCount, + expectedSha1, + sha1Digest, + hashKey, + signingEmailAddress); + } + else + { + request = CreateRevisionUpdateRequest( + metadata, + expectedThumbnailBlockCount, + expectedSha1, + sha1Digest, + signingEmailAddress); + } + + 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)) + { + throw; + } + } + + LogRevisionSealed(_draft.Uid); + + _draft.IsCompleted = true; + } + catch (Exception ex) when (!IsResumableError(ex)) + { + _draft.IsResumable = false; + throw; + } + } + + private static bool IsResumableError(Exception ex) + { + return ex is not ProtonApiException { TransportCode: >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode } + and not NodeWithSameNameExistsException + 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, expectedThumbnailBlockCount, 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, + int expectedThumbnailBlockCount, + Lazy>? expectedSha1, + byte[] sha1Digest, + 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 MissingContentBlockIntegrityException(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 != _draft.IntendedUploadSize) + { + throw new ContentSizeMismatchIntegrityException( + uploadedSize: uploadedContentSize, + expectedSize: _draft.IntendedUploadSize); + } + + if (expectedThumbnailBlockCount != _draft.OrderedThumbnailUploadResults.Count) + { + throw new ThumbnailCountMismatchIntegrityException( + uploadedBlockCount: _draft.OrderedThumbnailUploadResults.Count, + expectedBlockCount: expectedThumbnailBlockCount); + } + + var checksumVerified = false; + if (expectedSha1 is not null) + { + if (!expectedSha1.Value.Span.SequenceEqual(sha1Digest)) + { + throw new ChecksumMismatchIntegrityException( + actualChecksum: sha1Digest, + expectedChecksum: expectedSha1.Value.ToArray()); + } + + checksumVerified = true; + } + + 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), + ChecksumVerified = checksumVerified, + SignatureEmailAddress = signingEmailAddress, + ExtendedAttributes = encryptedExtendedAttributes, + }; + + return request; + } + + private RevisionUpdateRequest CreatePhotosRevisionUpdateRequest( + PhotosFileUploadMetadata metadata, + int expectedThumbnailBlockCount, + Lazy>? expectedSha1, + byte[] sha1Digest, + ReadOnlyMemory parentHashKey, + string signingEmailAddress) + { + var request = CreateRevisionUpdateRequest( + metadata, + expectedThumbnailBlockCount, + expectedSha1, + 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; + } + + private async ValueTask UploadContentBlockAsync( + int blockNumber, + BlockUploadPlainData plainData, + Action? onBlockProgress, + CancellationToken cancellationToken) + { + try + { + var result = await _client.BlockUploader.UploadContentAsync(_draft, blockNumber, plainData, onBlockProgress, cancellationToken) + .ConfigureAwait(false); + + _draft.SetContentBlockUploadResult(blockNumber, result); + + await plainData.DisposeAsync().ConfigureAwait(false); + + _client.UploadQueue.DecreaseFileRemainingBlockCount(_queueToken, 1); + + return result; + } + finally + { + _client.UploadQueue.DequeueBlocks(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; + } + + _client.UploadQueue.IncreaseFileBlockCount(_queueToken, 1); + + await WaitForBlockUploaderAsync(uploadTasks, cancellationToken).ConfigureAwait(false); + + var uploadTask = UploadThumbnailBlockAsync(thumbnail, cancellationToken).AsTask(); + + uploadTasks.Enqueue(uploadTask); + } + + return blockCount; + } + + private async ValueTask UploadThumbnailBlockAsync(Thumbnail thumbnail, CancellationToken cancellationToken) + { + try + { + var result = await _client.BlockUploader.UploadThumbnailAsync(_draft, thumbnail, cancellationToken).ConfigureAwait(false); + + _draft.SetThumbnailUploadResult(thumbnail.Type, result); + + _client.UploadQueue.DecreaseFileRemainingBlockCount(_queueToken, 1); + + return result; + } + finally + { + _client.UploadQueue.DequeueBlocks(1); + } + } + + private async ValueTask UploadContentBlocksAsync( + Action? onProgress, + HashingReadStream hashingContentStream, + Queue> uploadTasks, + int expectedThumbnailBlockCount, + 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(SourceReadingCancellationDelay))) + { + int? currentBlockNumber = null; + + while ( + await TryGetNextContentBlockPlainDataAsync( + currentBlockNumber, + hashingContentStream, + _draft.BlockVerifier.DataPacketPrefixMaxLength, + delayedCancellationTokenSource.Token).ConfigureAwait(false) is var (newBlockNumber, plainData)) + { + cancellationToken.ThrowIfCancellationRequested(); + + currentBlockNumber = newBlockNumber; + + _client.UploadQueue.ApplyFileMinimumTotalBlockCount(_queueToken, currentBlockNumber.Value + expectedThumbnailBlockCount); + + // 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?); + + // ReSharper disable once PossiblyMistakenUseOfCancellationToken + var uploadTask = UploadContentBlockAsync(currentBlockNumber.Value, plainData, onBlockProgress, cancellationToken).AsTask(); + + uploadTasks.Enqueue(uploadTask); + } + } + } + + private async ValueTask<(int BlockNumber, BlockUploadPlainData PlainData)?> TryGetNextContentBlockPlainDataAsync( + int? currentBlockNumber, + Stream contentStream, + int prefixLength, + CancellationToken cancellationToken) + { + if (_draft.TryGetNextContentBlockPlainData(currentBlockNumber, out var result)) + { + result.Value.PlainData.Stream.Seek(0, SeekOrigin.Begin); + return result; + } + + currentBlockNumber = _draft.GetNewContentBlockNumber(); + + 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; + } + + plainDataStream.Seek(0, SeekOrigin.Begin); + + var plainData = new BlockUploadPlainData(plainDataStream, plainDataPrefixBuffer); + + _draft.SetContentBlockPlainData(currentBlockNumber.Value, plainData); + + return (currentBlockNumber.Value, plainData); + } + catch + { + // TODO: Seek the content stream and allow resuming the upload. Currently, the HashingReadStream prevents seeking. + _draft.IsResumable = false; + + await plainDataStream.DisposeAsync().ConfigureAwait(false); + + throw; + } + } + catch + { + ArrayPool.Shared.Return(plainDataPrefixBuffer); + throw; + } + } + + private async ValueTask WaitForBlockUploaderAsync(Queue> uploadTasks, CancellationToken cancellationToken) + { + if (!_client.UploadQueue.TryEnqueueBlock()) + { + if (uploadTasks.TryDequeue(out var uploadTask)) + { + await uploadTask.ConfigureAwait(false); + } + + await _client.UploadQueue.EnqueueBlockAsync(cancellationToken).ConfigureAwait(false); + } + } + + 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/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/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs new file mode 100644 index 00000000..0ce4f5b5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/UploadController.cs @@ -0,0 +1,168 @@ +using Proton.Sdk.Threading; + +namespace Proton.Drive.Sdk.Nodes.Upload; + +public sealed class UploadController : IAsyncDisposable +{ + private readonly Task _revisionDraftTask; + private readonly Func> _resumeFunction; + private readonly ITaskControl _taskControl; + private readonly Stream? _sourceStreamToDispose; + private readonly Func? _onFailedAsync; + private readonly Func? _onSucceededAsync; + + private bool _isDisposed; + + internal UploadController( + Task revisionDraftTask, + Task uploadTask, + Func> resumeFunction, + Stream? sourceStreamToDispose, + ITaskControl taskControl, + Func? onFailedAsync = null, + Func? onSucceededAsync = null) + { + _revisionDraftTask = revisionDraftTask; + _resumeFunction = resumeFunction; + _taskControl = taskControl; + _sourceStreamToDispose = sourceStreamToDispose; + _onFailedAsync = onFailedAsync; + _onSucceededAsync = onSucceededAsync; + + Completion = PauseOnResumableErrorAsync(uploadTask, taskControl.Attempt); + } + + public bool IsPaused => _taskControl.IsPaused; + + public Task Completion { get; private set; } + + public void Pause() + { + _taskControl.Pause(); + } + + public void Resume() + { + if (!_taskControl.TryResume()) + { + return; + } + + var previousCompletion = Completion; + Completion = ResumeAfterPreviousCompletionAsync(previousCompletion, _taskControl.Attempt); + } + + public async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + try + { + try + { + Exception? exception = null; + try + { + await Completion.ConfigureAwait(false); + } + catch (Exception ex) + { + exception = ex; + } + + var draft = _revisionDraftTask.GetResultIfCompletedSuccessfully(); + + try + { + if (exception is not null and not OperationCanceledException && _onFailedAsync is not null) + { + var numberOfPlainBytesDone = draft?.NumberOfPlainBytesDone ?? 0; + + await _onFailedAsync.Invoke(exception, numberOfPlainBytesDone).ConfigureAwait(false); + } + } + finally + { + if (draft is not null) + { + await draft.DisposeAsync().ConfigureAwait(false); + } + } + } + finally + { + _taskControl.Dispose(); + } + } + finally + { + if (_sourceStreamToDispose is not null) + { + await _sourceStreamToDispose.DisposeAsync().ConfigureAwait(false); + } + } + } + + 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 + { + var result = await uploadTask.ConfigureAwait(false); + + await InvokeOnSucceededAsync().ConfigureAwait(false); + + return result; + } + catch (Exception) when (IsResumable()) + { + if (_taskControl.Attempt == attempt) + { + _taskControl.Pause(); + } + + throw; + } + catch + { + if (_taskControl.IsPaused) + { + _taskControl.AbortPause(); + } + + throw; + } + } + + private async ValueTask InvokeOnSucceededAsync() + { + var onSucceededHandler = _onSucceededAsync; + if (onSucceededHandler is null) + { + return; + } + + var revisionDraft = await _revisionDraftTask.ConfigureAwait(false); + + await onSucceededHandler.Invoke(revisionDraft.NumberOfPlainBytesDone).ConfigureAwait(false); + } + + private bool IsResumable() + { + return _revisionDraftTask is { IsCompletedSuccessfully: true, Result.IsResumable: true }; + } +} 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.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs new file mode 100644 index 00000000..0947d385 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifier.cs @@ -0,0 +1,67 @@ +using CommunityToolkit.HighPerformance; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.BlockVerification; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal sealed class BlockVerifier : IBlockVerifier +{ + private const int MaxVerificationLength = 16; + + private readonly PgpSessionKey _sessionKey; + private readonly ReadOnlyMemory _verificationCode; + + private BlockVerifier(PgpSessionKey sessionKey, ReadOnlyMemory verificationCode) + { + _sessionKey = sessionKey; + _verificationCode = verificationCode; + } + + public int DataPacketPrefixMaxLength => _verificationCode.Length; + + public static async ValueTask CreateAsync( + IBlockVerificationApiClient apiClient, + RevisionUid revisionUid, + PgpPrivateKey key, + CancellationToken cancellationToken) + { + var verificationInput = + await apiClient.GetVerificationInputAsync(revisionUid.NodeUid.VolumeId, revisionUid.NodeUid.LinkId, revisionUid.RevisionId, cancellationToken) + .ConfigureAwait(false); + + PgpSessionKey sessionKey; + try + { + sessionKey = key.DecryptSessionKey(verificationInput.ContentKeyPacket.Span); + } + catch (Exception e) + { + throw new NodeKeyAndSessionKeyMismatchException(e); + } + + return new BlockVerifier(sessionKey, verificationInput.VerificationCode); + } + + public VerificationToken VerifyBlock(ReadOnlyMemory dataPacketPrefix, ReadOnlySpan plainDataPrefix) + { + 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])) + { + throw new SessionKeyAndDataPacketMismatchException("Mismatched plaintext verification"); + } + } + catch (Exception e) + { + throw new SessionKeyAndDataPacketMismatchException(e); + } + + return VerificationToken.Create(_verificationCode.Span, dataPacketPrefix.Span); + } +} 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..2a0baefb --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/BlockVerifierFactory.cs @@ -0,0 +1,17 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.BlockVerification; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal sealed class BlockVerifierFactory(HttpClient httpClient) : IBlockVerifierFactory +{ + private readonly IBlockVerificationApiClient _apiClient = new BlockVerificationApiClient(httpClient); + + public async ValueTask CreateAsync( + RevisionUid revisionUid, + PgpPrivateKey key, + CancellationToken cancellationToken) + { + return await BlockVerifier.CreateAsync(_apiClient, revisionUid, 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..c7c2ef5a --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/IBlockVerifierFactory.cs @@ -0,0 +1,8 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +internal interface IBlockVerifierFactory +{ + ValueTask CreateAsync(RevisionUid revisionUid, PgpPrivateKey key, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs new file mode 100644 index 00000000..571b07e8 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/NodeKeyAndSessionKeyMismatchException.cs @@ -0,0 +1,23 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public sealed class NodeKeyAndSessionKeyMismatchException : IntegrityException +{ + public NodeKeyAndSessionKeyMismatchException(string message) + : base(message) + { + } + + public NodeKeyAndSessionKeyMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NodeKeyAndSessionKeyMismatchException() + { + } + + public NodeKeyAndSessionKeyMismatchException(Exception innerException) + : base(string.Empty, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs new file mode 100644 index 00000000..04507213 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/SessionKeyAndDataPacketMismatchException.cs @@ -0,0 +1,23 @@ +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public sealed class SessionKeyAndDataPacketMismatchException : IntegrityException +{ + public SessionKeyAndDataPacketMismatchException(string message) + : base(message) + { + } + + public SessionKeyAndDataPacketMismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public SessionKeyAndDataPacketMismatchException() + { + } + + public SessionKeyAndDataPacketMismatchException(Exception innerException) + : base(string.Empty, innerException) + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs new file mode 100644 index 00000000..ad50450d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Nodes/Upload/Verification/VerificationToken.cs @@ -0,0 +1,40 @@ +using System.Numerics.Tensors; + +namespace Proton.Drive.Sdk.Nodes.Upload.Verification; + +public readonly struct VerificationToken +{ + private readonly ReadOnlyMemory _data; + + private VerificationToken(ReadOnlyMemory data) + { + _data = data; + } + + public static VerificationToken Create(ReadOnlySpan verificationCode, ReadOnlySpan dataPacketPrefix) + { + // In the unlikely event that the back-end decides to increase the length of the verification code such that it may exceed + // the length of the data packet prefix, we have padding logic to deal with it, as per the agreed verification protocol. + var dataPacketPrefixForToken = GetPaddedOrTruncatedBytes(dataPacketPrefix, verificationCode.Length); + + var tokenData = new byte[verificationCode.Length]; + + TensorPrimitives.Xor(verificationCode, dataPacketPrefixForToken, tokenData); + + return new VerificationToken(tokenData); + } + + public ReadOnlyMemory AsReadOnlyMemory() => _data; + + private static ReadOnlySpan GetPaddedOrTruncatedBytes(ReadOnlySpan originalBytes, int length) + { + if (originalBytes.Length >= length) + { + return originalBytes[..length]; + } + + var result = new byte[length]; + originalBytes.CopyTo(result); + return result; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj new file mode 100644 index 00000000..6bc30714 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Proton.Drive.Sdk.csproj @@ -0,0 +1,27 @@ + + + + true + Cloud Storage Volume Folder File + Provides the means to interact with the Proton Drive services. + true + true + snupkg + + + + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs new file mode 100644 index 00000000..c359d537 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClient.cs @@ -0,0 +1,341 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +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.Http; +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; +using Proton.Sdk.Caching; +using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk; + +public sealed class ProtonDriveClient +{ + private const int DefaultDegreeOfBlockTransferParallelism = 6; + private const int MaxDegreeOfThumbnailDownloadParallelism = 8; + + /// + /// Creates a new instance of . + /// + /// Authenticated API 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, + (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?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.StorageApiTimeoutSecondsOverride ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + accountClient, + new DriveClientCache(entityCacheRepository, secretCacheRepository), + featureFlagProvider, + telemetry, + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + creationParameters?.Uid, + creationParameters?.DegreeOfBlockTransferParallelismOverride) + { + } + + internal ProtonDriveClient( + ProtonApiSession session, + Func driveApiClientsFactory, + string? uid = null) + : this( + 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, + session.ClientConfiguration.Telemetry, + driveApiClientsFactory, + uid, + degreeOfBlockTransferParallelism: null) + { + } + + 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( + creationParameters?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds), + new SdkHttpClientFactoryDecorator(httpClientFactory, creationParameters?.BindingsLanguage).CreateClientWithTimeout( + creationParameters?.StorageApiTimeoutSecondsOverride ?? ProtonDriveDefaults.StorageApiTimeoutSeconds), + accountClient, + new DriveClientCache(entityCacheRepository, secretCacheRepository), + featureFlagProvider, + telemetry, + driveApiClientsFactory, + creationParameters?.Uid, + creationParameters?.DegreeOfBlockTransferParallelismOverride) + { + } + + internal ProtonDriveClient( + IAccountClient accountClient, + IDriveApiClients api, + IDriveClientCache cache, + IBlockVerifierFactory blockVerifierFactory, + IFeatureFlagProvider featureFlagProvider, + ITelemetry telemetry, + string? uid, + int? degreeOfBlockTransferParallelism = null) + { + Uid = uid ?? Guid.NewGuid().ToString(); + + Account = accountClient; + Api = api; + Cache = cache; + BlockVerifierFactory = blockVerifierFactory; + Telemetry = telemetry; + FeatureFlagProvider = featureFlagProvider; + + var maxDegreeOfBlockTransferParallelism = degreeOfBlockTransferParallelism ?? DefaultDegreeOfBlockTransferParallelism; + + 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")); + + BlockUploader = new BlockUploader(this); + BlockDownloader = new BlockDownloader(this); + ThumbnailBlockDownloader = new BlockDownloader(this); + PgpEnvironment.DefaultAeadStreamingChunkLength = PgpAeadStreamingChunkLength.ChunkLength; + } + + private ProtonDriveClient( + HttpClient defaultApiHttpClient, + HttpClient storageApiHttpClient, + IAccountClient accountClient, + IDriveClientCache cache, + IFeatureFlagProvider featureFlagProvider, + ITelemetry telemetry, + Func driveApiClientsFactory, + string? uid, + int? degreeOfBlockTransferParallelism = null) + : this( + accountClient, + driveApiClientsFactory.Invoke(defaultApiHttpClient, storageApiHttpClient), + cache, + new BlockVerifierFactory(defaultApiHttpClient), + featureFlagProvider, + telemetry, + uid, + degreeOfBlockTransferParallelism) + { + } + + // 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; } + + internal IAccountClient Account { get; } + internal IDriveApiClients Api { get; } + internal IDriveClientCache Cache { get; } + internal IBlockVerifierFactory BlockVerifierFactory { get; } + internal ITelemetry Telemetry { get; } + internal IFeatureFlagProvider FeatureFlagProvider { get; } + + internal TransferQueue UploadQueue { get; } + internal TransferQueue DownloadQueue { get; } + internal TransferQueue ThumbnailDownloadQueue { get; } + + internal int TargetBlockSize { get; set; } = RevisionWriter.DefaultBlockSize; + + internal BlockUploader BlockUploader { get; } + internal BlockDownloader BlockDownloader { get; } + internal BlockDownloader ThumbnailBlockDownloader { get; } + + internal Func> GetAlternateFileNames { get; } = AlternateFileNameGenerator.GetNames; + + public ValueTask GetMyFilesFolderAsync(CancellationToken cancellationToken) + { + return NodeOperations.GetOrCreateMyFilesFolderAsync(this, cancellationToken); + } + + public ValueTask GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + return NodeOperations + .EnumerateNodesAsync(this, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: false, cancellationToken) + .FirstOrDefaultAsync(cancellationToken); + } + + public IAsyncEnumerable EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) + { + return NodeOperations.EnumerateNodesAsync(this, nodeUids, forPhotos: false, cancellationToken); + } + + public ValueTask CreateFolderAsync(NodeUid parentId, string name, DateTime? lastModificationTime, CancellationToken cancellationToken) + { + return FolderOperations.CreateAsync(this, parentId, name, lastModificationTime, cancellationToken); + } + + public IAsyncEnumerable EnumerateFolderChildrenAsync(NodeUid folderId, CancellationToken cancellationToken = default) + { + return FolderOperations.EnumerateChildrenAsync(this, folderId, cancellationToken); + } + + public IAsyncEnumerable EnumerateThumbnailsAsync( + IEnumerable fileUids, + ThumbnailType type, + CancellationToken cancellationToken = default) + { + 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, + string mediaType, + long size, + FileUploadMetadata metadata, + bool overrideExistingDraftByOtherClient, + CancellationToken cancellationToken) + { + var draftProvider = new NewFileDraftProvider(this, parentFolderUid, name, mediaType, overrideExistingDraftByOtherClient); + + 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( + RevisionUid currentActiveRevisionUid, + long size, + FileUploadMetadata metadata, + CancellationToken cancellationToken) + { + var draftProvider = new NewRevisionDraftProvider(this, currentActiveRevisionUid.NodeUid, currentActiveRevisionUid.RevisionId); + + 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) + { + return await FileDownloader.CreateAsync(this, revisionUid, cancellationToken).ConfigureAwait(false); + } + + // FIXME: unit tests, including name collision cases + public ValueTask GetAvailableNameAsync(NodeUid parentUid, string name, CancellationToken cancellationToken) + { + return NodeOperations.GetAvailableNameAsync(this, parentUid, name, cancellationToken); + } + + public async ValueTask MoveNodesAsync(IEnumerable uids, NodeUid newParentFolderUid, CancellationToken cancellationToken) + { + // FIXME: finalize the implementation that uses the batch move endpoint, and use it instead of this naïve code + foreach (var uid in uids) + { + await NodeOperations.MoveSingleAsync(this, uid, newParentFolderUid, newName: null, cancellationToken).ConfigureAwait(false); + } + } + + public ValueTask RenameNodeAsync(NodeUid uid, string newName, string? newMediaType, CancellationToken cancellationToken) + { + return NodeOperations.RenameAsync(this, uid, newName, newMediaType, cancellationToken); + } + + public ValueTask>> TrashNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.TrashAsync(this, uids, cancellationToken); + } + + public ValueTask>> DeleteNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.DeleteFromTrashAsync(this, uids, cancellationToken); + } + + public ValueTask>> RestoreNodesAsync(IEnumerable uids, CancellationToken cancellationToken) + { + return NodeOperations.RestoreFromTrashAsync(this, uids, cancellationToken); + } + + public async IAsyncEnumerable EnumerateTrashAsync([EnumeratorCancellation] CancellationToken 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, forPhotos: false, cancellationToken).ConfigureAwait(false)) + { + yield return entry; + } + } + + public async ValueTask EmptyTrashAsync(CancellationToken cancellationToken) + { + var volumeId = await VolumeOperations.TryGetMainVolumeIdAsync(this, cancellationToken).ConfigureAwait(false); + + if (volumeId is null) + { + return; + } + + await VolumeOperations.EmptyTrashAsync(this, volumeId.Value, cancellationToken).ConfigureAwait(false); + } +} 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..4975a7c9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveClientOptions.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk; + +public record struct ProtonDriveClientOptions( + string? Uid, + string? BindingsLanguage, + int? DefaultApiTimeoutSecondsOverride, + int? StorageApiTimeoutSecondsOverride, + int? DegreeOfBlockTransferParallelismOverride); diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs new file mode 100644 index 00000000..2274c144 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveDefaults.cs @@ -0,0 +1,8 @@ +namespace Proton.Drive.Sdk; + +internal static class ProtonDriveDefaults +{ + public const string DriveBaseRoute = "drive/"; + + public const int StorageApiTimeoutSeconds = 300; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs new file mode 100644 index 00000000..c4324192 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveError.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk; + +public class ProtonDriveError(string? message, ProtonDriveError? innerError = null) +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; } = message; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProtonDriveError? InnerError { get; } = innerError; + + public Exception ToException() + { + return new InvalidOperationException(Message, InnerError?.ToException()); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveErrorExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveErrorExtensions.cs new file mode 100644 index 00000000..eeec4975 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveErrorExtensions.cs @@ -0,0 +1,34 @@ +namespace Proton.Drive.Sdk; + +public static class ProtonDriveErrorExtensions +{ + // TODO: Find a way to share the share this logic with ExceptionExtensions.FlattenMessage + public static string FlattenMessage(this ProtonDriveError error) + { + var previousMessage = string.Empty; + + return string.Join( + " → ", + EnumerateErrorHierarchy(error) + .Select(e => 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/ProtonDriveException.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs new file mode 100644 index 00000000..47cb20a6 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonDriveException.cs @@ -0,0 +1,18 @@ +namespace Proton.Drive.Sdk; + +public class ProtonDriveException : Exception +{ + public ProtonDriveException(string message) + : base(message) + { + } + + public ProtonDriveException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ProtonDriveException() + { + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs new file mode 100644 index 00000000..b1c6d088 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/ProtonPhotosClient.cs @@ -0,0 +1,199 @@ +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; +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk; + +public sealed class ProtonPhotosClient +{ + public ProtonPhotosClient(ProtonApiSession session, string? uid = null) + { + DriveClient = new ProtonDriveClient( + session, + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + uid); + + var httpClient = session.GetHttpClient(ProtonDriveDefaults.DriveBaseRoute, TimeSpan.FromSeconds(ProtonApiDefaults.DefaultTimeoutSeconds)); + + PhotosApi = new PhotosApiClient(httpClient); + } + + public ProtonPhotosClient( + IHttpClientFactory httpClientFactory, + IAccountClient accountClient, + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + IFeatureFlagProvider featureFlagProvider, + ITelemetry telemetry, + ProtonDriveClientOptions? creationParameters = null) + { + DriveClient = new ProtonDriveClient( + httpClientFactory, + accountClient, + entityCacheRepository, + secretCacheRepository, + featureFlagProvider, + telemetry, + (defaultApiHttpClient, storageApiHttpClient) => new DriveApiClients(defaultApiHttpClient, storageApiHttpClient), + creationParameters); + + var httpClient = new SdkHttpClientFactoryDecorator(httpClientFactory).CreateClientWithTimeout( + creationParameters?.DefaultApiTimeoutSecondsOverride ?? ProtonApiDefaults.DefaultTimeoutSeconds); + + PhotosApi = new PhotosApiClient(httpClient); + } + + internal IPhotosApiClient PhotosApi { get; } + + 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, + 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 await GetFileUploaderAsync(draftProvider, photosRoot.Uid, size, metadata, cancellationToken).ConfigureAwait(false); + } + + public ValueTask> FindDuplicatesAsync(string name, Action generateSha1, CancellationToken cancellationToken) + { + _ = DriveClient; + throw new NotImplementedException(); + } + + public ValueTask GetNodeAsync(NodeUid nodeUid, CancellationToken cancellationToken) + { + return NodeOperations + .EnumerateNodesAsync(DriveClient, nodeUid.VolumeId, [nodeUid.LinkId], forPhotos: true, cancellationToken) + .FirstOrDefaultAsync(cancellationToken); + } + + public IAsyncEnumerable EnumerateNodesAsync(IEnumerable nodeUids, CancellationToken cancellationToken = default) + { + return NodeOperations.EnumerateNodesAsync(DriveClient, nodeUids, forPhotos: true, cancellationToken); + } + + [Experimental("Photos")] + public IAsyncEnumerable EnumerateTimelineAsync(CancellationToken cancellationToken) + { + 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); + } + + public IAsyncEnumerable EnumerateThumbnailsAsync( + IEnumerable photoUids, + ThumbnailType thumbnailType = ThumbnailType.Thumbnail, + CancellationToken cancellationToken = default) + { + 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, forPhotos: true, 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) + { + return PhotosNodeOperations.GetOrCreatePhotosFolderAsync(DriveClient, 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.Drive.Sdk/RecyclableMemoryStreamExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs new file mode 100644 index 00000000..3ab9d9c5 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/RecyclableMemoryStreamExtensions.cs @@ -0,0 +1,16 @@ +using System.Buffers; +using Microsoft.IO; + +namespace Proton.Drive.Sdk; + +public static class RecyclableMemoryStreamExtensions +{ + public static ReadOnlyMemory GetFirstBytes(this RecyclableMemoryStream stream, long maxLength) + { + var sequence = stream.GetReadOnlySequence(); + + return sequence.First.Length >= maxLength + ? sequence.First + : sequence.Slice(0, Math.Min(maxLength, sequence.Length)).ToArray(); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/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.Drive.Sdk/RevisionDraftConflictException.cs b/cs/sdk/src/Proton.Drive.Sdk/RevisionDraftConflictException.cs new file mode 100644 index 00000000..1f41665d --- /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 new file mode 100644 index 00000000..aa426eed --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveApiSerializerContext.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Files; +using Proton.Drive.Sdk.Api.Folders; +using Proton.Drive.Sdk.Api.Links; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, + RespectRequiredConstructorParameters = true, +#endif + Converters = + [ + typeof(PgpArmoredMessageJsonConverter), + typeof(PgpArmoredSignatureJsonConverter), + typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredPublicKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(VolumeCreationRequest))] +[JsonSerializable(typeof(VolumeCreationResponse))] +[JsonSerializable(typeof(VolumeResponse))] +[JsonSerializable(typeof(LinkDetailsRequest))] +[JsonSerializable(typeof(LinkDetailsResponse))] +[JsonSerializable(typeof(ExtendedAttributes))] +[JsonSerializable(typeof(ShareResponse))] +[JsonSerializable(typeof(ShareListResponse))] +[JsonSerializable(typeof(ShareResponseV2))] +[JsonSerializable(typeof(ContextShareResponse))] +[JsonSerializable(typeof(FolderChildrenResponse))] +[JsonSerializable(typeof(FolderCreationRequest))] +[JsonSerializable(typeof(FolderCreationResponse))] +[JsonSerializable(typeof(FileCreationRequest))] +[JsonSerializable(typeof(FileCreationResponse))] +[JsonSerializable(typeof(NodeNameAvailabilityRequest))] +[JsonSerializable(typeof(NodeNameAvailabilityResponse))] +[JsonSerializable(typeof(RevisionCreationRequest))] +[JsonSerializable(typeof(RevisionCreationResponse))] +[JsonSerializable(typeof(RevisionErrorResponse))] +[JsonSerializable(typeof(RevisionConflict))] +[JsonSerializable(typeof(BlockUploadPreparationRequest))] +[JsonSerializable(typeof(BlockUploadPreparationResponse))] +[JsonSerializable(typeof(RevisionUpdateRequest))] +[JsonSerializable(typeof(BlockVerificationInputResponse))] +[JsonSerializable(typeof(RevisionResponse))] +[JsonSerializable(typeof(ThumbnailBlockListRequest))] +[JsonSerializable(typeof(ThumbnailBlockListResponse))] +[JsonSerializable(typeof(ThumbnailBlockError))] +[JsonSerializable(typeof(MoveSingleLinkRequest))] +[JsonSerializable(typeof(MoveMultipleLinksRequest))] +[JsonSerializable(typeof(RenameLinkRequest))] +[JsonSerializable(typeof(MultipleLinksNullaryRequest))] +[JsonSerializable(typeof(AggregateApiResponse))] +[JsonSerializable(typeof(VolumeTrashResponse))] +internal sealed partial class DriveApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs new file mode 100644 index 00000000..cc8d6561 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveEntitiesSerializerContext.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +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; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + RespectRequiredConstructorParameters = true, + Converters = + [ + typeof(RefResultJsonConverter), + typeof(RefResultJsonConverter), + typeof(ValResultJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(Share))] +[JsonSerializable(typeof(CachedNodeInfo))] +[JsonSerializable(typeof(VolumeId?))] +[JsonSerializable(typeof(SerializableRefResult))] +[JsonSerializable(typeof(SerializableRefResult))] +[JsonSerializable(typeof(SerializableValResult))] +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 new file mode 100644 index 00000000..3fe2c8cc --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/DriveSecretsSerializerContext.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + RespectRequiredConstructorParameters = true, + Converters = + [ + typeof(PgpPrivateKeyJsonConverter), + typeof(PgpSessionKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(FolderSecrets))] +[JsonSerializable(typeof(FileSecrets))] +internal sealed partial class DriveSecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs new file mode 100644 index 00000000..2b5a13a2 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/ICompositeUid.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Drive.Sdk.Serialization; + +internal interface ICompositeUid + where TUid : struct, ICompositeUid +{ + static abstract bool TryCreate(string baseUidString, string relativeIdString, [NotNullWhen(true)] out TUid? uid); + + static bool TryParse([NotNullWhen(true)] string? value, [NotNullWhen(true)] out TUid? result) + { + if (string.IsNullOrEmpty(value)) + { + result = null; + return false; + } + + var separatorIndex = value.LastIndexOf('~'); + + if (separatorIndex < 0 || separatorIndex >= value.Length - 1) + { + result = null; + return false; + } + + var baseUidString = value[..separatorIndex]; + var relativeIdString = value[(separatorIndex + 1)..]; + + return TUid.TryCreate(baseUidString, relativeIdString, out result); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/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.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs new file mode 100644 index 00000000..67531dec --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PgpSessionKeyJsonConverter.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +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.AsSpan()[1..], SymmetricCipher.Aes256); + } + + public override void Write(Utf8JsonWriter writer, PgpSessionKey value, JsonSerializerOptions options) + { + 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); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs new file mode 100644 index 00000000..f53d01a4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/PhotosApiSerializerContext.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using Proton.Drive.Sdk.Api.Photos; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, + RespectRequiredConstructorParameters = 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))] +[JsonSerializable(typeof(TimelinePhotoListRequest))] +[JsonSerializable(typeof(TimelinePhotoListResponse))] +internal sealed partial class PhotosApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs b/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs new file mode 100644 index 00000000..0095b3cd --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Serialization/UidJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Drive.Sdk.Serialization; + +internal sealed class UidJsonConverter : JsonConverter + where TUid : struct, ICompositeUid +{ + public override TUid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ICompositeUid.TryParse(reader.GetString(), out var uid) ? uid.Value : default; + } + + public override void Write(Utf8JsonWriter writer, TUid value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs new file mode 100644 index 00000000..64a388c3 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/Share.cs @@ -0,0 +1,13 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Shares; + +internal sealed class Share(ShareId id, NodeUid rootFolderId, 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 new file mode 100644 index 00000000..910fd468 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareCrypto.cs @@ -0,0 +1,32 @@ +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; + +namespace Proton.Drive.Sdk.Shares; + +internal static class ShareCrypto +{ + public static async ValueTask<(Share Share, PgpPrivateKey Key)> DecryptShareAsync( + ProtonDriveClient client, + ShareId shareId, + PgpArmoredPrivateKey lockedKey, + 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, 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 new file mode 100644 index 00000000..285ffd27 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareOperations.cs @@ -0,0 +1,64 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Shares; + +internal static class ShareOperations +{ + 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); + + (share, shareKey) = await ShareCrypto.DecryptShareAsync( + client, + shareId, + response.Key, + response.Passphrase, + response.AddressId, + rootFolderId, + response.Type, + cancellationToken).ConfigureAwait(false); + + 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); + } + + 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, + NodeMetadata nodeMetadata, + bool useCacheOnly, + CancellationToken cancellationToken) + { + var contextRoot = await TraversalOperations.FindRootForNode(client, nodeMetadata, useCacheOnly, cancellationToken).ConfigureAwait(false); + var contextShareId = contextRoot.MembershipShareId; + + if (!contextShareId.HasValue) + { + throw new ProtonDriveException("Node does not have a valid context share"); + } + + return await GetShareAsync(client, (ShareId)contextShareId, useCacheOnly, cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs new file mode 100644 index 00000000..8243d1c0 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Shares/ShareType.cs @@ -0,0 +1,9 @@ +namespace Proton.Drive.Sdk.Shares; + +public enum ShareType +{ + Main = 1, + Standard = 2, + Device = 3, + Photos = 4, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs new file mode 100644 index 00000000..6c6df24d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/StreamExtensions.cs @@ -0,0 +1,48 @@ +using System.Buffers; + +namespace Proton.Drive.Sdk; + +internal static class StreamExtensions +{ + private const int CopyBufferSize = 81_920; + + public static async Task PartiallyCopyToAsync( + this Stream source, + Stream destination, + int lengthToCopy, + Memory sampleOutput, + CancellationToken cancellationToken) + { + var copyBuffer = ArrayPool.Shared.Rent(Math.Min(lengthToCopy, CopyBufferSize)); + try + { + var remainingLengthToCopy = lengthToCopy; + int numberOfBytesRead; + do + { + var copyBufferMemory = copyBuffer.AsMemory(0, Math.Min(remainingLengthToCopy, copyBuffer.Length)); + + numberOfBytesRead = await source.ReadAsync(copyBufferMemory, cancellationToken).ConfigureAwait(false); + + var readBytes = copyBuffer.AsMemory()[..numberOfBytesRead]; + + if (sampleOutput.Length > 0) + { + var lengthForSample = Math.Min(readBytes.Length, sampleOutput.Length); + readBytes.Span[..lengthForSample].CopyTo(sampleOutput.Span); + sampleOutput = sampleOutput[lengthForSample..]; + } + + await destination.WriteAsync(readBytes, cancellationToken).ConfigureAwait(false); + + remainingLengthToCopy -= numberOfBytesRead; + } while (numberOfBytesRead > 0 && remainingLengthToCopy > 0); + + return lengthToCopy - remainingLengthToCopy; + } + finally + { + ArrayPool.Shared.Return(copyBuffer); + } + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs new file mode 100644 index 00000000..d2eb7d0c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/BlockVerificationErrorEvent.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Telemetry; + +namespace Proton.Drive.Sdk.Telemetry; + +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/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs new file mode 100644 index 00000000..78cf46e4 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DecryptionErrorEvent.cs @@ -0,0 +1,19 @@ +using Proton.Drive.Sdk.Nodes; +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 NodeUid 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..040697fa --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadError.cs @@ -0,0 +1,13 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum DownloadError +{ + ServerError, + NetworkError, + DecryptionError, + IntegrityError, + RateLimited, + ValidationError, + 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..5a9bed75 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/DownloadEvent.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +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 long DownloadedSize { get; set; } + + public long ApproximateDownloadedSize { get; set; } + + public long? ClaimedFileSize { get; set; } + + public long? ApproximateClaimedFileSize { get; set; } + + public DownloadError? Error { get; set; } + + [JsonIgnore] + public Exception? OriginalError { get; set; } +} 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/Privacy.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs new file mode 100644 index 00000000..2cae3510 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/Privacy.cs @@ -0,0 +1,33 @@ +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; + } + + public static long? ReduceSizePrecision(long? size) + { + return size is null ? null : ReduceSizePrecision(size.Value); + } +} 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..35698438 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryErrorResolver.cs @@ -0,0 +1,84 @@ +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; +using Proton.Sdk.Http; + +namespace Proton.Drive.Sdk.Telemetry; + +internal static class TelemetryErrorResolver +{ + public static DownloadError? GetDownloadErrorFromException(Exception exception) + { + return exception switch + { + ValidationException => DownloadError.ValidationError, + + // Reported as download success + CompletedDownloadManifestVerificationException => null, + DataIntegrityException => exception.GetBaseException() is CompletedDownloadManifestVerificationException ? null : DownloadError.IntegrityError, + + // Download errors + NodeKeyAndSessionKeyMismatchException or SessionKeyAndDataPacketMismatchException => DownloadError.IntegrityError, + FileContentsDecryptionException => DownloadError.DecryptionError, + CryptographicException => DownloadError.DecryptionError, + + HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => DownloadError.ServerError, + HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => 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 { 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, + + // 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, + }; + } + + public static UploadError GetUploadErrorFromException(Exception exception) + { + return exception switch + { + ValidationException => UploadError.ValidationError, + + // Upload errors + IntegrityException => UploadError.IntegrityError, + + HttpRequestException { HttpRequestError: HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded } => UploadError.ServerError, + HttpRequestException { StatusCode: HttpStatusCode.RequestTimeout } => 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 { 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, + + // 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, + }; + } +} 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..02befaaf --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryEventFactory.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Volumes; + +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, + Node node, + IReadOnlyDictionary failedFields, + CancellationToken cancellationToken) + { + var fromBefore2024 = node.CreationTime.CompareTo(LegacyBoundary) < 1; + + var volumeType = await ResolveVolumeTypeAsync(client, node.Uid, cancellationToken).ConfigureAwait(false); + + return failedFields.Select(field => new DecryptionErrorEvent + { + Uid = node.Uid, + Field = field.Key, + VolumeType = volumeType, + FromBefore2024 = fromBefore2024, + Error = field.Value.FlattenMessage(), + }); + } + + /// + /// Creates a VerificationErrorEvent using a node UID. + /// + public static async Task CreateVerificationErrorEventAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + string? error, + CancellationToken cancellationToken) + { + return new VerificationErrorEvent + { + Uid = nodeUid, + Field = field, + VolumeType = await ResolveVolumeTypeAsync(client, nodeUid, cancellationToken).ConfigureAwait(false), + FromBefore2024 = creationTime.CompareTo(LegacyBoundary) < 1, + Error = error, + }; + } + + /// + /// 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 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 VolumeType.Shared; + } + catch (Exception ex) + { + client.Telemetry.GetLogger("TelemetryEventFactory") + .LogDebug(ex, "Failed to resolve volume type for node {NodeUid}", nodeUid); + 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 new file mode 100644 index 00000000..bf3b77c9 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/TelemetryRecorder.cs @@ -0,0 +1,54 @@ +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, + Node node, + IReadOnlyDictionary failedFields, + CancellationToken cancellationToken) + { + try + { + var events = await TelemetryEventFactory.CreateDecryptionErrorEventsAsync(client, node, 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 verification error event using a node UID. + /// + public static async Task TryRecordVerificationErrorAsync( + ProtonDriveClient client, + NodeUid nodeUid, + EncryptedField field, + DateTime creationTime, + string? error, + CancellationToken cancellationToken) + { + try + { + var @event = await TelemetryEventFactory.CreateVerificationErrorEventAsync(client, nodeUid, field, creationTime, error, cancellationToken) + .ConfigureAwait(false); + + client.Telemetry.RecordMetric(@event); + } + catch + { + // Do nothing - telemetry failures should not break the main flow + } + } +} 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..61f62137 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadError.cs @@ -0,0 +1,12 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum UploadError +{ + ServerError, + NetworkError, + IntegrityError, + RateLimited, + ValidationError, + 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..f1b5d696 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/UploadEvent.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +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 ApproximateUploadedSize { get; set; } + + public required long ExpectedSize { get; set; } + + public required long ApproximateExpectedSize { get; set; } + + public UploadError? Error { get; set; } + + [JsonIgnore] + public Exception? OriginalError { get; set; } +} 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.Drive.Sdk/Telemetry/VerificationErrorEvent.cs b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs new file mode 100644 index 00000000..2e606a29 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VerificationErrorEvent.cs @@ -0,0 +1,21 @@ +using Proton.Drive.Sdk.Nodes; +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 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 new file mode 100644 index 00000000..ea53151c --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Telemetry/VolumeType.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Telemetry; + +public enum VolumeType +{ + Unknown, + OwnVolume, + OwnPhotosVolume, + Shared, + SharedPublic, +} 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/Proton.Drive.Sdk/Volumes/Volume.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs new file mode 100644 index 00000000..db18cd37 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/Volume.cs @@ -0,0 +1,23 @@ +using Proton.Drive.Sdk.Api.Shares; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Nodes; + +namespace Proton.Drive.Sdk.Volumes; + +internal sealed class Volume(VolumeId id, ShareId rootShareId, NodeUid rootFolderId, VolumeState state, long? maxSpace) +{ + internal Volume(VolumeDto dto) + : this(dto.Id, dto.Root.ShareId, new NodeUid(dto.Id, dto.Root.LinkId), dto.State, dto.MaxSpace) + { + } + + public VolumeId Id { get; } = id; + + public ShareId RootShareId { get; } = rootShareId; + + public NodeUid RootFolderId { get; } = rootFolderId; + + public VolumeState State { get; } = state; + + public long? MaxSpace { get; } = maxSpace; +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs new file mode 100644 index 00000000..ad133e18 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Drive.Sdk.Volumes; + +[JsonConverter(typeof(StrongIdJsonConverter))] +internal readonly record struct VolumeId : IStrongId +{ + private readonly string? _value; + + internal VolumeId(string? value) + { + _value = value; + } + + public static explicit operator VolumeId(string? value) + { + return new VolumeId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs new file mode 100644 index 00000000..e188646d --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeOperations.cs @@ -0,0 +1,313 @@ +using System.Runtime.CompilerServices; +using Proton.Cryptography.Pgp; +using Proton.Drive.Sdk.Api.Photos; +using Proton.Drive.Sdk.Api.Volumes; +using Proton.Drive.Sdk.Cryptography; +using Proton.Drive.Sdk.Nodes; +using Proton.Drive.Sdk.Shares; +using Proton.Sdk.Addresses; + +namespace Proton.Drive.Sdk.Volumes; + +internal static class VolumeOperations +{ + private const string RootFolderName = "root"; + private const int TrashPageSize = 500; + + public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreateVolumeAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) + { + var defaultAddress = await client.Account.GetDefaultAddressAsync(cancellationToken).ConfigureAwait(false); + + var addressKey = await client.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 client.Api.Volumes.CreateVolumeAsync(request, cancellationToken).ConfigureAwait(false); + + var volume = new Volume(response.Volume); + + var share = new Share(volume.RootShareId, volume.RootFolderId, defaultAddress.Id, ShareType.Main); + + 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, + OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), + Errors = [], + }; + + // The volume root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + + await client.Cache.Entities.SetMainVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetMyFilesShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + + await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); + + return (volume, share, rootFolder); + } + + public static async IAsyncEnumerable EnumerateTrashAsync( + ProtonDriveClient client, + VolumeId volumeId, + bool forPhotos, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var page = 0; + var mustTryMoreResults = true; + + while (mustTryMoreResults) + { + var response = await client.Api.Trash.GetTrashAsync(volumeId, TrashPageSize, page, cancellationToken).ConfigureAwait(false); + + mustTryMoreResults = response.TrashByShare.Sum(x => x.LinkIds.Count) == TrashPageSize; + + foreach (var (shareId, linkIds, _) in response.TrashByShare) + { + var (_, shareKey) = await ShareOperations.GetShareAsync(client, shareId, useCacheOnly: false, cancellationToken).ConfigureAwait(false); + + var batchLoader = new VolumeTrashBatchLoader(client, volumeId, shareKey, forPhotos); + + foreach (var linkId in linkIds) + { + var uid = new NodeUid(volumeId, linkId); + var cachedNodeInfoOrNull = await client.Cache.Entities.TryGetNodeAsync(uid, cancellationToken).ConfigureAwait(false); + + if (cachedNodeInfoOrNull is not var (node, _, _)) + { + await foreach (var nodeResult in batchLoader.QueueAndTryLoadBatchAsync(linkId, cancellationToken).ConfigureAwait(false)) + { + yield return nodeResult; + } + } + else + { + yield return node; + } + } + + await foreach (var node in batchLoader.LoadRemainingAsync(cancellationToken).ConfigureAwait(false)) + { + yield return node; + } + } + + ++page; + } + } + + public static async ValueTask<(Volume Volume, Share Share, FolderNode RootFolder)> CreatePhotosVolumeAsync( + ProtonDriveClient client, + CancellationToken cancellationToken) + { + var defaultAddress = await client.Account.GetDefaultAddressAsync(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 client.Api.Photos.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, + OwnedBy = new OwnedBy(Email: defaultAddress.EmailAddress), + Errors = [], + }; + + // The volume root folder never has siblings and does not need a name hash digest + var nameHashDigest = ReadOnlyMemory.Empty; + + await client.Cache.Entities.SetPhotosVolumeIdAsync(volume.Id, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetNodeAsync(volume.RootFolderId, rootFolder, share.Id, nameHashDigest, cancellationToken) + .ConfigureAwait(false); + + await client.Cache.Entities.SetPhotosShareIdAsync(share.Id, cancellationToken).ConfigureAwait(false); + await client.Cache.Entities.SetShareAsync(share, cancellationToken).ConfigureAwait(false); + + await client.Cache.Secrets.SetShareKeyAsync(volume.RootShareId, rootShareKey, cancellationToken).ConfigureAwait(false); + await client.Cache.Secrets.SetFolderSecretsAsync(volume.RootFolderId, rootFolderSecrets, cancellationToken).ConfigureAwait(false); + + return (volume, share, rootFolder); + } + + public static async ValueTask EmptyTrashAsync(ProtonDriveClient client, VolumeId volumeId, CancellationToken cancellationToken) + { + await client.Api.Trash.EmptyAsync(volumeId, 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); + + return myFilesFolder?.Uid.VolumeId; + } + + private static VolumeCreationRequest GetCreationRequest( + AddressId addressId, + AddressKeyId addressKeyId, + PgpPrivateKey addressKey, + out PgpPrivateKey rootShareKey, + out FolderSecrets rootFolderSecrets) + { + rootShareKey = CryptoGenerator.GeneratePrivateKey(); + + var rootFolderKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderPassphraseSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderNameSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderHashKey = CryptoGenerator.GenerateFolderHashKey(); + + rootFolderSecrets = new FolderSecrets + { + Key = rootFolderKey, + PassphraseSessionKey = rootFolderPassphraseSessionKey, + NameSessionKey = rootFolderNameSessionKey, + HashKey = rootFolderHashKey, + }; + + 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 = rootFolderKey.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderPassphraseSessionKey); + var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( + folderPassphrase, + folderPassphraseEncryptionSecrets, + addressKey, + out var folderPassphraseSignature); + + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderNameSessionKey); + var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); + + var encryptedHashKey = rootFolderKey.EncryptAndSign(rootFolderHashKey, addressKey); + + return new VolumeCreationRequest + { + AddressId = addressId, + AddressKeyId = addressKeyId, + ShareKey = lockedShareKey.ToBytes(), + SharePassphrase = encryptedSharePassphrase, + SharePassphraseSignature = sharePassphraseSignature, + FolderName = encryptedName, + FolderKey = lockedFolderKey.ToBytes(), + FolderPassphrase = encryptedFolderPassphrase, + FolderPassphraseSignature = folderPassphraseSignature, + FolderHashKey = encryptedHashKey, + }; + } + + private static PhotosVolumeCreationRequest GetPhotosCreationRequest( + AddressId addressId, + AddressKeyId addressKeyId, + PgpPrivateKey addressKey, + out PgpPrivateKey rootShareKey, + out FolderSecrets rootFolderSecrets) + { + rootShareKey = CryptoGenerator.GeneratePrivateKey(); + + var rootFolderKey = CryptoGenerator.GeneratePrivateKey(); + var rootFolderPassphraseSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderNameSessionKey = CryptoGenerator.GenerateSessionKey(); + var rootFolderHashKey = CryptoGenerator.GenerateFolderHashKey(); + + rootFolderSecrets = new FolderSecrets + { + Key = rootFolderKey, + PassphraseSessionKey = rootFolderPassphraseSessionKey, + NameSessionKey = rootFolderNameSessionKey, + HashKey = rootFolderHashKey, + }; + + 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 = rootFolderKey.Lock(folderPassphrase); + + var folderPassphraseEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderPassphraseSessionKey); + var encryptedFolderPassphrase = PgpEncrypter.EncryptAndSign( + folderPassphrase, + folderPassphraseEncryptionSecrets, + addressKey, + out var folderPassphraseSignature); + + var nameEncryptionSecrets = new EncryptionSecrets(rootShareKey, rootFolderNameSessionKey); + var encryptedName = PgpEncrypter.EncryptAndSignText(RootFolderName, nameEncryptionSecrets, addressKey); + + var encryptedHashKey = rootFolderKey.EncryptAndSign(rootFolderHashKey, 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.Drive.Sdk/Volumes/VolumeState.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs new file mode 100644 index 00000000..b222df68 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeState.cs @@ -0,0 +1,10 @@ +namespace Proton.Drive.Sdk.Volumes; + +public enum VolumeState +{ + None = 0, + Active = 1, + Deleted = 2, + Locked = 3, + Restored = 4, +} diff --git a/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs new file mode 100644 index 00000000..b750f315 --- /dev/null +++ b/cs/sdk/src/Proton.Drive.Sdk/Volumes/VolumeTrashBatchLoader.cs @@ -0,0 +1,57 @@ +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, 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.GetLinkDetailsAsync(_volumeId, MemoryMarshal.ToEnumerable(ids), _forPhotos, cancellationToken).ConfigureAwait(false); + + foreach (var linkDetails in response.Links) + { + PgpPrivateKey parentKey; + + if (linkDetails.Link.ParentId is { } parentId) + { + if (!_parentKeys.TryGetValue(parentId, out parentKey)) + { + var folderSecretsResult = await FolderOperations.GetSecretsAsync(_client, new NodeUid(_volumeId, parentId), _forPhotos, cancellationToken) + .ConfigureAwait(false); + + // 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; + } + } + else + { + parentKey = _shareKey; + } + + var (node, _, _, _) = await DtoToMetadataConverter.ConvertDtoToNodeMetadataAsync( + _client, + _volumeId, + linkDetails, + parentKey, + cancellationToken) + .ConfigureAwait(false); + + yield return node; + } + } +} 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.Sdk.CExports/ExceptionExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs new file mode 100644 index 00000000..f06fd48e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/ExceptionExtensions.cs @@ -0,0 +1,43 @@ +namespace Proton.Sdk.CExports; + +internal static class ExceptionExtensions +{ + public static Error ToProtoError(this Exception exception, Action setDomainAndCodesFunction) + { + if (exception is InteropErrorException { Error: not null } interopErrorException) + { + return interopErrorException.Error; + } + + var error = new Error + { + Type = GetTypeName(exception.GetType()), + Message = exception.Message, + }; + + var context = exception.StackTrace; + if (context is not null) + { + error.Context = context; + } + + setDomainAndCodesFunction.Invoke(error, exception); + + error.InnerError = exception.InnerException?.ToProtoError(setDomainAndCodesFunction); + + return error; + } + + private static string GetTypeName(Type type) + { + if (!type.IsGenericType) + { + return type.FullName ?? $"{nameof(System)}.{nameof(Exception)}"; + } + + var baseName = type.GetGenericTypeDefinition().FullName!; + baseName = baseName[..baseName.IndexOf('`')]; + var argNames = type.GetGenericArguments().Select(GetTypeName); + return baseName + "[" + string.Join(",", argNames) + "]"; + } +} diff --git a/cs/sdk/src/Proton.Sdk.CExports/Interop.cs b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs new file mode 100644 index 00000000..3d6152aa --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Interop.cs @@ -0,0 +1,84 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +internal static class Interop +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long AllocHandle(T obj) + where T : class + { + return GCHandle.ToIntPtr(GCHandle.Alloc(obj)); + } + + public static T GetFromHandle(long handle) + where T : class + { + return GetFromHandle(handle, free: false); + } + + public static T GetFromHandleAndFree(long handle) + where T : class + { + return GetFromHandle(handle, free: true); + } + + public static T FreeHandle(long handle) + where T : class + { + var gcHandle = GCHandle.FromIntPtr((nint)handle); + + if (gcHandle.Target is not T target) + { + throw InvalidHandleException.Create((nint)handle); + } + + gcHandle.Free(); + + return target; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static CancellationToken GetCancellationToken(long cancellationTokenSourceHandle) + { + return cancellationTokenSourceHandle != 0 + ? GetFromHandle(cancellationTokenSourceHandle).Token + : CancellationToken.None; + } + + 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(); + } + + 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/InteropAction.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs new file mode 100644 index 00000000..dd8e2736 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropAction.cs @@ -0,0 +1,121 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropAction + where T : unmanaged +{ + 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) + { + } + + 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 + where T1 : unmanaged + where T2 : unmanaged +{ + 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) + { + } + + 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 + where T1 : unmanaged + where T2 : unmanaged + where T3 : unmanaged +{ + 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) + { + } + + public void Invoke(T1 arg1, T2 arg2, T3 arg3) + { + _pointer(arg1, arg2, arg3); + } + + 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 new file mode 100644 index 00000000..3bc18faf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropActionExtensions.cs @@ -0,0 +1,134 @@ +using Google.Protobuf; +using Proton.Sdk.CExports.Tasks; + +namespace Proton.Sdk.CExports; + +internal static class InteropActionExtensions +{ + 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(bindingsHandle, new InteropArray(responsePointer, responseBytes.Length)); + } + } + + 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(bindingsHandle, new InteropArray(responsePointer, responseBytes.Length), sdkHandle); + } + } + + public static unsafe nint InvokeWithMessage( + 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, + 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 (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/InteropArray.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs new file mode 100644 index 00000000..f325d24e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropArray.cs @@ -0,0 +1,40 @@ +using System.Runtime.InteropServices; + +namespace Proton.Sdk.CExports; + +[StructLayout(LayoutKind.Sequential)] +internal readonly unsafe struct InteropArray(T* pointer, nint length) + where T : unmanaged +{ + public readonly T* Pointer = pointer; + public readonly nint Length = length; + + public static InteropArray Null => default; + + public bool IsNullOrEmpty => Pointer is null || Length == 0; + + public T[] ToArray() + { + return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length).ToArray() : []; + } + + public T[]? ToArrayOrNull() + { + return !IsNullOrEmpty ? new ReadOnlySpan(Pointer, (int)Length).ToArray() : null; + } + + public Span AsSpan() + { + 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); + } +} 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..6fa601f3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropCancellationTokenSource.cs @@ -0,0 +1,30 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Proton.Sdk.CExports; + +internal static class InteropCancellationTokenSource +{ + public static IMessage HandleCreate(CancellationTokenSourceCreateRequest request) + { + return new Int64Value { Value = Interop.AllocHandle(new CancellationTokenSource()) }; + } + + public static IMessage? HandleCancel(CancellationTokenSourceCancelRequest request) + { + var cancellationTokenSource = Interop.GetFromHandle(request.CancellationTokenSourceHandle); + + cancellationTokenSource.Cancel(); + + return null; + } + + public static IMessage? HandleFree(CancellationTokenSourceFreeRequest request) + { + var cancellationTokenSource = Interop.FreeHandle(request.CancellationTokenSourceHandle); + + cancellationTokenSource.Dispose(); + + return null; + } +} 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..527b715c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropErrorConverter.cs @@ -0,0 +1,63 @@ +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; + if (ex.TransportCode is not null) + { + error.SecondaryCode = ex.TransportCode.Value; + } + + 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 TimeoutException or 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; + + default: + error.Domain = ErrorDomain.Undefined; + break; + } + } +} 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/InteropFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs new file mode 100644 index 00000000..f54c2416 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFeatureFlagProvider.cs @@ -0,0 +1,30 @@ +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..f45a2166 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropFunction.cs @@ -0,0 +1,70 @@ +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 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. +/// +[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/Proton.Sdk.CExports/InteropHttpClientFactory.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs new file mode 100644 index 00000000..a9242d64 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropHttpClientFactory.cs @@ -0,0 +1,130 @@ +using System.Net; +using Proton.Sdk.CExports.Tasks; +using Proton.Sdk.Http; + +namespace Proton.Sdk.CExports; + +internal sealed class InteropHttpClientFactory : IHttpClientFactory +{ + private readonly string _baseUrl; + + public InteropHttpClientFactory( + nint bindingsHandle, + string baseUrl, + string? bindingsLanguage, + InteropFunction, nint, nint> requestFunction, + InteropFunction, nint, nint> responseContentReadFunction, + InteropAction cancellationAction) + { + _baseUrl = baseUrl; + BindingsHandle = bindingsHandle; + RequestFunction = requestFunction; + ResponseContentReadFunction = responseContentReadFunction; + CancellationAction = cancellationAction; + } + + private nint BindingsHandle { get; } + private InteropFunction, nint, nint> RequestFunction { get; } + private InteropFunction, nint, nint> ResponseContentReadFunction { get; } + private InteropAction CancellationAction { get; } + + public HttpClient CreateClient(string name) + { + var httpMessageHandler = new CryptographyTimeProvisionHandler + { + InnerHandler = new InteropHttpMessageHandler(this), + }; + + return new HttpClient(httpMessageHandler) { BaseAddress = new Uri(_baseUrl) }; + } + + private sealed class InteropHttpMessageHandler(InteropHttpClientFactory owner) : HttpMessageHandler + { + private readonly InteropHttpClientFactory _owner = owner; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var taskCompletionSource = new ValueTaskCompletionSource(); + var taskCompletionSourceHandle = Interop.AllocHandle(taskCompletionSource); + + var interopHttpRequest = await ConvertHttpRequestToInteropAsync(request, cancellationToken).ConfigureAwait(false); + + try + { + var foreignCancellationHandle = _owner.RequestFunction.InvokeWithMessage( + _owner.BindingsHandle, + interopHttpRequest, + (nint)taskCompletionSourceHandle); + + await using (cancellationToken.Register(x => ((InteropHttpClientFactory)x!).CancellationAction.Invoke(foreignCancellationHandle), _owner)) + { + var interopHttpResponse = await taskCompletionSource.Task.ConfigureAwait(false); + + return ConvertHttpResponseFromInterop(interopHttpResponse); + } + } + finally + { + if (interopHttpRequest.HasSdkContentHandle) + { + Interop.FreeHandle(interopHttpRequest.SdkContentHandle); + } + } + } + + 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, Type = (HttpRequestType)request.GetRequestType() }; + + var headers = request.Headers.AsEnumerable(); + + if (request.Content is not null) + { + headers = headers.Concat(request.Content.Headers); + + var contentStream = await request.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + interopHttpRequest.SdkContentHandle = Interop.AllocHandle(contentStream); + } + + interopHttpRequest.Headers.AddRange( + headers.Select(h => + { + var header = new HttpHeader { Name = h.Key }; + header.Values.AddRange(h.Value); + return header; + })); + + return interopHttpRequest; + } + + private HttpResponseMessage ConvertHttpResponseFromInterop(HttpResponse interopHttpResponse) + { + var response = new HttpResponseMessage((HttpStatusCode)interopHttpResponse.StatusCode); + + if (interopHttpResponse.HasBindingsContentHandle) + { + response.Content = new StreamContent( + new InteropStream(null, (nint)interopHttpResponse.BindingsContentHandle, _owner.ResponseContentReadFunction)); + } + + foreach (var interopHttpResponseHeader in interopHttpResponse.Headers) + { + 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)) + { + continue; + } + + response.Headers.TryAddWithoutValidation(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 new file mode 100644 index 00000000..403ce67a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropMessageHandler.cs @@ -0,0 +1,169 @@ +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, + Int64Value.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) + { + 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.StreamRead + => await InteropStream.HandleReadAsync(request.StreamRead).ConfigureAwait(false), + + Request.PayloadOneofCase.SessionBegin + => await ProtonApiSessionRequestHandler.HandleBeginAsync(request.SessionBegin, bindingsHandle).ConfigureAwait(false), + + Request.PayloadOneofCase.SessionResume + => ProtonApiSessionRequestHandler.HandleResume(request.SessionResume, bindingsHandle), + + 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, bindingsHandle), + + Request.PayloadOneofCase.SessionTokensRefreshedUnsubscribe + => ProtonApiSessionRequestHandler.HandleUnsubscribeFromTokensRefreshed(request.SessionTokensRefreshedUnsubscribe), + + Request.PayloadOneofCase.LoggerProviderCreate + => InteropLoggerProvider.HandleCreate(request.LoggerProviderCreate, bindingsHandle), + + Request.PayloadOneofCase.None or _ + => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)), + }; + + 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) + { + var error = e.ToProtoError(InteropErrorConverter.SetDomainAndCodes); + + 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); + 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, Error error) + { + var tfs = Interop.GetFromHandleAndFree(tcsHandle); + + if (error.Domain == ErrorDomain.SuccessfulCancellation) + { + tfs.SetException(new OperationCanceledException( + "The operation was canceled by the client", + new InteropErrorException(error))); + } + else + { + tfs.SetException(new InteropErrorException(error)); + } + } +} 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/InteropStream.cs b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs new file mode 100644 index 00000000..42fc9a9d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/InteropStream.cs @@ -0,0 +1,201 @@ +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 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, + InteropFunction, nint, nint>? readFunction, + InteropAction? cancelAction = null) + { + _length = length; + _bindingsHandle = bindingsHandle; + _readFunction = readFunction; + _writeFunction = null; + _cancelAction = cancelAction; + } + + public InteropStream( + nint bindingsHandle, + InteropFunction, nint, nint>? writeFunction, + InteropAction, nint>? seekAction = null, + InteropAction? cancelAction = null) + { + _length = 0; + _bindingsHandle = bindingsHandle; + _readFunction = null; + _writeFunction = writeFunction; + _seekAction = seekAction; + _cancelAction = cancelAction; + } + + public override bool CanRead => _readFunction != 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 + { + 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() + { + } + + 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 (_readFunction is null) + { + throw new NotSupportedException("Reading not supported"); + } + + using var memoryHandle = buffer.Pin(); + + cancellationToken.ThrowIfCancellationRequested(); + + var (readTask, operationHandle) = _readFunction.Value.InvokeWithBuffer(_bindingsHandle, buffer.Span); + _operationHandle = operationHandle; + + 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: {readByteCount.Value}"); + } + + _position += readByteCount.Value; + + return readByteCount.Value; + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (_seekAction is null) + { + throw new NotSupportedException("Seeking not supported"); + } + + var request = new StreamSeekRequest + { + Offset = offset, + Origin = (int)origin, + }; + + var requestBytes = request.ToByteArray(); + + // TODO: use sync call + var newPosition = _seekAction.Value.InvokeWithBufferAsync(_bindingsHandle, requestBytes).AsTask().GetAwaiter().GetResult(); + + _position = newPosition.Value; + + return _position; + } + + 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 (_writeFunction == null) + { + throw new NotSupportedException("Writing not supported"); + } + + using var memoryHandle = buffer.Pin(); + + cancellationToken.ThrowIfCancellationRequested(); + + 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); + } + + 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.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..81901a67 --- /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/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 new file mode 100644 index 00000000..f74f859c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLogger.cs @@ -0,0 +1,49 @@ +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.CExports.Logging; + +internal sealed class InteropLogger(nint bindingsHandle, InteropAction> logAction, string categoryName) : ILogger +{ + private readonly nint _bindingsHandle = bindingsHandle; + private readonly InteropAction> _logAction = logAction; + 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); + if (exception != null) + { + message = message + Environment.NewLine + exception; + } + + var logEvent = new LogEvent { Level = (int)logLevel, Message = message, CategoryName = _categoryName }; + + var messageBytes = logEvent.ToByteArray(); + + fixed (byte* messagePointer = messageBytes) + { + _logAction.Invoke(_bindingsHandle, new InteropArray(messagePointer, messageBytes.Length)); + } + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + private sealed class DummyDisposable : IDisposable + { + public void Dispose() + { + // Nothing to do + } + } +} 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..1411f7a0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Logging/InteropLoggerProvider.cs @@ -0,0 +1,30 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Proton.Sdk.CExports.Logging; + +internal sealed class InteropLoggerProvider(nint bindingsHandle, InteropAction> logAction) : ILoggerProvider +{ + private readonly nint _bindingsHandle = bindingsHandle; + private readonly 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); + } + + public void Dispose() + { + // Nothing to do + } +} 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..d8fd731f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Proton.Sdk.CExports.csproj @@ -0,0 +1,33 @@ + + + + $(NativeLibPrefix)proton_sdk + true + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + 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..5a1fe489 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/ProtonApiSessionRequestHandler.cs @@ -0,0 +1,182 @@ +using System.Text; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Proton.Sdk.Authentication; +using Proton.Sdk.Caching; + +namespace Proton.Sdk.CExports; + +internal static class ProtonApiSessionRequestHandler +{ + public static async ValueTask HandleBeginAsync(SessionBeginRequest request, nint bindingsHandle) + { + var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle); + + var telemetry = request.Options.Telemetry.ToTelemetry(bindingsHandle); + + ICacheRepository secretCacheRepository = request.HasSecretCachePath + ? SqliteCacheRepository.OpenFile(request.SecretCachePath) + : new InMemoryCacheRepository(); + + ICacheRepository entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : new InMemoryCacheRepository(); + + var options = new ProtonSessionOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + Telemetry = telemetry, + TlsPolicy = (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, nint bindingsHandle) + { + var telemetry = request.Options.Telemetry.ToTelemetry(bindingsHandle); + + var secretCacheRepository = SqliteCacheRepository.OpenFile(request.SecretCachePath); + + ICacheRepository entityCacheRepository = request.Options.HasEntityCachePath + ? SqliteCacheRepository.OpenFile(request.Options.EntityCachePath) + : new InMemoryCacheRepository(); + + var options = new Sdk.ProtonClientOptions + { + BaseUrl = new Uri(request.Options.BaseUrl), + UserAgent = request.Options.UserAgent, + BindingsLanguage = request.Options.BindingsLanguage, + Telemetry = telemetry, + TlsPolicy = (Http.ProtonClientTlsPolicy?)request.Options.TlsPolicy, + EntityCacheRepository = entityCacheRepository, + SecretCacheRepository = secretCacheRepository, + }; + + var passwordMode = request.IsWaitingForDataPassword ? PasswordMode.Dual : PasswordMode.Single; + + var session = ProtonApiSession.Resume( + new SessionId(request.SessionId), + request.Username, + new Users.UserId(request.UserId), + 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 SessionId(request.SessionId), + 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 IMessage HandleSubscribeToTokensRefreshed(SessionTokensRefreshedSubscribeRequest request, nint bindingsHandle) + { + var session = Interop.GetFromHandle((nint)request.SessionHandle); + + var tokenRefreshedAction = new InteropAction>(request.TokensRefreshedAction); + + var subscription = TokensRefreshedSubscription.Create(session, bindingsHandle, tokenRefreshedAction); + + 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 class TokensRefreshedSubscription : IDisposable + { + private readonly ProtonApiSession _session; + private readonly nint _bindingsHandle; + private readonly InteropAction> _tokensRefreshedAction; + + private TokensRefreshedSubscription( + ProtonApiSession session, + nint bindingsHandle, + InteropAction> tokensRefreshedAction) + { + _session = session; + _bindingsHandle = bindingsHandle; + _tokensRefreshedAction = tokensRefreshedAction; + } + + public static TokensRefreshedSubscription Create( + ProtonApiSession session, + nint bindingsHandle, + InteropAction> tokensRefreshedAction) + { + var subscription = new TokensRefreshedSubscription(session, bindingsHandle, tokensRefreshedAction); + + session.TokenCredential.TokensRefreshed += subscription.Handle; + + return subscription; + } + + public void Dispose() + { + _session.TokenCredential.TokensRefreshed -= Handle; + } + + private unsafe void Handle(string accessToken, string refreshToken) + { + var tokensMessageBytes = new SessionTokens { AccessToken = accessToken, RefreshToken = refreshToken }.ToByteArray(); + + fixed (byte* tokensMessagePointer = tokensMessageBytes) + { + _tokensRefreshedAction.Invoke(_bindingsHandle, new InteropArray(tokensMessagePointer, tokensMessageBytes.Length)); + } + } + } +} 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/Tasks/TaskExtensions.cs b/cs/sdk/src/Proton.Sdk.CExports/Tasks/TaskExtensions.cs new file mode 100644 index 00000000..a1538687 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk.CExports/Tasks/TaskExtensions.cs @@ -0,0 +1,12 @@ +namespace Proton.Sdk.CExports.Tasks; + +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.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/Addresses/Address.cs b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs new file mode 100644 index 00000000..d1e187a5 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/Address.cs @@ -0,0 +1,11 @@ +namespace Proton.Sdk.Addresses; + +public sealed class Address(AddressId id, int order, string emailAddress, AddressStatus status, IReadOnlyList keys, int primaryKeyIndex) +{ + public AddressId Id { get; } = id; + public int Order { get; } = order; + public string EmailAddress { get; } = emailAddress; + public AddressStatus Status { get; } = status; + public IReadOnlyList Keys { get; } = keys; + public int PrimaryKeyIndex { get; } = primaryKeyIndex; +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs new file mode 100644 index 00000000..cdac7101 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressExtensions.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Addresses; + +public static class AddressExtensions +{ + public static AddressKey GetPrimaryKey(this Address address) + { + return address.Keys[address.PrimaryKeyIndex]; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs new file mode 100644 index 00000000..7fe41e36 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct AddressId : IStrongId +{ + private readonly string? _value; + + internal AddressId(string? value) + { + _value = value; + } + + public static explicit operator AddressId(string? value) + { + return new AddressId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs new file mode 100644 index 00000000..cf578127 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKey.cs @@ -0,0 +1,17 @@ +namespace Proton.Sdk.Addresses; + +public sealed class AddressKey( + AddressId addressId, + AddressKeyId addressKeyId, + bool isPrimary, + bool isActive, + bool isAllowedForEncryption, + bool isAllowedForVerification) +{ + public AddressId AddressId { get; } = addressId; + public AddressKeyId AddressKeyId { get; } = addressKeyId; + public bool IsPrimary { get; } = isPrimary; + public bool IsActive { get; } = isActive; + public bool IsAllowedForEncryption { get; } = isAllowedForEncryption; + public bool IsAllowedForVerification { get; } = isAllowedForVerification; +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs new file mode 100644 index 00000000..463c5f6d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressKeyId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Addresses; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct AddressKeyId : IStrongId +{ + private readonly string? _value; + + internal AddressKeyId(string? value) + { + _value = value; + } + + public static explicit operator AddressKeyId(string? value) + { + return new AddressKeyId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs new file mode 100644 index 00000000..03a6e9bd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressOperations.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Api; +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Addresses; + +internal static class AddressOperations +{ + public static async ValueTask> GetCurrentUserAddressesAsync(ProtonAccountClient client, CancellationToken cancellationToken) + { + var result = await client.Cache.Entities.TryGetCurrentUserAddressesAsync(cancellationToken).ConfigureAwait(false); + + if (result is null) + { + var addressListResponse = await client.Api.Addresses.GetAddressesAsync(cancellationToken).ConfigureAwait(false); + + var addresses = new List
(addressListResponse.Addresses.Count); + + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + foreach (var dto in addressListResponse.Addresses) + { + try + { + var address = await ConvertFromDtoAsync(client, dto, userKeys, cancellationToken).ConfigureAwait(false); + + addresses.Add(address); + } + catch (Exception e) + { + client.Logger.LogError(e, "Failed to load address {AddressId}", dto.Id); + } + } + + await client.Cache.Entities.SetCurrentUserAddressesAsync(addresses, cancellationToken).ConfigureAwait(false); + + result = addresses; + } + + return result; + } + + public static async ValueTask
GetAddressAsync(ProtonAccountClient client, AddressId addressId, CancellationToken cancellationToken) + { + var address = await client.Cache.Entities.TryGetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (address is null) + { + var userKeys = await client.GetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + var response = await client.Api.Addresses.GetAddressAsync(addressId, cancellationToken).ConfigureAwait(false); + + address = await ConvertFromDtoAsync(client, response.Address, userKeys, cancellationToken).ConfigureAwait(false); + + await client.Cache.Entities.SetAddressAsync(address, cancellationToken).ConfigureAwait(false); + } + + return address; + } + + public static async ValueTask
GetCurrentUserDefaultAddressAsync(ProtonAccountClient client, CancellationToken cancellationToken) + { + var addresses = await GetCurrentUserAddressesAsync(client, cancellationToken).ConfigureAwait(false); + + if (addresses.Count == 0) + { + throw new ProtonApiException("User has no address"); + } + + return addresses.OrderBy(x => x.Order).First(); + } + + public static async ValueTask> GetAddressPrivateKeysAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) + { + var addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + await GetAddressAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + addressKeys = await client.Cache.Secrets.TryGetAddressKeysAsync(addressId, cancellationToken).ConfigureAwait(false); + + if (addressKeys is null) + { + throw new ProtonApiException($"Could not get address keys for address {addressId}"); + } + } + + return addressKeys; + } + + public static async ValueTask GetAddressPrimaryPrivateKeyAsync( + ProtonAccountClient client, + AddressId addressId, + CancellationToken cancellationToken) + { + var address = await GetAddressAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + var addressKeys = await GetAddressPrivateKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys[address.PrimaryKeyIndex]; + } + + public static async ValueTask GetAddressPrivateKeyAsync( + ProtonAccountClient client, + AddressId addressId, + int index, + CancellationToken cancellationToken) + { + var addressKeys = await GetAddressPrivateKeysAsync(client, addressId, cancellationToken).ConfigureAwait(false); + + return addressKeys[index]; + } + + public static async ValueTask> GetPublicKeysAsync( + ProtonAccountClient client, + string emailAddress, + CancellationToken cancellationToken) + { + if (!client.Cache.PublicKeys.TryGetPublicKeys(emailAddress, out var cachedPublicKeys)) + { + try + { + var publicKeysResponse = await client.Api.Keys.GetActivePublicKeysAsync(emailAddress, cancellationToken).ConfigureAwait(false); + + var publicKeys = new List(publicKeysResponse.Address.Keys.Count); + + var publicKeyQuery = publicKeysResponse.Address.Keys + .Where(x => x.Status.HasFlag(PublicKeyStatus.IsNotCompromised)) + .Select(x => PgpPublicKey.Import(x.PublicKey)); + + publicKeys.AddRange(publicKeyQuery); + + client.Cache.PublicKeys.SetPublicKeys(emailAddress, publicKeys); + + cachedPublicKeys = publicKeys; + } + catch (ProtonApiException e) when (e.Code is ResponseCode.UnknownAddress) + { + client.Logger.LogError(e, "Unknown address {EmailAddress}", emailAddress); + + cachedPublicKeys = []; + } + } + + return cachedPublicKeys; + } + + private static async ValueTask
ConvertFromDtoAsync( + ProtonAccountClient client, + AddressDto dto, + IReadOnlyList userKeys, + CancellationToken cancellationToken) + { + int? primaryKeyIndex = null; + + var keys = new List(dto.Keys.Count); + var unlockedKeys = new List(dto.Keys.Count); + var keyIndex = 0; + + foreach (var keyDto in dto.Keys) + { + if (!keyDto.IsActive) + { + continue; + } + + try + { + PgpPrivateKey unlockedKey; + + if (keyDto is { Token: not null, Signature: not null }) + { + var passphrase = GetAddressKeyTokenPassphrase(keyDto.Token.Value, keyDto.Signature.Value, userKeys); + unlockedKey = PgpPrivateKey.ImportAndUnlock(keyDto.PrivateKey, passphrase.Span); + } + else + { + var passphrase = await client.Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync( + keyDto.Id.ToString(), + cancellationToken).ConfigureAwait(false); + + if (passphrase is null) + { + client.Logger.LogWarning("No passphrase found for address key {UserKeyId}", keyDto.Id); + continue; + } + + unlockedKey = PgpPrivateKey.ImportAndUnlock(keyDto.PrivateKey, passphrase.Value.Span); + } + + unlockedKeys.Add(unlockedKey); + } + catch (Exception ex) + { + client.Logger.LogWarning(ex, "Failed to import and unlock address key {UserKeyId}", keyDto.Id); + continue; + } + + var key = new AddressKey( + dto.Id, + keyDto.Id, + keyDto.IsPrimary, + keyDto.IsActive, + (keyDto.Capabilities & AddressKeyCapabilities.IsAllowedForEncryption) != 0, + (keyDto.Capabilities & AddressKeyCapabilities.IsAllowedForSignatureVerification) != 0); + + keys.Add(key); + + if (keyDto.IsPrimary) + { + primaryKeyIndex = keyIndex; + } + + ++keyIndex; + } + + if (primaryKeyIndex is null) + { + throw new ProtonApiException($"Address {dto.Id} has no primary key"); + } + + await client.Cache.Secrets.SetAddressKeysAsync(dto.Id, unlockedKeys, cancellationToken).ConfigureAwait(false); + + return new Address(dto.Id, dto.Order, dto.Email, dto.Status, keys.AsReadOnly(), primaryKeyIndex.Value); + } + + private static ReadOnlyMemory GetAddressKeyTokenPassphrase( + PgpArmoredMessage token, + PgpArmoredSignature signature, + IReadOnlyList userKeys) + { + 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"); + } + + // TODO: avoid another allocation + return passphraseStream.ToArray(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs b/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs new file mode 100644 index 00000000..01483a43 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Addresses/AddressStatus.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Addresses; + +public enum AddressStatus +{ + Disabled = 0, + Enabled = 1, + Deleting = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs new file mode 100644 index 00000000..ee48ae0f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/AlwaysDisabledFeatureFlagProvider.cs @@ -0,0 +1,19 @@ +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 Task IsEnabledAsync(string flagName, CancellationToken cancellationToken) + { + return Task.FromResult(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs new file mode 100644 index 00000000..8ffbf101 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/AccountApiClients.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; + +namespace Proton.Sdk.Api; + +internal sealed class AccountApiClients(HttpClient httpClient) : IAccountApiClients +{ + public IKeysApiClient Keys { get; } = ApiClientFactory.Instance.CreateKeysApiClient(httpClient); + public IUsersApiClient Users { get; } = ApiClientFactory.Instance.CreateUsersApiClient(httpClient); + public IAddressesApiClient Addresses { get; } = ApiClientFactory.Instance.CreateAddressesApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs new file mode 100644 index 00000000..7748d7df --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Api.Addresses; + +internal sealed record AddressDto +{ + [JsonPropertyName("ID")] + public required AddressId Id { get; init; } + + public required string Email { get; init; } + + public required AddressStatus Status { get; init; } + + public required int Order { get; init; } + + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs new file mode 100644 index 00000000..906c95a2 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyCapabilities.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Api.Addresses; + +[Flags] +public enum AddressKeyCapabilities +{ + None = 0, + IsAllowedForSignatureVerification = 1, + IsAllowedForEncryption = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs new file mode 100644 index 00000000..7bf203c6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressKeyDto.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Addresses; + +internal sealed class AddressKeyDto +{ + [JsonPropertyName("ID")] + public required AddressKeyId Id { get; init; } + + public required int Version { get; init; } + + public PgpArmoredPrivateKey PrivateKey { get; init; } + + public PgpArmoredMessage? Token { get; init; } + + public PgpArmoredSignature? Signature { get; init; } + + [JsonPropertyName("Primary")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrimary { get; init; } + + [JsonPropertyName("Active")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsActive { get; init; } + + [JsonPropertyName("Flags")] + public required AddressKeyCapabilities Capabilities { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs new file mode 100644 index 00000000..5c4752e9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressListResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Addresses; + +internal sealed class AddressListResponse : ApiResponse +{ + public required IReadOnlyList Addresses { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs new file mode 100644 index 00000000..99177713 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Addresses; + +internal sealed class AddressResponse : ApiResponse +{ + public required AddressDto Address { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs new file mode 100644 index 00000000..3eb255d9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/AddressesApiClient.cs @@ -0,0 +1,24 @@ +using Proton.Sdk.Addresses; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Addresses; + +internal sealed class AddressesApiClient(HttpClient httpClient) : IAddressesApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetAddressesAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressListResponse) + .GetAsync("core/v4/addresses", cancellationToken).ConfigureAwait(false); + } + + public async Task GetAddressAsync(AddressId id, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressResponse) + .GetAsync($"core/v4/addresses/{id}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs new file mode 100644 index 00000000..5cf93a87 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Addresses/IAddressesApiClient.cs @@ -0,0 +1,10 @@ +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Api.Addresses; + +internal interface IAddressesApiClient +{ + Task GetAddressesAsync(CancellationToken cancellationToken); + + Task GetAddressAsync(AddressId id, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs b/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs new file mode 100644 index 00000000..b3b75b8d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/AggregateApiResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api; + +internal sealed class AggregateApiResponse : ApiResponse +{ + public required IReadOnlyList Responses { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs new file mode 100644 index 00000000..0709f969 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ApiClientFactory.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Api; + +internal sealed class ApiClientFactory : IApiClientFactory +{ + private ApiClientFactory() + { + } + + public static IApiClientFactory Instance { get; set; } = new ApiClientFactory(); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs b/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs new file mode 100644 index 00000000..eb3c8c6a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api; + +internal class ApiResponse +{ + public required ResponseCode Code { get; init; } + + [JsonPropertyName("Error")] + public string? ErrorMessage { get; init; } + + public bool IsSuccess => Code is ResponseCode.Success; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs new file mode 100644 index 00000000..f2e28cec --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationApiClient.cs @@ -0,0 +1,100 @@ +using Proton.Cryptography.Srp; +using Proton.Sdk.Authentication; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class AuthenticationApiClient(HttpClient httpClient, Uri refreshRedirectUri) : IAuthenticationApiClient +{ + private readonly Uri _refreshRedirectUri = refreshRedirectUri; + + private readonly HttpClient _httpClient = httpClient; + + public async Task InitiateSessionAsync(string username, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.SessionInitiationResponse) + .PostAsync( + "auth/v4/info", + new SessionInitiationRequest(username), + ProtonApiSerializerContext.Default.SessionInitiationRequest, + cancellationToken).ConfigureAwait(false); + } + + public async Task AuthenticateAsync( + SessionInitiationResponse initiationResponse, + SrpClientHandshake srpClientHandshake, + string username, + CancellationToken cancellationToken) + { + var request = new AuthenticationRequest + { + ClientEphemeral = srpClientHandshake.Ephemeral, + ClientProof = srpClientHandshake.Proof, + SrpSessionId = initiationResponse.SrpSessionId, + Username = username, + }; + + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AuthenticationResponse) + .PostAsync("auth/v4", request, ProtonApiSerializerContext.Default.AuthenticationRequest, cancellationToken).ConfigureAwait(false); + } + + public async Task ValidateSecondFactorAsync(string secondFactorCode, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ScopesResponse) + .PostAsync( + "auth/v4/2fa", + new SecondFactorValidationRequest(secondFactorCode), + ProtonApiSerializerContext.Default.SecondFactorValidationRequest, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task EndSessionAsync() + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("auth/v4", CancellationToken.None).ConfigureAwait(false); + } + + public async Task EndSessionAsync(string sessionId, string accessToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ApiResponse) + .DeleteAsync("auth/v4", sessionId, accessToken, CancellationToken.None).ConfigureAwait(false); + } + + public async Task RefreshSessionAsync( + SessionId sessionId, + string accessToken, + string refreshToken, + CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.SessionRefreshResponse) + .PostAsync( + "auth/v4/refresh", + sessionId, + accessToken, + new SessionRefreshRequest(refreshToken, "token", "refresh_token", _refreshRedirectUri), + ProtonApiSerializerContext.Default.SessionRefreshRequest, + cancellationToken).ConfigureAwait(false); + } + + public async Task GetScopesAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ScopesResponse) + .GetAsync("auth/v4/scopes", cancellationToken).ConfigureAwait(false); + } + + public async Task GetRandomSrpModulusAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.ModulusResponse) + .GetAsync("auth/v4/modulus", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs new file mode 100644 index 00000000..d1966957 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class AuthenticationRequest +{ + public required string Username { get; init; } + + public required ReadOnlyMemory ClientEphemeral { get; init; } + + public required ReadOnlyMemory ClientProof { get; init; } + + [JsonPropertyName("SRPSession")] + public required string SrpSessionId { get; init; } + + [JsonPropertyName("TwoFactorCode")] + public string? SecondFactorCode { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs new file mode 100644 index 00000000..ea52cdff --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/AuthenticationResponse.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Authentication; +using Proton.Sdk.Events; +using Proton.Sdk.Users; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class AuthenticationResponse : ApiResponse +{ + [JsonPropertyName("UID")] + public required SessionId SessionId { get; init; } + + [JsonPropertyName("UserID")] + public required UserId UserId { get; init; } + + [JsonPropertyName("EventID")] + public EventId? EventId { get; init; } + + public required ReadOnlyMemory ServerProof { get; init; } + + public required string TokenType { get; init; } + + public required string AccessToken { get; init; } + + public required string RefreshToken { get; init; } + + public required IReadOnlyList Scopes { get; init; } + + public required PasswordMode PasswordMode { get; init; } + + [JsonPropertyName("2FA")] + public SecondFactorParameters? SecondFactorParameters { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs new file mode 100644 index 00000000..d505f4e9 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/IAuthenticationApiClient.cs @@ -0,0 +1,31 @@ +using Proton.Cryptography.Srp; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Api.Authentication; + +internal interface IAuthenticationApiClient +{ + Task InitiateSessionAsync(string username, CancellationToken cancellationToken); + + Task AuthenticateAsync( + SessionInitiationResponse initiationResponse, + SrpClientHandshake srpClientHandshake, + string username, + CancellationToken cancellationToken); + + Task ValidateSecondFactorAsync(string secondFactorCode, CancellationToken cancellationToken); + + Task EndSessionAsync(); + + Task EndSessionAsync(string sessionId, string accessToken); + + Task RefreshSessionAsync( + SessionId sessionId, + string accessToken, + string refreshToken, + CancellationToken cancellationToken); + + Task GetScopesAsync(CancellationToken cancellationToken); + + Task GetRandomSrpModulusAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs new file mode 100644 index 00000000..b8f5a146 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ModulusResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class ModulusResponse : ApiResponse +{ + public required string Modulus { get; set; } + + [JsonPropertyName("ModulusID")] + public required string ModulusId { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs new file mode 100644 index 00000000..e439393e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/ScopesResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Authentication; + +internal sealed class ScopesResponse : ApiResponse +{ + public required IReadOnlyList Scopes { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs new file mode 100644 index 00000000..3f6dd3eb --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorParameters.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +public readonly struct SecondFactorParameters +{ + [JsonPropertyName("Enabled")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsEnabled { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs new file mode 100644 index 00000000..439e6444 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SecondFactorValidationRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal readonly struct SecondFactorValidationRequest(string secondFactorCode) +{ + [JsonPropertyName("TwoFactorCode")] + public string SecondFactorCode => secondFactorCode; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs new file mode 100644 index 00000000..11e5fe4e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationRequest.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Authentication; + +internal readonly struct SessionInitiationRequest(string username) +{ + public string Username => username; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs new file mode 100644 index 00000000..801cf18f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionInitiationResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class SessionInitiationResponse : ApiResponse +{ + public required int Version { get; init; } + + // TODO: make this ReadOnlyMemory + public required string Modulus { get; init; } + + public required ReadOnlyMemory ServerEphemeral { get; init; } + + public required ReadOnlyMemory Salt { get; init; } + + [JsonPropertyName("SRPSession")] + public required string SrpSessionId { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs new file mode 100644 index 00000000..b0e6e5f0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Authentication; + +internal readonly struct SessionRefreshRequest(string refreshToken, string responseType, string grantType, Uri redirectUri) +{ + public string RefreshToken { get; } = refreshToken; + + [JsonInclude] + public string ResponseType => responseType; + + public string GrantType => grantType; + + [JsonPropertyName("RedirectURI")] + public Uri RedirectUri => redirectUri; +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs new file mode 100644 index 00000000..efe63b7a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Authentication/SessionRefreshResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Api.Authentication; + +internal sealed class SessionRefreshResponse : ApiResponse +{ + public required string AccessToken { get; init; } + + public string? TokenType { get; init; } + + public required IReadOnlyList Scopes { get; init; } + + [JsonPropertyName("UID")] + public required SessionId SessionId { get; init; } + + public required string RefreshToken { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs b/cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs new file mode 100644 index 00000000..b9ae3894 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/AddressEvent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; +using Proton.Sdk.Api.Addresses; + +namespace Proton.Sdk.Api.Events; + +internal sealed class AddressEvent +{ + public required EventAction Action { get; init; } + + [JsonPropertyName("ID")] + public required AddressId AddressId { get; init; } + + public AddressDto? Address { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs new file mode 100644 index 00000000..b6c3f646 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventAction.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Api.Events; + +internal enum EventAction +{ + Delete = 0, + Create = 1, + Update = 2, + UpdateFlags = 3, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs new file mode 100644 index 00000000..900e504d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventListResponse.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Events; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Events; + +internal sealed class EventListResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required EventId LastEventId { get; init; } + + [JsonPropertyName("More")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool MoreEntriesExist { get; init; } + + [JsonPropertyName("Refresh")] + public EventsRefreshMask RefreshMask { get; init; } + + public IReadOnlyList? AddressEvents { get; init; } + + public long? UsedSpace { get; init; } + + public long? UsedDriveSpace { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs new file mode 100644 index 00000000..d38b9461 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsApiClient.cs @@ -0,0 +1,25 @@ +using Proton.Sdk.Events; +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Events; + +// FIXME: make sure that we don't listen to core events twice when Drive will need them to listen to "shared with me" events +internal readonly struct EventsApiClient(HttpClient httpClient) +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetLatestEventAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.LatestEventResponse) + .GetAsync("core/v6/events/latest", cancellationToken).ConfigureAwait(false); + } + + public async Task GetEventsAsync(EventId baselineEventId, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.EventListResponse) + .GetAsync($"core/v6/events/{baselineEventId}", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs b/cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs new file mode 100644 index 00000000..5e033ea3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/EventsRefreshMask.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Api.Events; + +[Flags] +internal enum EventsRefreshMask : byte +{ + None = 0, + Mail = 1, + Contacts = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs new file mode 100644 index 00000000..5e2fbbb5 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Events/LatestEventResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Events; + +namespace Proton.Sdk.Api.Events; + +internal sealed class LatestEventResponse : ApiResponse +{ + [JsonPropertyName("EventID")] + public required EventId EventId { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs new file mode 100644 index 00000000..f2d11a66 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/IAccountApiClients.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; + +namespace Proton.Sdk.Api; + +internal interface IAccountApiClients +{ + IKeysApiClient Keys { get; } + IUsersApiClient Users { get; } + IAddressesApiClient Addresses { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs new file mode 100644 index 00000000..4bac630a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/IApiClientFactory.cs @@ -0,0 +1,21 @@ +using Proton.Sdk.Api.Addresses; +using Proton.Sdk.Api.Authentication; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Api.Users; + +namespace Proton.Sdk.Api; + +internal interface IApiClientFactory +{ + public IAuthenticationApiClient CreateAuthenticationApiClient(HttpClient httpClient, Uri refreshRedirectUri) + => new AuthenticationApiClient(httpClient, refreshRedirectUri); + + public IKeysApiClient CreateKeysApiClient(HttpClient httpClient) + => new KeysApiClient(httpClient); + + public IUsersApiClient CreateUsersApiClient(HttpClient httpClient) + => new UsersApiClient(httpClient); + + public IAddressesApiClient CreateAddressesApiClient(HttpClient httpClient) + => new AddressesApiClient(httpClient); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs new file mode 100644 index 00000000..48668586 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/AddressPublicKeyListResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Keys; + +internal sealed class AddressPublicKeyListResponse : ApiResponse +{ + public required PublicKeyListAddress Address { get; init; } + + public IReadOnlyList? Warnings { get; init; } + + [JsonPropertyName("ProtonMX")] + public required bool IsProtonMxDomain { get; init; } + + [JsonPropertyName("IsProton")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsProtonAddress { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs new file mode 100644 index 00000000..e62e70b3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/IKeysApiClient.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Api.Keys; + +internal interface IKeysApiClient +{ + Task GetActivePublicKeysAsync(string emailAddress, CancellationToken cancellationToken); + + Task GetKeySaltsAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs new file mode 100644 index 00000000..be318fe4 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySalt.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Api.Keys; + +public sealed class KeySalt +{ + [JsonPropertyName("ID")] + public required string KeyId { get; init; } + + [JsonPropertyName("KeySalt")] + public required ReadOnlyMemory Value { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs new file mode 100644 index 00000000..874593ed --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeySaltListResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Keys; + +internal sealed class KeySaltListResponse : ApiResponse +{ + public required IReadOnlyList KeySalts { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs new file mode 100644 index 00000000..53f18256 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/KeysApiClient.cs @@ -0,0 +1,23 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Keys; + +internal sealed class KeysApiClient(HttpClient httpClient) : IKeysApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetActivePublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.AddressPublicKeyListResponse) + .GetAsync($"core/v4/keys/all?InternalOnly=1&Email={emailAddress}", cancellationToken).ConfigureAwait(false); + } + + public async Task GetKeySaltsAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.KeySaltListResponse) + .GetAsync("core/v4/keys/salts", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs new file mode 100644 index 00000000..2ff76408 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyEntry.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Api.Keys; + +internal sealed class PublicKeyEntry +{ + [JsonPropertyName("Flags")] + public required PublicKeyStatus Status { get; init; } + + public required PgpArmoredPublicKey PublicKey { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs new file mode 100644 index 00000000..8864cf53 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyListAddress.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Keys; + +internal sealed record PublicKeyListAddress +{ + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs new file mode 100644 index 00000000..7a167ffd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Keys/PublicKeyStatus.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Api.Keys; + +[Flags] +internal enum PublicKeyStatus +{ + IsNotCompromised = 1, + IsNotObsolete = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs new file mode 100644 index 00000000..5533976a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/ResponseCode.cs @@ -0,0 +1,67 @@ +using System.Net; + +namespace Proton.Sdk.Api; + +public enum ResponseCode +{ + Unknown = 0, + + Unauthorized = HttpStatusCode.Unauthorized, + Forbidden = HttpStatusCode.Forbidden, + RequestTimeout = HttpStatusCode.RequestTimeout, + + Success = 1000, + MultipleResponses = 1001, + InvalidRequirements = 2000, + InvalidValue = 2001, + NotEnoughPermissions = 2011, + NotEnoughPermissionsToGrantPermissions = 2026, + InvalidEncryptedIdFormat = 2061, + AlreadyExists = 2500, + DoesNotExist = 2501, + Timeout = 2503, + IncompatibleState = 2511, + InvalidApp = 5002, + OutdatedApp = 5003, + Offline = 7001, + IncorrectLoginCredentials = 8002, + + /// + /// Account is disabled + /// + AccountDeleted = 10_002, + + /// + /// Account is disabled due to abuse or fraud + /// + AccountDisabled = 10_003, + + InvalidRefreshToken = 10013, + + /// + /// Free account + /// + NoActiveSubscription = 22_110, + + UnknownAddress = 33_102, + + 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, + SessionRefreshFailed = CustomCode + 3, + SrpError = CustomCode + 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs new file mode 100644 index 00000000..f2b0b375 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/IUsersApiClient.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Users; + +internal interface IUsersApiClient +{ + Task GetAuthenticatedUserAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs b/cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs new file mode 100644 index 00000000..51ca8e53 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/Subscriptions.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Api.Users; + +[Flags] +internal enum Subscriptions +{ + None = 0, + Mail = 1, + Vpn = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs new file mode 100644 index 00000000..2b96d637 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; +using Proton.Sdk.Users; + +namespace Proton.Sdk.Api.Users; + +internal sealed class UserDto +{ + [JsonPropertyName("ID")] + public required UserId Id { get; init; } + + public required string Name { get; init; } + public required string DisplayName { get; init; } + + [JsonPropertyName("Email")] + public required string EmailAddress { get; init; } + + public UserType Type { get; init; } + + public required long MaxSpace { get; init; } + public required long UsedSpace { get; init; } + + [JsonPropertyName("Private")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrivate { get; init; } + + [JsonPropertyName("Subscribed")] + public required Subscriptions Subscriptions { get; init; } + + [JsonPropertyName("Services")] + public required Services ActiveServices { get; init; } + + [JsonPropertyName("Delinquent")] + public DelinquentState DelinquentState { get; init; } + + public required IReadOnlyList Keys { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs new file mode 100644 index 00000000..ccd2530e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserKeyDto.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Cryptography; +using Proton.Sdk.Serialization; +using Proton.Sdk.Users; + +namespace Proton.Sdk.Api.Users; + +internal sealed class UserKeyDto +{ + [JsonPropertyName("ID")] + public required UserKeyId Id { get; init; } + + public required int Version { get; init; } + + public required PgpArmoredPrivateKey PrivateKey { get; init; } + + [JsonPropertyName("Primary")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsPrimary { get; init; } + + [JsonPropertyName("Active")] + [JsonConverter(typeof(BooleanToIntegerJsonConverter))] + public required bool IsActive { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs new file mode 100644 index 00000000..8d2e1c93 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UserResponse.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk.Api.Users; + +internal sealed class UserResponse : ApiResponse +{ + public required UserDto User { get; init; } +} diff --git a/cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs b/cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs new file mode 100644 index 00000000..b0614049 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Api/Users/UsersApiClient.cs @@ -0,0 +1,16 @@ +using Proton.Sdk.Http; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Api.Users; + +internal sealed class UsersApiClient(HttpClient httpClient) : IUsersApiClient +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task GetAuthenticatedUserAsync(CancellationToken cancellationToken) + { + return await _httpClient + .Expecting(ProtonApiSerializerContext.Default.UserResponse) + .GetAsync("core/v4/users", cancellationToken).ConfigureAwait(false); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs new file mode 100644 index 00000000..cb0d2a53 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/AuthorizationHandler.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication; + +internal sealed class AuthorizationHandler(ProtonApiSession session) : DelegatingHandler +{ + private const string SessionIdHeaderName = "x-pm-uid"; + + private readonly ProtonApiSession _session = session; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Add(SessionIdHeaderName, _session.SessionId.ToString()); + + var (accessToken, _) = await _session.TokenCredential.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + var response = await SendWithTokenAsync(request, accessToken, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response = await HandleUnauthorizedAsync(request, response, accessToken, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + private async Task HandleUnauthorizedAsync( + HttpRequestMessage request, + HttpResponseMessage response, + string rejectedAccessToken, + CancellationToken cancellationToken) + { + var apiResponse = await response.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); + + if (apiResponse?.Code is ResponseCode.AccountDeleted or ResponseCode.AccountDisabled) + { + return response; + } + + var accessToken = await _session.TokenCredential.GetRefreshedAccessTokenAsync(rejectedAccessToken, cancellationToken).ConfigureAwait(false); + + return await SendWithTokenAsync(request, accessToken, cancellationToken).ConfigureAwait(false); + } + + private Task SendWithTokenAsync(HttpRequestMessage request, string accessToken, CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs b/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs new file mode 100644 index 00000000..e4d1d261 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/PasswordMode.cs @@ -0,0 +1,7 @@ +namespace Proton.Sdk.Authentication; + +public enum PasswordMode +{ + Single = 1, + Dual = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs new file mode 100644 index 00000000..ae7d5047 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/SessionId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Authentication; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct SessionId : IStrongId +{ + private readonly string? _value; + + internal SessionId(string? value) + { + _value = value; + } + + public static explicit operator SessionId(string? value) + { + return new SessionId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs new file mode 100644 index 00000000..b31e9f95 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Authentication/TokenCredential.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.Logging; +using Proton.Sdk.Api; +using Proton.Sdk.Api.Authentication; + +namespace Proton.Sdk.Authentication; + +public sealed class TokenCredential +{ + private readonly IAuthenticationApiClient _client; + private readonly SessionId _sessionId; + private readonly ILogger _logger; + + private Lazy> _tokensTask; + + internal TokenCredential(IAuthenticationApiClient client, SessionId sessionId, string accessToken, string refreshToken, ILogger logger) + { + _client = client; + _sessionId = sessionId; + _logger = logger; + + _tokensTask = new Lazy>(Task.FromResult((accessToken, refreshToken))); + } + + public event Action? TokensRefreshed; + public event Action? RefreshTokenExpired; + + public Task<(string AccessToken, string RefreshToken)> GetTokensAsync(CancellationToken cancellationToken) + { + return _tokensTask.Value.WaitAsync(cancellationToken); + } + + public async Task<(string AccessToken, string RefreshToken)> GetAccessTokenAsync(CancellationToken cancellationToken) + { + return await _tokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task GetRefreshedAccessTokenAsync(string rejectedAccessToken, CancellationToken cancellationToken) + { + var currentTokensTask = _tokensTask; + + var (currentAccessToken, currentRefreshToken) = await currentTokensTask.Value.WaitAsync(cancellationToken).ConfigureAwait(false); + + var isLikelyAlreadyRefreshedToken = currentAccessToken != rejectedAccessToken; + if (isLikelyAlreadyRefreshedToken) + { + return currentAccessToken; + } + + var refreshedTokensTask = new Lazy>( + async () => + { + try + { + _logger.LogDebug("Refreshing token for {SessionId}", _sessionId); + var response = await _client.RefreshSessionAsync(_sessionId, currentAccessToken, currentRefreshToken, cancellationToken) + .ConfigureAwait(false); + + return (response.AccessToken, response.RefreshToken); + } + catch (ProtonApiException ex) when (ex.Code == ResponseCode.InvalidRefreshToken) + { + throw; + } + catch (Exception ex) + { + // Return expired access token to allow refreshing again later + _logger.LogDebug(ex, "Failed to refresh token for {SessionId}", _sessionId); + return (currentAccessToken, currentRefreshToken); + } + }); + + var tokensTaskReplaced = Interlocked.CompareExchange(ref _tokensTask, refreshedTokensTask, currentTokensTask) == currentTokensTask; + + try + { + var (accessToken, refreshToken) = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + + if (tokensTaskReplaced) + { + OnTokensRefreshed(accessToken, refreshToken); + } + + return accessToken; + } + catch (ProtonApiException ex) when (ex.Code == ResponseCode.InvalidRefreshToken) + { + if (tokensTaskReplaced) + { + OnRefreshTokenExpired(); + } + + throw; + } + } + + private void OnTokensRefreshed(string accessToken, string refreshToken) + { + TokensRefreshed?.Invoke(accessToken, refreshToken); + } + + private void OnRefreshTokenExpired() + { + RefreshTokenExpired?.Invoke(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs new file mode 100644 index 00000000..76db91c1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountClientCache.cs @@ -0,0 +1,12 @@ +namespace Proton.Sdk.Caching; + +internal sealed class AccountClientCache( + ICacheRepository entityCacheRepository, + ICacheRepository secretCacheRepository, + ISessionSecretCache sessionSecretCache) : IAccountClientCache +{ + public IAccountEntityCache Entities { get; } = new AccountEntityCache(entityCacheRepository); + public IAccountSecretCache Secrets { get; } = new AccountSecretCache(secretCacheRepository); + public ISessionSecretCache SessionSecrets { get; } = sessionSecretCache; + public IPublicKeyCache PublicKeys { get; } = new PublicKeyCache(); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs new file mode 100644 index 00000000..7682a31d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountEntityCache.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using Proton.Sdk.Addresses; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Caching; + +internal sealed class AccountEntityCache(ICacheRepository repository) : IAccountEntityCache +{ + private static readonly string[] CurrentUserAddressTags = ["user:current:address"]; + + private readonly ICacheRepository _repository = repository; + + public ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken) + { + var value = JsonSerializer.Serialize(address, AccountEntitiesSerializerContext.Default.Address); + + return _repository.SetAsync(GetAddressCacheKey(address.Id), value, cancellationToken); + } + + public async ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + var value = await _repository.TryGetAsync(GetAddressCacheKey(addressId), cancellationToken).ConfigureAwait(false); + + return value is not null ? JsonSerializer.Deserialize(value, AccountEntitiesSerializerContext.Default.Address) : null; + } + + public async ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken) + { + await _repository.SetCompleteCollection( + addresses, + address => GetAddressCacheKey(address.Id), + CurrentUserAddressTags, + AccountEntitiesSerializerContext.Default.Address, + cancellationToken).ConfigureAwait(false); + } + + public async ValueTask?> TryGetCurrentUserAddressesAsync(CancellationToken cancellationToken) + { + return await _repository.TryGetCompleteCollection( + CurrentUserAddressTags, + AccountEntitiesSerializerContext.Default.Address, + cancellationToken).ConfigureAwait(false); + } + + private static string GetAddressCacheKey(AddressId addressId) + { + return $"address:{addressId}"; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs new file mode 100644 index 00000000..b5d8b6df --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/AccountSecretCache.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Caching; + +internal sealed class AccountSecretCache(ICacheRepository repository) : IAccountSecretCache +{ + private const string UserKeysCacheKey = "user:current:keys"; + + private readonly ICacheRepository _repository = repository; + + public ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(unlockedKeys, SecretsSerializerContext.Default.IEnumerablePgpPrivateKey); + + return _repository.SetAsync(UserKeysCacheKey, serializedValue, cancellationToken); + } + + public async ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(UserKeysCacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKeyArray) + : null; + } + + public ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken) + { + var serializedValue = JsonSerializer.Serialize(unlockedKeys, SecretsSerializerContext.Default.IEnumerablePgpPrivateKey); + + return _repository.SetAsync(GetAddressKeysCacheKey(addressId), serializedValue, cancellationToken); + } + + public async ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken) + { + var serializedValue = await _repository.TryGetAsync(GetAddressKeysCacheKey(addressId), cancellationToken).ConfigureAwait(false); + + return serializedValue is not null + ? JsonSerializer.Deserialize(serializedValue, SecretsSerializerContext.Default.PgpPrivateKeyArray) + : null; + } + + private static string GetAddressKeysCacheKey(AddressId addressId) + { + return $"address:{addressId}:keys"; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs new file mode 100644 index 00000000..3de3fc90 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/CacheRepositoryExtensions.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Caching; + +internal static class CacheRepositoryExtensions +{ + private const string CompleteTagCacheKeyFormat = "cache:tag:{0}:complete"; + + public static ValueTask SetAsync(this ICacheRepository repository, string key, string value, CancellationToken cancellationToken) + { + return repository.SetAsync(key, value, [], cancellationToken); + } + + public static async ValueTask<(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, + Func getCacheKeyFunction, + IReadOnlyList tags, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) + { + foreach (var value in values) + { + var serializedValue = JsonSerializer.Serialize(value, jsonTypeInfo); + + var cacheKey = getCacheKeyFunction.Invoke(value); + + await repository.SetAsync(cacheKey, serializedValue, tags, cancellationToken).ConfigureAwait(false); + } + + await repository.MarkTagAsCompleteAsync(tags[0], cancellationToken).ConfigureAwait(false); + } + + public static async ValueTask?> TryGetCompleteCollection( + this ICacheRepository repository, + IReadOnlyList tags, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken) + { + if (!await repository.GetTagIsCompleteAsync(tags[0], cancellationToken).ConfigureAwait(false)) + { + return null; + } + + var entries = repository.GetByTagsAsync(tags, cancellationToken); + + var deserializedValues = new List(); + + await foreach (var entry in entries.ConfigureAwait(false)) + { + try + { + var deserializedValue = JsonSerializer.Deserialize(entry.Value, jsonTypeInfo); + if (deserializedValue is null) + { + return null; + } + + deserializedValues.Add(deserializedValue); + } + catch + { + // There is something wrong with the cache, remove the problematic entry, and return null to incite the caller to refresh the collection + await repository.RemoveAsync(entry.Key, cancellationToken).ConfigureAwait(false); + + return null; + } + } + + return deserializedValues; + } + + /// + /// Creates a cache entry that serves as a hint that querying by the given tag will return complete information. + /// + /// + /// 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. + /// + private static async ValueTask MarkTagAsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + { + var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); + + await repository.SetAsync(cacheKey, string.Empty, cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask GetTagIsCompleteAsync(this ICacheRepository repository, string tag, CancellationToken cancellationToken) + { + var cacheKey = string.Format(CompleteTagCacheKeyFormat, tag); + + return await repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not null; + } +} 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..865a0220 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/EncryptedCacheRepository.cs @@ -0,0 +1,164 @@ +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); + + 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( + IEnumerable tags, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var (key, encryptedValue) in _inner.GetByTagsAsync(tags, cancellationToken).ConfigureAwait(false)) + { + 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); + } + } + + 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/Caching/IAccountClientCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs new file mode 100644 index 00000000..d72453a0 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountClientCache.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Caching; + +internal interface IAccountClientCache +{ + IAccountEntityCache Entities { get; } + IAccountSecretCache Secrets { get; } + ISessionSecretCache SessionSecrets { get; } + IPublicKeyCache PublicKeys { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs new file mode 100644 index 00000000..3eae8f26 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountEntityCache.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal interface IAccountEntityCache +{ + ValueTask SetAddressAsync(Address address, CancellationToken cancellationToken); + ValueTask TryGetAddressAsync(AddressId addressId, CancellationToken cancellationToken); + + ValueTask SetCurrentUserAddressesAsync(IEnumerable
addresses, CancellationToken cancellationToken); + ValueTask?> TryGetCurrentUserAddressesAsync(CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs new file mode 100644 index 00000000..2867e61e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IAccountSecretCache.cs @@ -0,0 +1,13 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Caching; + +internal interface IAccountSecretCache +{ + ValueTask SetUserKeysAsync(IEnumerable unlockedKeys, CancellationToken cancellationToken); + ValueTask?> TryGetUserKeysAsync(CancellationToken cancellationToken); + + ValueTask SetAddressKeysAsync(AddressId addressId, IEnumerable unlockedKeys, CancellationToken cancellationToken); + ValueTask?> TryGetAddressKeysAsync(AddressId addressId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs new file mode 100644 index 00000000..bd31f444 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/ICacheRepository.cs @@ -0,0 +1,16 @@ +namespace Proton.Sdk.Caching; + +public interface ICacheRepository : IAsyncDisposable +{ + ValueTask SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken); + + ValueTask RemoveAsync(string key, CancellationToken cancellationToken); + + ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken); + + ValueTask ClearAsync(); + + ValueTask TryGetAsync(string key, CancellationToken cancellationToken); + + IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken = default); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs b/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs new file mode 100644 index 00000000..ba021e8f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/IPublicKeyCache.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Caching; + +internal interface IPublicKeyCache +{ + void SetPublicKeys(string emailAddress, IReadOnlyList publicKeys); + bool TryGetPublicKeys(string emailAddress, [MaybeNullWhen(false)] out IReadOnlyList publicKeys); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs new file mode 100644 index 00000000..37030462 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/ISessionSecretCache.cs @@ -0,0 +1,7 @@ +namespace Proton.Sdk.Caching; + +internal interface ISessionSecretCache +{ + ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken); + ValueTask?> TryGetAccountKeyPassphraseAsync(string keyId, CancellationToken cancellationToken); +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/InMemoryCacheRepository.cs new file mode 100644 index 00000000..f047f5ed --- /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; + +public 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); + + 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) + { + _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 = [.. 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/Caching/NullCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs new file mode 100644 index 00000000..f049918b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/NullCacheRepository.cs @@ -0,0 +1,41 @@ +namespace Proton.Sdk.Caching; + +internal sealed class NullCacheRepository : ICacheRepository +{ + public static readonly NullCacheRepository Instance = new(); + + public ValueTask SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask ClearAsync() + { + return ValueTask.CompletedTask; + } + + public ValueTask TryGetAsync(string key, CancellationToken cancellationToken) + { + return ValueTask.FromResult(default(string?)); + } + + public IAsyncEnumerable<(string Key, string Value)> GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken = default) + { + return AsyncEnumerable.Empty<(string, string)>(); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs new file mode 100644 index 00000000..30c7a015 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/PublicKeyCache.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Caching.Memory; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Caching; + +internal sealed class PublicKeyCache + : IPublicKeyCache +{ + public const int NumberOfMinutesBeforeExpiration = 30; + + private readonly IMemoryCache _memoryCache = new MemoryCache(new MemoryCacheOptions()); + + public void SetPublicKeys(string emailAddress, IReadOnlyList publicKeys) + { + _memoryCache.Set(emailAddress, publicKeys, TimeSpan.FromMinutes(NumberOfMinutesBeforeExpiration)); + } + + public bool TryGetPublicKeys(string emailAddress, [MaybeNullWhen(false)] out IReadOnlyList publicKeys) + { + return _memoryCache.TryGetValue(emailAddress, out publicKeys); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs new file mode 100644 index 00000000..0c926a85 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/SessionSecretCache.cs @@ -0,0 +1,29 @@ +namespace Proton.Sdk.Caching; + +internal sealed class SessionSecretCache(ICacheRepository repository) : ISessionSecretCache +{ + private readonly ICacheRepository _repository = repository; + + public ValueTask SetAccountKeyPassphraseAsync(string keyId, ReadOnlyMemory passphrase, CancellationToken cancellationToken) + { + var cacheKey = GetAccountPassphraseCacheKey(keyId); + + var serializedValue = Convert.ToBase64String(passphrase.Span); + + return _repository.SetAsync(cacheKey, serializedValue, cancellationToken); + } + + public async ValueTask?> TryGetAccountKeyPassphraseAsync(string keyId, CancellationToken cancellationToken) + { + var cacheKey = GetAccountPassphraseCacheKey(keyId); + + var serializedValue = await _repository.TryGetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + + return serializedValue is not null ? (ReadOnlyMemory?)Convert.FromBase64String(serializedValue) : null; + } + + private static string GetAccountPassphraseCacheKey(string keyId) + { + return $"account:passphrase:{keyId}"; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs new file mode 100644 index 00000000..85718a70 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Caching/SqliteCacheRepository.cs @@ -0,0 +1,427 @@ +using System.Data; +using Microsoft.Data.Sqlite; + +namespace Proton.Sdk.Caching; + +public sealed class SqliteCacheRepository : ICacheRepository, IDisposable +{ + private readonly SqliteConnection _connection; + private readonly int? _maxCacheSize; + + private SqliteCacheRepository(SqliteConnection connection, int? maxCacheSize) + { + _connection = connection; + _maxCacheSize = maxCacheSize; + } + + public static SqliteCacheRepository OpenInMemory(int? maxCacheSize = 1024) + { + // Avoiding SqliteConnectionStringBuilder due to IL2113 warning in AOT scenarios + var connectionString = $"Data Source={Guid.NewGuid().ToString()};Mode=Memory;Cache=Shared"; + + return Open(connectionString, maxCacheSize); + } + + public static SqliteCacheRepository OpenFile(string path, int? maxCacheSize = 1024) + { + // Avoiding SqliteConnectionStringBuilder due to IL2113 warning in AOT scenarios + var connectionString = $"Data Source=\"{path}\""; + + return Open(connectionString, maxCacheSize); + } + + ValueTask ICacheRepository.SetAsync(string key, string value, IEnumerable tags, CancellationToken cancellationToken) + { + try + { + Set(key, value, tags); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.RemoveAsync(string key, CancellationToken cancellationToken) + { + try + { + Remove(key); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.RemoveByTagAsync(string tag, CancellationToken cancellationToken) + { + try + { + RemoveByTag(tag); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + public ValueTask ClearAsync() + { + try + { + Clear(); + + return ValueTask.CompletedTask; + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + ValueTask ICacheRepository.TryGetAsync(string key, CancellationToken cancellationToken) + { + try + { + return ValueTask.FromResult(TryGet(key)); + } + catch (Exception e) + { + return ValueTask.FromException(e); + } + } + + IAsyncEnumerable<(string Key, string Value)> ICacheRepository.GetByTagsAsync(IEnumerable tags, CancellationToken cancellationToken) + { + return GetByTags(tags).ToAsyncEnumerable(); + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + Dispose(); + + return ValueTask.CompletedTask; + } + + public void Set(string key, string value, IEnumerable tags) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + + // 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, 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(); + + command.CommandText = "DELETE FROM Tags WHERE Key = @key"; + + command.ExecuteNonQuery(); + + command.CommandText = "INSERT INTO Tags (Tag, Key) VALUES (@tag, @key)"; + + var tagParameter = command.CreateParameter(); + tagParameter.ParameterName = "@tag"; + command.Parameters.Add(tagParameter); + + foreach (var tag in tags) + { + tagParameter.Value = tag; + + command.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + public void Remove(string key) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries WHERE Key = @key"; + command.Parameters.AddWithValue("@key", key); + + command.ExecuteNonQuery(); + } + + public void RemoveByTag(string tag) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries AS e WHERE EXISTS (SELECT 1 FROM Tags WHERE Tag = @tag AND Key = e.Key)"; + command.Parameters.AddWithValue("@tag", tag); + + command.ExecuteNonQuery(); + } + + public void Clear() + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entries"; + + command.ExecuteNonQuery(); + } + + public string? TryGet(string key) + { + using var connection = new SqliteConnection(_connection.ConnectionString); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + using var command = connection.CreateCommand(); + command.Transaction = transaction; + + // Read value + command.CommandText = "SELECT Value FROM Entries WHERE Key = @key"; + command.Parameters.AddWithValue("@key", key); + + string value; + using (var reader = command.ExecuteReader()) + { + if (!reader.Read()) + { + return null; + } + + value = reader.GetFieldValue("Value"); + } + + // 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(); + + // Collect all matching entries first (existing query logic) + var results = new List<(string Key, string Value)>(); + + using (var command = connection.CreateCommand()) + { + command.Connection = connection; + + var i = 0; + foreach (var tag in tags) + { + command.Parameters.AddWithValue($"@tag{i++}", tag); + } + + var inClause = string.Join(", ", command.Parameters.Cast().Select(x => x.ParameterName)); + + command.CommandText = + $""" + SELECT 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))); + } + } + + // 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 (var i = 0; i < results.Count; i++) + { + updateCommand.Parameters.AddWithValue($"@key{i}", results[i].Key); + } + + updateCommand.ExecuteNonQuery(); + } + + // Return via yield + foreach (var result in results) + { + yield return result; + } + } + + public void Dispose() + { + SqliteConnection.ClearPool(_connection); + _connection.Close(); + _connection.Dispose(); + } + + 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(string connectionString, int? maxCacheSize) + { + if (maxCacheSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxCacheSize), "Max cache size must be greater than 0 or null to disable LRU."); + } + + var connection = new SqliteConnection(connectionString); + + try + { + connection.Open(); + + InitializeDatabase(connection); + + return new SqliteCacheRepository(connection, maxCacheSize); + } + catch + { + connection.Dispose(); + throw; + } + } + + private static void InitializeDatabase(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + + command.CommandText = "PRAGMA journal_mode = WAL"; + + command.ExecuteNonQuery(); + + command.CommandText = "PRAGMA synchronous = NORMAL"; + + command.ExecuteNonQuery(); + + command.CommandText = + """ + 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 ( + Tag TEXT NOT NULL, + Key TEXT NOT NULL, + PRIMARY KEY (Tag, Key), + FOREIGN KEY (Key) REFERENCES Entries(Key) ON DELETE CASCADE + ) + """; + + command.ExecuteNonQuery(); + + command.CommandText = + """ + CREATE TABLE IF NOT EXISTS QueryableTags ( + Tag TEXT NOT NULL, + PRIMARY KEY (Tag) + ) + """; + + command.ExecuteNonQuery(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/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/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs new file mode 100644 index 00000000..ab2a557d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/IPgpArmoredBlock.cs @@ -0,0 +1,13 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal interface IPgpArmoredBlock + where T : IPgpArmoredBlock +{ + ReadOnlyMemory Bytes { get; } + + static virtual implicit operator Stream(T block) => block.Bytes.AsStream(); + static virtual implicit operator ReadOnlyMemory(T block) => block.Bytes; + static virtual implicit operator ReadOnlySpan(T block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs new file mode 100644 index 00000000..d0c5f1b7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredMessage.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredMessage(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredMessage(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredMessage(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredMessage(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredMessage block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredMessage block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredMessage block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs new file mode 100644 index 00000000..8413ad6d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPrivateKey.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredPrivateKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredPrivateKey(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredPrivateKey(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredPrivateKey(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredPrivateKey block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPrivateKey block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPrivateKey block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs new file mode 100644 index 00000000..e5df84e8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredPublicKey.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredPublicKey(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredPublicKey(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredPublicKey(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredPublicKey(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredPublicKey block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredPublicKey block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredPublicKey block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs new file mode 100644 index 00000000..b9281e5c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Cryptography/PgpArmoredSignature.cs @@ -0,0 +1,16 @@ +using CommunityToolkit.HighPerformance; + +namespace Proton.Sdk.Cryptography; + +internal readonly struct PgpArmoredSignature(ReadOnlyMemory bytes) : IPgpArmoredBlock +{ + public ReadOnlyMemory Bytes { get; } = bytes; + + public static implicit operator PgpArmoredSignature(Memory bytes) => new(bytes); + public static implicit operator PgpArmoredSignature(ReadOnlyMemory bytes) => new(bytes); + public static implicit operator PgpArmoredSignature(ArraySegment bytes) => new(bytes); + + public static implicit operator Stream(PgpArmoredSignature block) => block.Bytes.AsStream(); + public static implicit operator ReadOnlyMemory(PgpArmoredSignature block) => block.Bytes; + public static implicit operator ReadOnlySpan(PgpArmoredSignature block) => block.Bytes.Span; +} diff --git a/cs/sdk/src/Proton.Sdk/Either.cs b/cs/sdk/src/Proton.Sdk/Either.cs new file mode 100644 index 00000000..1492a742 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Either.cs @@ -0,0 +1,47 @@ +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; + } + + public bool TryGetFirst([NotNullWhen(true)] out T1? first) + { + first = _first; + return IsFirst; + } + + public bool TryGetSecond([NotNullWhen(true)] out T2? second) + { + second = _second; + return IsSecond; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Events/EventId.cs b/cs/sdk/src/Proton.Sdk/Events/EventId.cs new file mode 100644 index 00000000..d8d71adf --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Events/EventId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Events; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct EventId : IStrongId +{ + private readonly string? _value; + + internal EventId(string? value) + { + _value = value; + } + + public static explicit operator EventId(string? value) + { + return new EventId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/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/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 new file mode 100644 index 00000000..d4445404 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/CryptographyTimeProvisionHandler.cs @@ -0,0 +1,21 @@ +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Http; + +internal sealed class CryptographyTimeProvisionHandler : DelegatingHandler +{ + private static readonly CryptographyTimeProvider CryptographyTimeProvider = new(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var responseMessage = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (responseMessage.Headers.Date is { } time) + { + CryptographyTimeProvider.UpdateTime(time); + PgpEnvironment.DefaultTimeProviderOverride = CryptographyTimeProvider; + } + + return responseMessage; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs new file mode 100644 index 00000000..a51331c7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpApiCallBuilder.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Api; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Http; + +// FIXME: add unit tests +internal readonly struct HttpApiCallBuilder + where TFailure : ApiResponse +{ + private readonly HttpClient _httpClient; + private readonly JsonTypeInfo _successTypeInfo; + private readonly JsonTypeInfo _failureTypeInfo; + + internal HttpApiCallBuilder(HttpClient httpClient, JsonTypeInfo successTypeInfo, JsonTypeInfo failureTypeInfo) + { + _httpClient = httpClient; + _successTypeInfo = successTypeInfo; + _failureTypeInfo = failureTypeInfo; + } + + public async ValueTask GetAsync(string requestUri, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, requestUri); + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask GetAsync(string requestUri, string sessionId, string accessToken, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Get, requestUri, sessionId, accessToken); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PostAsync( + string requestUri, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, body, bodyTypeInfo); + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PostAsync( + string requestUri, + SessionId sessionId, + string accessToken, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Post, requestUri, sessionId, accessToken, body, bodyTypeInfo); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask PutAsync( + string requestUri, + TRequestBody body, + JsonTypeInfo bodyTypeInfo, + CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Put, requestUri, body, bodyTypeInfo); + + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteAsync(string requestUri, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Delete, requestUri); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DeleteAsync(string requestUri, string sessionId, string accessToken, CancellationToken cancellationToken) + { + using var requestMessage = HttpRequestMessageFactory.Create(HttpMethod.Delete, requestUri, sessionId, accessToken); + return await SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) + { + 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 (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The operation has timed out.", e); + } + } +} 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..3760a36c --- /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).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).ConfigureAwait(false); + if (requestContentString is not null) + { + _logger.LogInformation($"Request body:{NewLine}{{Body}}", requestContentString); + } + + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var responseContentString = await TryGetContentAsString(response.Content, cancellationToken).ConfigureAwait(false); + 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/Http/HttpClientExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs new file mode 100644 index 00000000..40b3b6b3 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpClientExtensions.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Api; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Http; + +internal static class HttpClientExtensions +{ + public static HttpApiCallBuilder Expecting(this HttpClient httpClient, JsonTypeInfo successTypeInfo) + { + return new HttpApiCallBuilder(httpClient, successTypeInfo, ProtonApiSerializerContext.Default.ApiResponse); + } + + public static HttpApiCallBuilder Expecting( + this HttpClient httpClient, + JsonTypeInfo successTypeInfo, + JsonTypeInfo failureTypeInfo) + where TFailure : ApiResponse + { + return new HttpApiCallBuilder(httpClient, successTypeInfo, failureTypeInfo); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs new file mode 100644 index 00000000..b72235b8 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestHeadersExtensions.cs @@ -0,0 +1,14 @@ +using System.Net.Http.Headers; + +namespace Proton.Sdk.Http; + +internal static class HttpRequestHeadersExtensions +{ + private const string ContentType = "application/vnd.protonmail.api+json"; + + public static void AddApiRequestHeaders(this HttpRequestHeaders headerCollection) + { + // FIXME: Add Accept-Language header + headerCollection.Accept.Add(new MediaTypeWithQualityHeaderValue(ContentType)); + } +} 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..1b09bea4 --- /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 SetRequestType(this HttpRequestMessage requestMessage, HttpRequestType requestType) + { + requestMessage.Options.Set(HttpRequestOptionKeys.RequestType, requestType); + } + + public static HttpRequestType GetRequestType(this HttpRequestMessage requestMessage) + { + return requestMessage.Options.TryGetValue(HttpRequestOptionKeys.RequestType, out var requestType) ? requestType : HttpRequestType.RegularApi; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs new file mode 100644 index 00000000..30704651 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpRequestMessageFactory.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization.Metadata; +using Proton.Sdk.Authentication; + +namespace Proton.Sdk.Http; + +internal static class HttpRequestMessageFactory +{ + public static HttpRequestMessage Create(HttpMethod method, string uri) + { + return new HttpRequestMessage(method, uri); + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string accessToken) + { + var result = Create(method, uri); + result.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string sessionId, string accessToken) + { + var result = Create(method, uri, accessToken); + result.Headers.Add("x-pm-uid", sessionId); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, TBody body, JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri); + result.Content = JsonContent.Create(body, bodyTypeInfo); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, string accessToken, TBody body, JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri, body, bodyTypeInfo); + result.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return result; + } + + public static HttpRequestMessage Create(HttpMethod method, string uri, HttpContent content) + { + var result = Create(method, uri); + result.Content = content; + return result; + } + + public static HttpRequestMessage Create( + HttpMethod method, + string uri, + SessionId sessionId, + string accessToken, + TBody body, + JsonTypeInfo bodyTypeInfo) + { + var result = Create(method, uri, accessToken, body, bodyTypeInfo); + result.Headers.Add("x-pm-uid", sessionId.ToString()); + return result; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/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/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/Http/HttpResponseMessageExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs new file mode 100644 index 00000000..dc0421fe --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/HttpResponseMessageExtensions.cs @@ -0,0 +1,72 @@ +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; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Http; + +internal static class HttpResponseMessageExtensions +{ + // TODO: add unit test + public static async Task EnsureApiSuccessAsync( + this HttpResponseMessage responseMessage, + JsonTypeInfo failureTypeInfo, + CancellationToken cancellationToken) + where TFailure : ApiResponse + { + switch (responseMessage.StatusCode) + { + case HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict: + { + var response = await responseMessage.Content.ReadFromJsonAsync(failureTypeInfo, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + + throw new ProtonApiException(responseMessage.StatusCode, response); + } + + case HttpStatusCode.BadRequest: + { + var response = await responseMessage.Content.ReadFromJsonAsync(ProtonApiSerializerContext.Default.ApiResponse, cancellationToken) + .ConfigureAwait(false) ?? throw new JsonException(); + + 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/Http/ProtonClientTlsPolicy.cs b/cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs new file mode 100644 index 00000000..6e5c1615 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/ProtonClientTlsPolicy.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Http; + +public enum ProtonClientTlsPolicy +{ + Strict = 0, + NoCertificatePinning = 1, + NoCertificateValidation = 2, +} diff --git a/cs/sdk/src/Proton.Sdk/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"; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs new file mode 100644 index 00000000..0229e38e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/SocketsHttpHandlerExtensions.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Net.Security; + +namespace Proton.Sdk.Http; + +internal static class SocketsHttpHandlerExtensions +{ + public static SocketsHttpHandler AddAutomaticDecompression(this SocketsHttpHandler handler) + { + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli; + return handler; + } + + public static SocketsHttpHandler ConfigureCookies(this SocketsHttpHandler handler, CookieContainer cookieContainer) + { + handler.CookieContainer = cookieContainer; + return handler; + } + + /// + /// Configures the to apply server certificate public key pinning for an . + /// + /// The . + /// The handler passed as parameter, for fluent chaining. + public static SocketsHttpHandler AddTlsPinning(this SocketsHttpHandler handler) + { + handler.SslOptions.RemoteCertificateValidationCallback = + (_, certificate, chain, sslPolicyErrors) => sslPolicyErrors == SslPolicyErrors.None && TlsRemoteCertificateValidator.Validate(certificate, chain); + + return handler; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Http/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; +} diff --git a/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs new file mode 100644 index 00000000..848add70 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Http/TlsRemoteCertificateValidator.cs @@ -0,0 +1,77 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Proton.Sdk.Http; + +internal static class TlsRemoteCertificateValidator +{ + private static readonly IReadOnlyCollection KnownPublicKeySha256Digests = + [ + Convert.FromBase64String("CT56BhOTmj5ZIPgb/xD5mH8rY3BLo/MlhP7oPyJUEDo="), + Convert.FromBase64String("35Dx28/uzN3LeltkCBQ8RHK0tlNSa2kCpCRGNp34Gxc="), + Convert.FromBase64String("qYIukVc63DEITct8sFT7ebIq5qsWmuscaIKeJx+5J5A="), + ]; + + public static bool Validate(X509Certificate? certificate, X509Chain? chain) + { + if (certificate == null || chain == null) + { + return false; + } + + var certificateIsValid = IsValid(certificate); + + // TODO: TLS certificate pinning report + + // Ignore other potential SSL policy errors if the certificate is valid. + return certificateIsValid; + } + + private static bool IsValid(X509Certificate certificate) + { + using var certificate2 = new X509Certificate2(certificate); + Span hashDigestBuffer = stackalloc byte[SHA256.HashSizeInBytes]; + if (!TryGetPublicKeySha256Digest(certificate2, hashDigestBuffer)) + { + return false; + } + + var validHashFound = false; +#pragma warning disable S3267 // 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)) + { + validHashFound = true; + break; + } + } +#pragma warning restore S3267 // Loops should be simplified with "LINQ" expressions + + return validHashFound; + } + + private static bool TryGetPublicKeySha256Digest(X509Certificate2 certificate, Span outputBuffer) + { + var publicKey = certificate.GetRSAPublicKey() as AsymmetricAlgorithm + ?? certificate.GetDSAPublicKey() + ?? throw new NotSupportedException("No supported key algorithm"); + + // Expected length of public key info is around 550 bytes + var publicKeyInfoBuffer = ArrayPool.Shared.Rent(1024); + + try + { + var publishKeyInfo = publicKey.TryExportSubjectPublicKeyInfo(publicKeyInfoBuffer, out var publicKeyInfoLength) + ? publicKeyInfoBuffer.AsSpan()[..publicKeyInfoLength] + : publicKey.ExportSubjectPublicKeyInfo(); + + return SHA256.TryHashData(publishKeyInfo, outputBuffer, out _); + } + finally + { + ArrayPool.Shared.Return(publicKeyInfoBuffer); + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs new file mode 100644 index 00000000..81a082b1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/IFeatureFlagProvider.cs @@ -0,0 +1,6 @@ +namespace Proton.Sdk; + +public interface IFeatureFlagProvider +{ + Task IsEnabledAsync(string flagName, CancellationToken cancellationToken); +} 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/Proton.Sdk.csproj b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj new file mode 100644 index 00000000..4b01b42e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Proton.Sdk.csproj @@ -0,0 +1,43 @@ + + + + true + Authentication Session Account + Provides the means to authenticate with the Proton API and get user account information. + true + true + snupkg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs new file mode 100644 index 00000000..6393de6f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonAccountClient.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Pgp; +using Proton.Sdk.Addresses; +using Proton.Sdk.Api; +using Proton.Sdk.Caching; +using Proton.Sdk.Telemetry; + +namespace Proton.Sdk; + +public sealed class ProtonAccountClient +{ + public ProtonAccountClient(ProtonApiSession session) + : this( + new AccountApiClients(session.GetHttpClient()), + new AccountClientCache(session.ClientConfiguration.EntityCacheRepository, session.ClientConfiguration.SecretCacheRepository, session.SecretCache), + session.ClientConfiguration.Telemetry.GetLogger()) + { + } + + internal ProtonAccountClient(IAccountApiClients apiClients, IAccountClientCache cache, ILogger logger) + { + Api = apiClients; + Cache = cache; + Logger = logger; + } + + internal IAccountApiClients Api { get; } + + internal IAccountClientCache Cache { get; } + + internal ILogger Logger { get; } + + public ValueTask
GetAddressAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressAsync(this, addressId, cancellationToken); + } + + public ValueTask> GetCurrentUserAddressesAsync(CancellationToken cancellationToken) + { + return AddressOperations.GetCurrentUserAddressesAsync(this, cancellationToken); + } + + public ValueTask
GetCurrentUserDefaultAddressAsync(CancellationToken cancellationToken) + { + 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); + } + + internal ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken) + { + return AddressOperations.GetAddressPrimaryPrivateKeyAsync(this, addressId, cancellationToken); + } + + internal ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken) + { + return AddressOperations.GetPublicKeysAsync(this, emailAddress, cancellationToken); + } + + internal async ValueTask> GetUserKeysAsync(CancellationToken cancellationToken) + { + var userKeys = await Cache.Secrets.TryGetUserKeysAsync(cancellationToken).ConfigureAwait(false); + + if (userKeys is null) + { + var response = await Api.Users.GetAuthenticatedUserAsync(cancellationToken).ConfigureAwait(false); + + var unlockedKeys = new List(response.User.Keys.Count); + + var activeKeyFound = false; + + foreach (var userKey in response.User.Keys) + { + if (!userKey.IsActive) + { + continue; + } + + activeKeyFound = true; + + var passphrase = await Cache.SessionSecrets.TryGetAccountKeyPassphraseAsync(userKey.Id.ToString(), cancellationToken).ConfigureAwait(false); + + if (passphrase is null) + { + Logger.LogWarning("No passphrase found for user key {UserKeyId}", userKey.Id); + continue; + } + + var unlockedUserKey = PgpPrivateKey.ImportAndUnlock(userKey.PrivateKey.Bytes.Span, passphrase.Value.Span); + + unlockedKeys.Add(unlockedUserKey); + } + + if (unlockedKeys.Count == 0) + { + throw new ProtonApiException(activeKeyFound ? "At least one active user key exists, but none could be unlocked." : "No active user key found"); + } + + await Cache.Secrets.SetUserKeysAsync(unlockedKeys, cancellationToken).ConfigureAwait(false); + + userKeys = unlockedKeys; + } + + return userKeys; + } +} 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) + { + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs new file mode 100644 index 00000000..cb5a58bd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiDefaults.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk; + +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"); +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs new file mode 100644 index 00000000..64d7616f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException.cs @@ -0,0 +1,41 @@ +using System.Net; +using Proton.Sdk.Api; + +namespace Proton.Sdk; + +public class ProtonApiException : Exception +{ + public ProtonApiException() + { + } + + public ProtonApiException(string? message) + : base(message) + { + } + + public ProtonApiException(string? message, Exception? innerException) + : base(message, innerException) + { + } + + public ProtonApiException(string? message, int? transportCode, ResponseCode code) + : this(message) + { + Code = code; + TransportCode = transportCode; + } + + internal ProtonApiException(HttpStatusCode statusCode, ApiResponse response) + : this(response.ErrorMessage, (int)statusCode, response.Code) + { + } + + internal ProtonApiException(ApiResponse response) + : this(response.ErrorMessage, null, response.Code) + { + } + + public ResponseCode Code { get; } + public int? TransportCode { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs b/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs new file mode 100644 index 00000000..172eb533 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiException{T}.cs @@ -0,0 +1,30 @@ +using System.Net; +using Proton.Sdk.Api; + +namespace Proton.Sdk; + +internal sealed class ProtonApiException : ProtonApiException + where T : ApiResponse +{ + public ProtonApiException() + { + } + + public ProtonApiException(string message) + : base(message) + { + } + + public ProtonApiException(string message, Exception innerException) + : base(message, innerException) + { + } + + public ProtonApiException(HttpStatusCode statusCode, T response) + : base(statusCode, response) + { + Response = response; + } + + public T? Response { get; } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs new file mode 100644 index 00000000..1a7b2307 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonApiSession.cs @@ -0,0 +1,324 @@ +using Microsoft.Extensions.Logging; +using Proton.Cryptography.Srp; +using Proton.Sdk.Api; +using Proton.Sdk.Api.Authentication; +using Proton.Sdk.Api.Keys; +using Proton.Sdk.Authentication; +using Proton.Sdk.Caching; +using Proton.Sdk.Telemetry; +using Proton.Sdk.Users; + +namespace Proton.Sdk; + +public sealed class ProtonApiSession +{ + private readonly HttpClient _httpClient; + + private bool _isEnded; + private Action? _ended; + private IAuthenticationApiClient? _authenticationApi; + private IKeysApiClient? _keysApi; + + internal ProtonApiSession( + SessionId sessionId, + string username, + UserId userId, + TokenCredential tokenCredential, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, + ProtonClientConfiguration clientConfiguration) + { + _httpClient = clientConfiguration.GetHttpClient(this); + + Username = username; + UserId = userId; + SessionId = sessionId; + TokenCredential = tokenCredential; + Scopes = scopes.ToArray().AsReadOnly(); + IsWaitingForSecondFactorCode = isWaitingForSecondFactorCode; + PasswordMode = passwordMode; + ClientConfiguration = clientConfiguration; + SecretCache = new SessionSecretCache(clientConfiguration.SecretCacheRepository); + } + + public event Action? Ended + { + add + { + _ended += value; + TokenCredential.RefreshTokenExpired -= OnRefreshTokenExpired; + TokenCredential.RefreshTokenExpired += OnRefreshTokenExpired; + } + remove + { + _ended -= value; + TokenCredential.RefreshTokenExpired -= OnRefreshTokenExpired; + } + } + + public SessionId SessionId { get; } + + public string Username { get; } + + public UserId UserId { get; } + + public TokenCredential TokenCredential { get; } + + public IReadOnlyList Scopes { get; private set; } + + public bool IsWaitingForSecondFactorCode { get; private set; } + + public PasswordMode PasswordMode { get; } + + internal ProtonClientConfiguration ClientConfiguration { get; } + + internal SessionSecretCache SecretCache { get; } + + private IAuthenticationApiClient AuthenticationApi + => _authenticationApi ??= ApiClientFactory.Instance.CreateAuthenticationApiClient(_httpClient, ClientConfiguration.RefreshRedirectUri); + + private IKeysApiClient KeysApi => _keysApi ??= ApiClientFactory.Instance.CreateKeysApiClient(_httpClient); + + public static ValueTask BeginAsync(string username, ReadOnlyMemory password, string appVersion, CancellationToken cancellationToken) + { + return BeginAsync(username, password, appVersion, new ProtonSessionOptions(), cancellationToken); + } + + public static async ValueTask BeginAsync( + string username, + ReadOnlyMemory password, + string appVersion, + ProtonSessionOptions options, + CancellationToken cancellationToken) + { + var configuration = new ProtonClientConfiguration(appVersion, options); + var logger = configuration.Telemetry.GetLogger(); + + 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, + sessionInitResponse.Salt.Span, + sessionInitResponse.Modulus, + SrpClient.GetDefaultModulusVerificationKey()); + + var srpClientHandshake = srpClient.ComputeHandshake(sessionInitResponse.ServerEphemeral.Span, 2048); + + var authResponse = await authApiClient.AuthenticateAsync(sessionInitResponse, srpClientHandshake, username, cancellationToken) + .ConfigureAwait(false); + + logger.LogDebug("API session {SessionId} authenticated with password", authResponse.SessionId); + + var tokenCredential = new TokenCredential( + authApiClient, + authResponse.SessionId, + authResponse.AccessToken, + authResponse.RefreshToken, + configuration.Telemetry.GetLogger()); + + var session = new ProtonApiSession( + authResponse.SessionId, + username, + authResponse.UserId, + tokenCredential, + authResponse.Scopes, + authResponse.SecondFactorParameters?.IsEnabled == true, + authResponse.PasswordMode, + configuration); + + if (session is { IsWaitingForSecondFactorCode: false, PasswordMode: PasswordMode.Single }) + { + try + { + await session.ApplyDataPasswordAsync(password, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to apply data password"); + } + } + + return session; + } + + public static ProtonApiSession Resume( + SessionId sessionId, + string username, + UserId userId, + string accessToken, + string refreshToken, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, + string appVersion, + ICacheRepository secretCacheRepository) + { + return Resume( + sessionId, + username, + userId, + accessToken, + refreshToken, + scopes, + isWaitingForSecondFactorCode, + passwordMode, + appVersion, + secretCacheRepository, + new ProtonClientOptions()); + } + + public static ProtonApiSession Resume( + SessionId sessionId, + string username, + UserId userId, + string accessToken, + string refreshToken, + IEnumerable scopes, + bool isWaitingForSecondFactorCode, + PasswordMode passwordMode, + string appVersion, + ICacheRepository secretCacheRepository, + ProtonClientOptions options) + { + options = options with { SecretCacheRepository = secretCacheRepository }; + + var configuration = new ProtonClientConfiguration(appVersion, options); + + var logger = configuration.Telemetry.GetLogger(); + + var tokenCredential = new TokenCredential( + ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri), + sessionId, + accessToken, + refreshToken, + configuration.Telemetry.GetLogger()); + + var session = new ProtonApiSession( + sessionId, + username, + userId, + tokenCredential, + scopes, + isWaitingForSecondFactorCode, + passwordMode, + configuration); + + logger.LogDebug("Session {SessionId} was resumed", session.SessionId); + + 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.Telemetry.GetLogger()); + + 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); + + var authApiClient = ApiClientFactory.Instance.CreateAuthenticationApiClient(configuration.GetHttpClient(), configuration.RefreshRedirectUri); + + await authApiClient.EndSessionAsync(id, accessToken).ConfigureAwait(false); + } + + public async Task ApplySecondFactorCodeAsync(string secondFactorCode, CancellationToken cancellationToken) + { + var response = await AuthenticationApi.ValidateSecondFactorAsync(secondFactorCode, cancellationToken).ConfigureAwait(false); + + IsWaitingForSecondFactorCode = false; + Scopes = response.Scopes; + } + + public async Task ApplyDataPasswordAsync(ReadOnlyMemory password, CancellationToken cancellationToken) + { + var response = await KeysApi.GetKeySaltsAsync(cancellationToken).ConfigureAwait(false); + + foreach (var keySalt in response.KeySalts) + { + if (keySalt.Value.IsEmpty) + { + continue; + } + + var passphrase = DeriveSecretFromPassword(password.Span, keySalt.Value.Span); + + await SecretCache.SetAccountKeyPassphraseAsync(keySalt.KeyId, passphrase, cancellationToken).ConfigureAwait(false); + } + } + + public async Task RefreshScopesAsync(CancellationToken cancellationToken) + { + var scopesResponse = await AuthenticationApi.GetScopesAsync(cancellationToken).ConfigureAwait(false); + + Scopes = scopesResponse.Scopes; + } + + public async Task EndAsync() + { + if (_isEnded) + { + return true; + } + + var response = await AuthenticationApi.EndSessionAsync().ConfigureAwait(false); + + if (response.IsSuccess) + { + _isEnded = true; + + _ended?.Invoke(); + } + + return _isEnded; + } + + internal HttpClient GetHttpClient(string? baseRoutePath = null, TimeSpan? attemptTimeout = null, TimeSpan? totalTimeout = null) + { + return baseRoutePath is null && attemptTimeout is null && totalTimeout is null + ? _httpClient + : ClientConfiguration.GetHttpClient(this, baseRoutePath, attemptTimeout, totalTimeout); + } + + private static ReadOnlyMemory DeriveSecretFromPassword(ReadOnlySpan password, ReadOnlySpan salt) + { + var hashDigest = SrpClient.HashPassword(password, salt).AsMemory(); + + // Skip the first 29 characters which include the algorithm type, the number of rounds and the salt. + return hashDigest[29..]; + } + + private void OnRefreshTokenExpired() + { + _isEnded = true; + _ended?.Invoke(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs new file mode 100644 index 00000000..7e8dfb5b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfiguration.cs @@ -0,0 +1,25 @@ +using Proton.Sdk.Caching; +using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; + +namespace Proton.Sdk; + +internal sealed class ProtonClientConfiguration(string appVersion, ProtonClientOptions? options = null) +{ + public Uri BaseUrl { get; } = options?.BaseUrl ?? ProtonApiDefaults.BaseUrl; + public string AppVersion { get; } = appVersion; + public string UserAgent { get; } = options?.UserAgent ?? string.Empty; + + public ProtonClientTlsPolicy TlsPolicy { get; } = + options?.TlsPolicy is { } tlsPolicy && Enum.IsDefined(tlsPolicy) + ? tlsPolicy + : ProtonClientTlsPolicy.Strict; + + public Func? CustomHttpMessageHandlerFactory { get; } = options?.CustomHttpMessageHandlerFactory; + public ICacheRepository SecretCacheRepository { get; } = options?.SecretCacheRepository ?? 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; + public string? BindingsLanguage { get; } = options?.BindingsLanguage; +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs new file mode 100644 index 00000000..2ff3779c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientConfigurationExtensions.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Proton.Sdk.Authentication; +using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; + +namespace Proton.Sdk; + +internal static class ProtonClientConfigurationExtensions +{ + private static readonly CookieContainer CookieContainer = new(); + + public static HttpClient GetHttpClient( + this ProtonClientConfiguration config, + ProtonApiSession? session = null, + string? baseRoutePath = null, + TimeSpan? attemptTimeout = null, + TimeSpan? totalTimeout = null) + { + var baseAddress = config.BaseUrl + (baseRoutePath ?? string.Empty); + + var services = new ServiceCollection(); + + services.AddSingleton(config.Telemetry.ToLoggerFactory()); + + services.ConfigureHttpClientDefaults( + builder => + { + builder.RedactLoggedHeaders(header => header.StartsWith("Auth")); + + builder.UseSocketsHttpHandler( + (handler, _) => + { + handler.PooledConnectionLifetime = TimeSpan.FromMinutes(2); + + handler.AddAutomaticDecompression(); + handler.ConfigureCookies(CookieContainer); + + switch (config.TlsPolicy) + { + case ProtonClientTlsPolicy.Strict: + handler.AddTlsPinning(); + break; + + case ProtonClientTlsPolicy.NoCertificateValidation: +#pragma warning disable S4830 // Certificates are intentionally not verified + handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true; +#pragma warning restore S4830 + break; + } + }); + + builder.SetHandlerLifetime(Timeout.InfiniteTimeSpan); + + if (config.CustomHttpMessageHandlerFactory is not null) + { + builder.AddHttpMessageHandler(() => config.CustomHttpMessageHandlerFactory.Invoke()); + } + +#if DEBUG + builder.AddHttpMessageHandler(() => new HttpBodyLoggingHandler(config.Telemetry.GetLogger())); +#endif + + builder.AddHttpMessageHandler(() => new CryptographyTimeProvisionHandler()); + + builder.AddStandardResilienceHandler( + options => + { + if (attemptTimeout is not null) + { + options.AttemptTimeout.Timeout = attemptTimeout.Value; + options.CircuitBreaker.SamplingDuration = options.AttemptTimeout.Timeout * 2; + } + + if (totalTimeout is not null) + { + options.TotalRequestTimeout.Timeout = totalTimeout.Value; + } + + var defaultShouldHandleRetryPredicate = options.Retry.ShouldHandle; + + 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); + options.Retry.BackoffType = DelayBackoffType.Exponential; + options.Retry.UseJitter = false; + options.Retry.MaxRetryAttempts = 4; + + options.CircuitBreaker.FailureRatio = 0.5; + }); + + if (session is not null) + { + builder.AddHttpMessageHandler(() => new AuthorizationHandler(session)); + } + + builder.ConfigureHttpClient( + httpClient => + { + var executingAssembly = Assembly.GetExecutingAssembly(); + var versionAttribute = executingAssembly.GetCustomAttribute(); + var sdkVersion = versionAttribute?.InformationalVersion + ?? executingAssembly.GetName().Version?.ToString(fieldCount: 3) + ?? "0.0.0"; + + var bindingsSuffix = config.BindingsLanguage is not null + ? "-" + config.BindingsLanguage.ToLowerInvariant() + : string.Empty; + + var sdkTechnicalStack = "dotnet" + bindingsSuffix; + + httpClient.BaseAddress = new Uri(baseAddress); + httpClient.DefaultRequestHeaders.Add("x-pm-appversion", config.AppVersion); + httpClient.DefaultRequestHeaders.Add("x-pm-drive-sdk-version", $"{sdkTechnicalStack}@{sdkVersion}"); + + if (!string.IsNullOrEmpty(config.UserAgent)) + { + httpClient.DefaultRequestHeaders.Add("User-Agent", config.UserAgent); + } + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } +} diff --git a/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs new file mode 100644 index 00000000..4d975f78 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonClientOptions.cs @@ -0,0 +1,20 @@ +using Proton.Sdk.Caching; +using Proton.Sdk.Http; +using Proton.Sdk.Telemetry; + +namespace Proton.Sdk; + +public record ProtonClientOptions +{ + public Uri? BaseUrl { get; set; } + 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 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/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs b/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs new file mode 100644 index 00000000..7de91ac6 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ProtonSessionOptions.cs @@ -0,0 +1,12 @@ +using Proton.Sdk.Caching; + +namespace Proton.Sdk; + +public sealed record ProtonSessionOptions : ProtonClientOptions +{ + public new ICacheRepository? SecretCacheRepository + { + get => base.SecretCacheRepository; + set => base.SecretCacheRepository = value; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Result.cs b/cs/sdk/src/Proton.Sdk/Result.cs new file mode 100644 index 00000000..04e40f86 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Result.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +public readonly struct Result +{ + private readonly ResultStatus _status; + private readonly T? _value; + private readonly TError? _error; + + public Result(T value) + { + _status = ResultStatus.Success; + _value = value; + _error = default; + } + + public Result(TError error) + { + _status = ResultStatus.Failure; + _error = error; + _value = default; + } + + 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); + + 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([NotNullWhen(true)] out T? value, [NotNullWhen(false)] out TError? error) + { + value = _value; + error = _error; + return IsSuccess; + } +} diff --git a/cs/sdk/src/Proton.Sdk/ResultExtensions.cs b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs new file mode 100644 index 00000000..a03695ad --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/ResultExtensions.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Proton.Sdk; + +public static class ResultExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? GetValueOrDefault(this Result result) + { + return result.TryGetValueElseError(out var value, out _) ? value : default; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrDefault(this Result result, T defaultValue) + { + return result.TryGetValueElseError(out var value, out _) ? value : defaultValue; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T GetValueOrThrow(this Result result) + { + return result.TryGetValueElseError(out var value, out _) ? value : throw new InvalidOperationException("Cannot get value from failed result"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetValue(this Result result, [MaybeNullWhen(false)] out T value) + { + return result.TryGetValueElseError(out value, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TError? GetErrorOrDefault(this Result result, TError? defaultError = null) + where TError : class? + { + return result.TryGetValueElseError(out _, out var error) ? defaultError : error; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetError(this Result result, [NotNullWhen(true)] out TError? error) + { + return !result.TryGetValueElseError(out _, out error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TMerged Merge( + this Result result, + Func convertValue, + Func convertError) + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Result Convert( + this Result result, + Func convertValue, + Func convertError) + { + return result.TryGetValueElseError(out var value, out var error) ? convertValue.Invoke(value) : convertError.Invoke(error); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Result{TError}.cs b/cs/sdk/src/Proton.Sdk/Result{TError}.cs new file mode 100644 index 00000000..8b66951c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Result{TError}.cs @@ -0,0 +1,51 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Proton.Sdk; + +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) + { + _status = ResultStatus.Failure; + _error = error; + } + + 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(TError error) => new(error); + + public static Result Failure(TError error) + { + return new Result(error); + } + + public bool TryGetError([MaybeNullWhen(false)] out TError error) + { + error = _error; + return IsFailure; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs new file mode 100644 index 00000000..e0dba82f --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/AccountEntitiesSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Addresses; + +namespace Proton.Sdk.Serialization; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, RespectRequiredConstructorParameters = true)] +[JsonSerializable(typeof(Address))] +internal sealed partial class AccountEntitiesSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs new file mode 100644 index 00000000..9079629e --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/BooleanToIntegerJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class BooleanToIntegerJsonConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var number = reader.GetInt32(); + return number != 0; + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value ? 1 : 0); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs new file mode 100644 index 00000000..96188182 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/EpochSecondsJsonConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class EpochSecondsJsonConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var number = reader.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(number).DateTime; + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteNumberValue(new DateTimeOffset(value).ToUnixTimeSeconds()); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs new file mode 100644 index 00000000..29a06f40 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ForgivingBytesToHexJsonConverter.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class ForgivingBytesToHexJsonConverter : JsonConverter> +{ + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null || reader.GetValueLength() is not (var valueLength and > 0)) + { + return ReadOnlyMemory.Empty; + } + + try + { + if (reader.HasUnescapedValueSpan) + { + 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; + } + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + if (value.Length == 0) + { + writer.WriteNullValue(); + return; + } + + var maxByteCount = value.Length * 2; + + var hexStringBuffer = MemoryPolicy.IsTooLargeForStack(maxByteCount) ? new byte[maxByteCount] : stackalloc byte[maxByteCount]; + + if (!Convert.TryToHexStringLower(value.Span, hexStringBuffer, out var hexStringLength)) + { + throw new JsonException("Could not convert to hex string"); + } + + writer.WriteStringValue(hexStringBuffer[..hexStringLength]); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs new file mode 100644 index 00000000..3a61f547 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/IStrongId.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Serialization; + +internal interface IStrongId + where T : IStrongId +{ + public static virtual implicit operator string(T id) => id.ToString(); + public static abstract explicit operator T(string? value); + + public string ToString(); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs new file mode 100644 index 00000000..0b7e4344 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredBlockJsonConverterBase.cs @@ -0,0 +1,65 @@ +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 + where T : IPgpArmoredBlock +{ + protected abstract PgpBlockType BlockType { get; } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException( + $"Unexpected token type '{reader.TokenType}' when converting to {typeof(T).Name}, expected '{nameof(JsonTokenType.String)}'"); + } + + if (reader.HasUnescapedValueSpan) + { + return Decode(reader.ValueSpan); + } + + var unescapedValueBuffer = ArrayPool.Shared.Rent(reader.GetValueLength()); + + try + { + var unescapedValueLength = reader.CopyString(unescapedValueBuffer); + + return Decode(unescapedValueBuffer.AsSpan()[..unescapedValueLength]); + } + finally + { + ArrayPool.Shared.Return(unescapedValueBuffer); + } + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var buffer = ArrayPool.Shared.Rent(PgpArmorEncoder.GetMaxLengthAfterEncoding(value.Bytes.Length)); + + try + { + var numberOfBytesWritten = PgpArmorEncoder.Encode(value, BlockType, buffer); + + writer.WriteStringValue(buffer.AsSpan()[..numberOfBytesWritten]); + } + finally + { + 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 new file mode 100644 index 00000000..b7ed872d --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredMessageJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredMessageJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.Message; + + protected override PgpArmoredMessage CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs new file mode 100644 index 00000000..69e8c5d7 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPrivateKeyJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredPrivateKeyJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.PrivateKey; + + protected override PgpArmoredPrivateKey CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs new file mode 100644 index 00000000..fce2f0cb --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredPublicKeyJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredPublicKeyJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.PublicKey; + + protected override PgpArmoredPublicKey CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs new file mode 100644 index 00000000..29783749 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpArmoredSignatureJsonConverter.cs @@ -0,0 +1,11 @@ +using Proton.Cryptography.Pgp; +using Proton.Sdk.Cryptography; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpArmoredSignatureJsonConverter : PgpArmoredBlockJsonConverterBase +{ + protected override PgpBlockType BlockType => PgpBlockType.Signature; + + protected override PgpArmoredSignature CreateValue(ReadOnlyMemory bytes) => new(bytes); +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs new file mode 100644 index 00000000..2da2d7ad --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/PgpPrivateKeyJsonConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +namespace Proton.Sdk.Serialization; + +internal sealed class PgpPrivateKeyJsonConverter : JsonConverter +{ + public override PgpPrivateKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var bytes = reader.GetBytesFromBase64(); + + return PgpPrivateKey.Import(bytes); + } + + public override void Write(Utf8JsonWriter writer, PgpPrivateKey value, JsonSerializerOptions options) + { + writer.WriteBase64StringValue(value.ToBytes()); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs new file mode 100644 index 00000000..3fc84359 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ProtonApiSerializerContext.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +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; + +namespace Proton.Sdk.Serialization; + +#pragma warning disable SA1114, SA1118 // Disable style analysis warnings due to attribute spanning multiple lines +[JsonSourceGenerationOptions( +#if DEBUG + WriteIndented = true, + RespectRequiredConstructorParameters = true, +#endif + Converters = + [ + typeof(PgpArmoredMessageJsonConverter), + typeof(PgpArmoredSignatureJsonConverter), + typeof(PgpArmoredPrivateKeyJsonConverter), + typeof(PgpArmoredPublicKeyJsonConverter), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(SessionInitiationRequest))] +[JsonSerializable(typeof(SessionInitiationResponse))] +[JsonSerializable(typeof(AuthenticationRequest))] +[JsonSerializable(typeof(AuthenticationResponse))] +[JsonSerializable(typeof(SecondFactorValidationRequest))] +[JsonSerializable(typeof(ScopesResponse))] +[JsonSerializable(typeof(SessionRefreshRequest))] +[JsonSerializable(typeof(SessionRefreshResponse))] +[JsonSerializable(typeof(UserResponse))] +[JsonSerializable(typeof(AddressListResponse))] +[JsonSerializable(typeof(AddressResponse))] +[JsonSerializable(typeof(AddressPublicKeyListResponse))] +[JsonSerializable(typeof(ModulusResponse))] +[JsonSerializable(typeof(KeySaltListResponse))] +[JsonSerializable(typeof(LatestEventResponse))] +[JsonSerializable(typeof(EventListResponse))] +internal sealed partial class ProtonApiSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs new file mode 100644 index 00000000..cdac349c --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/RefResultJsonConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Serialization; + +internal sealed class RefResultJsonConverter : JsonConverter> + where T : class? + where TError : class? +{ + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dto = JsonSerializer.Deserialize( + ref reader, + (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableRefResult))); + + Result? result; + if (dto.IsSuccess) + { + result = dto.Value ?? throw new JsonException("Missing \"Value\" property for success result."); + } + else + { + result = dto.Error ?? throw new JsonException("Missing \"Error\" property for failure result."); + } + + return result.Value; + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + var dto = value.TryGetValueElseError(out var innerValue, out var error) + ? new SerializableRefResult { IsSuccess = true, Value = innerValue } + : new SerializableRefResult { Error = error }; + + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableRefResult)); + JsonSerializer.Serialize(writer, dto, jsonTypeInfo); + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs new file mode 100644 index 00000000..517705f1 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SecretsSerializerContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using Proton.Cryptography.Pgp; + +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), + ])] +#pragma warning restore SA1114, SA1118 +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(PgpPrivateKey[]))] +internal sealed partial class SecretsSerializerContext : JsonSerializerContext; diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs new file mode 100644 index 00000000..761f3cfd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableRefResult.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal struct SerializableRefResult + where T : class? + where TError : class? +{ + public bool IsSuccess { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public T? Value { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TError? Error { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs b/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs new file mode 100644 index 00000000..a6d7f2bd --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/SerializableValResult.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal struct SerializableValResult + where T : struct + where TError : class? +{ + public bool IsSuccess { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public T? Value { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TError? Error { get; set; } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs new file mode 100644 index 00000000..ffca0b91 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/StrongIdJsonConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Proton.Sdk.Serialization; + +internal sealed class StrongIdJsonConverter : JsonConverter + where T : struct, IStrongId +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return (T)value; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} 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; + } + } +} diff --git a/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs new file mode 100644 index 00000000..4f2df02a --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Serialization/ValResultJsonConverter.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Proton.Sdk.Serialization; + +internal sealed class ValResultJsonConverter : JsonConverter> + where T : struct + where TError : class? +{ + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dto = JsonSerializer.Deserialize( + ref reader, + (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult))); + + Result? result; + if (dto.IsSuccess) + { + if (dto.Value is null) + { + throw new JsonException("Missing \"Value\" property for success result."); + } + + result = dto.Value; + } + else + { + result = dto.Error ?? throw new JsonException("Missing \"Error\" property for failure result."); + } + + return result.Value; + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + var dto = value.TryGetValueElseError(out var innerValue, out var error) + ? new SerializableValResult { IsSuccess = true, Value = innerValue } + : new SerializableValResult { Error = error }; + + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(SerializableValResult)); + JsonSerializer.Serialize(writer, dto, jsonTypeInfo); + } +} 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..4ec1dbd5 --- /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; + +public 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/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; + } +} 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; } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs b/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs new file mode 100644 index 00000000..a4997bee --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/DelinquentState.cs @@ -0,0 +1,10 @@ +namespace Proton.Sdk.Users; + +public enum DelinquentState +{ + Paid = 0, + Available = 1, + Overdue = 2, + Delinquent = 3, + NotReceived = 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Users/Services.cs b/cs/sdk/src/Proton.Sdk/Users/Services.cs new file mode 100644 index 00000000..48bfe599 --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/Services.cs @@ -0,0 +1,9 @@ +namespace Proton.Sdk.Users; + +[Flags] +internal enum Services +{ + None = 0, + Mail = 1, + Vpn = 4, +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserId.cs b/cs/sdk/src/Proton.Sdk/Users/UserId.cs new file mode 100644 index 00000000..f7a014ac --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct UserId : IStrongId +{ + private readonly string? _value; + + internal UserId(string? value) + { + _value = value; + } + + public static explicit operator UserId(string? value) + { + return new UserId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs new file mode 100644 index 00000000..eb35fd6b --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserKeyId.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Proton.Sdk.Serialization; + +namespace Proton.Sdk.Users; + +[JsonConverter(typeof(StrongIdJsonConverter))] +public readonly record struct UserKeyId : IStrongId +{ + private readonly string? _value; + + internal UserKeyId(string? value) + { + _value = value; + } + + public static explicit operator UserKeyId(string? value) + { + return new UserKeyId(value); + } + + public override string ToString() + { + return _value ?? string.Empty; + } +} diff --git a/cs/sdk/src/Proton.Sdk/Users/UserType.cs b/cs/sdk/src/Proton.Sdk/Users/UserType.cs new file mode 100644 index 00000000..83fc3cce --- /dev/null +++ b/cs/sdk/src/Proton.Sdk/Users/UserType.cs @@ -0,0 +1,8 @@ +namespace Proton.Sdk.Users; + +public enum UserType +{ + Proton = 1, + Managed = 2, + External = 3, +} 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..1b31f041 --- /dev/null +++ b/cs/sdk/src/protos/proton.drive.sdk.proto @@ -0,0 +1,879 @@ +edition = "2023"; +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 { + 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; + DriveClientGetAvailableNameRequest drive_client_get_available_name = 1006; + 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; + DriveClientGetNodeRequest drive_client_get_node = 1017; + + UploadFromStreamRequest upload_from_stream = 1100; + UploadFromFileRequest upload_from_file = 1101; + FileUploaderFreeRequest file_uploader_free = 1102; + UploadControllerIsPausedRequest upload_controller_is_paused = 1103; + UploadControllerAwaitCompletionRequest upload_controller_await_completion = 1104; + UploadControllerPauseRequest upload_controller_pause = 1105; + UploadControllerResumeRequest upload_controller_resume = 1106; + UploadControllerDisposeRequest upload_controller_dispose = 1107; + UploadControllerFreeRequest upload_controller_free = 1108; + + DownloadToStreamRequest download_to_stream = 1200; + DownloadToFileRequest download_to_file = 1201; + FileDownloaderFreeRequest file_downloader_free = 1202; + DownloadControllerIsPausedRequest download_controller_is_paused = 1203; + 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; + + DrivePhotosClientCreateRequest drive_photos_client_create = 1300; + DrivePhotosClientCreateFromSessionRequest drive_photos_client_create_from_session = 1301; + DrivePhotosClientFreeRequest drive_photos_client_free = 1302; + 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; + 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; + }; +} + +// Account client interface + +message AccountRequest { + oneof payload { + 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 must be an Address. +message GetAddressRequest { + string address_id = 1; +} + +// The response value must be an Address. +message GetDefaultAddressRequest { +} + +// The response value must be a BytesValue. +message GetAddressPrimaryPrivateKeyRequest { + string address_id = 1; +} + +// The response value must be a RepeatedBytesValue. +message GetAddressPrivateKeysRequest { + string address_id = 1; +} + +// The response value must be a RepeatedBytesValue. +message GetAddressPublicKeysRequest { + string email_address = 1; +} + +// 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; + int64 size_on_cloud_storage = 3; + 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 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; + 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; + 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 { + Author claimed_author = 1; // optional - preserves error context + string message = 2; // optional +} + +message AuthorResult { + oneof result { + Author value = 1; + SignatureVerificationError error = 2; + } +} + +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; // optional +} + +message Thumbnail { + ThumbnailType type = 1; + int64 data_pointer = 2; + int64 data_length = 3; +} + +message FileThumbnail { + string file_uid = 1; + oneof result { + bytes data = 2; + DriveError error = 3; + } +} + +message FileThumbnailList { + repeated FileThumbnail thumbnails = 1; +} + +message UploadResult { + string node_uid = 1; + string revision_uid = 2; +} + +message HttpClient { + // Pointer to C function that will be called: + // 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 + // 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); +} + +message ProtonDriveClientOptions { + // Client UID, optional + // If a null value is provided, the SDK automatically generates a UUID during initialization + 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. +message DriveClientCreateRequest { + string base_url = 1; + 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 = 4; + + 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 = 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 +} + +// The response value must be an Int64Value carrying a handle to an instance of ProtonDriveClient. +message DriveClientCreateFromSessionRequest { + int64 session_handle = 1; +} + +// The response must not have a value. +message DriveClientFreeRequest { + int64 client_handle = 1; +} + +// 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 (or 0 if no_waiting is true and no slot was free).. +message DriveClientGetFileUploaderRequest { + int64 client_handle = 1; + string parent_folder_uid = 2; + string name = 3; + string mediaType = 4; + int64 size = 5; + 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). + // 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 (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; // 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). + 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. +message UploadFromStreamRequest { + 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; + 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: 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. +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; + 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. +message FileUploaderFreeRequest { + int64 file_uploader_handle = 1; +} + +// The response 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; +} + +// The response must not have a value. +message UploadControllerPauseRequest { + int64 upload_controller_handle = 1; +} + +// The response must not have a value. +message UploadControllerResumeRequest { + int64 upload_controller_handle = 1; +} + +// The response must not have a value. +message UploadControllerDisposeRequest { + int64 upload_controller_handle = 1; +} + +// The response must not have a value. +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; +} + +// The response must not have a value. +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 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 NodeResultListResponse +message DriveClientTrashNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +message NodeResultPair { + string node_uid = 1; + proton.sdk.Error error = 2; +} + +message NodeResultListResponse { + repeated NodeResultPair results = 1; +} + +// The response message must be of type NodeResultListResponse +message DriveClientDeleteNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response message must be of type NodeResultListResponse +message DriveClientRestoreNodesRequest { + int64 client_handle = 1; + repeated string node_uids = 2; + int64 cancellation_token_source_handle = 3; +} + +// The response must not have a value, yield_action will be called for each item. +message DriveClientEnumerateTrashRequest { + int64 client_handle = 1; + int64 yield_action = 2; + int64 cancellation_token_source_handle = 3; +} + +message TrashedNodeList { + repeated Node nodes = 1; +} + +// The response must not have a value. +message DriveClientEmptyTrashRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + +// 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 yield_action = 4; + int64 cancellation_token_source_handle = 5; +} + +// 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 yield_action = 3; + int64 cancellation_token_source_handle = 4; +} + +// The response message must be of type FolderNode. +message DriveClientGetMyFilesFolderRequest { + int64 client_handle = 1; + int64 cancellation_token_source_handle = 2; +} + +// 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 Node { + oneof node { + FolderNode folder = 1; + FileNode file = 2; + } +} + +message DriveError { + string message = 1; // optional + DriveError inner_error = 2; // optional +} + +message StringResult { + oneof result { + string value = 1; + DriveError error = 2; + } +} + +message FileContentDigests { + bytes sha1 = 1; // optional + bool sha1_verified = 2; +} + +message ThumbnailHeader { + string id = 1; + ThumbnailType type = 2; +} + +// 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). +message DriveClientGetFileDownloaderRequest { + int64 client_handle = 1; + string revision_uid = 2; + // 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. +message DownloadToStreamRequest { + 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; + 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. +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; +} + +// The response must not have a value. +message FileDownloaderFreeRequest { + int64 file_downloader_handle = 1; +} + +// The response message must be of type BoolValue. +message DownloadControllerIsPausedRequest { + int64 download_controller_handle = 1; +} + +// 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 response must not have a value. +message DownloadControllerPauseRequest { + int64 download_controller_handle = 1; +} + +// The response must not have a value. +message DownloadControllerResumeRequest { + int64 download_controller_handle = 1; +} + +// The response must not have a value. +message DownloadControllerFreeRequest { + int64 download_controller_handle = 1; +} + +// Drive - Photos client + +message DrivePhotosClientCreateRequest { + string base_url = 1; + 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 = 4; + + 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 = 8; // 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 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 yield_action = 4; + int64 cancellation_token_source_handle = 5; +} + +// The response must not have a value, yield_action will be called for each item. +message DrivePhotosClientEnumerateTimelineRequest { + int64 client_handle = 1; + int64 yield_action = 2; + int64 cancellation_token_source_handle = 3; +} + +// 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 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 PhotosFileDownloader (or 0 if no_waiting is true and no slot was free).. +message DrivePhotosClientGetPhotoDownloaderRequest { + int64 client_handle = 1; + string photo_uid = 2; + // 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; +} + +// Photos file 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; + 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. +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; +} + +// 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; + string media_type = 3; + int64 size = 4; + PhotosFileUploadMetadata metadata = 5; + bool override_existing_draft_by_other_client = 6; + // 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 { + 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_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; +} + +// 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; + 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. +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; + 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. +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 must not have a value, yield_action will be called for each item. +message DrivePhotosClientEnumerateTrashRequest { + int64 client_handle = 1; + int64 yield_action = 3; + int64 cancellation_token_source_handle = 2; +} + +// 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; + VOLUME_TYPE_OWN_VOLUME = 1; + VOLUME_TYPE_OWN_PHOTO_VOLUME = 2; + VOLUME_TYPE_SHARED = 3; + VOLUME_TYPE_SHARED_PUBLIC = 4; +} + +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; + DOWNLOAD_ERROR_VALIDATION_ERROR = 7; +} + +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; + UPLOAD_ERROR_VALIDATION_ERROR = 6; +} + +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 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; + 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 { + 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 { + VolumeType volume_type = 1; + bool retry_helped = 2; +} + +message NodeNameConflictErrorData { + bool conflicting_node_is_file_draft = 1; + 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; +} + +message NodeNotFoundErrorData { + string node_uid = 1; +} diff --git a/cs/sdk/src/protos/proton.sdk.proto b/cs/sdk/src/protos/proton.sdk.proto new file mode 100644 index 00000000..56d13642 --- /dev/null +++ b/cs/sdk/src/protos/proton.sdk.proto @@ -0,0 +1,261 @@ +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 cancellation_token_source_create = 100; + CancellationTokenSourceCancelRequest cancellation_token_source_cancel = 101; + CancellationTokenSourceFreeRequest cancellation_token_source_free = 102; + + 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; + }; +} + +message Response { + oneof result { // Optional, if no return value and no error + google.protobuf.Any value = 1; + Error error = 2; + } +} + +message RepeatedBytesValue { + repeated bytes value = 1; +} + +// Cancellation tokens + +// The response value must be an Int64Value carrying a handle to an instance of CancellationTokenSource. +message CancellationTokenSourceCreateRequest {} + +// The reponse must not have a value. +message CancellationTokenSourceCancelRequest { + int64 cancellation_token_source_handle = 1; +} + +// The reponse must not have a value. +message CancellationTokenSourceFreeRequest { + int64 cancellation_token_source_handle = 1; +} + +// Sessions + +// The response value type must be Int64Value. +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 type must be Int64Value. +message SessionResumeRequest { + string username = 1; + string app_version = 2; + string session_id = 3; + string 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 type must be Int64Value. +message SessionRenewRequest { + int64 old_session_handle = 1; + string 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; +} + +// The reponse must not have a value. +message SessionEndRequest { + int64 session_handle = 1; +} + +// 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; +} + +// The reponse must not have a value. +message SessionTokensRefreshedUnsubscribeRequest { + int64 subscription_handle = 1; +} + +// The reponse must not have a value. +message SessionFreeRequest { + int64 session_handle = 1; +} + +// The response value must be an Int64Value carrying a handle to an instance of ILoggerProvider. +message LoggerProviderCreate { + int64 log_action = 1; +} + +message LogEvent { + int32 level = 1; + string message = 2; + 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; + 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 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 + Telemetry telemetry = 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; + BusinessLogic = 8; +} + +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 + google.protobuf.Any additional_data = 8; // Optional +} + +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 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; +} + +// 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; +} + +// 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; + HTTP_REQUEST_TYPE_STORAGE_UPLOAD = 2; +} + +message HttpHeader { + string name = 1; + repeated string values = 2; +} + +// The response value must be an HttpResponse. +message HttpRequest { + 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 { + int32 status_code = 1; + repeated HttpHeader headers = 2; + int64 bindings_content_handle = 3; // Optional +} + +message ApiRetrySucceededEventPayload { + string url = 1; + int32 failed_attempts = 2; +} diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md new file mode 100644 index 00000000..ed7f6acd --- /dev/null +++ b/js/CHANGELOG.md @@ -0,0 +1,411 @@ +# 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 +* Remove slash validation name after decryption + +## 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 + +## js/v0.14.9 (2026-04-27) + +* Expose savePhotosToTimeline + +## 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 +* Fix verifying signature contexts + +## 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 + +## 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 +* 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 + +## 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 +* 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 +* 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 +* 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 +* 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 +* 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 +* 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 + +## js/v0.9.7 (2026-02-05) + +* [DRVWEB-5135] Add empty trash for photo volume + +## js/v0.9.6 (2026-02-02) + +* Add experimental createDocument to create Docs/Sheets +* Add function to create bookmark + +## js/v0.9.5 (2026-01-29) + +* Remove check of NodeType inside iterateThumbnails +* Fix file with content check for diagnostics + +## js/v0.9.4 (2026-01-22) + +* 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 + +## js/v0.9.3 (2026-01-16) + +* Fix invitation node type +* Upgrade CryptoProxy and SRP + +## js/v0.9.2 (2026-01-13) + +* Fix typing of CryptoProxy and CLI +* Add tree structure to diagnostics +* Multiple public fixes + +## js/v0.9.1 (2026-01-07) + +* Handle timeouts during uploads +* Fix buffered seekable stream +* Catch TypeError when calling releaseLock + +## js/v0.9.0 (2025-12-17) + +* Allow download with signature issues +* Add empty-trash Implementation +* Handle failed upload due to double-commit attempt + +## js/v0.8.0 (2025-12-15) + +* 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) + +* Create findPhotoDuplicates to get uids of duplicates + +## js/v0.7.2 (2025-12-11) + +* Fix photo node type +* Add getMyPhotosRootFolder + +## js/v0.7.1 (2025-12-08) + +* Photos entity to support full decryption and access to photo attributes +* Add onMessage to ProtonDrivePublicLinkClient +* Add modification time to the node entity +* Add new name param to copy + +## js/v0.7.0 (2025-11-28) + +* 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) + +* 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) + +* 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) + +* Parametrize shared with me and invitations for Photos SDK +* Expose sharing for Photos SDK +* Add getAvailableName method + +## js/v0.5.1 (2025-10-22) + +* Add expectedStrcuture options for diagnostics +* Convert revisions to public interface +* Update public access to new APIs +* Return new UID of copied node +* Throw NodeWithSameNameExists from createFolder +* 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) + +* Do not send cleartext file size +* Add propagating offline error to SDK events +* fileUpload completion should return nodeUid and nodeRevisionUid +* 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 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) + +* Add isSharedPublicly to node based on ShareURLID +* Implement CLI photo download +* Implement photo upload + +## js/v0.4.0 (2025-09-22) + +* 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) + +* 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) + +* 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) + +* 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 +* Add node details to diagnostic results + +## js/v0.2.1 (2025-08-20) + +* Separate custom password from bookmark url +* Fix parsing claimedModificationTime in NodesCache +* Invalid value code is ValidationError + +## js/v0.2.0 (2025-08-14) + +* Add node membership +* Update telemetry object +* Fix download +* Add download unit tests +* Add seeking support for download + +## js/v0.1.2 (2025-08-04) + +* Fix event subscriptions +* Fix invalidating cache after upload + +## js/v0.1.1 (2025-08-01) + +* 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: +* 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 + +## js/v0.0.13 (2025-07-18) + +* 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 +* add existingNodeUid on NodeAlreadyExistsValidationError + +## js/v0.0.12 (2025-07-10) + +* No changes + +## js/v0.0.11 (2025-07-10) + +* Remove sensitive info from logs +* Implement bookmarks management +* 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 +* Use ExpirationTime instead of ExpirationDuration for public link management +* Align error categories for upload/download telemetry with definitions +* Add missing re-export of the interface + +## 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) + +* Add resend invite implementation +* implement getNodeUid +* Update decryption telemetry according to documentation +* L10N-4186 Add test/extract job ttag +* Create type structure for keys + +## js/v0.0.8 (2025-06-19) + +* use nodeUid for external invite instead of volumeId +* 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) + +* add getNode method +* add block verification telemetry +* configuration for npm package publishing +* add experimental getDocsKey +* reuse array buffer +* fix getting address key +* handle missing public address + +## js/v0.0.4 (2025-06-02) + +* add getNode method +* add block verification telemetry +* configuration for npm package publishing +* add experimental getDocsKey +* reuse array buffer +* fix getting address key +* handle missing public address diff --git a/js/sdk/.eslintrc.js b/js/sdk/.eslintrc.js new file mode 100644 index 00000000..9863f4ec --- /dev/null +++ b/js/sdk/.eslintrc.js @@ -0,0 +1,54 @@ +module.exports = { + extends: [ + 'plugin:@typescript-eslint/recommended' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + ecmaVersion: 2018, + 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: [ + { + files: [ + "*.test.ts", + ], + rules: { + // Any is used during prototyping - remove once all the types are available to fix all the places. + "@typescript-eslint/no-explicit-any": "off", + // Many variables are unused during prototyping - remove later once more modules are implemented. + "@typescript-eslint/no-unused-vars": "off", + }, + }, + ], + plugins: [ + "@typescript-eslint/eslint-plugin", + "eslint-plugin-tsdoc", + "simple-import-sort", + ] +}; diff --git a/js/sdk/README.md b/js/sdk/README.md new file mode 100644 index 00000000..1d9cbb31 --- /dev/null +++ b/js/sdk/README.md @@ -0,0 +1,22 @@ +# Drive SDK for web + +Use only what is exported by the library. This is the public supported API of the SDK. Anything else is internal implementation that can change without warning. + +Start by creating instance of the `ProtonDriveClient`. That instance has then available many methods to access nodes, devices, upload and download content, or manage sharing. + +```js +import { ProtonDriveClient, MemoryCache, OpenPGPCryptoWithCryptoProxy } from 'proton-drive-sdk'; + +const sdk = new ProtonDriveClient({ + httpClient, + entitiesCache: new MemoryCache(), + cryptoCache: new MemoryCache(), + account, + openPGPCryptoModule: new OpenPGPCryptoWithCryptoProxy(cryptoProxy), +}); +``` + +### 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 new file mode 100644 index 00000000..076f15c0 --- /dev/null +++ b/js/sdk/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + moduleDirectories: ['/node_modules', 'node_modules'], + testPathIgnorePatterns: ['/dist'], + collectCoverage: false, + transformIgnorePatterns: [], + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, + moduleNameMapper: {}, + reporters: ['default'], + setupFiles: ['/src/polyfill.ts'] +}; diff --git a/js/sdk/locales/.locale-state.metadata b/js/sdk/locales/.locale-state.metadata new file mode 100644 index 00000000..121e7a33 --- /dev/null +++ b/js/sdk/locales/.locale-state.metadata @@ -0,0 +1,4 @@ +{ + "project": "fe-drive-sdk", + "locale": "a3571884f94608b8d67f20fee0002b27fd706a7f" +} \ 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..702f07af --- /dev/null +++ b/js/sdk/locales/be_BY.json @@ -0,0 +1,278 @@ +{ + "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": { + "Bookmark password is not available": [ + "Пароль закладкі недаступны" + ], + "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": [ + "Аперацыя капіявання перарвана" + ], + "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": [ + "Стварэнне файлаў у асяроддзі, якое не належыць папкам забаронена" + ], + "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 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 }" + ], + "Failed to decrypt item name: ${ message }": [ + "Не ўдалося расшыфраваць назву элемента: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Не ўдалося расшыфраваць ключ вузла: ${ message }" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Не ўдалося расшыфраваць мініяцюру: ${ message }" + ], + "Failed to get inviter keys": [ + "Не ўдалося атрымаць ключы запрашальніка" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Не ўдалося атрымаць звесткі аб абагульванні для вузла ${ nodeUid }" + ], + "Failed to get verification keys": [ + "Не ўдалося атрымаць ключы праверкі" + ], + "Failed to load some items": [ + "Не ўдалося загрузіць некаторыя элементы" + ], + "Failed to load some nodes": [ + "Не ўдалося загрузіць некаторыя вузлы" + ], + "Failed to verify invitation": [ + "Не ўдалося спраўдзіць запрашэнне" + ], + "File download failed due to empty response": [ + "Спампоўка файла не атрымалася па прычыне пустога адказу" + ], + "File has no active revision": [ + "У файла адсутнічае актыўная рэдакцыя" + ], + "File has no content key": [ + "Файл не мае ключа змесціва" + ], + "File has no revision": [ + "У файла адсутнічае рэдакцыя" + ], + "File hash does not match expected hash": [ + "Хэш файла не супадае з чаканым хэшам" + ], + "Invalid URL": [ + "Памылковы URL-адрас" + ], + "Invitation not found": [ + "Запрашэнне не знойдзена" + ], + "Item cannot be decrypted": [ + "Немагчыма расшыфраваць элемент" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Немагчыма абнавіць старую публічную спасылку. Стварыце новую публічную спасылку." + ], + "Missing integrity signature": [ + "Адсутнічае подпіс цэласнасці" + ], + "Missing inviter email": [ + "Адсутнічае адрас электроннай пошты запрашальніка" + ], + "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": [ + "Назва не можа быць пустой" + ], + "No available name found": [ + "Даступная назва не знойдзена" + ], + "Node has no thumbnail": [ + "У вузла адсутнічае мініяцюра" + ], + "Node is not accessible": [ + "Вузел недаступны" + ], + "Node is not shared": [ + "Вузел не абагулены" + ], + "Node not found": [ + "Вузел не знойдзены" + ], + "Only admins can convert non-Proton invitations": [ + "Толькі адміністратары могуць пераўтвараць запрашэнні, якія не належаць Proton" + ], + "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": [ + "Перайменаванне каранёвага элемента забаронена" + ], + "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": [ + "Збой запампоўвання некаторых байтаў файла" + ], + "Some file parts failed to upload": [ + "Збой запампоўвання некаторых частак файла" + ], + "The node is not shared anymore": [ + "Вузел больш не абагулены" + ], + "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": [ + "Вы можаце пакінуць толькі элемент, які абагулены з вамі" + ] + }, + "Info": { + "Author is not provided on public link": [ + "Аўтар не пазначаны ў публічнай спасылцы" + ] + }, + "Property": { + "attributes": [ + "атрыбуты" + ], + "content key": [ + "ключ змесціва" + ], + "hash key": [ + "хэш-ключ" + ], + "key": [ + "ключ" + ], + "membership": [ + "членства" + ], + "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..56210cc2 --- /dev/null +++ b/js/sdk/locales/ca_ES.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "ca_ES" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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" + ], + "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 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 }" + ], + "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 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 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" + ], + "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." + ], + "File has no active revision": [ + "El fitxer no té revisió activa." + ], + "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" + ], + "Invalid URL": [ + "L'URL no és vàlid" + ], + "Invitation not found": [ + "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 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 trobat 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." + ], + "No available name found": [ + "No s'ha trobat cap nom disponible" + ], + "Node has no thumbnail": [ + "El node no té miniatura" + ], + "Node is not accessible": [ + "Aquest node no és accessible" + ], + "Node is not shared": [ + "Node no compartit" + ], + "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ó" + ], + "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 is already in the target album": [ + "La foto ja és a l'àlbum de destinació" + ], + "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" + ], + "Request aborted": [ + "Sol·licitud cancel·lada" + ], + "Signature is missing": [ + "Falta la signatura" + ], + "Signature verification failed": [ + "Ha fallat la verificació de 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" + ], + "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" + ], + "Too many server errors, please try again later": [ + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "L'autor no es proporciona a l'enllaç públic" + ] + }, + "Property": { + "attributes": [ + "atributs" + ], + "content key": [ + "clau de contingut" + ], + "hash key": [ + "clau d'empremta electrònica" + ], + "key": [ + "clau" + ], + "membership": [ + "subscripció" + ], + "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..1459644f --- /dev/null +++ b/js/sdk/locales/config/locales.json @@ -0,0 +1,20 @@ +{ + "en_US": "English", + "de_DE": "Deutsch", + "fr_FR": "Français", + "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": "Русский", + "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": "Беларуская" +} \ 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..2e0ef23b --- /dev/null +++ b/js/sdk/locales/de_DE.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "de_DE" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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" + ], + "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 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 }" + ], + "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 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 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" + ], + "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." + ], + "File has no active revision": [ + "Datei hat keine aktive Revision" + ], + "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" + ], + "Invalid URL": [ + "Ungültige URL" + ], + "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." + ], + "Missing integrity signature": [ + "Fehlende Integritätssignatur" + ], + "Missing inviter email": [ + "E-Mail-Adresse des Einladenden fehlt" + ], + "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" + ], + "No available name found": [ + "Kein verfügbarer Name gefunden" + ], + "Node has no thumbnail": [ + "Node hat kein Vorschaubild" + ], + "Node is not accessible": [ + "Node ist nicht erreichbar" + ], + "Node is not shared": [ + "Node ist nicht geteilt" + ], + "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" + ], + "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 is already in the target album": [ + "Foto ist bereits im Zielalbum" + ], + "Photo not found": [ + "Foto nicht gefunden" + ], + "Renaming root item is not allowed": [ + "Umbenennen des Stammeintrags ist nicht erlaubt." + ], + "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" + ], + "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" + ], + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "Der Autor wird unter dem öffentlichen Link nicht angegeben." + ] + }, + "Property": { + "attributes": [ + "Attribute" + ], + "content key": [ + "Inhaltsschlüssel" + ], + "hash key": [ + "Hash-Schlüssel" + ], + "key": [ + "Schlüssel" + ], + "membership": [ + "Mitgliedschaft" + ], + "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..59e3a4c4 --- /dev/null +++ b/js/sdk/locales/el_GR.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "el_GR" + }, + "contexts": { + "Error": { + "Bookmark password is not available": [ + "Η σελιδοδείκτηση κωδικού δεν είναι διαθέσιμη" + ], + "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": [ + "Η αντιγραφή ακυρώθηκε" + ], + "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": [ + "Δεν επιτρέπεται η δημιουργία αρχείων σε μη-φακέλους." + ], + "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 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 }" + ], + "Failed to decrypt item name: ${ message }": [ + "Αποτυχία αποκρυπτογράφησης ονόματος στοιχείου: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Αποτυχία αποκρυπτογράφησης κλειδιού κόμβου: ${ message }" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "Αποτυχία αποκρυπτογράφησης μικρογραφίας: ${ message }" + ], + "Failed to get inviter keys": [ + "Αποτυχία λήψης κλειδιών προσκαλούντος" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "Δεν ήταν δυνατή η λήψη πληροφοριών κοινοποίησης για τον κόμβο ${ nodeUid }" + ], + "Failed to get verification keys": [ + "Αποτυχία λήψης κλειδιών επαλήθευσης" + ], + "Failed to load some items": [ + "Αποτυχία φόρτωσης κάποιων στοιχείων" + ], + "Failed to load some nodes": [ + "Αποτυχία φόρτωσης κάποιων κόμβων" + ], + "Failed to verify invitation": [ + "Αποτυχία επαλήθευσης πρόσκλησης" + ], + "File download failed due to empty response": [ + "Η λήψη αρχείου απέτυχε λόγω κενής απόκρισης" + ], + "File has no active revision": [ + "Το αρχείο δεν έχει ενεργή αναθεώρηση" + ], + "File has no content key": [ + "Το αρχείο δεν έχει κλειδί περιεχομένου" + ], + "File has no revision": [ + "Το αρχείο δεν έχει αναθεώρηση" + ], + "File hash does not match expected hash": [ + "Το hash του αρχείου δεν ταιριάζει με τον αναμενόμενο hash" + ], + "Invalid URL": [ + "Μη έγκυρη διεύθυνση URL" + ], + "Invitation not found": [ + "Η πρόσκληση δεν βρέθηκε" + ], + "Item cannot be decrypted": [ + "Το αντικείμενο δεν μπορεί να αποκρυπτογραφηθεί" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "Ο παλαιού τύπου δημόσιος σύνδεσμος δεν μπορεί να ενημερωθεί. Παρακαλούμε δημιουργήστε εκ νέου έναν νέο δημόσιο σύνδεσμο." + ], + "Missing integrity signature": [ + "Λείπει η υπογραφή ακεραιότητας" + ], + "Missing inviter email": [ + "Λείπει η ηλεκτρονική διεύθυνση του προσκαλούντος" + ], + "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": [ + "Το όνομα δεν πρέπει να είναι κενό" + ], + "No available name found": [ + "Δεν βρέθηκε διαθέσιμο όνομα" + ], + "Node has no thumbnail": [ + "Ο κόμβος δεν έχει μικρογραφία" + ], + "Node is not accessible": [ + "Ο κόμβος δεν είναι προσβάσιμος" + ], + "Node is not shared": [ + "Ο κόμβος δεν έχει κοινοποιηθεί" + ], + "Node not found": [ + "Ο κόμβος δεν βρέθηκε" + ], + "Only admins can convert non-Proton invitations": [ + "Μόνο οι διαχειριστές μπορούν να μετατρέψουν προσκλήσεις που δεν είναι της Proton" + ], + "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": [ + "Δεν επιτρέπεται η μετονομασία του ριζικού στοιχείου" + ], + "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 αρχείου απέτυχαν να μεταφορτωθούν" + ], + "Some file parts failed to upload": [ + "Κάποια μέρη αρχείων απέτυχαν να μεταφορτωθούν" + ], + "The node is not shared anymore": [ + "Ο κόμβος δεν είναι πλέον κοινόχρηστος" + ], + "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": [ + "Μπορείτε να αποχωρήσετε μόνο από το στοιχείο που έχει κοινοποιηθεί σε εσάς." + ] + }, + "Info": { + "Author is not provided on public link": [ + "Ο συγγραφέας δεν παρέχεται στον δημόσιο σύνδεσμο." + ] + }, + "Property": { + "attributes": [ + "ιδιότητες" + ], + "content key": [ + "κλειδί περιεχομένου" + ], + "hash key": [ + "κλειδί hash" + ], + "key": [ + "κλειδί" + ], + "membership": [ + "συνδρομή" + ], + "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..0dbb008e --- /dev/null +++ b/js/sdk/locales/es_ES.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "es_ES" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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." + ], + "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 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 }" + ], + "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 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 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" + ], + "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" + ], + "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." + ], + "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" + ], + "Invalid URL": [ + "La URL no es válida." + ], + "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." + ], + "Missing integrity signature": [ + "Falta la firma de integridad." + ], + "Missing inviter email": [ + "Falta el correo electrónico de quien invita" + ], + "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." + ], + "No available name found": [ + "No se encontró ningún nombre disponible" + ], + "Node has no thumbnail": [ + "El nodo no tiene miniatura." + ], + "Node is not accessible": [ + "El nodo no es accesible." + ], + "Node is not shared": [ + "El nodo no está compartido" + ], + "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" + ], + "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 is already in the target album": [ + "La foto ya está en el álbum de destino" + ], + "Photo not found": [ + "Foto no encontrada" + ], + "Renaming root item is not allowed": [ + "No se permite cambiar el nombre del elemento principal." + ], + "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." + ], + "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" + ], + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "El autor no se proporciona en el enlace público" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "clave de contenido" + ], + "hash key": [ + "clave hash" + ], + "key": [ + "clave" + ], + "membership": [ + "membresía" + ], + "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..97f47b56 --- /dev/null +++ b/js/sdk/locales/es_LA.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "es_LA" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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" + ], + "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 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 }" + ], + "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 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 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" + ], + "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" + ], + "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" + ], + "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" + ], + "Invalid URL": [ + "URL inválida" + ], + "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." + ], + "Missing integrity signature": [ + "Falta la firma de integridad" + ], + "Missing inviter email": [ + "Falta el correo electrónico de quien invita" + ], + "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" + ], + "No available name found": [ + "No se encontró ningún nombre disponible" + ], + "Node has no thumbnail": [ + "El nodo no tiene miniatura" + ], + "Node is not accessible": [ + "El nodo no es accesible" + ], + "Node is not shared": [ + "El nodo no está compartido" + ], + "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" + ], + "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 is already in the target album": [ + "La foto ya está en el álbum de destino" + ], + "Photo not found": [ + "Foto no encontrada" + ], + "Renaming root item is not allowed": [ + "No se permite cambiar el nombre del elemento principal." + ], + "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" + ], + "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" + ], + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "El autor no se proporciona en el enlace público" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "clave de contenido" + ], + "hash key": [ + "clave hash" + ], + "key": [ + "clave" + ], + "membership": [ + "membresía" + ], + "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..e7a1c176 --- /dev/null +++ b/js/sdk/locales/fr_FR.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n >= 2);", + "language": "fr_FR" + }, + "contexts": { + "Error": { + "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 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." + ], + "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 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." + ], + "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 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 }" + ], + "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 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 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." + ], + "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." + ], + "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." + ], + "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." + ], + "Invalid URL": [ + "L'URL n'est pas valide." + ], + "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." + ], + "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." + ], + "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." + ], + "No available name found": [ + "Aucun nom disponible trouvé" + ], + "Node has no thumbnail": [ + "Le nœud n'a pas de vignette." + ], + "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." + ], + "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." + ], + "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 is already in the target album": [ + "La photo est déjà dans l’album cible." + ], + "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." + ], + "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." + ], + "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." + ], + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "L'auteur n'est pas indiqué sur le lien public" + ] + }, + "Property": { + "attributes": [ + "attributs" + ], + "content key": [ + "clé de contenu" + ], + "hash key": [ + "clé de hachage" + ], + "key": [ + "clé" + ], + "membership": [ + "abonnement" + ], + "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..be831aea --- /dev/null +++ b/js/sdk/locales/it_IT.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "it_IT" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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." + ], + "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 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 }" + ], + "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 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 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" + ], + "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." + ], + "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" + ], + "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" + ], + "Invalid URL": [ + "URL non valido" + ], + "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." + ], + "Missing integrity signature": [ + "Firma di integrità mancante" + ], + "Missing inviter email": [ + "Non è stata inserita l’email dell’invitante" + ], + "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" + ], + "No available name found": [ + "Non è stato trovato nessun nome disponibile" + ], + "Node has no thumbnail": [ + "Il nodo non ha alcuna miniatura" + ], + "Node is not accessible": [ + "Il nodo non è accessibile" + ], + "Node is not shared": [ + "Il nodo non è condiviso" + ], + "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" + ], + "Operation failed, try again later": [ + "Operazione non riuscita, riprova più tardi." + ], + "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" + ], + "Renaming root item is not allowed": [ + "Non è consentito rinominare l'elemento radice" + ], + "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" + ], + "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" + ], + "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" + ] + }, + "Info": { + "Author is not provided on public link": [ + "L'autore non è fornito sul link pubblico." + ] + }, + "Property": { + "attributes": [ + "attributi" + ], + "content key": [ + "chiave contenuto" + ], + "hash key": [ + "chiave hash" + ], + "key": [ + "chiave" + ], + "membership": [ + "abbonamento" + ], + "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..2a18de1a --- /dev/null +++ b/js/sdk/locales/ko_KR.json @@ -0,0 +1,260 @@ +{ + "headers": { + "plural-forms": "nplurals=1; plural=0;", + "language": "ko_KR" + }, + "contexts": { + "Error": { + "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": [ + "폴더가 아닌 곳에 폴더를 생성할 수 없습니다" + ], + "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 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 }" + ], + "Failed to decrypt item name: ${ message }": [ + "항목 이름을 복호화할 수 없음: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "노드 키 복호화 실패: ${ message }" + ], + "Failed to decrypt thumbnail: ${ message }": [ + "섬내일 복호화 실패: ${ message }" + ], + "Failed to get inviter keys": [ + "초대자 키를 가져오는 데 실패했습니다" + ], + "Failed to get sharing info for node ${ nodeUid }": [ + "노드 ${ nodeUid }에 대한 공유 정보를 가져오지 못했습니다" + ], + "Failed to get verification keys": [ + "인증 키를 가져올 수 없습니다" + ], + "Failed to load some nodes": [ + "일부 로드 불러오기 실패" + ], + "Failed to verify invitation": [ + "초대 확인 실패" + ], + "File download failed due to empty response": [ + "빈 응답으로 인하여 다운로드에 실패했습니다" + ], + "File has no active revision": [ + "파일에 활성 수정 버전이 없습니다" + ], + "File has no content key": [ + "파일에 콘텐츠 키가 없습니다" + ], + "File has no revision": [ + "파일에 수정 버전이 없습니다" + ], + "File hash does not match expected hash": [ + "파일 해시가 예측한 해시값과 일치하지 않습니다." + ], + "Invalid URL": [ + "잘못된 URL" + ], + "Invitation not found": [ + "초대를 찾을 수 없음" + ], + "Item cannot be decrypted": [ + "항목을 복호화할 수 없습니다" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "레거시 공개 링크는 업데이트가 불가능합니다. 새로운 공개 링크를 다시 생성해 주세요." + ], + "Missing integrity signature": [ + "무결성 서명 없음" + ], + "Missing inviter email": [ + "초대자 이메일 누락" + ], + "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": [ + "이름은 비워둘 수 없습니다" + ], + "No available name found": [ + "사용 가능한 이름을 찾을 수 없음" + ], + "Node has no thumbnail": [ + "노드에 섬내일이 없습니다" + ], + "Node is not accessible": [ + "노드에 접근할 수 없습니다" + ], + "Node is not shared": [ + "노드가 공유되지 않습니다" + ], + "Node not found": [ + "노드를 찾을 수 없음" + ], + "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": [ + "상위 항목의 이름 변경이 허용되지 않습니다" + ], + "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": [ + "일부 파일 바이트 업로드에 실패했습니다" + ], + "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": [ + "나와 공유된 항목에서만 나갈 수 있습니다." + ] + }, + "Info": { + "Author is not provided on public link": [ + "공개 링크에는 작성자가 제공되지 않습니다" + ] + }, + "Property": { + "attributes": [ + "속성" + ], + "content key": [ + "콘텐츠 키" + ], + "hash key": [ + "해시 키" + ], + "key": [ + "키" + ], + "membership": [ + "멤버십" + ], + "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..c202ae44 --- /dev/null +++ b/js/sdk/locales/nl_NL.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "nl_NL" + }, + "contexts": { + "Error": { + "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 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" + ], + "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 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" + ], + "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 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 }" + ], + "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 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 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 items": [ + "Fout bij het laden van sommige items" + ], + "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" + ], + "File has no active revision": [ + "Bestand heeft geen actieve revisie" + ], + "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" + ], + "Invalid URL": [ + "Ongeldige URL" + ], + "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." + ], + "Missing integrity signature": [ + "Integriteitshandtekening ontbreekt" + ], + "Missing inviter email": [ + "E-mailadres van uitnodiger 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" + ], + "No available name found": [ + "Geen beschikbare naam gevonden" + ], + "Node has no thumbnail": [ + "Node heeft geen miniatuur" + ], + "Node is not accessible": [ + "Node is niet toegankelijk" + ], + "Node is not shared": [ + "Node is niet gedeeld" + ], + "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" + ], + "Operation failed, try again later": [ + "Actie mislukt, probeer het later opnieuw" + ], + "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" + ], + "Renaming root item is not allowed": [ + "Het hernoemen van het hoofditem is niet toegelaten" + ], + "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" + ], + "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" + ], + "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" + ] + }, + "Info": { + "Author is not provided on public link": [ + "Auteur is niet opgegeven op openbare link" + ] + }, + "Property": { + "attributes": [ + "attributen" + ], + "content key": [ + "inhoudssleutel" + ], + "hash key": [ + "hash sleutel" + ], + "key": [ + "sleutel" + ], + "membership": [ + "lidmaatschap" + ], + "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..d4b3cf11 --- /dev/null +++ b/js/sdk/locales/pl_PL.json @@ -0,0 +1,239 @@ +{ + "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": { + "Bookmark password is not available": [ + "Hasło zakładki nie jest dostępne" + ], + "Cannot download a folder": [ + "Nie można pobrać folderu" + ], + "Cannot share root folder": [ + "Nie można udostępnić folderu głównego" + ], + "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" + ], + "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 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 }" + ], + "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 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 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" + ], + "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" + ], + "File has no active revision": [ + "Plik nie ma aktywnej rewizji" + ], + "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" + ], + "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." + ], + "Missing integrity signature": [ + "Brak podpisu integralności" + ], + "Missing inviter email": [ + "Brak adresu e-mail nadawcy zaproszenia" + ], + "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" + ], + "No available name found": [ + "Nie znaleziono dostępnej nazwy" + ], + "Node has no thumbnail": [ + "Węzeł nie ma podglądu" + ], + "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 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" + ], + "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" + ] + }, + "Info": { + "Author is not provided on public link": [ + "Autor nie jest podany w publicznym linku" + ] + }, + "Property": { + "attributes": [ + "atrybuty" + ], + "content key": [ + "klucz zawartości" + ], + "hash key": [ + "kryptograficzny skrót klucza" + ], + "key": [ + "klucz" + ], + "membership": [ + "członkostwo" + ], + "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..39b2d54f --- /dev/null +++ b/js/sdk/locales/pt_BR.json @@ -0,0 +1,261 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n > 1);", + "language": "pt_BR" + }, + "contexts": { + "Error": { + "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." + ], + "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 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." + ], + "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 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 }" + ], + "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 thumbnail: ${ message }": [ + "Erro ao descriptografar a miniatura: ${ message }" + ], + "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 items": [ + "Erro ao carregar alguns itens" + ], + "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" + ], + "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." + ], + "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" + ], + "Invalid URL": [ + "O URL não é válido." + ], + "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." + ], + "Missing integrity signature": [ + "Assinatura de integridade ausente" + ], + "Missing inviter email": [ + "E-mail do convidante 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" + ], + "No available name found": [ + "Nenhum nome disponível encontrado" + ], + "Node has no thumbnail": [ + "Nó não tem miniatura" + ], + "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" + ], + "Only admins can convert non-Proton invitations": [ + "Apenas administradores podem converter convites que não sejam da Proton" + ], + "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" + ], + "Renaming root item is not allowed": [ + "Não é permitido alterar o nome do item raiz" + ], + "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" + ], + "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" + ], + "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ê" + ] + }, + "Info": { + "Author is not provided on public link": [ + "Autor não fornecido no link público" + ] + }, + "Property": { + "attributes": [ + "atributos" + ], + "content key": [ + "chave de conteúdo" + ], + "hash key": [ + "chave hash" + ], + "key": [ + "chave" + ], + "membership": [ + "subscrição" + ], + "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..0b7cd641 --- /dev/null +++ b/js/sdk/locales/pt_PT.json @@ -0,0 +1,193 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n != 1);", + "language": "pt_PT" + }, + "contexts": { + "Error": { + "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." + ], + "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 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 }" + ], + "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 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." + ], + "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." + ], + "Node has no thumbnail": [ + "O nó não tem miniatura." + ], + "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." + ] + }, + "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..1cd0692a --- /dev/null +++ b/js/sdk/locales/ro_RO.json @@ -0,0 +1,277 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);", + "language": "ro_RO" + }, + "contexts": { + "Error": { + "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 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." + ], + "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 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ă." + ], + "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 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 }" + ], + "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 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 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." + ], + "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." + ], + "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." + ], + "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." + ], + "Invalid URL": [ + "Adresă URL nevalidă." + ], + "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ă." + ], + "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ă." + ], + "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." + ], + "No available name found": [ + "Nu a fost găsit niciun nume disponibil." + ], + "Node has no thumbnail": [ + "Nodul nu are miniatură." + ], + "Node is not accessible": [ + "Nodul nu este accesibil." + ], + "Node is not shared": [ + "Nodul nu este partajat." + ], + "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ă" + ], + "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 is already in the target album": [ + "Fotografia există deja în albumul țintă." + ], + "Photo not found": [ + "Fotografia nu a fost găsită" + ], + "Renaming root item is not allowed": [ + "Redenumirea rădăcinii nu este permisă." + ], + "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." + ], + "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ă" + ], + "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." + ] + }, + "Info": { + "Author is not provided on public link": [ + "Autorul nu este furnizat pentru legătura publică." + ] + }, + "Property": { + "attributes": [ + "atribute" + ], + "content key": [ + "cheie de conținut" + ], + "hash key": [ + "cheie criptată" + ], + "key": [ + "cheie" + ], + "membership": [ + "apartenență" + ], + "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..f6aaca60 --- /dev/null +++ b/js/sdk/locales/ru_RU.json @@ -0,0 +1,213 @@ +{ + "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": { + "Bookmark password is not available": [ + "Пароль закладки недоступен" + ], + "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 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 }" + ], + "Failed to decrypt item name: ${ message }": [ + "Не удалось расшифровать имя элемента: ${ message }" + ], + "Failed to decrypt node key: ${ message }": [ + "Не удалось расшифровать ключ узла: ${ message }" + ], + "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": [ + "Не удалось скачать файл из-за пустого ответа" + ], + "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.": [ + "Устаревшую публичную ссылку невозможно обновить. Создайте новую публичную ссылку." + ], + "Missing integrity signature": [ + "Отсутствует подпись целостности" + ], + "Missing inviter email": [ + "Отсутствует адрес электронной почты пригласившего" + ], + "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": [ + "Название не может быть пустым" + ], + "Node has no thumbnail": [ + "У узла нет значка" + ], + "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 failed: ${ errorMessage }": [ + "Проверка подписи не удалась: ${ errorMessage }" + ], + "Signature verification for ${ signatureType } failed": [ + "Проверка подписи для ${ signatureType } не удалась" + ], + "Signature verification for ${ signatureType } failed: ${ errorMessage }": [ + "Проверка подписи для ${ signatureType } не удалась: ${ errorMessage }" + ], + "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": [ + "Вы можете покинуть только тот элемент, к которому вам предоставили доступ" + ] + }, + "Property": { + "attributes": [ + "атрибуты" + ], + "content key": [ + "ключ содержимого" + ], + "hash key": [ + "хеш-ключ" + ], + "key": [ + "ключ" + ], + "membership": [ + "членство" + ], + "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..9f2850c8 --- /dev/null +++ b/js/sdk/locales/sk_SK.json @@ -0,0 +1,278 @@ +{ + "headers": { + "plural-forms": "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;", + "language": "sk_SK" + }, + "contexts": { + "Error": { + "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 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." + ], + "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 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é." + ], + "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 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 }" + ], + "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 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 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" + ], + "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." + ], + "File has no active revision": [ + "Súbor nemá aktívnu revíziu" + ], + "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" + ], + "Invalid URL": [ + "Neplatná URL" + ], + "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." + ], + "Missing integrity signature": [ + "Chýbajúci podpis integrity" + ], + "Missing inviter email": [ + "Chýba email odosielateľa pozvánky" + ], + "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" + ], + "No available name found": [ + "Nenašiel sa žiadny dostupný názov" + ], + "Node has no thumbnail": [ + "Uzol nemá miniatúru" + ], + "Node is not accessible": [ + "Uzol nie je dostupný" + ], + "Node is not shared": [ + "Uzol nie je zdieľaný" + ], + "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á" + ], + "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 is already in the target album": [ + "Fotografia sa už v cieľovom albume nachádza" + ], + "Photo not found": [ + "Fotka sa nenašla" + ], + "Renaming root item is not allowed": [ + "Premenovanie koreňovej položky nie je povolené" + ], + "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ť" + ], + "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á" + ], + "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á." + ] + }, + "Info": { + "Author is not provided on public link": [ + "Autor nie je uvedený na verejnom odkaze." + ] + }, + "Property": { + "attributes": [ + "atribúty" + ], + "content key": [ + "kľúč obsahu" + ], + "hash key": [ + "hash kľúč" + ], + "key": [ + "kľúč" + ], + "membership": [ + "členstvo" + ], + "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..82ae1cbe --- /dev/null +++ b/js/sdk/locales/tr_TR.json @@ -0,0 +1,276 @@ +{ + "headers": { + "plural-forms": "nplurals=2; plural=(n > 1);", + "language": "tr_TR" + }, + "contexts": { + "Error": { + "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 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" + ], + "Copy operation aborted": [ + "Kopyalama işlemi iptal edildi" + ], + "Copying item to a non-folder is not allowed": [ + "Ö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 olmayan ögeler içinde dosya oluşturulamaz" + ], + "Creating folders in non-folders is not allowed": [ + "Klasör olmayan ögeler içinde klasör oluşturulamaz" + ], + "Creating revisions in non-files is not allowed": [ + "Dosya olmayan ögelerde sürüm oluşturulamaz" + ], + "Data integrity check failed": [ + "Veri bütünlüğü denetlenemedi" + ], + "Data integrity check of one part failed": [ + "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şte bir tarih olamaz" + ], + "Failed to decrypt active revision: ${ 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 }": [ + "Ö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 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 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" + ], + "Failed to verify invitation": [ + "Davet doğrulanamadı" + ], + "File download failed due to empty response": [ + "Boş yanıt nedeniyle dosya indirilemedi" + ], + "File has no active revision": [ + "Dosyanın etkin bir sürümü yok" + ], + "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" + ], + "Invalid URL": [ + "Adres geçersiz" + ], + "Invitation not found": [ + "Davet bulunamadı" + ], + "Item cannot be decrypted": [ + "Ögenin şifresi çözülemedi" + ], + "Legacy public link cannot be updated. Please re-create a new public link.": [ + "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" + ], + "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": [ + "Öge klasör olmayan bir ögeye taşınamaz" + ], + "Moving root item is not allowed": [ + "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.", + "Ad en fazla ${ MAX_NODE_NAME_LENGTH } karakter uzunluğunda olmalıdır." + ], + "Name must not be empty": [ + "Ad boş olamaz" + ], + "No available name found": [ + "Kullanılabilecek bir ad bulunamadı" + ], + "Node has no thumbnail": [ + "Düğümün küçük görseli yok" + ], + "Node is not accessible": [ + "Düğüme erişilemiyor" + ], + "Node is not shared": [ + "Düğüm paylaşılmamış" + ], + "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" + ], + "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 is already in the target album": [ + "Fotoğraf zaten hedef albümde bulunuyor" + ], + "Photo not found": [ + "Fotoğraf bulunamadı" + ], + "Renaming root item is not allowed": [ + "Kök öge yeniden adlandırılamaz" + ], + "Request aborted": [ + "İstek iptal edildi" + ], + "Signature is missing": [ + "İmza eksik" + ], + "Signature verification failed": [ + "İmza doğrulanamadı" + ], + "Signature verification failed: ${ errorMessage }": [ + "İmza doğrulanamadı: ${ errorMessage }" + ], + "Signature verification for ${ signatureType } failed": [ + "${ 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" + ], + "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ı" + ], + "Too many server errors, please try again later": [ + "Ç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." + ], + "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ı kullanılamıyor" + ], + "Verification keys for ${ signatureType } are not available": [ + "${ signatureType } için doğrulama anahtarları kullanılamıyor" + ], + "You can leave only item that is shared with you": [ + "Yalnızca sizinle paylaşılan bir ögeden ayrılabilirsiniz" + ] + }, + "Info": { + "Author is not provided on public link": [ + "Yazar herkese açık bağlantıda belirtilmemiş" + ] + }, + "Property": { + "attributes": [ + "öznitelikler" + ], + "content key": [ + "içerik anahtarı" + ], + "hash key": [ + "karma anahtarı" + ], + "key": [ + "anahtar" + ], + "membership": [ + "üyelik" + ], + "name": [ + "ad" + ] + } + } +} \ No newline at end of file diff --git a/js/sdk/package-lock.json b/js/sdk/package-lock.json new file mode 100644 index 00000000..9b61fe7a --- /dev/null +++ b/js/sdk/package-lock.json @@ -0,0 +1,11533 @@ +{ + "name": "@protontech/drive-sdk", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@protontech/drive-sdk", + "version": "0.0.1", + "license": "MIT", + "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-simple-import-sort": "^13.0.0", + "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" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", + "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.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.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz", + "integrity": "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.6.0.tgz", + "integrity": "sha512-kj4gkZ6qUggkprRq3Uh5KP8XnE1MdIO0J7MhdDX8+rAbB6dJ2UrensGIS+0NPZAaaJ1Vr0PN6oLUgXMU1uMcSg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-chaining": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", + "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", + "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.26.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", + "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.26.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", + "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz", + "integrity": "sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz", + "integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz", + "integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-flow": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", + "integrity": "sha512-ez3a2it5Fn6P54W8QkbfIyyIbxlXvcxyWHHvno1Wg0Ej5eiJY5hBb8ExttoIOJJk7V2dZE6prP7iby5q2aQ0Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-flow-strip-types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", + "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "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": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "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": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.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": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "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", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdn/browser-compat-data": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz", + "integrity": "sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", + "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.0.tgz", + "integrity": "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "ajv": "~8.12.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@openpgp/web-stream-tools": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@openpgp/web-stream-tools/-/web-stream-tools-0.1.3.tgz", + "integrity": "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ==", + "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", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.20.3.tgz", + "integrity": "sha512-Nyyv1Bj7GgYwj/l46O0nkH1GTKWbO3Ixe7KFcn021aZipkZd+z8Vlu1BwkhqtVgivcKaClaExtWU/lDHkjBzag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.30.0.tgz", + "integrity": "sha512-ZZc+FXKoQXJ9cOR7qRKHxOfKOsGCj2wSodklKdtM2FofzyjzvIwn1rksD5+9iJxvHuORPOPv3ppAHcM+iMr/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.20.1", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.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" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "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": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "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": "3.21.0" + } + }, + "node_modules/@shikijs/themes": { + "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": "3.21.0" + } + }, + "node_modules/@shikijs/types": { + "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.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@swc/core": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.3.tgz", + "integrity": "sha512-c4NeXW8P3gPqcFwtm+4aH+F2Cj5KJLMiLaKhSj3mpv19glq+jmekomdktAw/VHyjsXlsmouOeNWrk8rVlkCRsg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.23" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.12.3", + "@swc/core-darwin-x64": "1.12.3", + "@swc/core-linux-arm-gnueabihf": "1.12.3", + "@swc/core-linux-arm64-gnu": "1.12.3", + "@swc/core-linux-arm64-musl": "1.12.3", + "@swc/core-linux-x64-gnu": "1.12.3", + "@swc/core-linux-x64-musl": "1.12.3", + "@swc/core-win32-arm64-msvc": "1.12.3", + "@swc/core-win32-ia32-msvc": "1.12.3", + "@swc/core-win32-x64-msvc": "1.12.3" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.3.tgz", + "integrity": "sha512-QCV9vQ/s27AMxm8j8MTDL/nDoiEMrANiENRrWnb0Fxvz/O39CajPVShp/W7HlOkzt1GYtUXPdQJpSKylugfrWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.3.tgz", + "integrity": "sha512-LylCMfzGhdvl5tyKaTT9ePetHUX7wSsST7hxWiHzS+cUMj7FnhcfdEr6kcNVT7y1RJn3fCvuv7T98ZB+T2q3HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.3.tgz", + "integrity": "sha512-DQODb7S+q+pwQY41Azcavwb2rb4rGxP70niScRDxB9X68hHOM9D0w9fxzC+Nr3AHcPSmVJUYUIiq5h38O5hVgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.3.tgz", + "integrity": "sha512-nTxtJSq78AjeaQBueYImoFBs5j7qXbgOxtirpyt8jE29NQBd0VFzDzRBhkr6I9jq0hNiChgMkqBN4eUkEQjytg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.3.tgz", + "integrity": "sha512-lBGvC5UgPSxqLr/y1NZxQhyRQ7nXy3/Ec1Z47YNXtqtpKiG1EcOGPyS0UZgwiYQkXqq8NBFMHnyHmpKnXTvRDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.3.tgz", + "integrity": "sha512-61wZ8hwxNYzBY9MCWB50v90ICzdIhOuPk1O1qXswz9AXw5O6iQStEBHQ1rozPkfQ/rmhepk0pOf/6LCwssJOwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.3.tgz", + "integrity": "sha512-NNeBiTpCgWt80vumTKVoaj6Fa/ZjUcaNQNM7np3PIgB8EbuXfyztboV7vUxpkmD/lUgsk8GlEFYViHvo6VMefQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.3.tgz", + "integrity": "sha512-fxraM7exaPb1/W0CoHW45EFNOQUQh0nonBEcNFm2iv095mziBwttyxZyQBoDkQocpkd5NtsZw3xW5FTBPnn+Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.3.tgz", + "integrity": "sha512-FFIhMPXIDjRcewomwbYGPvem7Fj76AsuzbRahnAyp+OzJwrrtxVmra/kyUCfj4kix7vdGByY0WvVfiVCf5b7Mg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.3.tgz", + "integrity": "sha512-Sf4iSg+IYT5AzFSDDmii08DfeKcvtkVxIuo+uS8BJMbiLjFNjgMkkVlBthknGyJcSK15ncg9248XjnM4jU8DZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.38.tgz", + "integrity": "sha512-HMoZgXWMqChJwffdDjvplH53g9G2ALQes3HKXDEdliB/b85OQ0CTSbxG8VSeCwiAn7cOaDVEt4mwmZvbHcS52w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", + "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", + "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", + "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/formidable": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-1.2.8.tgz", + "integrity": "sha512-6psvrUy5VDYb+yaPJReF1WrRsz+FBwyJutK9Twz1Efa27tm07bARNIkK2B8ZPWq80dXqpKfrxTO96xrtPp+AuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse5": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", + "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "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.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.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.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": { + "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.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/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^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": { + "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.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.58.1", + "@typescript-eslint/types": "^8.58.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.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "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.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "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": { + "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/type-utils": { + "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.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "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": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "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": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.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.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": "^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/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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "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.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" + }, + "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/visitor-keys": { + "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.58.1", + "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/visitor-keys/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", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@web/dev-server-core": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.5.tgz", + "integrity": "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "^2.11.6", + "@types/ws": "^7.4.0", + "@web/parse5-utils": "^2.1.0", + "chokidar": "^4.0.1", + "clone": "^2.1.2", + "es-module-lexer": "^1.0.0", + "get-stream": "^6.0.0", + "is-stream": "^2.0.0", + "isbinaryfile": "^5.0.0", + "koa": "^2.13.0", + "koa-etag": "^4.0.0", + "koa-send": "^5.0.1", + "koa-static": "^5.0.0", + "lru-cache": "^8.0.4", + "mime-types": "^2.1.27", + "parse5": "^6.0.1", + "picomatch": "^2.2.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/dev-server-core/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/@web/dev-server-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", + "integrity": "sha512-ia1LxBwwRiQBYhJ7/RtLenHyPjzle3SvTw3jOZaeGv8UGXVPOkQV8fR05caOtW/DPPZaZovNAybzRKVnNiYIZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdn/browser-compat-data": "^4.0.0", + "@web/dev-server-core": "^0.7.4", + "esbuild": "^0.25.0", + "parse5": "^6.0.1", + "ua-parser-js": "^1.0.33" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web/parse5-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", + "integrity": "sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse5": "^6.0.1", + "parse5": "^6.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "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", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-const-enum": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-const-enum/-/babel-plugin-const-enum-1.2.0.tgz", + "integrity": "sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-typescript": "^7.3.3", + "@babel/traverse": "^7.16.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-ttag": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/babel-plugin-ttag/-/babel-plugin-ttag-1.8.16.tgz", + "integrity": "sha512-UmA4KAvg3K1nzTBaqWox945CS3C1zjJu6lGZjmbOYW3NO2ps6mlIm8fnj9wjzNm2Y2nzUuD73aiAK9Rd3vTZgQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/generator": "^7.12.5", + "@babel/template": "^7.10.4", + "@babel/types": "^7.12.6", + "ajv": "6.12.3", + "babel-plugin-macros": "^2.8.0", + "dedent": "1.5.1", + "gettext-parser": "6.0.0", + "mkdirp": "^1.0.4", + "plural-forms": "^0.5.3" + } + }, + "node_modules/babel-plugin-ttag/node_modules/ajv": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz", + "integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/babel-plugin-ttag/node_modules/babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "node_modules/babel-plugin-ttag/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-ttag/node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/babel-plugin-ttag/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-ttag/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/babel-preset-const-enum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-preset-const-enum/-/babel-preset-const-enum-1.0.0.tgz", + "integrity": "sha512-DHfcv3mkgIqPaFODzig3Esb91cCqZlnImSl7VAwJDtIsqJvx4H08kpl051um68gjqnAXg5up5nnn6NK+Cq0blA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-const-enum": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001700", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", + "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.3.1.tgz", + "integrity": "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-5.2.0.tgz", + "integrity": "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inflation": "^2.0.0", + "qs": "^6.4.0", + "raw-body": "^2.2.0", + "type-is": "^1.6.14" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/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", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-js": { + "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", + "integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.104", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz", + "integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-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", + "integrity": "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "@microsoft/tsdoc-config": "0.17.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "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", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==", + "dev": true, + "license": "Apache2" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gettext-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-6.0.0.tgz", + "integrity": "sha512-eWFsR78gc/eKnzDgc919Us3cbxQbzxK1L8vAIZrKMQqOUgULyeqmczNlBjTlVTk2FaB7nV9C1oobd/PGBOqNmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.4", + "encoding": "^0.1.13", + "readable-stream": "^4.1.0", + "safe-buffer": "^5.2.1" + } + }, + "node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hunspell-spellchecker": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hunspell-spellchecker/-/hunspell-spellchecker-1.0.2.tgz", + "integrity": "sha512-4DwmFAvlz+ChsqLDsZT2cwBsYNXh+oWboemxXtafwKIyItq52xfR4e4kr017sLAoPaSYVofSOvPUfmOAhXyYvw==", + "dev": true, + "license": "Apache 2", + "bin": { + "hunspell-tojson": "bin/hunspell-tojson.js" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", + "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-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", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "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", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/koa": { + "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": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-body": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/koa-body/-/koa-body-4.2.0.tgz", + "integrity": "sha512-wdGu7b9amk4Fnk/ytH8GuWwfs4fsB5iNkY8kZPpgQVb04QZSv85T0M8reb+cJmvLE8cjPYvBzRikD3s6qz8OoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/formidable": "^1.0.31", + "co-body": "^5.1.1", + "formidable": "^1.1.1" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dev": true, + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-etag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/koa-etag/-/koa-etag-4.0.0.tgz", + "integrity": "sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "etag": "^1.8.1" + } + }, + "node_modules/koa-router": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-9.4.0.tgz", + "integrity": "sha512-RO/Y8XqSNM2J5vQeDaBI/7iRpL50C9QEudY4d3T4D1A2VMKLH0swmfjxDFPiIpVDLuNN6mVD9zBI1eFTHB6QaA==", + "deprecated": "**IMPORTANT 10x+ PERFORMANCE UPGRADE**: Please upgrade to v12.0.1+ as we have fixed an issue with debuglog causing 10x slower router benchmark performance, see https://github.com/koajs/router/pull/173", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.1.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", + "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha512-mmPrW0Fh2fxOzdBbFv4g1m6pR72haFLPJ2G5SJEELf1y+iaQrDG6cWCPjy54RHYbZAt7X+ls690Kw62AdWXBzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "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", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", + "dev": true + }, + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/openapi-typescript": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.6.1.tgz", + "integrity": "sha512-F7RXEeo/heF3O9lOXo2bNjCOtfp7u+D6W3a3VNEH2xE6v+fxLtn5nq0uvUcA1F5aT+CMhNeC5Uqtg5tlXFX/ag==", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/openapi-typescript/node_modules/type-fest": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.35.0.tgz", + "integrity": "sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "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", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-1.3.0.tgz", + "integrity": "sha512-6DFzEwRJxz7o/0K+7ecOLwSaWT5M0xuvb+1knfQbyi+GFk4O9bMX9NdDizLaORMcEy8kZyu3OjYNFItRa4MNOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^1.1.1", + "cli-cursor": "^2.1.0", + "cli-spinners": "^1.0.0", + "log-symbols": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "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": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/plural-forms": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.5.tgz", + "integrity": "sha512-rJw4xp22izsfJOVqta5Hyvep2lR3xPkFUtj7dyQtpf/FbxUiX7PQCajTn2EHDRylizH5N/Uqqodfdu22I0ju+g==", + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "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": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "3.59.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", + "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "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.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "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": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/ttag": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/ttag/-/ttag-1.8.7.tgz", + "integrity": "sha512-k9Ym8cvG7SHwikudT6GHe0Qmy1D+Ib1q87lKRQbQIGxUdHbaXgbU5p1gv2wcO5ouhjMorm/X0MvMNgr3iyI1JA==", + "license": "MIT", + "dependencies": { + "dedent": "1.5.1", + "plural-forms": "^0.5.3" + } + }, + "node_modules/ttag-cli": { + "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", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/generator": "^7.12.5", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.1", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "7.6.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.12.1", + "@babel/preset-flow": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@babel/preset-typescript": "7.7.0", + "@babel/template": "^7.10.4", + "babel-plugin-ttag": "1.8.16", + "babel-preset-const-enum": "^1.0.0", + "chalk": "^2.4.2", + "cross-spawn": "^5.1.0", + "estree-walker": "^2.0.1", + "gettext-parser": "^6.0.0", + "hunspell-spellchecker": "^1.0.2", + "ignore": "^5.1.8", + "koa": "^2.13.0", + "koa-body": "^4.2.0", + "koa-router": "^9.1.0", + "mkdirp": "^0.5.1", + "node-fetch": "^2.6.1", + "open": "^6.4.0", + "ora": "1.3.0", + "plural-forms": "0.5.3", + "readline-sync": "^1.4.7", + "serialize-javascript": "^4.0.0", + "svelte": "^3.20.1", + "tmp": "0.0.33", + "vue-sfc-parser": "^0.1.2", + "walk": "2.3.9", + "yargs": "^15.4.1" + }, + "bin": { + "ttag": "bin/ttag" + } + }, + "node_modules/ttag-cli/node_modules/@babel/preset-typescript": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.7.0.tgz", + "integrity": "sha512-WZ3qvtAJy8w/i6wqq5PuDnkCUXaLUTHIlJujfGHmHxsT5veAbEdEjl3cC/3nXfyD0bzlWsIiMdUhZgrXjd9QWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-transform-typescript": "^7.7.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/ttag-cli/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/ttag-cli/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ttag-cli/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "node_modules/ttag-cli/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ttag-cli/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/ttag-cli/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ttag-cli/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/plural-forms": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/plural-forms/-/plural-forms-0.5.3.tgz", + "integrity": "sha512-t/hkjsTeDwaK9n/z6tUiSHySTC8sPnTiS5YF3Y5p4L+eomzXh7O0vEemkjwb68/82w0Rjw4uED3X84X7vXf9lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ttag-cli/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ttag-cli/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ttag-cli/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ttag-cli/node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ttag-cli/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ttag-cli/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/ttag-cli/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ttag-cli/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ttag/node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedoc": { + "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", + "yaml": "^2.8.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "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" + } + }, + "node_modules/typescript": { + "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": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", + "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", + "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vue-sfc-parser": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/vue-sfc-parser/-/vue-sfc-parser-0.1.2.tgz", + "integrity": "sha512-fvYu4i5oxK4J25qYblmsotMINSY0KhP1LW/ElKaMin4CXQ2UqyjeUgAZaE2Zs1zYpIKGoEuMjEY+lmBghls1WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.mapvalues": "^4.6.0" + } + }, + "node_modules/walk": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz", + "integrity": "sha512-nEvC/LocusNlMqpnY76juQYCx7H/ZNhtEQ3qTI+Kbh9zw8nc8jp5v0C3g+V1RNTH7TXrp4YC8qtzk6FN03+lMg==", + "dev": true, + "dependencies": { + "foreachasync": "^3.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "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.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/js/sdk/package.json b/js/sdk/package.json new file mode 100644 index 00000000..7da419fc --- /dev/null +++ b/js/sdk/package.json @@ -0,0 +1,53 @@ +{ + "name": "@protontech/drive-sdk", + "version": "0.0.1", + "description": "Proton Drive SDK", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "build:ci": "rm -rf dist tsconfig.tsbuildinfo && tsc", + "check-types": "tsc --noEmit", + "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')", + "test": "jest", + "test:ci": "jest --runInBand --no-cache", + "test:watch": "jest --watch --coverage=false", + "lint:ttag": "node tasks/linter.mjs src --verbose", + "extract:ttag": "ttag extract src --output po/template.pot" + }, + "dependencies": { + "@noble/hashes": "^1.8.0", + "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-simple-import-sort": "^13.0.0", + "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" + } +} diff --git a/js/sdk/src/cache/index.ts b/js/sdk/src/cache/index.ts new file mode 100644 index 00000000..fde52d19 --- /dev/null +++ b/js/sdk/src/cache/index.ts @@ -0,0 +1,3 @@ +export type { EntityResult, ProtonDriveCache } from './interface'; +export { MemoryCache } from './memoryCache'; +export { NullCache } from './nullCache'; diff --git a/js/sdk/src/cache/interface.ts b/js/sdk/src/cache/interface.ts new file mode 100644 index 00000000..bbd24cf2 --- /dev/null +++ b/js/sdk/src/cache/interface.ts @@ -0,0 +1,104 @@ +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; +} + +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; + + /** + * 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"] }); + * await Array.fromAsync(cache.iterateEntitiesByTag("parentUid:abc123")); // returns ["node-abc42"] + * await cache.getEntity("node-abc42"); // returns "{ node abc42 serialised data }" + * await Array.fromAsync(cache.iterateEntities(["node-abc42"])); // returns ["{ node abc42 serialised data }"] + * ``` + * + * @example Stored data + * ```json + * { + * type: "node", + * version: 1, + * internal: { + * isStale, + * claimedDigests, + * // ... + * } + * node: { + * // same as node entity, here some example + * uid, + * parentUid, + * // ... + * } + * } + * ``` + * + * @param 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; + + /** + * Returns the data of the entity stored locally. + * + * @throws Exception if entity is not present. + */ + 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>; + + /** + * 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>; + + /** + * Removes completely the entity stored locally from the database. + * + * It is no-op if entity is not present. + */ + removeEntities(keys: string[]): Promise; +} + +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 new file mode 100644 index 00000000..1370abbd --- /dev/null +++ b/js/sdk/src/cache/memoryCache.test.ts @@ -0,0 +1,150 @@ +import { EntityResult } from './interface'; +import { MemoryCache } from './memoryCache'; + +describe('MemoryCache', () => { + let cache: MemoryCache; + + beforeEach(async () => { + cache = new MemoryCache(); + + await cache.setEntity('key1', 'value1', ['tag1:hello', 'tag2:world']); + await cache.setEntity('key2', 'value2', ['tag2:world']); + await cache.setEntity('key3', 'value3'); + }); + + it('should store and retrieve an entity', async () => { + const key = 'newkey'; + const value = 'newvalue'; + + await cache.setEntity(key, value); + const result = await cache.getEntity(key); + + expect(result).toBe(value); + }); + + it('should update an entity with tags - remove old and add new tags', async () => { + const key = 'newkey'; + + await cache.setEntity(key, 'value1', ['tag1', 'tag2']); + await cache.setEntity(key, 'value2', ['tag2', 'tag3']); + + const result = await cache.getEntity(key); + expect(result).toBe('value2'); + + const tag1 = await Array.fromAsync(cache.iterateEntitiesByTag('tag1')); + expect(tag1).toEqual([]); + const tag2 = await Array.fromAsync(cache.iterateEntitiesByTag('tag2')); + expect(tag2).toEqual([{ key, ok: true, value: 'value2' }]); + const tag3 = await Array.fromAsync(cache.iterateEntitiesByTag('tag3')); + expect(tag3).toEqual([{ key, ok: true, value: 'value2' }]); + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const key = 'newkey'; + + try { + await cache.getEntity(key); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should iterate over entities', async () => { + const results = []; + for await (const result of cache.iterateEntities(['key1', 'key2', 'key100'])) { + results.push(result); + } + + expect(results).toEqual([ + { key: 'key1', ok: true, value: 'value1' }, + { key: 'key2', ok: true, value: 'value2' }, + { key: 'key100', ok: false, error: 'Error: Entity not found' }, + ]); + }); + + it('should iterate over entities by tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('tag2:world')) { + results.push(result); + } + + expect(results).toEqual([ + { key: 'key1', ok: true, value: 'value1' }, + { key: 'key2', ok: true, value: 'value2' }, + ]); + }); + + it('should iterate over entities with multiple tags by tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('tag1:hello')) { + results.push(result); + } + + expect(results).toEqual([{ key: 'key1', ok: true, value: 'value1' }]); + }); + + it('should iterate over entities by empty tag', async () => { + const results = []; + for await (const result of cache.iterateEntitiesByTag('nonexistent')) { + results.push(result); + } + + expect(results).toEqual([]); + }); + + 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(); + results.push(key1); + await cache.removeEntities([key1]); + + let value = await iterator.next(); // key2 + results.push(value.value.key); + + value = await iterator.next(); // key3 + results.push(value.value.key); + + expect(results).toEqual(['key1', 'key2', 'key3']); + }); + + it('should remove entities', async () => { + await cache.removeEntities(['key1', 'key3']); + + const results = []; + for await (const result of cache.iterateEntities(['key1', 'key2', 'key3'])) { + results.push(result); + } + + expect(results).toEqual([ + { key: 'key1', ok: false, error: 'Error: Entity not found' }, + { key: 'key2', ok: true, value: 'value2' }, + { key: 'key3', ok: false, error: 'Error: Entity not found' }, + ]); + + const results2 = []; + for await (const result of cache.iterateEntitiesByTag('tag1:hello')) { + results2.push(result); + } + expect(results2).toEqual([]); + }); + + it('should clear the cache', async () => { + await cache.clear(); + + const results = []; + for await (const result of cache.iterateEntities(['key1', 'key2', 'key3'])) { + results.push(result); + } + + expect(results).toEqual([ + { key: 'key1', ok: false, error: 'Error: Entity not found' }, + { key: 'key2', ok: false, error: 'Error: Entity not found' }, + { key: 'key3', ok: false, error: 'Error: Entity not found' }, + ]); + }); +}); diff --git a/js/sdk/src/cache/memoryCache.ts b/js/sdk/src/cache/memoryCache.ts new file mode 100644 index 00000000..859c1061 --- /dev/null +++ b/js/sdk/src/cache/memoryCache.ts @@ -0,0 +1,85 @@ +import type { EntityResult, ProtonDriveCache } from './interface'; + +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. + */ +export class MemoryCache implements ProtonDriveCache { + private entities: KeyValueCache = {}; + private entitiesByTag: TagsCache = {}; + + async clear() { + this.entities = {}; + } + + async setEntity(key: string, value: T, tags?: string[]) { + this.entities[key] = value; + + for (const tag of Object.keys(this.entitiesByTag)) { + const index = this.entitiesByTag[tag].indexOf(key); + if (index !== -1) { + this.entitiesByTag[tag].splice(index, 1); + if (this.entitiesByTag[tag].length === 0) { + delete this.entitiesByTag[tag]; + } + } + } + + if (tags) { + for (const tag of tags) { + if (!this.entitiesByTag[tag]) { + this.entitiesByTag[tag] = []; + } + this.entitiesByTag[tag].push(key); + } + } + } + + async getEntity(key: string): Promise { + const value = this.entities[key]; + if (!value) { + throw Error('Entity not found'); + } + return 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 keys = this.entitiesByTag[tag]; + if (!keys) { + return; + } + + // Pass copy of keys so concurrent changes to the cache do not affect + // results from iterating entities. + yield* this.iterateEntities([...keys]); + } + + async removeEntities(keys: string[]) { + for (const key of keys) { + delete this.entities[key]; + Object.values(this.entitiesByTag).forEach((tagKeys) => { + const index = tagKeys.indexOf(key); + if (index !== -1) { + tagKeys.splice(index, 1); + } + }); + } + } +} diff --git a/js/sdk/src/cache/nullCache.ts b/js/sdk/src/cache/nullCache.ts new file mode 100644 index 00000000..83dbdf31 --- /dev/null +++ b/js/sdk/src/cache/nullCache.ts @@ -0,0 +1,38 @@ +import type { EntityResult, ProtonDriveCache } 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/config.ts b/js/sdk/src/config.ts new file mode 100644 index 00000000..90122268 --- /dev/null +++ b/js/sdk/src/config.ts @@ -0,0 +1,24 @@ +import { ProtonDriveConfig } from './interface'; + +/** + * 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 { + 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/driveCrypto.test.ts b/js/sdk/src/crypto/driveCrypto.test.ts new file mode 100644 index 00000000..e9836bd2 --- /dev/null +++ b/js/sdk/src/crypto/driveCrypto.test.ts @@ -0,0 +1,51 @@ +import { getMockTelemetry } from '../tests/telemetry'; +import { DriveCrypto, uint8ArrayToUtf8 } from './driveCrypto'; + +describe('uint8ArrayToUtf8', () => { + it('should convert a Uint8Array to a UTF-8 string', () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); + const expectedOutput = 'Hello'; + const result = uint8ArrayToUtf8(input); + expect(result).toBe(expectedOutput); + }); + + it('should handle an empty Uint8Array', () => { + const input = new Uint8Array([]); + const expectedOutput = ''; + const result = uint8ArrayToUtf8(input); + expect(result).toBe(expectedOutput); + }); + + it('should throw if input is invalid', () => { + const input = new Uint8Array([887987979887897989]); + expect(() => uint8ArrayToUtf8(input)).toThrow('The encoded data was not valid for encoding utf-8'); + }); +}); + +describe('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(getMockTelemetry(), 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, + { enableAeadWithEncryptionKeys: false }, + ); + }); +}); diff --git a/js/sdk/src/crypto/driveCrypto.ts b/js/sdk/src/crypto/driveCrypto.ts new file mode 100644 index 00000000..5c4dfc1a --- /dev/null +++ b/js/sdk/src/crypto/driveCrypto.ts @@ -0,0 +1,858 @@ +import { importKey as importHmacKey, signData as computeHmacSignature } from '@protontech/crypto/subtle/hmac.ts'; + +import { ProtonDriveTelemetry } from '../interface'; +import { + OpenPGPCrypto, + PrivateKey, + PublicKey, + SessionKey, + SRPModule, + SRPVerifier, + VERIFICATION_STATUS, +} from './interface'; + +enum SIGNING_CONTEXTS { + SHARING_INVITER = 'drive.share-member.inviter', + SHARING_INVITER_EXTERNAL_INVITATION = 'drive.share-member.external-invitation', + SHARING_MEMBER = 'drive.share-member.member', +} + +/** + * Drive crypto layer to provide general operations for Drive crypto. + * + * This layer focuses on providing general Drive crypto functions. Only + * high-level functions that are required on multiple places should be + * peresent. E.g., no specific implementation how keys are encrypted, + * but we do share same key generation across shares and nodes modules, + * for example, which we can generelise here and in each module just + * call with specific arguments. + * + * 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( + private telemetry: ProtonDriveTelemetry, + private openPGPCrypto: OpenPGPCrypto, + private srpModule: SRPModule, + ) { + this.telemetry = telemetry; + this.openPGPCrypto = openPGPCrypto; + this.srpModule = srpModule; + } + + /** + * It generates passphrase and key that is encrypted with the + * generated passphrase. + * + * `encrpytionKeys` are used to generate session key, which is + * also used to encrypt the passphrase. The encrypted passphrase + * is signed with `signingKey`. + * + * @returns Object with: + * - encrypted (armored) data (key, passphrase and passphrase + * signature) for sending to the server + * - decrypted data (key, sessionKey) for crypto usage + */ + async generateKey( + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + { enableAead }: { enableAead: boolean } = { enableAead: false }, + ): Promise<{ + encrypted: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + decrypted: { + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + }> { + const passphrase = this.openPGPCrypto.generatePassphrase(); + const [{ privateKey, armoredKey }, passphraseSessionKey] = await Promise.all([ + 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( + passphrase, + passphraseSessionKey, + encryptionKeys, + signingKey, + ); + + return { + encrypted: { + armoredKey, + armoredPassphrase, + armoredPassphraseSignature, + }, + decrypted: { + passphrase, + key: privateKey, + passphraseSessionKey, + }, + }; + } + + /** + * It generates content key from node key for encrypting file blocks. + * + * @param encryptionKey - Its own node key. + * @returns Object with serialised key packet and decrypted session key. + */ + async generateContentKey(encryptionKey: PrivateKey): Promise<{ + encrypted: { + contentKeyPacket: Uint8Array; + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + }; + decrypted: { + contentKeyPacketSessionKey: SessionKey; + }; + }> { + // 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], + ); + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(contentKeyPacketSessionKey, [encryptionKey]); + + return { + encrypted: { + contentKeyPacket: keyPacket, + base64ContentKeyPacket: keyPacket.toBase64(), + armoredContentKeyPacketSignature, + }, + decrypted: { + contentKeyPacketSessionKey, + }, + }; + } + + /** + * It encrypts passphrase with provided session and encryption keys. + * This should be used only for re-encrypting the passphrase with + * different key (e.g., moving the node to different parent). + * + * @returns Object with armored passphrase and passphrase signature. + */ + async encryptPassphrase( + passphrase: string, + sessionKey: SessionKey, + encryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ): Promise<{ + armoredPassphrase: string; + armoredPassphraseSignature: string; + }> { + const { armoredData: armoredPassphrase, armoredSignature: armoredPassphraseSignature } = + await this.openPGPCrypto.encryptAndSignDetachedArmored( + new TextEncoder().encode(passphrase), + sessionKey, + encryptionKeys, + signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ); + + return { + armoredPassphrase, + armoredPassphraseSignature, + }; + } + + /** + * It decrypts key generated via `generateKey`. + * + * Armored data are passed from the server. `decryptionKeys` are used + * to decrypt the session key from the `armoredPassphrase`. Then the + * session key is used with `verificationKeys` to decrypt and verify + * the passphrase. Finally, the armored key is decrypted. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + * + * @returns key and sessionKey for crypto usage, and verification status + */ + async decryptKey( + armoredKey: string, + armoredPassphrase: string, + armoredPassphraseSignature: string | undefined, + decryptionKeys: PrivateKey[], + verificationKeys: PublicKey[], + ): Promise<{ + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const passphraseSessionKey = await this.openPGPCrypto.decryptArmoredSessionKey( + armoredPassphrase, + decryptionKeys, + ); + + const { + data: decryptedPassphrase, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerifyDetached( + armoredPassphrase, + armoredPassphraseSignature, + passphraseSessionKey, + verificationKeys, + ); + + const passphrase = uint8ArrayToUtf8(decryptedPassphrase); + + const key = await this.openPGPCrypto.decryptKey(armoredKey, passphrase); + return { + passphrase, + key, + passphraseSessionKey, + verified, + verificationErrors, + }; + } + + /** + * It encrypts session key with provided encryption key. + */ + async encryptSessionKey( + sessionKey: SessionKey, + encryptionKey: PublicKey, + ): Promise<{ + base64KeyPacket: string; + }> { + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(sessionKey, [encryptionKey]); + return { + base64KeyPacket: 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 + * the srp-compatible salted passphrase and generates the corresponding SRP verifier. + */ + async encryptPublicLinkPasswordAndSessionKey( + password: string, + addressKey: PrivateKey, + 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), + [addressKey], + undefined, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ), + this.openPGPCrypto.encryptSessionKeyWithPassword(sharePassphraseSessionKey, saltedPassphrase), + this.srpModule.getSrpVerifier(password), + ]); + + return { + armoredPassword, + base64SharePassphraseKeyPacket: keyPacket.toBase64(), + base64SharePasswordSalt, + srp, + }; + } + + /** + * It decrypts the key using the password that was verified via SRP protocol. + * + * 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. + */ + 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. + * + * `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); + return sessionKey; + } + + async decryptAndVerifySessionKey( + base64data: string, + armoredSignature: string | undefined, + decryptionKeys: PrivateKey | PrivateKey[], + verificationKeys: PublicKey[], + ): Promise<{ + sessionKey: SessionKey; + verified?: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const data = Uint8Array.fromBase64(base64data); + + const sessionKey = await this.openPGPCrypto.decryptSessionKey(data, decryptionKeys); + + let verified; + let verificationErrors; + if (armoredSignature) { + const result = await this.openPGPCrypto.verifyArmored(sessionKey.data, armoredSignature, verificationKeys); + verified = result.verified; + verificationErrors = result.verificationErrors; + } + + return { + sessionKey, + verified, + verificationErrors, + }; + } + + /** + * It decrypts key similarly like `decryptKey`, but without signature + * verification. This is used for invitations. + */ + async decryptUnsignedKey( + armoredKey: string, + armoredPassphrase: string, + decryptionKeys: PrivateKey | PrivateKey[], + ): Promise { + const { data: decryptedPassphrase } = await this.openPGPCrypto.decryptArmoredAndVerify( + armoredPassphrase, + decryptionKeys, + [], + ); + + const passphrase = uint8ArrayToUtf8(decryptedPassphrase); + + const key = await this.openPGPCrypto.decryptKey(armoredKey, passphrase); + + return key; + } + + /** + * It encrypts and armors signature with provided session and encryption keys. + */ + async encryptSignature( + signature: Uint8Array, + encryptionKey: PrivateKey, + sessionKey: SessionKey, + ): Promise<{ + armoredSignature: string; + }> { + const { armoredData: armoredSignature } = await this.openPGPCrypto.encryptArmored( + signature, + [encryptionKey], + sessionKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ); + 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; + }> { + // Once all clients can use non-ascii bytes, switch to simple + // generating of random bytes without encoding it into base64: + //const passphrase crypto.getRandomValues(new Uint8Array(32)); + const passphrase = this.openPGPCrypto.generatePassphrase(); + const hashKey = new TextEncoder().encode(passphrase); + + const { armoredData: armoredHashKey } = await this.openPGPCrypto.encryptAndSignArmored( + hashKey, + undefined, + [encryptionAndSigningKey], + encryptionAndSigningKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ); + return { + armoredHashKey, + hashKey, + }; + } + + async generateLookupHash(newName: string, parentHashKey: Uint8Array): Promise { + const key = await importHmacKey(parentHashKey); + + const signature = await computeHmacSignature(key, new TextEncoder().encode(newName)); + return signature.toHex(); + } + + /** + * It converts node name into bytes array and encrypts and signs + * with provided keys. + * + * The function accepts either encryption or session key. Use encryption + * key if you want to encrypt the name for the new node. Use session key + * if you want to encrypt the new name for the existing node. + */ + async encryptNodeName( + nodeName: string, + sessionKey: SessionKey | undefined, + encryptionKey: PrivateKey | undefined, + signingKey: PrivateKey, + ): Promise<{ + armoredNodeName: string; + }> { + if (!sessionKey && !encryptionKey) { + throw new Error('Neither session nor encryption key provided for encrypting node name'); + } + + const { armoredData: armoredNodeName } = await this.openPGPCrypto.encryptAndSignArmored( + new TextEncoder().encode(nodeName), + sessionKey, + encryptionKey ? [encryptionKey] : [], + signingKey, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: false }, + ); + 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. + */ + async decryptNodeName( + armoredNodeName: string, + decryptionKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise<{ + name: string; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const { + data: name, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify(armoredNodeName, [decryptionKey], verificationKeys); + return { + name: uint8ArrayToUtf8(name), + verified, + verificationErrors, + }; + } + + /** + * It decrypts armored node hash key and verifies embeded signature. + * + * Note: The function doesn't throw in case of verification issue. + * You have to read `verified` result and act based on that. + */ + async decryptNodeHashKey( + armoredHashKey: string, + decryptionAndVerificationKey: PrivateKey, + extraVerificationKeys: PublicKey[], + ): Promise<{ + hashKey: Uint8Array; + verified: VERIFICATION_STATUS; + 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 + // nodes besides the root one, where address key was used instead. + // Similarly, iOS or Android used address key for all nodes. Latest + // versions should use node key in all cases, but we accept also + // address key. Its still signed with a valid key. + const { + data: hashKey, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify( + armoredHashKey, + [decryptionAndVerificationKey], + [decryptionAndVerificationKey, ...extraVerificationKeys], + ); + return { + hashKey, + verified, + verificationErrors, + }; + } + + async encryptExtendedAttributes( + extendedAttributes: string, + encryptionKey: PrivateKey, + signingKey: PrivateKey, + ): Promise<{ + armoredExtendedAttributes: string; + }> { + const { armoredData: armoredExtendedAttributes } = await this.openPGPCrypto.encryptAndSignArmored( + new TextEncoder().encode(extendedAttributes), + undefined, + [encryptionKey], + signingKey, + // See note in the interface documentation about AEAD encryption. + { compress: true, enableAeadWithEncryptionKeys: false }, + ); + return { + armoredExtendedAttributes, + }; + } + + async decryptExtendedAttributes( + armoreExtendedAttributes: string, + decryptionKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise<{ + extendedAttributes: string; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const { + data: decryptedExtendedAttributes, + verified, + verificationErrors, + } = await this.openPGPCrypto.decryptArmoredAndVerify( + armoreExtendedAttributes, + [decryptionKey], + verificationKeys, + ); + + return { + extendedAttributes: uint8ArrayToUtf8(decryptedExtendedAttributes), + verified, + verificationErrors, + }; + } + + async encryptInvitation( + shareSessionKey: SessionKey, + encryptionKey: PublicKey, + signingKey: PrivateKey, + ): Promise<{ + base64KeyPacket: string; + base64KeyPacketSignature: string; + }> { + const { keyPacket } = await this.openPGPCrypto.encryptSessionKey(shareSessionKey, encryptionKey); + const { signature: keyPacketSignature } = await this.openPGPCrypto.sign( + keyPacket, + signingKey, + SIGNING_CONTEXTS.SHARING_INVITER, + ); + return { + base64KeyPacket: keyPacket.toBase64(), + base64KeyPacketSignature: keyPacketSignature.toBase64(), + }; + } + + async verifyInvitation( + base64KeyPacket: string, + // TODO: Make API consistent and use only one version. + keyPacketSignature: { armored: string } | { base64: string }, + verificationKeys: PublicKey[], + ): Promise<{ + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + 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), + Uint8Array.fromBase64(keyPacketSignature.base64), + verificationKeys, + SIGNING_CONTEXTS.SHARING_INVITER, + ); + return { verified, verificationErrors }; + } + + async acceptInvitation( + base64KeyPacket: string, + decryptionKeys: PrivateKey[], + signingKey: PrivateKey, + ): Promise<{ + base64SessionKeySignature: string; + }> { + const sessionKey = await this.openPGPCrypto.decryptSessionKey( + Uint8Array.fromBase64(base64KeyPacket), + decryptionKeys, + ); + + const { signature } = await this.openPGPCrypto.sign( + sessionKey.data, + signingKey, + SIGNING_CONTEXTS.SHARING_MEMBER, + ); + + return { + base64SessionKeySignature: signature.toBase64(), + }; + } + + async encryptExternalInvitation( + shareSessionKey: SessionKey, + signingKey: PrivateKey, + inviteeEmail: string, + ): Promise<{ + base64ExternalInvitationSignature: string; + }> { + const { signature: externalInviationSignature } = await this.openPGPCrypto.sign( + new TextEncoder().encode(externalInvitationSignaturePayload(inviteeEmail, shareSessionKey)), + signingKey, + SIGNING_CONTEXTS.SHARING_INVITER_EXTERNAL_INVITATION, + ); + return { + base64ExternalInvitationSignature: externalInviationSignature.toBase64(), + }; + } + + 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, + signingKey: PrivateKey, + ): 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, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: true }, + ); + this.recordPerformance('content_encryption', sessionKey, thumbnailData.length, start); + + return { + encryptedData, + }; + } + + async decryptThumbnailBlock( + encryptedThumbnail: Uint8Array, + sessionKey: SessionKey, + verificationKeys: PublicKey[], + ): Promise<{ + decryptedThumbnail: Uint8Array; + 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', sessionKey, decryptedThumbnail.length, start); + return { + decryptedThumbnail, + verified, + verificationErrors, + }; + } + + async encryptBlock( + blockData: Uint8Array, + encryptionKey: PrivateKey, + sessionKey: SessionKey, + signingKey: PrivateKey, + ): Promise<{ + 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, + // See note in the interface documentation about AEAD encryption. + { enableAeadWithEncryptionKeys: true }, + ); + this.recordPerformance('content_encryption', sessionKey, blockData.length, start); + + const { armoredSignature } = await this.encryptSignature(signature, encryptionKey, sessionKey); + + return { + encryptedData, + armoredSignature, + }; + } + + async decryptBlock( + encryptedBlock: Uint8Array, + sessionKey: SessionKey, + ): Promise> { + const start = performance.now(); + const { data: decryptedBlock } = await this.openPGPCrypto.decryptAndVerify(encryptedBlock, sessionKey, []); + this.recordPerformance('content_decryption', sessionKey, decryptedBlock.length, start); + + return decryptedBlock; + } + + async signManifest( + manifest: Uint8Array, + signingKey: PrivateKey, + ): Promise<{ + armoredManifestSignature: string; + }> { + const { signature: armoredManifestSignature } = await this.openPGPCrypto.signArmored(manifest, signingKey); + return { + armoredManifestSignature, + }; + } + + async verifyManifest( + manifest: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey | PublicKey[], + ): Promise<{ + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }> { + const { verified, verificationErrors } = await this.openPGPCrypto.verifyArmored( + manifest, + armoredSignature, + verificationKeys, + ); + return { + verified, + verificationErrors, + }; + } + + async decryptShareUrlPassword(armoredPassword: string, decryptionKeys: PrivateKey[]): Promise { + 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, + // 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, + bytesProcessed, + milliseconds: duration, + }); + } +} + +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/index.ts b/js/sdk/src/crypto/index.ts new file mode 100644 index 00000000..2265b8f0 --- /dev/null +++ b/js/sdk/src/crypto/index.ts @@ -0,0 +1,4 @@ +export { DriveCrypto } from './driveCrypto'; +export type { OpenPGPCrypto, PrivateKey, PublicKey, SessionKey, SRPModule, SRPVerifier } from './interface'; +export { OpenPGPCryptoWithCryptoProxy } from './openPGPCrypto'; +export { VERIFICATION_STATUS } from '@protontech/crypto'; diff --git a/js/sdk/src/crypto/interface.ts b/js/sdk/src/crypto/interface.ts new file mode 100644 index 00000000..b9aa0480 --- /dev/null +++ b/js/sdk/src/crypto/interface.ts @@ -0,0 +1,220 @@ +import type { CryptoApiInterface, PrivateKeyReference as PrivateKey, PublicKeyReference as PublicKey, SessionKey, VERIFICATION_STATUS } from '@protontech/crypto'; + +export type { CryptoApiInterface, PrivateKey, PublicKey, SessionKey, 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; + generateKeySalt: () => string; +} + +export type SRPVerifier = { + 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 + * be somewhat compatible with current web app, and also be more efficient + * (current CryptoProxy can do encryption and armoring in one operation with + * less passing data between web workers). In the future, we want to separate + * this out of here more. + */ +export interface OpenPGPCrypto { + /** + * Generate a random passphrase. + * + * 32 random bytes are generated and encoded into a base64 string. + */ + generatePassphrase: () => string; + + generateSessionKey: ( + encryptionKeys: PublicKey[], + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise; + + encryptSessionKey: ( + sessionKey: SessionKey, + encryptionKeys: PublicKey | PublicKey[], + ) => 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, + options: { enableAead: boolean }, + ) => Promise<{ + privateKey: PrivateKey; + armoredKey: string; + }>; + + encryptArmored: ( + data: Uint8Array, + encryptionKeys: PublicKey[], + sessionKey: SessionKey | undefined, + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise<{ + armoredData: string; + }>; + + encryptAndSign: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise<{ + encryptedData: Uint8Array; + }>; + + encryptAndSignArmored: ( + data: Uint8Array, + sessionKey: SessionKey | undefined, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, + ) => Promise<{ + armoredData: string; + }>; + + encryptAndSignDetached: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise<{ + encryptedData: Uint8Array; + signature: Uint8Array; + }>; + + encryptAndSignDetachedArmored: ( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, + ) => Promise<{ + armoredData: string; + armoredSignature: string; + }>; + + sign: ( + data: Uint8Array, + signingKey: PrivateKey, + signatureContext: string, + ) => Promise<{ + signature: Uint8Array; + }>; + + signArmored: ( + data: Uint8Array, + signingKey: PrivateKey | PrivateKey[], + ) => Promise<{ + signature: string; + }>; + + verify: ( + data: Uint8Array, + signature: Uint8Array, + verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, + ) => 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; + + decryptArmoredSessionKey: (armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) => Promise; + + decryptKey: (armoredKey: string, passphrase: string) => Promise; + + decryptAndVerify( + data: Uint8Array, + sessionKey: SessionKey, + verificationKeys: PublicKey | PublicKey[], + ): Promise<{ + data: Uint8Array; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }>; + + decryptAndVerifyDetached( + data: Uint8Array, + signature: Uint8Array | undefined, + sessionKey: SessionKey, + verificationKeys?: PublicKey | PublicKey[], + ): Promise<{ + data: Uint8Array; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }>; + + decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]): Promise>; + + decryptArmoredAndVerify: ( + armoredData: string, + decryptionKeys: PrivateKey | PrivateKey[], + verificationKeys: PublicKey | PublicKey[], + ) => Promise<{ + data: Uint8Array; + verified: VERIFICATION_STATUS; + verificationErrors?: Error[]; + }>; + + decryptArmoredAndVerifyDetached: ( + armoredData: string, + armoredSignature: string | undefined, + sessionKey: SessionKey, + verificationKeys: PublicKey | PublicKey[], + ) => 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 new file mode 100644 index 00000000..5191e5ac --- /dev/null +++ b/js/sdk/src/crypto/openPGPCrypto.ts @@ -0,0 +1,400 @@ +import { c } from 'ttag'; + +import type { CryptoApiInterface, OpenPGPCrypto, PrivateKey, PublicKey, SessionKey } from './interface'; + +/** + * Implementation of OpenPGPCrypto interface using CryptoProxy from clients + * monorepo that must be passed as dependency. + */ +export class OpenPGPCryptoWithCryptoProxy implements OpenPGPCrypto { + constructor(private cryptoProxy: CryptoApiInterface) { + this.cryptoProxy = cryptoProxy; + } + + 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 value.toBase64(); + } + + 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[]) { + const keyPacket = await this.cryptoProxy.encryptSessionKey({ + ...sessionKey, + format: 'binary', + encryptionKeys, + }); + return { + keyPacket, + }; + } + + async encryptSessionKeyWithPassword(sessionKey: SessionKey, password: string) { + const keyPacket = await this.cryptoProxy.encryptSessionKey({ + ...sessionKey, + format: 'binary', + passwords: [password], + }); + return { + keyPacket, + }; + } + + 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({ + privateKey, + passphrase, + }); + + return { + armoredKey, + privateKey, + }; + } + + 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, + }; + } + + async encryptAndSign( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, + ) { + const { message: encryptedData } = await this.cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + signingKeys: signingKey, + 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, + }; + } + + async encryptAndSignArmored( + data: Uint8Array, + sessionKey: SessionKey | undefined, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { compress?: boolean; enableAeadWithEncryptionKeys: boolean }, + ) { + const { message: armoredData } = await this.cryptoProxy.encryptMessage({ + binaryData: data, + encryptionKeys, + sessionKey, + 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, + }; + } + + async encryptAndSignDetached( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, + ) { + const { message: encryptedData, signature } = await this.cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + signingKeys: signingKey, + 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, + signature: signature, + }; + } + + async encryptAndSignDetachedArmored( + data: Uint8Array, + sessionKey: SessionKey, + encryptionKeys: PublicKey[], + signingKey: PrivateKey, + options: { enableAeadWithEncryptionKeys: boolean }, + ) { + const { message: armoredData, signature: armoredSignature } = await this.cryptoProxy.encryptMessage({ + binaryData: data, + sessionKey, + 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, + armoredSignature: armoredSignature, + }; + } + + async sign(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[], signatureContext: string) { + const signature = await this.cryptoProxy.signMessage({ + binaryData: data, + signingKeys, + detached: true, + format: 'binary', + signatureContext: { critical: true, value: signatureContext }, + }); + return { + signature: signature, + }; + } + + async signArmored(data: Uint8Array, signingKeys: PrivateKey | PrivateKey[]) { + const signature = await this.cryptoProxy.signMessage({ + binaryData: data, + signingKeys, + detached: true, + format: 'armored', + }); + return { + signature: signature, + }; + } + + async verify( + 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, + verificationErrors: errors, + }; + } + + async verifyArmored( + data: Uint8Array, + armoredSignature: string, + verificationKeys: PublicKey | PublicKey[], + signatureContext?: string, + ) { + const { verificationStatus, errors } = await this.cryptoProxy.verifyMessage({ + binaryData: data, + armoredSignature, + verificationKeys, + signatureContext: signatureContext ? { required: true, value: signatureContext } : undefined, + }); + + return { + verified: verificationStatus, + verificationErrors: errors, + }; + } + + async decryptSessionKey(data: Uint8Array, decryptionKeys: PrivateKey | PrivateKey[]) { + const sessionKey = await this.cryptoProxy.decryptSessionKey({ + binaryMessage: data, + decryptionKeys, + }); + + if (!sessionKey) { + 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; + } + + async decryptArmoredSessionKey(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) { + const sessionKey = await this.cryptoProxy.decryptSessionKey({ + armoredMessage: armoredData, + decryptionKeys, + }); + + if (!sessionKey) { + throw new Error('Could not decrypt session key'); + } + + return sessionKey; + } + + async decryptKey(armoredKey: string, passphrase: string) { + const key = await this.cryptoProxy.importPrivateKey({ + armoredKey, + passphrase, + }); + return key; + } + + async decryptAndVerify(data: Uint8Array, sessionKey: SessionKey, verificationKeys: PublicKey[]) { + const { + data: decryptedData, + verificationStatus, + verificationErrors, + } = await this.cryptoProxy.decryptMessage({ + binaryMessage: data, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data: decryptedData, + verified: verificationStatus, + verificationErrors, + }; + } + + async decryptAndVerifyDetached( + data: Uint8Array, + signature: Uint8Array | undefined, + sessionKey: SessionKey, + verificationKeys?: PublicKey[], + ) { + const { + data: decryptedData, + verificationStatus, + verificationErrors, + } = await this.cryptoProxy.decryptMessage({ + binaryMessage: data, + binarySignature: signature, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data: decryptedData, + verified: verificationStatus, + verificationErrors, + }; + } + + async decryptArmored(armoredData: string, decryptionKeys: PrivateKey | PrivateKey[]) { + const { data } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + decryptionKeys, + format: 'binary', + }); + return data; + } + + async decryptArmoredAndVerify( + armoredData: string, + decryptionKeys: PrivateKey | PrivateKey[], + verificationKeys: PublicKey | PublicKey[], + ) { + const { data, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + decryptionKeys, + verificationKeys, + format: 'binary', + }); + + return { + data: data, + verified: verificationStatus, + verificationErrors, + }; + } + + async decryptArmoredAndVerifyDetached( + armoredData: string, + armoredSignature: string | undefined, + sessionKey: SessionKey, + verificationKeys: PublicKey | PublicKey[], + ) { + const { data, verificationStatus, verificationErrors } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + armoredSignature, + sessionKeys: sessionKey, + verificationKeys, + format: 'binary', + }); + + return { + data: data, + verified: verificationStatus, + verificationErrors: !armoredSignature + ? [new Error(c('Error').t`Signature is missing`)] + : verificationErrors, + }; + } + + async decryptArmoredWithPassword(armoredData: string, password: string) { + const { data } = await this.cryptoProxy.decryptMessage({ + armoredMessage: armoredData, + passwords: [password], + format: 'binary', + }); + return data; + } +} diff --git a/js/sdk/src/diagnostic/diagnostic.ts b/js/sdk/src/diagnostic/diagnostic.ts new file mode 100644 index 00000000..6158f079 --- /dev/null +++ b/js/sdk/src/diagnostic/diagnostic.ts @@ -0,0 +1,70 @@ +import { MaybeNode } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; +import { DiagnosticHTTPClient } from './httpClient'; +import { DiagnosticOptions, DiagnosticProgressCallback, DiagnosticResult, TreeNode } from './interface'; +import { SDKDiagnosticMain } from './sdkDiagnosticMain'; +import { SDKDiagnosticPhotos } from './sdkDiagnosticPhotos'; +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 Diagnostic { + constructor( + 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 SDKDiagnosticMain(this.protonDriveClient, options, onProgress); + yield* this.yieldEvents(diagnostic.verifyMyFiles(options?.expectedStructure)); + } + + async *verifyNodeTree( + node: MaybeNode, + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator { + 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 }); + } + + 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/eventsGenerator.ts b/js/sdk/src/diagnostic/eventsGenerator.ts new file mode 100644 index 00000000..34d8274a --- /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..e9ae23cb --- /dev/null +++ b/js/sdk/src/diagnostic/httpClient.ts @@ -0,0 +1,84 @@ +import { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, +} 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: ProtonDriveHTTPClientJsonRequest): 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: ProtonDriveHTTPClientBlobRequest): Promise { + return this.httpClient.fetchBlob(options); + } +} diff --git a/js/sdk/src/diagnostic/index.ts b/js/sdk/src/diagnostic/index.ts new file mode 100644 index 00000000..d1eeb845 --- /dev/null +++ b/js/sdk/src/diagnostic/index.ts @@ -0,0 +1,53 @@ +import { MemoryCache, NullCache } from '../cache'; +import { ProtonDriveClientContructorParameters } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; +import { Diagnostic as DiagnosticClass } from './diagnostic'; +import { DiagnosticHTTPClient } from './httpClient'; +import { Diagnostic } from './interface'; +import { DiagnosticTelemetry } from './telemetry'; + +export type { + Diagnostic, + DiagnosticOptions, + DiagnosticProgressCallback, + DiagnosticResult, + ExpectedTreeNode, +} 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 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/integrityVerificationStream.ts b/js/sdk/src/diagnostic/integrityVerificationStream.ts new file mode 100644 index 00000000..8db0db15 --- /dev/null +++ b/js/sdk/src/diagnostic/integrityVerificationStream.ts @@ -0,0 +1,54 @@ +import { sha1 } from '@noble/hashes/legacy'; + +/** + * 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 = this.sha1Hash.digest().toHex(); + 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; + } +} diff --git a/js/sdk/src/diagnostic/interface.ts b/js/sdk/src/diagnostic/interface.ts new file mode 100644 index 00000000..fb1a2067 --- /dev/null +++ b/js/sdk/src/diagnostic/interface.ts @@ -0,0 +1,234 @@ +import { AnonymousUser, Author, MaybeNode, MetricEvent, NodeType } from '../interface'; +import { LogRecord } from '../telemetry'; + +export interface Diagnostic { + verifyMyFiles( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; + verifyNodeTree( + node: MaybeNode, + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; + verifyPhotosTimeline( + options?: DiagnosticOptions, + onProgress?: DiagnosticProgressCallback, + ): AsyncGenerator; + getNodeTreeStructure(node: MaybeNode): Promise; + getPhotosTimelineStructure(): Promise; +} + +export type DiagnosticOptions = { + verifyContent?: boolean | 'peakOnly'; + verifyThumbnails?: boolean; + expectedStructure?: ExpectedTreeNode; +}; + +// 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 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: { + allNodesLoaded: boolean; + loadedNodes: number; + checkedNodes: number; +}) => void; + +export type DiagnosticResult = + | FatalErrorResult + | SdkErrorResult + | HttpErrorResult + | DegradedNodeResult + | UnverifiedAuthorResult + | ExtendedAttributesErrorResult + | ExtendedAttributesMissingFieldResult + | ContentFileMissingRevisionResult + | ContentIntegrityErrorResult + | ContentDownloadErrorResult + | ThumbnailsErrorResult + | ExpectedStructureMissingNode + | ExpectedStructureUnexpectedNode + | ExpectedStructureIntegrityError + | 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'; +} & NodeDetails; + +// Event representing that signature verification failing. +export type UnverifiedAuthorResult = { + type: 'unverified_author'; + authorType: string; + claimedAuthor?: string | AnonymousUser; + error: string; +} & 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'; + 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'; + missingField: 'sha1'; +} & NodeDetails; + +// Event representing that file is missing the active revision. +export type ContentFileMissingRevisionResult = { + type: 'content_file_missing_revision'; +} & NodeDetails; + +// Event representing that file content is not valid - either sha1 or size is not correct. +export type ContentIntegrityErrorResult = { + type: 'content_integrity_error'; + 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'; + error: unknown; +} & NodeDetails; + +// Event representing that getting the thumbnails failed. +// This can be connection issue or server error. +export type ThumbnailsErrorResult = { + type: 'thumbnails_error'; + 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: ExpectedTreeNode; + 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: ExpectedTreeNode; + claimedSha1?: string; + claimedSizeInBytes?: number; +} & NodeDetails; + +// 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; +}; + +export type NodeDetails = { + safeNodeDetails: { + nodeUid: string; + revisionUid: string | undefined; + nodeType: NodeType; + mediaType: string | undefined; + nodeCreationTime: Date; + keyAuthor: Author; + nameAuthor: Author; + contentAuthor: Author | undefined; + errors: { + field: string; + error: unknown; + }[]; + }; + sensitiveNodeDetails: MaybeNode; +}; diff --git a/js/sdk/src/diagnostic/nodeUtils.ts b/js/sdk/src/diagnostic/nodeUtils.ts new file mode 100644 index 00000000..d8fef83f --- /dev/null +++ b/js/sdk/src/diagnostic/nodeUtils.ts @@ -0,0 +1,100 @@ +import { MaybeNode, NodeType, Revision } from '../interface'; +import { + ExpectedTreeNode, + NodeDetails, +} 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/sdkDiagnosticBase.ts b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts new file mode 100644 index 00000000..964c3199 --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticBase.ts @@ -0,0 +1,390 @@ +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, +} from './interface'; +import { + getActiveRevision, + getExpectedTreeNodeDetails, + getMediaType, + getNodeDetails, + getNodeName, + getNodeType, +} from './nodeUtils'; + +const PROGRESS_REPORT_INTERVAL = 500; + +interface SDKClient { + getFileDownloader(nodeOrUid: NodeOrUid): Promise; + iterateThumbnails(nodeUids: string[], thumbnailType: ThumbnailType): AsyncGenerator; +} + +/** + * Base class for all SDK diagnostic tools that verifies the integrity of + * the individual nodes. + */ +export class SDKDiagnosticBase { + private options: Pick; + + private onProgress?: DiagnosticProgressCallback; + private progressReportInterval: NodeJS.Timeout | undefined; + + protected nodesQueue: { node: MaybeNode; expected?: ExpectedTreeNode }[] = []; + protected allNodesLoaded: boolean = false; + protected loadedNodes: number = 0; + protected checkedNodes: number = 0; + + constructor( + private sdkClient: SDKClient, + options?: Pick, + onProgress?: DiagnosticProgressCallback, + ) { + this.sdkClient = sdkClient; + this.options = options || { verifyContent: false, verifyThumbnails: false }; + this.onProgress = onProgress; + } + + protected startProgress(): void { + this.allNodesLoaded = false; + this.loadedNodes = 0; + this.checkedNodes = 0; + + this.reportProgress(); + this.progressReportInterval = setInterval(() => { + this.reportProgress(); + }, PROGRESS_REPORT_INTERVAL); + } + + protected 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, + }); + } + + protected async *verifyExpectedNodeChildren( + parentNodeUid: string, + children: MaybeNode[], + expectedStructure?: ExpectedTreeNode, + ): 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), + }; + } + } + } + + protected 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?: ExpectedTreeNode): AsyncGenerator { + if (!node.ok) { + 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, + ); + + const activeRevision = getActiveRevision(node); + if (activeRevision) { + yield* this.verifyAuthor(activeRevision.contentAuthor, 'content', node, expectedStructure); + } + + yield* this.verifyFileExtendedAttributes(node, expectedStructure); + + 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: 'key' | 'name' | 'content', + node: MaybeNode, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { + if (!author.ok) { + yield { + type: 'unverified_author', + authorType, + claimedAuthor: author.error.claimedAuthor, + error: author.error.error, + ...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( + node: MaybeNode, + expectedStructure?: ExpectedTreeNode, + ): AsyncGenerator { + const activeRevision = getActiveRevision(node); + + const isNodeWithContent = this.isNodeWithContent(node); + + const claimedSha1 = activeRevision?.claimedDigests?.sha1; + const claimedSizeInBytes = activeRevision?.claimedSize; + + if (isNodeWithContent && claimedSha1 && !/^[0-9a-f]{40}$/i.test(claimedSha1)) { + yield { + type: 'extended_attributes_error', + field: 'sha1', + value: claimedSha1, + ...getNodeDetails(node), + }; + } + + if (isNodeWithContent && !claimedSha1) { + yield { + type: 'extended_attributes_missing_field', + missingField: 'sha1', + ...getNodeDetails(node), + }; + } + + if (expectedStructure) { + const expectedSha1 = expectedStructure.expectedSha1; + const expectedSizeInBytes = 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(expectedStructure), + ...getNodeDetails(node), + }; + } + } + } + + private async *verifyContentPeak(node: MaybeNode): AsyncGenerator { + if (!this.isNodeWithContent(node)) { + 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 (!this.isNodeWithContent(node)) { + return; + } + const activeRevision = getActiveRevision(node); + if (!activeRevision) { + yield { + type: 'content_file_missing_revision', + ...getNodeDetails(node), + }; + 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; + } + + const claimedSha1 = activeRevision.claimedDigests?.sha1; + const claimedSizeInBytes = downloader.getClaimedSizeInBytes(); + + const integrityVerificationStream = new IntegrityVerificationStream(); + const controller = downloader.downloadToStream(integrityVerificationStream); + + try { + await controller.completion(); + await integrityVerificationStream.close(); + + const computedSha1 = integrityVerificationStream.computedSha1; + const computedSizeInBytes = integrityVerificationStream.computedSizeInBytes; + if (claimedSha1 !== computedSha1 || claimedSizeInBytes !== computedSizeInBytes) { + yield { + type: 'content_integrity_error', + claimedSha1, + computedSha1, + claimedSizeInBytes, + computedSizeInBytes, + ...getNodeDetails(node), + }; + } + } catch (error: unknown) { + yield { + type: 'content_download_error', + error, + ...getNodeDetails(node), + }; + } + } + + private async *verifyThumbnails(node: MaybeNode): 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)); + + 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', + error: result[0].error, + ...getNodeDetails(node), + }; + } + } catch (error: unknown) { + yield { + type: 'sdk_error', + call: `iterateThumbnails(${nodeUid})`, + error, + }; + } + } + + 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; + } +} diff --git a/js/sdk/src/diagnostic/sdkDiagnosticMain.ts b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts new file mode 100644 index 00000000..33f712c0 --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticMain.ts @@ -0,0 +1,134 @@ +import { MaybeNode, NodeType } from '../interface'; +import { ProtonDriveClient } from '../protonDriveClient'; +import { + DiagnosticOptions, + DiagnosticProgressCallback, + DiagnosticResult, + ExpectedTreeNode, + TreeNode, +} from './interface'; +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 + * 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)), + ); + } + } + } + + 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 new file mode 100644 index 00000000..e185da0f --- /dev/null +++ b/js/sdk/src/diagnostic/sdkDiagnosticPhotos.ts @@ -0,0 +1,115 @@ +import { MaybeNode } from '../interface'; +import { ProtonDrivePhotosClient } from '../protonDrivePhotosClient'; +import { + DiagnosticOptions, + DiagnosticProgressCallback, + DiagnosticResult, + ExpectedTreeNode, + TreeNode, +} from './interface'; +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 + * 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; + } + + 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; + } +} diff --git a/js/sdk/src/diagnostic/telemetry.ts b/js/sdk/src/diagnostic/telemetry.ts new file mode 100644 index 00000000..cb080bcf --- /dev/null +++ b/js/sdk/src/diagnostic/telemetry.ts @@ -0,0 +1,77 @@ +import { MetricEvent } from '../interface'; +import { LogLevel, LogRecord } 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, + }); + }); + } + + recordMetric(event: MetricEvent): void { + if (event.eventName === 'download' && !event.error) { + return; + } + if (event.eventName === 'volumeEventsSubscriptionsChanged') { + return; + } + if (event.eventName === 'performance') { + 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..3633826f --- /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..26d60ac6 --- /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/errors.ts b/js/sdk/src/errors.ts new file mode 100644 index 00000000..1fea891d --- /dev/null +++ b/js/sdk/src/errors.ts @@ -0,0 +1,177 @@ +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. + */ +export class ProtonDriveError extends Error { + name = 'ProtonDriveError'; +} + +/** + * Error thrown when the operation is aborted. + * + * This error is thrown when the operation is aborted by the user. + * For example, by calling `abort()` on the `AbortSignal`. + */ +export class AbortError extends ProtonDriveError { + name = 'AbortError'; + + constructor(message?: string) { + super(message || c('Error').t`Operation aborted`); + } +} + +/** + * Error thrown when the validation fails. + * + * This error is thrown when the validation of the input fails. + * Validation can be done on the client side or on the server side. + * + * For example, on the client, it can be thrown when the node name doesn't + * follow the required format, etc., while on the server side, it can be thrown + * when there is not enough permissions, etc. + */ +export class ValidationError extends ProtonDriveError { + name = 'ValidationError'; + + /** + * Internal API code. + * + * Use only for debugging purposes. + */ + public readonly 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; + } +} + +/** + * Error thrown when the node already exists. + * + * This error is thrown when the node with the same name already exists in the + * parent folder. The client should ask the user to replace the existing node + * or choose another name. The available name is provided in the `availableName` + * property (that will contain original name with the index that can be used). + */ +export class NodeWithSameNameExistsValidationError extends ValidationError { + name = 'NodeWithSameNameExistsValidationError'; + + public readonly existingNodeUid?: string; + + public readonly isUnfinishedUpload: boolean; + + constructor(message: string, code: number, existingNodeUid?: string, isUnfinishedUpload = false) { + super(message, code); + this.existingNodeUid = existingNodeUid; + this.isUnfinishedUpload = isUnfinishedUpload; + } +} + +/** + * 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 { + name = 'ServerError'; + + /** + * HTTP status code of the response. + * + * Use only for debugging purposes. + */ + public readonly statusCode?: number; + /** + * Internal API code. + * + * Use only for debugging purposes. + */ + public readonly code?: number; +} + +/** + * Error thrown when the client makes too many requests to the API. + * + * SDK is configured to stay below the rate limits, but it can still happen if + * client is running multiple SDKs in parallel, or if the rate limits are + * changed on the server side. + * + * SDK automatically retries the request before the error is thrown after + * waiting for the required time specified by the server. + * + * Client should slow down calling SDK when this error is thrown. + * + * You can be also notified about the rate limits by the `requestsThrottled` + * event. See `onMessage` method on the SDK class for more details. + */ +export class RateLimitedError extends ServerError { + name = 'RateLimitedError'; + + code = 429; +} + +/** + * Error thrown when the client is not connected to the internet. + * + * Client should check the internet connection when this error is thrown. + * + * You can also be notified about the connection status by the `offline` event + * See `onMessage` method on the SDK class for more details. + */ +export class ConnectionError extends ProtonDriveError { + name = 'ConnectionError'; +} + +/** + * Error thrown when the decryption fails. + * + * Client should report this error to the user and report bug report. + * + * In most cases, there is no decryption error. Every decryption error should + * be not exposed but set as empty value on the node, for example. But in the + * case of the file content, if block cannot be decrypted, decryption error + * is thrown. + */ +export class DecryptionError extends ProtonDriveError { + name = 'DecryptionError'; +} + +/** + * Error thrown when the data integrity check fails. + * + * Client should report this error to the user and report bug report. + * + * For example, it can happen when hashes don't match, etc. In some cases, + * SDK allows to run command without verification checks for debug purposes. + */ +export class IntegrityError extends ProtonDriveError { + name = 'IntegrityError'; + + public readonly debug?: object; + + constructor(message: string, debug?: object, options?: ErrorOptions) { + super(message, options); + this.debug = debug; + } +} diff --git a/js/sdk/src/featureFlags.ts b/js/sdk/src/featureFlags.ts new file mode 100644 index 00000000..8188ebe5 --- /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, signal?: AbortSignal): Promise { + return Promise.resolve(false); + } +} diff --git a/js/sdk/src/index.ts b/js/sdk/src/index.ts new file mode 100644 index 00000000..e6a06e7d --- /dev/null +++ b/js/sdk/src/index.ts @@ -0,0 +1,32 @@ +/** + * Use only what is exported here. This is the public supported API of the SDK. + */ + +import { makeNodeUid } from './internal/uids'; + +export * from './cache'; +export type { OpenPGPCrypto } from './crypto'; +export { OpenPGPCryptoWithCryptoProxy } from './crypto'; +export * from './errors'; +export { NullFeatureFlagProvider } from './featureFlags'; +export * from './interface'; +export type { CoreApiEvent, EventScheduler, EventSubscription } from './internal/events'; +export { ProtonDriveClient } from './protonDriveClient'; +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). + * @returns The node UID. + */ +export function generateNodeUid(volumeId: string, nodeId: string) { + return makeNodeUid(volumeId, nodeId); +} diff --git a/js/sdk/src/interface/account.ts b/js/sdk/src/interface/account.ts new file mode 100644 index 00000000..ba4df03a --- /dev/null +++ b/js/sdk/src/interface/account.ts @@ -0,0 +1,45 @@ +import { PrivateKey, PublicKey } from '../crypto'; + +export interface ProtonDriveAccount { + /** + * Get own primary address. + * + * @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. + * + * @throws Error If there is no address with given email or addressId. + */ + getOwnAddress(emailOrAddressId: string): Promise; + /** + * Returns whether given email can be used to share files with Proton Drive. + */ + hasProtonAccount(email: string): Promise; + /** + * Get public keys for given email. + * + * Does not throw if there is no public key for given email, but returns empty array. + * + * @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; +} + +export interface ProtonDriveAccountAddress { + email: string; + addressId: string; + primaryKeyIndex: number; + keys: { + id: string; + key: PrivateKey; + }[]; +} diff --git a/js/sdk/src/interface/author.ts b/js/sdk/src/interface/author.ts new file mode 100644 index 00000000..b12661b6 --- /dev/null +++ b/js/sdk/src/interface/author.ts @@ -0,0 +1,29 @@ +import { Result } from './result'; + +/** + * Author with verification status. + * + * It can be either a string (email) or an anonymous user. + * + * If author cannot be verified, the result is failure with an error. + * The client can still get claimed author from the error object, but + * it must be used with caution. + */ +export type Author = Result; + +/** + * Anonymous user. Used when user shares folder publicly and anonymous + * users can access the folder and upload new files without being logged in. + */ +export type AnonymousUser = null; + +/** + * Unverified author. + * + * If author cannot be verified, the result is this object containing + * the claimed author and the verification error. + */ +export type UnverifiedAuthorError = { + claimedAuthor?: string | AnonymousUser; + error: string; +}; diff --git a/js/sdk/src/interface/config.ts b/js/sdk/src/interface/config.ts new file mode 100644 index 00000000..a74a4af4 --- /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/devices.ts b/js/sdk/src/interface/devices.ts new file mode 100644 index 00000000..ae3f17ad --- /dev/null +++ b/js/sdk/src/interface/devices.ts @@ -0,0 +1,21 @@ +import { InvalidNameError } from './nodes'; +import { Result } from './result'; + +export type Device = { + 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', + MacOS = 'MacOS', + Linux = 'Linux', +} + +export type DeviceOrUid = Device | string; diff --git a/js/sdk/src/interface/download.ts b/js/sdk/src/interface/download.ts new file mode 100644 index 00000000..4df72edd --- /dev/null +++ b/js/sdk/src/interface/download.ts @@ -0,0 +1,97 @@ +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; + + /** + * 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 + */ + downloadToStream(streamFactory: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController; + + /** + * Same as `downloadToStream` but without verification checks. + * + * Use this only for debugging purposes. + */ + unsafeDownloadToStream( + 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 `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 + * 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 { + 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 { + /** + * 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/events.ts b/js/sdk/src/interface/events.ts new file mode 100644 index 00000000..51957e4a --- /dev/null +++ b/js/sdk/src/interface/events.ts @@ -0,0 +1,80 @@ +export enum SDKEvent { + TransfersPaused = 'transfersPaused', + TransfersResumed = 'transfersResumed', + RequestsThrottled = 'requestsThrottled', + RequestsUnthrottled = 'requestsUnthrottled', +} + +export interface LatestEventIdProvider { + getLatestEventId(treeEventScopeId: string): Promise; +} + +/** + * 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; + +export type DriveEvent = + | NodeEvent + | FastForwardEvent + | TreeRefreshEvent + | TreeRemovalEvent + | SharedWithMeUpdated; + +export type NodeEvent = + | { + type: DriveEventType.NodeCreated | DriveEventType.NodeUpdated; + 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; +}; + +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 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/featureFlags.ts b/js/sdk/src/interface/featureFlags.ts new file mode 100644 index 00000000..33e28e48 --- /dev/null +++ b/js/sdk/src/interface/featureFlags.ts @@ -0,0 +1,12 @@ +/** + * Provides feature flag evaluation for controlling SDK behavior. + * Applications must supply their own implementation. + */ +export interface FeatureFlagProvider { + isEnabled(flagName: FeatureFlags, signal?: AbortSignal): Promise; +} + +export enum FeatureFlags { + DriveCryptoEncryptBlocksWithPgpAead = 'DriveCryptoEncryptBlocksWithPgpAead', + DriveSmallFileUpload = 'DriveSmallFileUpload', +} diff --git a/js/sdk/src/interface/httpClient.ts b/js/sdk/src/interface/httpClient.ts new file mode 100644 index 00000000..d7dab545 --- /dev/null +++ b/js/sdk/src/interface/httpClient.ts @@ -0,0 +1,27 @@ +export interface ProtonDriveHTTPClient { + fetchJson(request: ProtonDriveHTTPClientJsonRequest): Promise; + fetchBlob(request: ProtonDriveHTTPClientBlobRequest): Promise; +} + +export type ProtonDriveHTTPClientJsonRequest = ProtonDriveHTTPClientBaseRequest & { + json?: object; + body?: XMLHttpRequestBodyInit; +}; + +export type ProtonDriveHTTPClientBlobRequest = ProtonDriveHTTPClientBaseRequest & { + body?: XMLHttpRequestBodyInit; + onProgress?: (progress: number) => void; +}; + +type ProtonDriveHTTPClientBaseRequest = { + url: string; + method: string; + headers: Headers; + /** + * The timeout in milliseconds. + * + * When timeout is reached, the request will be aborted with TimeoutError. + */ + timeoutMs: number; + signal?: AbortSignal; +}; diff --git a/js/sdk/src/interface/index.ts b/js/sdk/src/interface/index.ts new file mode 100644 index 00000000..68a40af6 --- /dev/null +++ b/js/sdk/src/interface/index.ts @@ -0,0 +1,137 @@ +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 { FeatureFlagProvider } from './featureFlags'; +import { ProtonDriveHTTPClient } from './httpClient'; +import { MetricEvent, Telemetry } from './telemetry'; + +export type { ProtonDriveAccount, ProtonDriveAccountAddress } from './account'; +export type { AnonymousUser, Author, UnverifiedAuthorError } from './author'; +export type { ProtonDriveConfig } from './config'; +export type { Device, DeviceOrUid } from './devices'; +export { DeviceType } from './devices'; +export type { DownloadController, FileDownloader, SeekableReadableStream } from './download'; +export type { + DriveEvent, + DriveListener, + FastForwardEvent, + LatestEventIdProvider, + NodeEvent, + SharedWithMeUpdated, + TreeRefreshEvent, + TreeRemovalEvent, +} from './events'; +export { DriveEventType, SDKEvent } from './events'; +export type { FeatureFlagProvider } from './featureFlags'; +export { FeatureFlags } from './featureFlags'; +export type { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, +} from './httpClient'; +export type { + DegradedNode, + InvalidNameError, + MaybeMissingNode, + MaybeNode, + Membership, + MissingNode, + NodeEntity, + NodeOrUid, + NodeResult, + NodeResultWithError, + NodeResultWithNewUid, + Revision, + RevisionOrUid, +} from './nodes'; +export { MemberRole, NodeType, RevisionState } from './nodes'; +export type { + AlbumAttributes, + DegradedPhotoNode, + MaybeMissingPhotoNode, + MaybePhotoNode, + PhotoAttributes, + PhotoNode, +} from './photos'; +export { PhotoTag } from './photos'; +export type { Result } from './result'; +export { resultError, resultOk } from './result'; +export type { + Bookmark, + BookmarkOrUid, + DegradedBookmark, + MaybeBookmark, + Member, + NonProtonInvitation, + NonProtonInvitationOrUid, + ProtonInvitation, + ProtonInvitationOrUid, + ProtonInvitationWithNode, + PublicLink, + ShareMembersSettings, + ShareNodeSettings, + SharePublicLinkSettings, + SharePublicLinkSettingsObject, + ShareResult, + UnshareNodeSettings, +} from './sharing'; +export { NonProtonInvitationState } from './sharing'; +export type { + Logger, + MetricAPIRetrySucceededEvent, + MetricBlockVerificationErrorEvent, + MetricDebounceLongWaitEvent, + MetricDecryptionErrorEvent, + MetricDownloadEvent, + MetricEvent, + MetricPerformanceEvent, + MetricsDecryptionErrorField, + MetricsDownloadErrorType, + MetricsUploadErrorType, + MetricUploadEvent, + MetricVerificationErrorEvent, + MetricVerificationErrorField, + MetricVolumeEventsSubscriptionsChangedEvent, + Telemetry, +} from './telemetry'; +export { MetricVolumeType } from './telemetry'; +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; +export type ProtonDriveCryptoCache = ProtonDriveCache; +export type CachedCryptoMaterial = { + 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 { + httpClient: ProtonDriveHTTPClient; + entitiesCache: ProtonDriveEntitiesCache; + cryptoCache: ProtonDriveCryptoCache; + account: ProtonDriveAccount; + openPGPCryptoModule: OpenPGPCrypto; + srpModule: SRPModule; + config?: ProtonDriveConfig; + telemetry?: ProtonDriveTelemetry; + featureFlagProvider?: FeatureFlagProvider; + latestEventIdProvider?: LatestEventIdProvider; +} diff --git a/js/sdk/src/interface/nodes.ts b/js/sdk/src/interface/nodes.ts new file mode 100644 index 00000000..e90bea7f --- /dev/null +++ b/js/sdk/src/interface/nodes.ts @@ -0,0 +1,241 @@ +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. + * + * In most cases, SDK returns `MaybeNode`, but in some specific cases, when + * client is requesting specific nodes, SDK must return `MissingNode` type + * to indicate the case when the node is not available. That can be when + * the node does not exist, or when the node is not available for the user + * (e.g. unshared with the user). + */ +export type MaybeMissingNode = Result; + +export type MissingNode = { + missingUid: string; +}; + +/** + * Node representing a file or folder in the system. + * + * This is a happy path representation of the node. It is used in the SDK to + * represent the node in a way that is easy to work with. Whenever any field + * cannot be decrypted, it is returned as `DegradedNode` type. + * + * SDK never returns this entity directly but wrapped in `MaybeNode`. + * + * Note on naming: Node is reserved by JS/DOM, thus we need exception how the + * entity is called. + */ +export type NodeEntity = { + uid: string; + parentUid?: string; + name: 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 + * option to decrypt latest versions. + */ + keyAuthor: Author; + /** + * Author of the name. + * + * Person who named the file. If user A uploads the file and user B renames + * the file, key and content author is user A, while name author is user B. + */ + nameAuthor: Author; + /** + * 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; + /** + * Owner of the node (who owns the volume where the node is located). + */ + ownedBy: { + email?: string; + organization?: string; + }; + 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; + /** + * 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. + * + * 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. + */ + creationTime: Date; + /** + * Modified on server (renamed, moved, etc.). + */ + modificationTime: Date; + trashTime?: Date; + /** + * Total size of all revisions, encrypted size on the server. + */ + totalStorageSize?: number; + activeRevision?: Revision; + 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. + */ +export type DegradedNode = Omit & { + name: Result; + activeRevision?: Result; + /** + * If the error is not related to any specific field, it is set here. + * + * For example, if the node has issue decrypting the name, the name will be + * set 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. + */ + errors?: unknown[]; +}; + +/** + * Invalid name error represents node name that includes invalid characters. + */ +export type InvalidNameError = { + /** + * Placeholder instead of node name that client can use to display. + */ + name: string; + error: string; +}; + +export enum NodeType { + File = 'file', + Folder = 'folder', + /** + * Album is returned only by `ProtonDrivePhotosClient`. + */ + Album = 'album', + /** + * Photo is returned only by `ProtonDrivePhotosClient`. + */ + Photo = 'photo', +} + +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', + Admin = 'admin', + Inherited = 'inherited', +} + +export type Revision = { + uid: string; + state: RevisionState; + creationTime: Date; // created on server date + contentAuthor: Author; + /** + * Encrypted size of the revision, as stored on the server. + */ + storageSize: number; + /** + * Raw size of the revision, as stored in extended attributes. + */ + claimedSize?: number; + /** + * Modification time on the file system. + */ + claimedModificationTime?: Date; + claimedDigests?: { + sha1?: string; + sha1Verified: boolean; + }; + claimedAdditionalMetadata?: object; +}; + +export enum RevisionState { + Active = 'active', + Superseded = 'superseded', +} + +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 NodeResultWithNewUid = { uid: string; newUid: 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 new file mode 100644 index 00000000..86955678 --- /dev/null +++ b/js/sdk/src/interface/photos.ts @@ -0,0 +1,102 @@ +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; + +/** + * 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 | NodeType.Album; + photo?: PhotoAttributes; + 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. + * + * 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: PhotoTag[]; +}; + +export enum PhotoTag { + Favorites = 0, + Screenshots = 1, + Videos = 2, + LivePhotos = 3, + MotionPhotos = 4, + Selfies = 5, + Portraits = 6, + Bursts = 7, + Panoramas = 8, + Raw = 9, +} + +/** + * Attributes of an album. + * + * Only nodes of type `NodeType.Album` have property of this type. + */ +export type AlbumAttributes = { + /** + * Number of photos in the album. + */ + photoCount: number; + /** + * 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/interface/result.ts b/js/sdk/src/interface/result.ts new file mode 100644 index 00000000..b43ec69e --- /dev/null +++ b/js/sdk/src/interface/result.ts @@ -0,0 +1,9 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export function resultOk(value: T): Result { + return { ok: true, value }; +} + +export function resultError(error: E): Result { + return { ok: false, error }; +} diff --git a/js/sdk/src/interface/sharing.ts b/js/sdk/src/interface/sharing.ts new file mode 100644 index 00000000..8d2520b4 --- /dev/null +++ b/js/sdk/src/interface/sharing.ts @@ -0,0 +1,117 @@ +import { UnverifiedAuthorError } from './author'; +import { InvalidNameError, MemberRole, NodeType } from './nodes'; +import { Result } from './result'; + +export type Member = { + uid: string; + invitationTime: Date; + addedByEmail: Result; + inviteeEmail: string; + role: MemberRole; +}; + +export type ProtonInvitation = Member; + +export type ProtonInvitationWithNode = ProtonInvitation & { + node: { + uid: string; + name: Result; + type: NodeType; + mediaType?: string; + }; +}; + +export type NonProtonInvitation = ProtonInvitation & { + state: NonProtonInvitationState; +}; + +export enum NonProtonInvitationState { + Pending = 'pending', + UserRegistered = 'userRegistered', +} + +export type PublicLink = { + 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; + customPassword?: string; + node: { + 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; + customPassword: Result; + node: Omit & { + name: Result; + }; +}; + +export type ProtonInvitationOrUid = ProtonInvitation | string; +export type NonProtonInvitationOrUid = NonProtonInvitation | string; +export type BookmarkOrUid = Bookmark | string; + +export type ShareNodeSettings = { + users?: ShareMembersSettings; + publicLink?: SharePublicLinkSettings; + emailOptions?: { + message?: string; + includeNodeName?: boolean; + }; + editorsCanShare?: boolean; +}; + +export type ShareMembersSettings = + | string[] + | { + email: string; + role: MemberRole; + }[]; + +export type SharePublicLinkSettings = boolean | SharePublicLinkSettingsObject; + +export type SharePublicLinkSettingsObject = { + role: MemberRole; + customPassword?: string | undefined; + expiration?: Date | undefined; +}; + +export type ShareResult = { + protonInvitations: ProtonInvitation[]; + nonProtonInvitations: NonProtonInvitation[]; + members: Member[]; + publicLink?: PublicLink; + editorsCanShare: boolean; +}; + +export type UnshareNodeSettings = { + users?: string[]; + publicLink?: 'remove'; +}; diff --git a/js/sdk/src/interface/telemetry.ts b/js/sdk/src/interface/telemetry.ts new file mode 100644 index 00000000..cacc16ce --- /dev/null +++ b/js/sdk/src/interface/telemetry.ts @@ -0,0 +1,141 @@ +export interface Telemetry { + getLogger: (name: string) => Logger; + recordMetric: (event: MetricEvent) => void; +} + +export interface Logger { + debug(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + info(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + warn(msg: string): void; // eslint-disable-line @typescript-eslint/no-explicit-any + error(msg: string, error?: unknown): void; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export type MetricEvent = + | MetricAPIRetrySucceededEvent + | MetricDebounceLongWaitEvent + | MetricUploadEvent + | MetricDownloadEvent + | MetricDecryptionErrorEvent + | MetricVerificationErrorEvent + | MetricBlockVerificationErrorEvent + | MetricVolumeEventsSubscriptionsChangedEvent + | MetricPerformanceEvent; + +export interface MetricAPIRetrySucceededEvent { + eventName: 'apiRetrySucceeded'; + url: string; + failedAttempts: number; + previousError?: unknown; +} + +export interface MetricDebounceLongWaitEvent { + eventName: 'debounceLongWait'; +} + +export interface MetricUploadEvent { + eventName: 'upload'; + volumeType: MetricVolumeType; + uploadedSize: number; + approximateUploadedSize: number; + expectedSize: number; + approximateExpectedSize: number; + error?: MetricsUploadErrorType; + originalError?: unknown; +} +export type MetricsUploadErrorType = + | 'server_error' + | 'network_error' + | 'integrity_error' + | 'rate_limited' + | 'validation_error' + | '4xx' + | 'unknown'; + +export interface MetricDownloadEvent { + eventName: 'download'; + volumeType: MetricVolumeType; + downloadedSize: number; + approximateDownloadedSize: number; + claimedFileSize?: number; + approximateClaimedFileSize?: number; + error?: MetricsDownloadErrorType; + originalError?: unknown; +} +export type MetricsDownloadErrorType = + | 'server_error' + | 'network_error' + | 'decryption_error' + | 'integrity_error' + | 'rate_limited' + | 'validation_error' + | '4xx' + | 'unknown'; + +export interface MetricDecryptionErrorEvent { + eventName: 'decryptionError'; + volumeType: MetricVolumeType; + field: MetricsDecryptionErrorField; + fromBefore2024?: boolean; + error?: unknown; + uid: string; +} +export type MetricsDecryptionErrorField = + | 'shareKey' + | 'shareUrlPassword' + | 'nodeKey' + | 'nodeName' + | 'nodeHashKey' + | 'nodeExtendedAttributes' + | 'nodeContentKey' + | 'content'; + +export interface MetricVerificationErrorEvent { + eventName: 'verificationError'; + volumeType: MetricVolumeType; + field: MetricVerificationErrorField; + addressMatchingDefaultShare?: boolean; + fromBefore2024?: boolean; + error?: unknown; + uid: string; +} +export type MetricVerificationErrorField = + | 'shareKey' + | 'membershipInviter' + | 'membershipInvitee' + | 'nodeKey' + | 'nodeName' + | 'nodeHashKey' + | 'nodeExtendedAttributes' + | 'nodeContentKey' + | 'content'; + +export interface MetricBlockVerificationErrorEvent { + eventName: 'blockVerificationError'; + volumeType: MetricVolumeType; + retryHelped: boolean; +} + +export interface MetricVolumeEventsSubscriptionsChangedEvent { + eventName: 'volumeEventsSubscriptionsChanged'; + numberOfVolumeSubscriptions: number; +} + +export enum MetricVolumeType { + Unknown = 'unknown', + OwnVolume = 'own_volume', + OwnPhotoVolume = 'own_photo_volume', + 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/interface/thumbnail.ts b/js/sdk/src/interface/thumbnail.ts new file mode 100644 index 00000000..e8cafb94 --- /dev/null +++ b/js/sdk/src/interface/thumbnail.ts @@ -0,0 +1,13 @@ +export type Thumbnail = { + type: ThumbnailType; + thumbnail: Uint8Array; +}; + +export enum ThumbnailType { + Type1 = 1, + Type2 = 2, +} + +export type ThumbnailResult = + | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: false; error: string }; diff --git a/js/sdk/src/interface/upload.ts b/js/sdk/src/interface/upload.ts new file mode 100644 index 00000000..003bc99c --- /dev/null +++ b/js/sdk/src/interface/upload.ts @@ -0,0 +1,81 @@ +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; + /** + * 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. + * + * 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; + /** + * 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 FileUploader { + /** + * 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. + */ + uploadFromStream( + 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. + */ + uploadFromFile( + fileObject: File, + thumnbails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise; +} + +export interface UploadController { + pause(): void; + resume(): void; + completion(): Promise<{ nodeRevisionUid: string, nodeUid: string }>; +} diff --git a/js/sdk/src/internal/apiService/apiService.test.ts b/js/sdk/src/internal/apiService/apiService.test.ts new file mode 100644 index 00000000..38615d4c --- /dev/null +++ b/js/sdk/src/internal/apiService/apiService.test.ts @@ -0,0 +1,511 @@ +import { AbortError } from '../../errors'; +import { MetricEvent, ProtonDriveHTTPClient, SDKEvent, Telemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { SDKEvents } from '../sdkEvents'; +import { DriveAPIService } from './apiService'; +import { ErrorCode, HTTPErrorCode } from './errorCodes'; + +jest.useFakeTimers(); + +function generateOkResponse() { + return new Response(JSON.stringify({ Code: ErrorCode.OK }), { status: HTTPErrorCode.OK }); +} + +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(), + 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])))), + }; + api = new DriveAPIService(telemetry, sdkEvents, httpClient, baseUrl, 'en'); + }); + + function expectSDKEvents(...events: SDKEvent[]) { + expect(sdkEvents.transfersPaused).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersPaused) ? 1 : 0); + expect(sdkEvents.transfersResumed).toHaveBeenCalledTimes(events.includes(SDKEvent.TransfersResumed) ? 1 : 0); + expect(sdkEvents.requestsThrottled).toHaveBeenCalledTimes(events.includes(SDKEvent.RequestsThrottled) ? 1 : 0); + expect(sdkEvents.requestsUnthrottled).toHaveBeenCalledTimes( + events.includes(SDKEvent.RequestsUnthrottled) ? 1 : 0, + ); + } + + 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'); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectFetchJsonToBeCalledWith('GET'); + }); + + 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 () => { + const result = await api.put('test', { data: 'test' }); + expect(result).toEqual({ Code: ErrorCode.OK }); + await expectFetchJsonToBeCalledWith('PUT', { data: 'test' }); + }); + + async function expectFetchJsonToBeCalledWith(method: string, data?: object) { + // @ts-expect-error: Fetch is mock. + const request = httpClient.fetchJson.mock.calls[0][0]; + expect(request.method).toEqual(method); + expect(request.timeoutMs).toEqual(30000); + expect(Array.from(request.headers.entries())).toEqual( + Array.from( + new Headers({ + Accept: 'application/vnd.protonmail.v1+json', + 'Content-Type': 'application/json', + Language: 'en', + 'x-pm-drive-sdk-version': `js@${process.env.npm_package_version}`, + }).entries(), + ), + ); + expect(await request.json).toEqual(data); + expectSDKEvents(); + 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); + expect(result).toEqual([new Uint8Array([1, 2, 3])]); + await expectFetchBlobToBeCalledWith('GET'); + }); + + it('storage POST request', async () => { + const data = new Blob(); + await api.postBlockStream('test', 'token', data); + await expectFetchBlobToBeCalledWith('POST', data); + }); + + async function expectFetchBlobToBeCalledWith(method: string, data?: object) { + // @ts-expect-error: Fetch is mock. + const request = httpClient.fetchBlob.mock.calls[0][0]; + expect(request.method).toEqual(method); + expect(request.timeoutMs).toEqual(600_000); + 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(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + } + }); + + 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(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + 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(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + 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(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + }); + + describe('should retry', () => { + it('on offline error', async () => { + const error = new Error('Network offline'); + error.name = 'OfflineError'; + 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 timeout error', async () => { + const error = new Error('Timeouted'); + error.name = 'TimeoutError'; + 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 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()); + + 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 () => { + 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'); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('on 429 response with default timeout', async () => { + jest.useFakeTimers(); + 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'); + + // 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(); + expectMetricEvent(429, 2); + }); + + 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); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expectSDKEvents(); + expectMetricEvent(429, 2); + }); + + 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'); + + await expect(result).resolves.toEqual({ Code: ErrorCode.OK }); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); + expectSDKEvents(); + expectMetricEvent(500, 1); + }); + + 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'); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(2); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + }); + + 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(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + 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'); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(50); + expectSDKEvents(SDKEvent.RequestsThrottled); + + // SDK will not send any requests for 60 seconds. + jest.advanceTimersByTime(90 * 1000); + 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 () => { + 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' }); + }); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).resolves.toEqual({ Code: ErrorCode.OK }); + // 20 calls * 5 retries till OK response + 1 last successful call + expect(httpClient.fetchJson).toHaveBeenCalledTimes(101); + expectSDKEvents(); + expectMetricEvent(429, 4); + }); + + 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'); + expect(httpClient.fetchJson).toHaveBeenCalledTimes(10); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + 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' }); + }); + + for (let i = 0; i < 20; i++) { + await api.get('test').catch(() => {}); + } + + await expect(api.get('test')).rejects.toThrow('Some error'); + // 15 erroring calls * 2 attempts + 5 successful calls + expect(httpClient.fetchJson).toHaveBeenCalledTimes(35); + expectSDKEvents(); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + + 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; + + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/js/sdk/src/internal/apiService/apiService.ts b/js/sdk/src/internal/apiService/apiService.ts new file mode 100644 index 00000000..e0825352 --- /dev/null +++ b/js/sdk/src/internal/apiService/apiService.ts @@ -0,0 +1,471 @@ +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'; +import { apiErrorFactory } from './errors'; + +/** + * The default timeout in milliseconds for all API requests (metadata). + */ +const DEFAULT_TIMEOUT_MS = 30000; + +/** + * The default timeout in milliseconds for all storage requests (file content). + */ +const DEFAULT_STORAGE_TIMEOUT_MS = 600_000; + +/** + * Maximum number of retry attempts for a timeout error. + */ +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. + */ +const TOO_MANY_SUBSEQUENT_429_ERRORS = 50; + +/** + * For how long the API service should cool down after reaching the limit + * of subsequent 429 errors. + */ +const TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS = 60; + +/** + * How many subsequent 5xx errors are allowed before we stop further requests. + */ +const TOO_MANY_SUBSEQUENT_SERVER_ERRORS = 10; + +/** + * For how long the API service should cool down after reaching the limit + * of subsequent 5xx errors. + */ +const TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS = 60; + +/** + * 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. + */ +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. + */ +const OFFLINE_RETRY_DELAY_SECONDS = 5; + +/** + * After how long to re-try after 429 error without specified retry-after header. + */ +const DEFAULT_429_RETRY_DELAY_SECONDS = 10; + +/** + * After how long to re-try after general error. + */ +const GENERAL_RETRY_DELAY_SECONDS = 1; + +/** + * Provides API communication used withing the Drive SDK. + * + * The service is responsible for handling general headers, errors, conversion, + * rate limiting, or basic re-tries. + * + * Error handling includes: + * + * * exception from HTTP client + * * retry on offline exc. (with delay from OFFLINE_RETRY_DELAY_SECONDS) + * * retry on 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) + * * if too many subsequent 429s, stop further requests (defined in TOO_MANY_SUBSEQUENT_429_ERRORS) + * * when limit is reached, cool down for TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS + * * HTTP status 5xx + * * retry ONCE (with delay from SERVER_ERROR_RETRY_DELAY_SECONDS) + * * if too many subsequent 5xxs, stop further requests (defined in TOO_MANY_SUBSEQUENT_SERVER_ERRORS) + * * when limit is reached, cool down for TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS + */ +export class DriveAPIService { + private subsequentTooManyRequestsCounter = 0; + private lastTooManyRequestsErrorAt?: number; + + private subsequentServerErrorsCounter = 0; + private lastServerErrorAt?: number; + + private subsequentOfflineErrorsCounter = 0; + + private logger: Logger; + + constructor( + private telemetry: ProtonDriveTelemetry, + private sdkEvents: SDKEvents, + private httpClient: ProtonDriveHTTPClient, + private baseUrl: string, + private language: string, + ) { + this.logger = telemetry.getLogger('api'); + this.sdkEvents = sdkEvents; + this.httpClient = httpClient; + this.baseUrl = baseUrl; + this.language = language; + this.telemetry = telemetry; + } + + async get(url: string, signal?: AbortSignal): Promise { + return this.makeRequest(url, 'GET', undefined, signal); + } + + async post( + url: string, + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { + 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, + signal?: AbortSignal, + ): Promise { + return this.makeRequest(url, 'PUT', data, signal); + } + + async delete( + url: string, + data?: RequestPayload, + signal?: AbortSignal, + ): Promise { + return this.makeRequest(url, 'DELETE', data, signal); + } + + protected async makeRequest( + url: string, + method = 'GET', + 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, + json: isJson && data ? data : undefined, + body: !isJson && data ? data : undefined, + timeoutMs: DEFAULT_TIMEOUT_MS, + signal, + }; + + const response = await this.fetch(request, () => this.httpClient.fetchJson(request)); + + try { + const result = await response.json(); + + if (!response.ok || !isCodeOk(result.Code)) { + throw apiErrorFactory({ response, result }); + } + if (isCodeOkAsync(result.Code)) { + this.logger.info(`${request.method} ${request.url}: deferred action`); + } + return result as ResponsePayload; + } catch (error: unknown) { + if (error instanceof ProtonDriveError) { + throw error; + } + throw apiErrorFactory({ response, error }); + } + } + + async getBlockStream(baseUrl: string, token: string, signal?: AbortSignal): Promise> { + const response = await this.makeStorageRequest('GET', baseUrl, token, undefined, undefined, signal); + if (!response.body) { + throw new Error(c('Error').t`File download failed due to empty response`); + } + return response.body; + } + + async postBlockStream( + baseUrl: string, + token: string, + data: XMLHttpRequestBodyInit, + onProgress?: (uploadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { + await this.makeStorageRequest('POST', baseUrl, token, data, onProgress, signal); + } + + protected async makeStorageRequest( + method: 'GET' | 'POST', + url: string, + token: string, + body?: XMLHttpRequestBodyInit, + onProgress?: (uploadedBytes: number) => void, + signal?: AbortSignal, + ): Promise { + const request = { + url, + method, + headers: new Headers({ + 'pm-storage-token': token, + Language: this.language, + 'x-pm-drive-sdk-version': `js@${VERSION}`, + }), + body, + onProgress, + timeoutMs: DEFAULT_STORAGE_TIMEOUT_MS, + signal, + }; + + const response = await this.fetch(request, () => this.httpClient.fetchBlob(request)); + + if (response.status >= 400) { + try { + const result = await response.json(); + throw apiErrorFactory({ response, result }); + } catch (error: unknown) { + if (error instanceof ProtonDriveError) { + throw error; + } + throw apiErrorFactory({ response, error }); + } + } + return response; + } + + // TODO: add priority header + // u=2 for interactive (user doing action, e.g., create folder), + // u=4 for normal (user secondary action, e.g., refresh children listing), + // u=5 for background (e.g., upload, download) + // u=7 for optional (e.g., metrics, telemetry) + private async fetch( + request: { method: string; url: string; signal?: AbortSignal }, + callback: () => Promise, + { + attempt, + previousError, + }: { + attempt: number; + previousError?: unknown; + } = { + attempt: 0, + }, + ): Promise { + if (request.signal?.aborted) { + throw new AbortError(c('Error').t`Request aborted`); + } + + 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'); + throw new ServerError(c('Error').t`Too many server errors, please try again later`); + } + if (this.hasReachedTooManyRequestsErrorLimit) { + this.logger.warn('Too many requests limit reached'); + throw new RateLimitedError(c('Error').t`Too many server requests, please try again later`); + } + + const start = Date.now(); + + let response; + try { + 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.offlineErrorHappened(); + this.logger.info(`${request.method} ${request.url}: Offline error, retrying`); + await waitSeconds(OFFLINE_RETRY_DELAY_SECONDS); + 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: 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); + await waitSeconds(GENERAL_RETRY_DELAY_SECONDS); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: error }); + } + this.logger.error(`${request.method} ${request.url}: failed`, error); + throw error; + } + + this.clearSubsequentOfflineErrors(); + + const end = Date.now(); + const duration = end - start; + + if (response.ok) { + this.logger.info(`${request.method} ${request.url}: ${response.status} (${duration}ms)`); + } else { + this.logger.warn(`${request.method} ${request.url}: ${response.status} (${duration}ms)`); + } + + if (response.status === HTTPErrorCode.TOO_MANY_REQUESTS) { + 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: attempt + 1, previousError: response.status }); + } else { + this.clearSubsequentTooManyRequestsError(); + } + + // Automatically re-try 5xx glitches on the server, but only once + // and report the incident so it can be followed up. + if (response.status >= 500) { + this.serverErrorHappened(); + + if (attempt > 0) { + this.logger.warn(`${request.method} ${request.url}: ${response.status} - retry failed`); + } else { + await waitSeconds(SERVER_ERROR_RETRY_DELAY_SECONDS); + return this.fetch(request, callback, { attempt: attempt + 1, previousError: response.status }); + } + } else { + if (attempt > 0) { + const previousErrorMessage = + previousError instanceof Error ? previousError.message : String(previousError); + const isWarning = + !(previousError instanceof Error) || + (previousError instanceof Error && + previousError.name !== 'TimeoutError' && + previousError.name !== 'OfflineError' && + !isNetworkError(previousError)); + + 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(); + } + + return response; + } + + private get hasReachedTooManyRequestsErrorLimit(): boolean { + const secondsSinceLast429Error = (Date.now() - (this.lastTooManyRequestsErrorAt || Date.now())) / 1000; + return ( + this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS && + secondsSinceLast429Error < TOO_MANY_SUBSEQUENT_429_ERRORS_TIMEOUT_IN_SECONDS + ); + } + + private tooManyRequestsErrorHappened() { + this.subsequentTooManyRequestsCounter++; + this.lastTooManyRequestsErrorAt = Date.now(); + + // Do not emit event if there is first few 429 errors, only when + // the client is very limited. This is generic event and it doesn't + // take into account that various endpoints can be rate limited + // independently. + if (this.subsequentTooManyRequestsCounter === TOO_MANY_SUBSEQUENT_429_ERRORS) { + this.sdkEvents.requestsThrottled(); + } + } + + private clearSubsequentTooManyRequestsError() { + if (this.subsequentTooManyRequestsCounter >= TOO_MANY_SUBSEQUENT_429_ERRORS) { + this.sdkEvents.requestsUnthrottled(); + } + + this.subsequentTooManyRequestsCounter = 0; + this.lastTooManyRequestsErrorAt = undefined; + } + + private get hasReachedServerErrorLimit(): boolean { + const secondsSinceLastServerError = (Date.now() - (this.lastServerErrorAt || Date.now())) / 1000; + return ( + this.subsequentServerErrorsCounter >= TOO_MANY_SUBSEQUENT_SERVER_ERRORS && + secondsSinceLastServerError < TOO_MANY_SUBSEQUENT_SERVER_ERRORS_TIMEOUT_IN_SECONDS + ); + } + + private serverErrorHappened() { + this.subsequentServerErrorsCounter++; + this.lastServerErrorAt = Date.now(); + } + + private clearSubsequentServerErrors() { + this.subsequentServerErrorsCounter = 0; + this.lastServerErrorAt = undefined; + } + + 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; + } +} diff --git a/js/sdk/src/internal/apiService/coreTypes.ts b/js/sdk/src/internal/apiService/coreTypes.ts new file mode 100644 index 00000000..315d23bc --- /dev/null +++ b/js/sdk/src/internal/apiService/coreTypes.ts @@ -0,0 +1,25742 @@ +/** + * 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/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; + 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/{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}/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}/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}/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; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Setup new non-subuser address. */ + post: operations["post_core-{_version}-addresses-setup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/canonical": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the canonical form of email addresses. */ + get: operations["get_core-{_version}-addresses-canonical"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a single address. */ + get: operations["get_core-{_version}-addresses-{enc_id}"]; + /** + * Update address. + * @description Update display name and/or signature. + */ + put: operations["put_core-{_version}-addresses-{enc_id}"]; + post?: never; + /** + * Delete a Disabled Address. + * @deprecated + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + delete: operations["delete_core-{_version}-addresses-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific domain's addresses. */ + get: operations["get_core-{_version}-domains-{enc_id}-addresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/claimedAddresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get external addresses belonging to users outside the organization + * with the same domain name as the specified domain. */ + get: operations["get_core-{_version}-domains-{enc_id}-claimedAddresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Enable Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-enable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Disable Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-disable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Delete a Disabled Address. + * @description This route now edit the Address & AddressUser objects. In the future, will edit only AddressUser object. + * + * Warning - Locked route + */ + put: operations["put_core-{_version}-addresses-{enc_id}-delete"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/type": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Change address type. + * @description As of now it is possible only to convert an external address into a custom address when a domain has been activated. + */ + put: operations["put_core-{_version}-addresses-{enc_id}-type"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/rename/internal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename address keeping the keys, keeping the same clean email */ + put: operations["put_core-{_version}-addresses-{enc_id}-rename-internal"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_id}/rename/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename unverified external addresses freely (any change is allowed) */ + put: operations["put_core-{_version}-addresses-{enc_id}-rename-external"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/addresses/{enc_addressId}/encryption": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set encryption signature flags. + * @description Allows setting "E2EE disabled" or "Do not expect signed" flags, address wide. + */ + put: operations["put_core-{_version}-addresses-{enc_addressId}-encryption"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/addresses/permissions/organization/switch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Switch an array of permissions for an array of addressIDs owned by the organization. + * @description Only custom addresses are affected. + * Having both PERMISSIONS_SEND_ALL and PERMISSIONS_SEND_ORG in the permissions array is forbidden. + * Having both PERMISSIONS_RECEIVE_ALL and PERMISSIONS_RECEIVE_ORG in the permissions array is forbidden. + */ + put: operations["put_core-{_version}-members-addresses-permissions-organization-switch"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/saml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-{memberId}-saml"]; + delete: operations["delete_core-{_version}-members-{memberId}-saml"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices/{deviceId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-members-{memberId}-devices-{deviceId}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-members-{memberId}-devices"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{id}/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-{id}-devices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/devices/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-members-devices-pending"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + 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; + 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}/auth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: 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}/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; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organization/communities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-organization-communities"]; + 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}/keys/user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-keys-user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organization/{organization_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + 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": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Domains. + * @description Get all domains for this user's organization and check their DNS's + */ + get: operations["get_core-{_version}-domains"]; + put?: never; + /** + * Create Domain. + * @description Create new Domain, Return domain info if success, locked route + * + * Response body on error: + * + * ```json + * { + * "Code": 30106, + * "Error": "Domain setup failed or domain is already in use within Proton Mail", + * "Details": } + * } + * ``` + */ + post: operations["post_core-{_version}-domains"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available domains. */ + get: operations["get_core-{_version}-domains-available"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/premium": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get premium domains. */ + get: operations["get_core-{_version}-domains-premium"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/optin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get opt-in domain if user is eligible. */ + get: operations["get_core-{_version}-domains-optin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Domain. + * @description Get a specific domains and its check DNS + */ + get: operations["get_core-{_version}-domains-{enc_id}"]; + put?: never; + post?: never; + /** + * Delete Domain. + * @description Delete a Domain, locked route + */ + delete: operations["delete_core-{_version}-domains-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/domains/{enc_id}/catchall": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set catch-all address, locked route. */ + put: operations["put_core-{_version}-domains-{enc_id}-catchall"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/subsidiaries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + 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; + 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/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/owners/add/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-owners-add-{enc_id}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-groups"]; + put?: never; + post: operations["post_core-{_version}-groups"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/unsubscribe/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-groups-unsubscribe-{jwt}"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-{enc_id}"]; + post?: never; + delete: operations["delete_core-{_version}-groups-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_core-{_version}-groups-members-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/members/{groupMemberId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-groups-members-{groupMemberId}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/external/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-members-external-{jwt}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/{group_enc_id}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-{group_enc_id}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_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-internal"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/groups/{group_enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-groups-{group_enc_id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/v4/groups/members/{group_member_enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-v4-groups-members-{group_member_enc_id}"]; + 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}/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. + */ + put: operations["put_core-{_version}-keys-user-{enc_id}"]; + post?: never; + delete: operations["delete_core-{_version}-keys-user-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/private/upgrade": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upgrade private keys with obsolete or incorrect metadata. + * @description Upgrade keys from lower version to current version. Done by webclient on login. + * + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used first. + */ + post: operations["post_core-{_version}-keys-private-upgrade"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/migrate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upgrade keys for key migration step 2 + * This route can not be used to re-activate keys that we don't have access to, + * in that case the route "Activate Key" must be used before or after. */ + post: operations["post_core-{_version}-keys-migrate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** (Legacy keys) Activate newly-provisioned member address key by sending a key copy encrypted with + * current mailbox password. */ + put: operations["put_core-{_version}-keys-{enc_id}-activate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** (Legacy keys) Activate just an address key, when access to the user key is lost */ + put: operations["put_core-{_version}-keys-{enc_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/keys/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Install a new key for each address. */ + post: operations["post_core-{_version}-keys-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/organizations/link/{organization_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + 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"]; + put?: never; + /** + * Create a new member. + * @description Locked route + * + * phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded + */ + post: operations["post_core-{_version}-members"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-members-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/invitations/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Edit a pending invitation. + * @description Locked route + */ + put: operations["put_core-{_version}-members-invitations-{enc_id}"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Disable a member. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-disable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Enable a member. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-enable"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/quota": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update disk space quota in bytes. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-quota"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/name": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update member name. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-name"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update member role. */ + put: operations["put_core-{_version}-members-{enc_id}-role"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{memberId}/ai": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update AI entitlement for member. */ + put: operations["put_core-{_version}-members-{memberId}-ai"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/{enc_id}/privatize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Make account private. + * @description Locked route + */ + put: operations["put_core-{_version}-members-{enc_id}-privatize"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/members/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; + 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?: never; + put?: never; + post?: 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": { + 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}/organizations/2fa/remind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + 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; + 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/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/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 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}/captcha": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Captcha page. + * @deprecated + */ + get: operations["get_core-{_version}-captcha"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/resources/captcha": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Captcha page. */ + get: operations["get_core-{_version}-resources-captcha"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/resources/zendesk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zendesk chat. */ + get: operations["get_core-{_version}-resources-zendesk"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-fields"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/xml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-xml"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/setup/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-saml-setup-url"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-configs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-configs-{enc_id}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-saml-configs-{enc_id}-fields"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/configs/{enc_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-saml-configs-{enc_id}-delete"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/sp/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-sp-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/edugain/info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-edugain-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/edugain/info/{domainName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-saml-edugain-info-{domainName}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/saml/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the XML representation of the Service Provider metadata. */ + get: operations["get_core-{_version}-saml-metadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get general settings. */ + get: operations["get_core-{_version}-settings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update login password. Only called in 2-password mode (or onboarding to 2-password mode). */ + put: operations["put_core-{_version}-settings-password"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/password/upgrade": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upgrade Password. + * @description Upgrade login password on login if version < 4. + */ + put: operations["put_core-{_version}-settings-password-upgrade"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-email"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify associated email address. */ + post: operations["post_core-{_version}-settings-email-verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/notify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Toggle email notifications. */ + put: operations["put_core-{_version}-settings-email-notify"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/email/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enable or disable login password reset by email. */ + put: operations["put_core-{_version}-settings-email-reset"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-phone"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify associated phone number. */ + post: operations["post_core-{_version}-settings-phone-verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/notify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Toggle phone notifications. */ + put: operations["put_core-{_version}-settings-phone-notify"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/phone/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Enable or disable login password reset by phone. */ + put: operations["put_core-{_version}-settings-phone-reset"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/locale": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-locale"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/logauth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update authentication logging. */ + put: operations["put_core-{_version}-settings-logauth"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/devicerecovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update device recovery enabled preference. */ + put: operations["put_core-{_version}-settings-devicerecovery"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/news": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update newsletter subscription. + * @deprecated + */ + put: operations["put_core-{_version}-settings-news"]; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch newsletter subscription. */ + patch: operations["patch_core-{_version}-settings-news"]; + trace?: never; + }; + "/core/{_version}/settings/news/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get newsletter subscription status as external user. */ + get: operations["get_core-{_version}-settings-news-external"]; + /** + * Update newsletter subscription as external user. + * @deprecated + */ + put: operations["put_core-{_version}-settings-news-external"]; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch newsletter subscription as external user. */ + patch: operations["patch_core-{_version}-settings-news-external"]; + trace?: never; + }; + "/core/{_version}/settings/density": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update the mail list density. */ + put: operations["put_core-{_version}-settings-density"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/invoicetext": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update invoice user-defined text. */ + put: operations["put_core-{_version}-settings-invoicetext"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/codes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Regenerate recovery codes. + * @description Replace current recovery codes with new ones. + */ + post: operations["post_core-{_version}-settings-2fa-codes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/totp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-2fa-totp"]; + /** Signup for TOTP. */ + post: operations["post_core-{_version}-settings-2fa-totp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Disable all the 2FA methods. */ + put: operations["put_core-{_version}-settings-2fa"]; + /** + * Signup for TOTP. + * @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": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request Reset 2FA. + * @description Reset all 2FA methods to disabled state. + */ + post: operations["post_core-{_version}-settings-2fa-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a challenge for registration of a FIDO2 credential. */ + get: operations["get_core-{_version}-settings-2fa-register"]; + put?: never; + /** Register a FIDO2 credential. */ + post: operations["post_core-{_version}-settings-2fa-register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/{credentialID}/remove": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Remove a FIDO2 credential. */ + post: operations["post_core-{_version}-settings-2fa-{credentialID}-remove"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/2fa/{credentialID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Rename a FIDO2 credential. */ + put: operations["put_core-{_version}-settings-2fa-{credentialID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/hide-side-panel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update HideSidePanel for the current client. */ + put: operations["put_core-{_version}-settings-hide-side-panel"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/username": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Set username for external ProtonAccount. */ + put: operations["put_core-{_version}-settings-username"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/theme": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-theme"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/themetype": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-themetype"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/weekstart": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-weekstart"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/dateformat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-dateformat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/timeformat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-timeformat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/welcome": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-welcome"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/earlyaccess": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update BetaFlags. */ + put: operations["put_core-{_version}-settings-earlyaccess"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-settings-flags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/telemetry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update telemetry enabled preference. */ + put: operations["put_core-{_version}-settings-telemetry"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/crashreports": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update crash reports enabled preference. */ + put: operations["put_core-{_version}-settings-crashreports"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/highsecurity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * High Security program - enable + * @description https://confluence.protontech.ch/display/MSA/High+Security+Program + */ + post: operations["post_core-{_version}-settings-highsecurity"]; + /** + * High Security program - disable + * @description https://confluence.protontech.ch/display/MSA/High+Security+Program + */ + delete: operations["delete_core-{_version}-settings-highsecurity"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/breachalerts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Breach Alert - enable + * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting + */ + post: operations["post_core-{_version}-settings-breachalerts"]; + /** + * Breach Alert - disable + * @description https://confluence.protontech.ch/pages/viewpage.action?pageId=176045452#Proposalfornotifications&resolvingthem-UserSettings.BreachAlertssetting + */ + delete: operations["delete_core-{_version}-settings-breachalerts"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/sessionaccountrecovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update session account recovery preference. */ + put: operations["put_core-{_version}-settings-sessionaccountrecovery"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/ai-assistant-flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update setting to enable or disable AI Assistant. */ + put: operations["put_core-{_version}-settings-ai-assistant-flags"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/news/unsubscribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_core-{_version}-settings-news-unsubscribe"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/support/schedulecall": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-support-schedulecall"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/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; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_core-{_version}-members-{memberId}-lumo"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/product-disabled": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update setting to enable or disable specific product for all platforms. */ + put: operations["put_core-{_version}-settings-product-disabled"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Can user delete account. + * @description > 1. Free user: delete (you’ll have to enter your password and we might want to do feedback like on web) + * + * > 2. Paid user and the only user: delete (there might be an unsubscribe first) + * + * > 3. Multi-user organization and are a proton a non-admin proton user: + * > you should be able to leave the org and delete (might not be built yet, not a current case). + * + * > 4. Multi-user organization and a proton user and an admin: + * > you need to be demoted by another admin first, then #3 applies + * + * > 5. Managed user in a multi-user organization (non-proton): you can’t delete yourself + */ + get: operations["get_core-{_version}-users-delete"]; + /** Delete self, will invalidate API access token. */ + put: operations["put_core-{_version}-users-delete"]; + post?: never; + /** Delete self, will invalidate API access token. */ + delete: operations["delete_core-{_version}-users-delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get available reset methods and account type. */ + get: operations["get_core-{_version}-users-reset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user's info. + * @description Alternative response for user without address: + * + * ```js + * { + * "User": { + * "ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + * "Name": "jason", + * "UsedSpace": 96691332, + * "Currency": "USD", + * "Credit": 0, + * "CreateTime": 1654615960, + * "MaxSpace": 10737418240, + * "MaxUpload": 26214400, + * "Role": 2, + * "Private": 1, + * "Subscribed": 1, + * "Services": 1, + * "Delinquent": 0, + * "Keys": [] + * }, + * "Code": 1000 + * } + * ``` + * + * Alternative response for organization admin logged in as a sub-user: + * + * ```js + * { + * "User": { + * "ID": "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==", + * "Name": "jason", + * "UsedSpace": 96691332, + * "Currency": "USD", + * "Credit": 0, + * "CreateTime": 1654615960, + * "MaxSpace": 10737418240, + * "MaxUpload": 26214400, + * "Role": 2, + * "Private": 1, + * "Subscribed": 1, + * "Services": 1, + * "Delinquent": 0, + * "OrganizationPrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----*", + * "Email": "jason@protonmail.ch", + * "DisplayName": "Jason", + * "Keys": [ + * { + * "ID": "IlnTbqicN-2HfUGIn-ki8bqZfLqNj5ErUB0z24Qx5g-4NvrrIc6GLvEpj2EPfwGDv28aKYVRRrSgEFhR_zhlkA==", + * "Version": 3, + * "PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----*-----END PGP PRIVATE KEY BLOCK-----", // correspond to OrgPrivateKey + * "Token": "-----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE-----", // contains the organization (keypackets, signature) pair + * "Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353", // DEPRECATED + * "Primary": 1 + * } + * ] + * }, + * "Code": 1000 + * } + * ``` + */ + get: operations["get_core-{_version}-users"]; + put?: never; + /** + * Create a user or ProtonID user with a 3rd party email as username. + * @description TODO(fsalathe): Refactor this function into a service [refactor] + */ + post: operations["post_core-{_version}-users"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a user or ProtonID user with a 3rd party email as username. + * @description TODO(fsalathe): Refactor this function into a service [refactor] + */ + post: operations["post_core-{_version}-users-external"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Check user creation token validity. */ + put: operations["put_core-{_version}-users-check"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/availableExternal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-availableExternal"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check if username already taken. */ + get: operations["get_core-{_version}-users-available"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/available/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @deprecated */ + get: operations["get_core-{_version}-users-available-{username}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/direct": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Deprecated. Placeholder left in place for handling old clients. */ + get: operations["get_core-{_version}-users-direct"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a verification code. */ + post: operations["post_core-{_version}-users-code"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/lock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Lock sensitive settings for keys/organization. */ + put: operations["put_core-{_version}-users-lock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/unlock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Unlock sensitive settings for keys/organization. */ + put: operations["put_core-{_version}-users-unlock"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Unlock password changes. */ + put: operations["put_core-{_version}-users-password"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/captcha/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get captcha (javascript) (hv1). */ + get: operations["get_core-{_version}-users-captcha-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/disable/{jwt}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-users-disable-{jwt}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/users/invitations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-users-invitations-{id}"]; + 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}/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}/nps/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + 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/{_version}/nps/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: 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/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; + post?: never; + delete?: never; + 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/{_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/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/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/cookies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set secure cookies, web app only. + * @description Cookies have lifetime of one year for persistent sessions. + * For non-persistent sessions cookie expiration is set to 0 and the client should garbage collect them at the end + * of the session. + */ + post: operations["post_core-{_version}-auth-cookies"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/auth/credentialless": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create and authenticate a credential-less user. */ + post: operations["post_core-{_version}-auth-credentialless"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get mnemonic keyring to restore keys. + * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys if a logged in user + * remembers an old mnemonic. + */ + get: operations["get_core-{_version}-settings-mnemonic"]; + /** + * Update or set mnemonic. + * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. + * If a keyring already exists the keys will be merged (newer replaces older). + */ + put: operations["put_core-{_version}-settings-mnemonic"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get mnemonic keyring to restore keys. + * @description Returns the mnemonic keyring and its encryption salt, to allow re-enabling user keys in the reset flow. + */ + get: operations["get_core-{_version}-settings-mnemonic-reset"]; + put?: never; + /** + * Reset account using a mnemonic. + * @description This route accepts a new password, returns the mnemonic keyring and its encryption salt, + * to allow resetting an account. This will change the session's scopes to the regular user's scopes. + * It logs out other sessions for security reasons. + */ + post: operations["post_core-{_version}-settings-mnemonic-reset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable mnemonic for current user. + * @description To re-enable it's needed to submit a new mnemonic via PUT /settings/mnemonic. + * This route removes the PASSWORD scope from the token. + */ + post: operations["post_core-{_version}-settings-mnemonic-disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/settings/mnemonic/reactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update or set mnemonic. + * @description This route allows submission of a new mnemonic or update an existing mnemonic, alongside a backup keyring. + * If a keyring already exists the keys will be merged (newer replaces older). + * + * This route requires LOCKED scope only to allow prompting a mnemonic by default right after login. + * It will work only if the mnemonic needs to be (re) activated and is to be prompted automatically (i.e. for + * states MNEMONIC_ENABLED and MNEMONIC_OUTDATED). + */ + put: operations["put_core-{_version}-settings-mnemonic-reactivate"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info. + * @deprecated + * @description List of active notifications for the current logged user. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info. + * @description List of active notifications for the current logged user. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes-active"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/active/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get active pushes info (using session). + * @description List of active notifications for the current logged user using the current session. + * Can be used by the clients to always know what should still be showed as active notification. + */ + get: operations["get_core-{_version}-pushes-active-session"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/pushes/{enc_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete the given push. + * @description If the session belongs to a family, the pushes for the whole session family will be deleted. + */ + delete: operations["delete_core-{_version}-pushes-{enc_id}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/core/{_version}/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}/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 + * ``` + * + * 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}/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/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/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": { + 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/user-deprecation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_core-{_version}-tests-user-deprecation"]; + 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}/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/{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/seen-completed-list/{checklistType}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 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/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: 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/init": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: 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/check-item": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 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/update-display": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 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}/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/{_version}/connection-information": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: 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; + 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; + }; + 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; + }; + 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; + }; + /** 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/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/{_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----- */ + 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----- */ + PrimaryKey: string; + /** + * @description RANDOMLY generated client-side + * @example + */ + KeySalt: string; + /** + * @description For setup using magic link, the primary key encrypted to the token contained in OrgActivationToken + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrgPrimaryUserKey: string; + /** + * @description For setup using magic link, a 32-byte random token encoded as hex and encrypted to the organization key, signed with the newly created address key. Context should be set to account.key-token.user-unprivatization + * @example -----BEGIN PGP MESSAGE-----.* + */ + OrgActivationToken: string; + AddressKeys: components["schemas"]["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"]; + }; + 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; + }[]; + /** + * @description If org admin (legacy scheme) that can decrypt org key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrganizationKey: string; + Auth: components["schemas"]["AuthInput"]; + /** + * @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: 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. + * @example noreply%40amazon.com + */ + Address?: string | null; + /** + * Domain to get the logo for. Either Domain or Address are required. + * @default null + * @example amazon.com + */ + Domain: string | null; + /** + * The size of the logo to be returned. + * @default 32 + * @example 64 + */ + Size: number; + /** + * The theme being used. + * @default light + * @enum {string} + */ + Mode: "light" | "dark"; + /** + * The bimi-selector of the message + * @default default + */ + BimiSelector: string; + /** + * The maximum factor an image can be scaled up. + * @default 2 + * @example 2 + * @enum {integer} + */ + MaxScaleUpFactor: 1 | 2 | 3 | 4; + /** + * Format to convert SVG images to + * @default null + * @enum {string|null} + */ + Format: "png" | null; + ComputedAddress: string; + }; + CreateAddressInput: { + /** @example me */ + Local: string; + /** + * @description Either custom domain or a protonmail domain + * @example funoccupied.com + */ + Domain: string; + /** + * @description Optional, default empty + * @example hi + */ + DisplayName: string; + /** + * @description Optional, default empty + * @example signature + */ + Signature: string; + MemberID: Record; + RequesterMemberId?: number | null; + AddressList: components["schemas"]["KTAddressListTransformer"]; + }; + 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"]; + }; + ChangeAddressTypeInput: { + /** + * @description 1: original, 2: Alias, 3: Custom, 4: Premium, 5: External + * @example 3 + */ + Type: number; + /** @default null */ + SignedKeyList: components["schemas"]["SignedKeyListInput"] | null; + }; + RenameUnverifiedAddressInput: { + /** @example me */ + Local: string; + /** + * @description either custom domain or a protonmail domain + * @example funoccupied.com + */ + Domain: string; + AddressList: components["schemas"]["KTAddressListTransformer"]; + AddressKeys: { + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + }; + UpdateAddressInput: { + /** + * @description Optional, if empty string - use default + * @example hi + */ + DisplayName: string; + /** + * @description Optional, if empty string - use default + * @example signature + */ + Signature: string; + }; + /** 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"]; + }; + 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; + /** + * 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; + /** @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: { + /** + * 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; + /** + * @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; + /** @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"]; + }; + ChildOrganizationDto: { + /** @description The ID of the organization */ + ID: components["schemas"]["Id"]; + /** @description The name of the organization */ + Name: string; + }; + GetChildOrganizationsResponse: { + /** @description List of child organizations */ + Organizations: components["schemas"]["ChildOrganizationDto"][]; + /** @description Total number of child organizations */ + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"]; + 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"]; + /** + * 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: 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; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GroupMembersResponse: { + Members: components["schemas"]["GroupMemberOutput"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"][]; + Total: number; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + InviteGroupOwnerRequest: { + GroupMemberID: components["schemas"]["Id"]; + EncryptionAddress: string; + TokenKeyPacket: string; + TokenSignaturePacket: components["schemas"]["PGPSignature"]; + }; + 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: number | null; + /** @default null */ + GroupVisibility: components["schemas"]["GroupVisibility"] | null; + /** @default null */ + MemberVisibility: number | null; + /** @default null */ + Description: string | null; + }; + AddressKeyInput2: Record; + UpdateFlagsInput: { + /** @example 1 */ + Flags: number; + 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"][]; + }; + MigrateKeyInput: { + AddressKeys: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.*-----END PGP MESSAGE----- */ + Token?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }[]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + }; + LegacyKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + SignedKeyList: components["schemas"]["SignedKeyListInput"]; + }; + ReactivateUserKeyInput: { + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey: string; + AddressKeyFingerprints: string[]; + SignedKeyLists: components["schemas"]["SignedKeyListInput"][]; + }; + ResetUserKeyInput: { + /** + * @description Required if not logged in + * @example user_name + */ + Username: string; + /** + * @description Reset token + * @example A194YN2F9R + */ + Token: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrimaryKey: string; + /** + * @description RANDOMLY generated client-side + * @example + */ + KeySalt: string; + AddressKeys: { + /** @example xRvCGwFq_TW7i8FtJaGyFEq0g== */ + AddressID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** + * @description For migrated accounts + * @example -----BEGIN PGP MESSAGE-----.* + */ + Token?: string; + /** + * @description For migrated accounts + * @example ----BEGIN PGP SIGNATURE-----.* + */ + Signature?: string; + /** @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 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; + /** + * @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"]; + /** + * @description Use only if type is 1=PROTON + * @example user_name + */ + Username: string; + /** @description Invitation object if created using magic link */ + Invitation?: components["schemas"]["MagicLinkInvitationInput"] | null; + Auth: components["schemas"]["AuthInfoInput"]; + /** + * @default 0 + * @enum {integer} + */ + MaxAI: 0 | 1; + /** + * @default 0 + * @enum {integer} + */ + MaxLumo: 0 | 1; + /** + * @description True if the user has a temporary password, false otherwise + * @default false + */ + TemporaryPassword: boolean; + }; + CreateMemberInvitationInput: { + /** + * Format: email + * @example ein@stein.com + */ + Email: string; + /** @example 100 */ + MaxSpace: number; + /** + * @default 0 + * @enum {integer} + */ + MaxAI: 0 | 1; + /** + * @default 0 + * @enum {integer} + */ + MaxLumo: 0 | 1; + }; + 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 The invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2, "Admin":true} + */ + 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: { + /** + * 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"]; + /** @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 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: { + /** + * @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; + }; + 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 + * @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 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; + /** + * @description Signature of the token made by the user key of the current user + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature: components["schemas"]["PGPSignature"] | null; + /** + * @description Invite all other private admins to the new key + * @default null + */ + AdminInvitations: components["schemas"]["ReplaceOrganizationKeyInvitationDto"][] | null; + /** + * @description Activate new key for all other non-private admins + * @default null + */ + AdminActivations: components["schemas"]["ReplaceOrganizationKeyActivationDto"][] | null; + /** + * @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: { + /** + * @description organization private key encrypted with mailbox password hash + * @default null + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- + */ + PrivateKey: string | null; + /** + * Format: base64 + * @description For passwordless key, the key packet needed to unlock the key, encrypted to the user key + * @default null + * @example TG9yZW0gaXBzdW0... + */ + TokenKeyPacket: string | null; + /** + * @description For passwordless key, signature of the token key packet + * @default null + * @example -----BEGIN PGP SIGNATURE-----*-----END PGP SIGNATURE----- + */ + Signature: string | null; + }; + 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: { + /** @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; + /** + * @description Activate new key for all other non-private admins + * @default 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: { + /** + * @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; + }; + UpdateOrganizationSettingsRequest: { + /** + * @description Whether to show organization name in sidebar or not + * @default null + * @example true + */ + ShowName: boolean | null; + /** + * @description Whether to show the Scribe writing assistant or not + * @default null + * @example true + */ + ShowScribeWritingAssistant: boolean | null; + /** + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null + * @example true + */ + 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 Whether Mail Category view is enabled or not for members + * @default null + */ + MailCategoryViewEnabled: boolean | null; + }; + Sso: { + /** + * @description IdP URL. Optional for eduGAIN SSO configurations + * @example https://account.buck.proton.black + */ + SSOURL: string; + /** + * @description SSOEntityID URL + * @example https://account.buck.proton.black + */ + SSOEntityID: string; + /** + * @description Blob content of the certificate. Optional for eduGAIN SSO configurations + * @example -----BEGIN CERTIFICATE-----... + */ + Certificate: string; + /** + * @description The encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + SCIMOauthClientID?: number | null; + /** + * @description Issuer ID (our side) + * @example https://sso.proton.me/sp + */ + IssuerID: string; + /** + * @description Reply (ACS) URL + * @example https://sso.proton.me/auth/saml + */ + CallbackURL: string; + /** + * @description Allowed domain name + * @example example.com + */ + AllowedDomain: string; + /** + * @description Whether this SSO configuration is enabled or not + * @default true + * @example true + */ + Enabled: boolean; + /** + * @description Whether this SSO configuration is for a regular IdP (1) or eduGain (2) + * @default 1 + * @example 1 + */ + Type: number; + /** + * @description eduGAIN affiliations allowed by the SSO configuration in case of eduGAIN setup + * @default [] + */ + EdugainAffiliations: string[]; + SsoId?: components["schemas"]["Id"] | null; + SendingSubject: boolean; + }; + SsoXml: { + /** + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + /** + * @description Base64 encoded XML blob + * @example + */ + XML: string; + }; + SsoUrl: { + /** + * @description the encrypted domain id + * @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== + */ + DomainID: string; + /** + * @description full URL to SAML metadata + * @example + */ + MetadataURL: string; + }; + PatchNewsInput: { + /** + * @description Proton Company announcements + * @default null + */ + Announcements: boolean | null; + /** + * @deprecated + * @description Proton Product announcements + * @default null + */ + Features: boolean | null; + /** + * @description Proton newsletter + * @default null + */ + Newsletter: boolean | null; + /** + * @description Proton beta announcements + * @default null + */ + Beta: boolean | null; + /** + * @description Proton for Business newsletter + * @default null + */ + Business: boolean | null; + /** + * @description Proton offers and promotions + * @default null + */ + Offers: boolean | null; + /** + * @description Proton new email notifications + * @default null + */ + NewEmailNotif: boolean | null; + /** + * @description Proton welcome emails + * @default null + */ + Onboarding: boolean | null; + /** + * @description Proton user surveys + * @default null + */ + UserSurveys: boolean | null; + /** + * @description Proton Mail and Calendar new features + * @default null + */ + InboxNews: boolean | null; + /** + * @description Proton VPN new features + * @default null + */ + VpnNews: boolean | null; + /** + * @description Proton Drive new features + * @default null + */ + DriveNews: boolean | null; + /** + * @description Proton Pass new features + * @default null + */ + PassNews: boolean | null; + /** + * @description Proton Wallet new features + * @default null + */ + WalletNews: boolean | null; + /** + * @description 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: { + /** + * @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; + }; + /** + * @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"]; + }; + /** + * @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} */ + MaxLumo: 0 | 1; + }; + /** 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; + /** @example owner@family.org */ + InviterEmail: string; + /** @example 1000000000 */ + MaxSpace: number; + /** @example My Organization */ + OrganizationName: string; + /** @example family2022 | passfamily2024 */ + OrganizationPlanName: string; + Validation: components["schemas"]["AcceptInvitationValidation"]; + }; + UserInvitationResponse: { + UserInvitation: components["schemas"]["GetUserInvitationOutput"]; + /** + * 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 + */ + 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 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[]; + }; + /** + * @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} + */ + Code: 1000; + }; + ListEligibleTrialsResponse: { + TrialPlans: unknown[]; + CreditCardRequiredPlans: unknown[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + BillingAddressDto: { + /** + * @description The country code. + * @example NL + */ + CountryCode: string; + /** + * @description The state. + * @example Noord-Holland + */ + State?: string | null; + /** + * @description The zip code. + * @example 1234AA + */ + ZipCode?: string | null; + /** + * @description The VAT identifier. + * @default null + * @example AA-1234 + */ + VatId: string | null; + }; + RegisterReferralInput: { + /** @example 12 */ + Cycle: number; + /** @example bundle2022 */ + Plan: string; + ReferralIdentifier: string; + /** @default null */ + ReferralID: string | null; + /** + * @default null + * @example payment-token + */ + 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== */ + ID: string; + /** @example jason */ + Name?: string | null; + /** @example jason */ + DisplayName?: string | null; + /** @example jason@proton.me */ + Email: string; + /** @example USD */ + Currency: string; + /** @example 0 */ + Credit: number; + /** + * @description 1: Proton (full), 2: Managed, 3: External, 4: CredentialLess + * @example 0 + */ + Type: number; + /** @example 1654615966 */ + CreateTime: number; + /** + * Format: int64 + * @description Max space (in bytes) + * @example 10737418240 + */ + MaxSpace: number; + /** + * Format: int64 + * @description Max upload space (in bytes) + * @example 26214400 + */ + MaxUpload: number; + /** + * Format: int64 + * @description Used space (in bytes) + * @example 70376905 + */ + UsedSpace: number; + ProductUsedSpace: components["schemas"]["UserUsage"]; + /** @description 1 when the user's member has an AI seat, 0 otherwise */ + NumAI: number; + /** @description the number of lumo seats attributed to the user, 0 otherwise */ + NumLumo: number; + /** @example 2 */ + Role: number; + /** @example 1 */ + Private: number; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate: 0 | 1; + /** + * @description + * 0: Mnemonic is disabled, + * 1: Mnemonic is enabled but not set, + * 2: Mnemonic is enabled but needs to be re-activated, + * 3: Mnemonic is enabled and set + * @example 1 + */ + MnemonicStatus: number; + /** + * @description Subscribed (bitmap): `1`: User has a mail subscription, `4`: User has a VPN subscription + * @example 5 + */ + Subscribed: number; + /** + * @description + * Activated services (bitmap): + * `1`: User has the mail product activated, + * `4`: User has the VPN activated + * @example 5 + */ + Services: number; + Delinquent: components["schemas"]["DelinquentState"]; + Keys: components["schemas"]["UserKey"]; + Flags: { + protected?: boolean; + "onboard-checklist-storage-granted"?: boolean; + "has-temporary-password"?: boolean; + "test-account"?: boolean; + "no-login"?: boolean; + "recovery-attempt"?: boolean; + sso?: boolean; + /** @description User have no or only external addresses */ + "no-proton-address"?: boolean; + /** @description Whether the user has at least one bring-your-own-email address */ + "has-a-byoe-address"?: boolean; + }; + }; + /** 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; + }; + 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; + }; + }; + /** + * @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; + /** + * Format: date-time + * @description Time the device was created + */ + CreateTime: string; + /** + * Format: date-time + * @description Time the device was activated + */ + ActivateTime?: string | null; + /** + * Format: date-time + * @description Time the device was rejected + */ + RejectTime?: string | null; + /** + * Format: date-time + * @description Time the device was last used (approximately to the hour) + */ + LastActivityTime: string; + /** @description PGP message encrypted to the AddressID containing a 64-char random hex-encoded token */ + ActivationToken?: components["schemas"]["PGPMessage"] | null; + ActivationAddressID?: components["schemas"]["Id"] | null; + MemberID?: components["schemas"]["Id"] | null; + /** + * @description DeviceToken of the created device + * @example wfih0367aa7dc0359bf5c42d15a93e6c + */ + DeviceToken?: string | null; + }; + AuthInput2: { + /** + * @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; + }; + 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"]; + 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 Invited (2), Member (1) or Disabled (0) + * @example 1 + */ + State: number; + /** @example 1 */ + Private: number; + /** + * @description 1: Proton (full), 2: Managed, 3: External, 4: CredentialLess + * @example 0 + */ + Type: number; + /** @example 100000000 */ + MaxSpace: number; + /** @example 0 */ + MaxVPN: number; + /** @example Jason */ + Name: string; + /** @example 81780955 */ + UsedSpace: number; + /** @example 1 */ + Self: number; + /** + * @example 1 + * @enum {integer} + */ + ToMigrate: 1 | 2; + /** + * @example 1 + * @enum {integer} + */ + BrokenSKL: 1 | 2; + /** @example 1 */ + Subscriber: number; + /** + * @example 1 + * @enum {integer} + */ + SSO: 0 | 1; + /** + * @description 2FA will be required to be set after TwoFactorRequiredTime timestamp + * @example 1679038286 + */ + TwoFactorRequiredTime: number; + /** + * @description bit map: 1=TOTP, 2=FIDO2 + * @example 3 + */ + "2faStatus": number; + Keys: string[]; + /** @example -----BEGIN PUBLIC KEY BLOCK-----.*-----END PUBLIC KEY BLOCK----- */ + PublicKey: string; + /** + * @description Permissions bitmap + * @example 1 + * @enum {integer} + */ + Permissions: number; + /** @description 0 - is not supposed to have access to org key, 1 - has access to org key, 2 - has lost access to key and needs to be re-invited, 3 - pending activation */ + AccessToOrgKey: number; + /** + * @description Whether or not the member has an AI seat + * @enum {integer} + */ + NumAI: 0 | 1; + /** @description Unprivatization info if member is undergoing one */ + Unprivatization?: unknown[] | null; + /** @description The number of lumo seats allocated to the member */ + NumLumo: Record; + }; + 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; + /** @default null */ + OrganizationKeyActivation: components["schemas"]["OrganizationKeyActivationDto"] | null; + }; + GetMemberUnprivatizationOutput: { + /** @description State of the Unprivatization (0: declined), 1: pending, 2: ready */ + State: number; + /** + * @description Invitation data + * @example {"Address":"member@internal-domain.com", "Revision":2} + */ + InvitationData?: string | null; + /** @description InvitationData signed with org key */ + InvitationSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Email to send the invitation to */ + InvitationEmail?: string | null; + /** @description Administrator email */ + AdminEmail: string; + /** @description Fingerprint of the org key signed with primary address key */ + OrgKeyFingerprintSignature?: components["schemas"]["PGPSignature"] | null; + /** @description Organization public key */ + OrgPublicKey?: components["schemas"]["PGPPublicKey"] | null; + /** @description Whether the member should remain private after creation or be unprivatized */ + PrivateIntent: boolean; + }; + /** @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: { + /** + * @description Encrypted user ID + * @example -Bpgivr5H2qGDRiUQ4-7gm5YLf215MEgZCdzOtLW5psxgB8oNc8OnoFRykab4Z23EGEW1ka3GtQPF9xwx9-VUA== + */ + UserID: string; + /** + * @description Unix timestamp of when the log happened. + * @example 1683644736 + */ + Time: number; + /** @description Status of the event. See AuthLogStatus enum for values. */ + 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. */ + Protection: components["schemas"]["AuthLogProtection"]; + /** + * @description Localized description of protection applied. + * Can be missing. Only present if user has High Security enabled. + * @example Anti-bot verification + */ + 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 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 */ + 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: { + /** + * @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; + }; + OrganizationSettingsInputOutput: { + /** + * @description Whether to show organization name in sidebar or not + * @default null + * @example true + */ + ShowName: boolean | null; + /** + * @description Whether to show the Scribe writing assistant or not + * @default null + * @example true + */ + ShowScribeWritingAssistant: boolean | null; + /** + * @description Whether the Zoom video conferencing feature is enabled or not + * @default null + * @example true + */ + 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: { + /** @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 Bitmap informing which news the user is subscribed to: + * - 1 (2^0): Announcement + * - 2 (2^1): Features + * - 4 (2^2): Newsletter + * - 8 (2^3): Beta + * - 16 (2^4): Business + * - 32 (2^5): Offers + * - 64 (2^6): New mail notification + * - 128 (2^7): Onboarding + * @example 244 + */ + News: number; + /** @example en_US */ + Locale: string; + /** + * @description 0 => Disabled, 1 => Basic, 2 => Advanced + * @example 2 + */ + LogAuth: number; + /** @example रिवार में हुआ। ज檷\n Cartoon Law Services\n 1 DisneyWorld Lane\n Orlando, FL, 12345\n VAT */ + InvoiceText: string; + /** + * @description 0 => Comfortable, 1 => Compact + * @example 0 + */ + Density: number; + Theme: components["schemas"]["Theme2"]; + /** @example 1 */ + ThemeType: number; + /** + * @description 0 => default, 1 => monday, 6 => saturday, 7 => sunday + * @example 1 + */ + WeekStart: number; + /** + * @description 0 => default, 1 => DD_MM_YYYY, 2 => MM_DD_YYYY, 3 => YYYY_MM_DD + * @example 1 + */ + DateFormat: number; + /** + * @description 0 => default, 1 => 24h, 2 => 12h + * @example 1 + */ + TimeFormat: number; + /** + * @description 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 + */ + Welcome: number; + /** + * @deprecated + * @description (Use `Welcome`) 0 => Has not been welcomed, 1 => Has been welcomed + * @example 1 + */ + WelcomeFlag: number; + /** + * @description 0 => Regular access, 1 => Beta access + * @example 1 + */ + EarlyAccess: number; + Flags: { + /** @description 1 or 0 */ + Welcomed?: number; + /** @description 1, or 0 */ + SupportPgpV6Keys?: number; + /** @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 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; + 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 0 or 1, 1 means session account recovery enabled + * @example 1 + * @enum {integer} + */ + SessionAccountRecovery: 0 | 1; + /** + * @description 0: unset, 1: off, 2: server-only, 3: client-only + * @example 1 + * @enum {integer} + */ + AIAssistantFlags: 0 | 1 | 2 | 3; + /** + * @deprecated + * @description Deprecated in favour of "UsedClients". First 64 bit of bitmap informing which client the user has logged in to. + * @example 1 + */ + UsedClientFlags: number; + /** + * @description List of clients the user has logged in to. + * @example [WebAccount, WebMail, iOSDrive] + */ + UsedClients: Record[]; + }; + 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 */ + StartTime: number; + /** @example 1687000169 */ + EndTime: number; + /** + * @description 0 => None, 1 => Cancelled, 2 => Authentication + * @example 1 + */ + 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: { + /** + * @description Certificate serial number + * @example 6561979746 + */ + SerialNumber: string; + /** + * @description Blob content of the certificate + * @example -----BEGIN CERTIFICATE-----... + */ + Certificate: string; + /** + * @description Fingerprint of the client public key + * @example bHZDBSYbd27GFd + */ + ClientKeyFingerprint: string; + /** + * @description The input or default mode + * @example 1505758141 + */ + ExpirationTime: number; + /** + * @description The input or default mode + * @example session + */ + Mode: string; + SessionUID: string; + Session?: components["schemas"]["Session"] | null; + UserID: number; + UserName: string; + MaxTier: number; + PublicKeyMode: string; + PublicKey: string; + DeviceName: string; + Features: number; + Groups: string[]; + RevocationTime: number; + TwoFactor: boolean; + RemoteSessions: { + RemoteID?: number; + ServerID?: number; + StartTime?: number; + LastRecordTime?: number; + }[]; + }; + FeatureTransformer: { + /** @example promo */ + Code: string; + /** + * @example enumeration + * @enum {string} + */ + Type: "boolean" | "integer" | "float" | "string" | "enumeration" | "mixed"; + /** @example 1 */ + Minimum: Record; + /** @example 100 */ + Maximum: Record; + /** @example false */ + Global: boolean; + /** @example true */ + Writable: boolean; + /** @example true */ + DefaultValue: Record; + /** @example true */ + Value: Record; + /** @example 1527262849 */ + ExpirationTime: number; + /** @example 1527262849 */ + UpdateTime: number; + }; + 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 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; + }; + 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; + }; + 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; + }; + }; + /** @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 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; + /** @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 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 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 */ + 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 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; + }; + 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; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + }; + }; + "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; + }; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + }; + }; + "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"]; + }; + }; + }; + }; + }; + "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"]["CreateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: { + /** @example vuGSa1zsx0kV0jsfhX_xKSDQ0dvcLdMduA_c2c9fhaC1ZYCZKe8gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + ID?: string; + /** @example X_bSECsnvCSHHR44lXWMDOYDiZpbTUzqnQFyf_pqDq-JjXxXJCv_jQmSOLhD3e3A== */ + DomainID?: string; + /** @example me@protonmail.com */ + Email?: string; + /** @example 0 */ + Send?: number; + /** + * @description 0 is disabled, 1 is enabled, can be set by user + * @example 1 + */ + Status?: number; + /** + * @description 1 is original PM, 2 is PM alias, 3 is custom domain address + * @example 1 + */ + Type?: number; + /** + * @description 1 is active address (Status=1 and has key), 0 is inactive (cannot send or receive) + * @example 0 + */ + Receive?: number; + /** @example 1 */ + Order?: number; + /** @example hi */ + DisplayName?: string; + /** @example signature */ + Signature?: string; + /** @example 0 */ + HasKeys?: number; + /** @example [] */ + Keys?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-addresses-canonical": { + parameters: { + query?: { + /** @description The list of email addresses, limited to maximum 100. They must be url encoded. */ + Emails?: string[]; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 1001 */ + Code?: number; + Responses?: { + /** @example john.doe+friend@gmail.com */ + Email?: string; + Response?: { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example johndoe@gmail.com */ + CanonicalEmail?: string; + }; + }[]; + }; + }; + }; + }; + }; + "get_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Address?: components["schemas"]["AddressUser"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-addresses-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-{enc_id}-addresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the encrypted domain id + * @example lKJlejjlk== + */ + domainid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Addresses?: (components["schemas"]["AddressUser"] & { + /** + * @description whether this is the catch-all address for this domain + * @example 0 + */ + CatchAll?: number; + /** @example gony7nIWbnqaj8gebXLCQre1H1ZTKkhhFxA== */ + MemberID?: string; + })[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains-{enc_id}-claimedAddresses": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: number; + }; + header?: never; + path: { + /** + * @description the encrypted domain id + * @example lKJle...jjlk== + */ + DomainId: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Addresses?: { + /** @example john.doe+friend@mydomain.com */ + Email?: string; + }[]; + }; + }; + }; + /** @description Domain does not exist */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2501 */ + Code?: number; + /** @example Domain does not exist */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-enable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-disable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted address id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-type": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ChangeAddressTypeInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-rename-internal": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJl...ejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example john.doe */ + Local?: string; + AddressKeys?: { + /** @example G1MbEt3Ep5P_...EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_id}-rename-external": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJle...jjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameUnverifiedAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-addresses-{enc_addressId}-encryption": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the address id + * @example ACXDmTaBub14w== + */ + addressid: string; + _version: string; + enc_addressId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateEncryptionSignatureFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-addresses-permissions-organization-switch": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressIdsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["SwitchAddressesOrganizationPermissionsTransformer"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-{memberId}-saml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-saml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-devices-{deviceId}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + deviceId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-members-{memberId}-devices": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-{id}-devices": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-devices-pending": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + AuthDevices?: components["schemas"]["AuthDeviceOutput"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-refresh": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example token */ + ResponseType?: string; + /** @example refresh_token */ + GrantType?: string; + /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ + RefreshToken?: string; + /** + * @deprecated + * @description This parameter is deprecated and should be passed via 'x-pm-uid' header instead + * @example m3mxv75of7tuy4na4c3fzkskaqnu35xj + */ + UID?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example abcDecryptedTokenAndNoSaltAndNoPrivateKey123 */ + AccessToken?: string; + /** + * @deprecated + * @example 360000 + */ + ExpiresIn?: number; + /** @example Bearer */ + TokenType?: string; + /** + * @deprecated + * @example full other_scopes + */ + Scope?: string; + Scopes?: string[]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example b894b4c4f20003f12d486900d8b88c7d68e67235 */ + RefreshToken?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + /** @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"]; + }; + }; + }; + }; + "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}-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AuthInput2"]; + }; + }; + 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; + /** + * @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}-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "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}-organization-communities": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateOrganizationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateOrganizationOutput"]; + }; + }; + }; + }; + "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; + }; + }; + }; + }; + }; + }; + "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; + }; + }; + }; + }; + }; + "delete_core-{_version}-organization-{organization_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + organization_id: 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}-settings-highsecurity-summary-email": { + 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"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_core-{_version}-settings-highsecurity-summary-email": { + 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"]["SuccessfulResponse"]; + }; + }; + }; + }; + "put_core-{_version}-domains-{enc_id}-flags": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PutDomainFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Domain?: components["schemas"]["DomainOutput2"]; + }; + }; + }; + }; + }; + "get_core-{_version}-domains": { + 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: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetDomainsResponse"]; + }; + }; + }; + }; + "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"]["DomainOutput2"]; + }; + }; + }; + }; + }; + "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?: { + Refresh?: components["schemas"]["BoolInt"]; + }; + 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"]["DomainOutput2"]; + }; + }; + }; + }; + }; + "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"]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; + }; + }; + "put_core-{_version}-domains-{enc_id}-catchall": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the encrypted id + * @example lKJlejjlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateCatchAllAddressInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-organizations-subsidiaries": { + 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: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetChildOrganizationsResponse"]; + }; + }; + }; + }; + "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}-members-organizations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-groups-external-{jwt}": { + parameters: { + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_core-{_version}-groups-external-{jwt}": { + parameters: { + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-groups-owners-accept-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AcceptGroupOwnerInviteRequest"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "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: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupMemberResponse"]; + }; + }; + }; + }; + "post_core-{_version}-groups-owners-add-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddGroupOwnerRequest"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-groups": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Total?: number; + Groups?: components["schemas"]["GroupResponse"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-groups": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateGroupRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; + }; + }; + }; + }; + "post_core-{_version}-groups-unsubscribe-{jwt}": { + parameters: { + query: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + /** @example ACXDmTaBub14w== */ + GroupID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + _version: string; + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "put_core-{_version}-groups-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["EncryptedId"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateGroupRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-groups-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-groups-members-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-groups-members-{groupMemberId}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + groupMemberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["EditGroupMemberRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupMemberResponse"]; + }; + }; + }; + }; + "get_core-v4-groups-members-external-{jwt}": { + parameters: { + query?: never; + header?: never; + path: { + jwt: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExternalGroupMembershipsResponse"]; + }; + }; + }; + }; + "get_core-v4-groups-{group_enc_id}-members": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + group_enc_id: components["schemas"]["EncryptedId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupMembersResponse"]; + }; + }; + }; + }; + "get_core-{_version}-groups-owners-invites": { + 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"]["GetGroupOwnerInvitesResponse"]; + }; + }; + }; + }; + "post_core-{_version}-groups-owners-invites": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InviteGroupOwnerRequest"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-v4-groups-members-internal": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalGroupMembershipsResponse"]; + }; + }; + }; + }; + "get_core-{_version}-groups-{group_enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + group_enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Group?: components["schemas"]["GroupResponse"]; + }; + }; + }; + }; + }; + "get_core-v4-groups-members-{group_member_enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + group_member_enc_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"]["GroupMemberResponse"]; + }; + }; + }; + }; + "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: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-invites": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example notification@email */ + Email?: string; + /** + * @description 1 for mail, 2 for VPN + * @example 1 + */ + Type?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-invites-unused": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-invites-check": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-keys-all": { + parameters: { + query: { + /** + * @description The lookup email + * @example test@example.com + */ + Email: string; + /** + * @description If 1, it will not perform any external lookup, and only provide information from the Proton DB + * @example 1 + */ + InternalOnly?: "0" | "1"; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Success code + * @example 1000 + */ + Code?: number; + /** @description Information about the internal address itself, if it exists. Since the SKL is mandatory, this will never be nullable. */ + Address?: { + /** @description Public key list for this address with metadata */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description Always (0) internal for verified keys + * @example 0 + */ + Source?: number; + }[]; + /** @description Signed metadata to verify the public key list */ + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + /** @description Information about the catch all address itself, if it exists. This can be null if the address keys are valid */ + CatchAll?: { + /** @description Public key list for the catch-all address with metadata */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description Always (0) internal for verified keys + * @example 0 + */ + Source?: number; + }[]; + /** @description Signed metadata to verify the public key list */ + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + } | null; + /** @description Any other key that cannot be verified, such as Proton legacy keys or WKD. This can be null if there are none. */ + Unverified?: { + /** @description Public key list without any trusted metadata. These keys should not be used for signature verification, but for opportunistic encryption only. Can be pinned. */ + Keys?: { + /** + * @description Armored OpenPGP public key + * @example -----BEGIN PGP PUBLIC KEY BLOCK-----.*-----END PGP PUBLIC KEY BLOCK----- + */ + PublicKey?: string; + /** + * @description 1 when primary, 0 when not + * @example 1 + */ + Primary?: number; + /** + * @description Key usage flags (see confluence docs about Signed Key Lists) + * @example 3 + */ + Flags?: number; + /** + * @description 0: Internal, 1: WKD, 2: KOO + * @example 0 + * @enum {integer} + */ + Source?: 0 | 1 | 2; + }[]; + } | null; + /** @description List of warnings to show to the user related to phishing and message routing */ + Warnings?: string[]; + /** + * @description True when domain has valid proton MX + * @example true + */ + ProtonMX?: boolean; + /** + * @description Tells whether this is an official Proton address + * @example 1 + */ + IsProton?: number; + }; + }; + }; + /** @description No address found */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Error code 33102 corresponds to a failed lookup. It is returned only when (a) internal only lookup is requested and the user does not exist or (b) when the address is routed towards an internal domain (with valid MX records) and it does not exist internally + * @example 33102 + */ + Code?: number; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-signedkeylists": { + parameters: { + query?: { + /** @deprecated */ + Email?: string | null; + Identifier?: string | null; + /** + * @deprecated + * @description It will return all SKLs where a revision change happened after the specified epoch ID + */ + AfterEpochID?: number; + /** @description It will return all SKLs where a revision change happened after the specified revision */ + AfterRevision?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyLists?: components["schemas"]["KTKeyList"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-signedkeylists": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"] | null; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-signedkeylist": { + parameters: { + query?: { + /** @deprecated */ + Email?: string | null; + Identifier?: string | null; + /** @description The returned SKL will be for the specified revision, if it exists */ + Revision?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SignedKeyList?: components["schemas"]["KTKeyList"]; + }; + }; + }; + }; + }; + "get_core-{_version}-keys-salts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + KeySalts?: { + /** @example */ + ID?: string; + /** @example */ + KeySalt?: string; + }[]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-address-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddressKeyInput2"]; + }; + }; + 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"]["AddressKeyInput2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-flags": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the key id + * @example ACXDmTaBub14w== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-tokens": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReplaceAddressTokensInput"]; + }; + }; + responses: { + /** @description Address tokens correctly uploaded */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Invalid token list */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-keys-user-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the user key id + * @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ReactivateUserKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-keys-user-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The User Key encrypted id + * @example lKJlej...jlk== + */ + enc_id: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User key correctly deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Unable to delete user key, some preconditions are missing */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-keys-private-upgrade": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + KeySalt?: string; + Keys?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + }[]; + UserKeys?: { + /** @example G1MbEt3Ep5P_EWz8WbHVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + }[]; + AddressKeys?: { + /** @example adsft3Ep5P_EWz8WbasdkVAOl_6h== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----.* */ + PrivateKey?: string; + /** @example -----BEGIN PGP MESSAGE-----.* */ + Token?: string; + /** @example ----BEGIN PGP SIGNATURE-----.* */ + Signature?: string; + }[]; + SignedKeyLists?: { + "CasdiSFq_TW7i8FtJGuQyFEq0=="?: { + /** @example JSON.stringify([{"SHA256Fingerprints": ["164ec63...53c93f7", "f767d...f53b0c"],"Fingerprint": "c93f767df53b0ca8395cfde90483475164ec6353","Primary": 1,"Flags": 3}]) */ + Data?: string; + /** @example -----BEGIN PGP SIGNATURE-----.*-----END PGP SIGNATURE----- */ + Signature?: string; + }; + }; + /** + * @description If org admin and can decrypt org key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----.*-----END PGP PRIVATE KEY BLOCK----- + */ + OrganizationKey?: string; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-migrate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}-activate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LegacyKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-keys-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LegacyKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-keys-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ResetUserKeyInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "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; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Members?: components["schemas"]["MemberInfo"][]; + }; + }; + }; + }; + }; + "post_core-{_version}-members": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateMemberInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "post_core-{_version}-members-invitations": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateMemberInvitationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-invitations-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberInvitationInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Member?: components["schemas"]["MemberInfo"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-disable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-enable": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-quota": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 9900000000 */ + MaxSpace?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-name": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example Jason */ + Name?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-role": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{memberId}-ai": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + memberId: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMemberAIEntitlementInput"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-privatize": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-members-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": { + 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; + }[]; + }; + }; + }; + }; + /** @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": { + 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; + }; + }; + }; + }; + }; + "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"]; + }; + }; + }; + }; + }; + "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; + /** + * @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; + /** @example 1 */ + MaxMeet?: number; + /** @example 1 */ + UsedMeet?: number; + }; + }; + }; + }; + }; + }; + "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; + 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; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + }; + }; + "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; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BadRequestResponse"]; + }; + }; + }; + }; + "get_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK-----*-----BEGIN PGP PRIVATE KEY BLOCK----- */ + PrivateKey?: string; + /** @example 0123456789abcdef */ + KeySalt?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-organizations-keys-backup": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationKeyBackupInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-name": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationNameInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @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": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationEmailInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationTwoFactorGracePeriodInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 25 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-organizations-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 1 = at least enforced for admin members, 2 = enforced for all members + * @example 1 + */ + Require?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 20 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: number; + }; + }; + }; + }; + }; + }; + "delete_core-{_version}-organizations-require2fa": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Organization?: { + /** @example E-Corp */ + Name?: string; + /** @example E-Corp */ + DisplayName?: string; + /** @example plus */ + PlanName?: string; + /** @example 0 */ + TwoFactorRequired?: number; + /** @example null */ + TwoFactorGracePeriod?: number | null; + /** @example null */ + Theme?: string | null; + /** @example null */ + Email?: string | null; + /** @example 4 */ + MaxDomains?: number; + /** @example 20 */ + MaxAddresses?: number; + /** @example 20 */ + MaxCalendars?: number; + /** @example 10000000000 */ + MaxSpace?: number; + /** @example 15 */ + MaxMembers?: number; + /** @example 5 */ + MaxVPN?: number; + /** @example 0 */ + Features?: number; + /** @example 0 */ + Flags?: number; + /** @example 0 */ + UsedDomains?: number; + /** @example 0 */ + UsedAddresses?: number; + /** @example 81788997 */ + UsedSpace?: number; + /** @example 10000000000 */ + AssignedSpace?: number; + /** @example 1 */ + UsedMembers?: number; + /** @example 5 */ + UsedVPN?: number; + /** @example 1 */ + HasKeys?: number; + /** @example 6 */ + MaxAI?: number; + /** @example 3 */ + UsedAI?: number; + /** @example 6 */ + MaxLumo?: number; + /** @example 3 */ + UsedLumo?: number; + /** @example 6 */ + MaxMeet?: number; + /** @example 3 */ + UsedMeet?: 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"]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + "x-pm-code": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnprocessableResponse"]; + }; + }; + }; + }; + "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-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"]["OrganizationSettingsInputOutput"]; + }; + }; + }; + }; + "put_core-{_version}-organizations-settings": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateOrganizationSettingsRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"] & components["schemas"]["OrganizationSettingsInputOutput"]; + }; + }; + }; + }; + "get_core-{_version}-captcha": { + parameters: { + query: { + /** @example 1 */ + Dark?: string | null; + /** @example 1 */ + ForceWebMessaging?: number | null; + /** @example a9mT4hlKgS_h66JKxe-MC5pp */ + Token: string; + }; + header?: { + "x-pm-nonce"?: string | null; + host?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Captcha HTML page */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-resources-captcha": { + parameters: { + query: { + /** @example 1 */ + Dark?: string | null; + /** @example 1 */ + ForceWebMessaging?: number | null; + /** @example a9mT4hlKgS_h66JKxe-MC5pp */ + Token: string; + }; + header?: { + "x-pm-nonce"?: string | null; + host?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Captcha HTML page */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-resources-zendesk": { + parameters: { + query?: { + /** @example 83fabdab-1337-4fd7-85c0-39baf5c114fe */ + Key?: string; + }; + header?: { + "x-pm-nonce"?: string | null; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Zendesk chat */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-saml-setup-fields": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Sso"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-saml-setup-xml": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SsoXml"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-saml-setup-url": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SsoUrl"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-configs": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-configs-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-saml-configs-{enc_id}-fields": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Sso"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-saml-configs-{enc_id}-delete": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + SSO?: components["schemas"]["SsoTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-sp-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Info"]; + }; + }; + }; + }; + "get_core-{_version}-saml-edugain-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-edugain-info-{domainName}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + domainName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-saml-metadata": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description XML representation of the SP metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/xml": string; + }; + }; + }; + }; + "get_core-{_version}-settings": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-password": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-password-upgrade": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example abc@gmail.com */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-email-verify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email-notify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example 1 + * @enum {integer} + */ + Notify?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-email-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description 0 for off, 1 for on + * @example 1 + */ + Reset?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example +18005555555 */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-phone-verify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone-notify": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example 1 + * @enum {integer} + */ + Notify?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-phone-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description 0 for off, 1 for on + * @example 1 + */ + Reset?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-locale": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example en_US */ + Locale?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-logauth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0 = off, 1 = on, 2 = on with IP logging + * @example 0 + */ + LogAuth?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-devicerecovery": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + DeviceRecovery?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-news": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "patch_core-{_version}-settings-news": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "patch_core-{_version}-settings-news-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchNewsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: { + /** + * @description 0 - 255 bitmap. 1 is announcements, 2 is features, 4 is newsletter, 8 is beta, 16 is business. 32, 64, and 128 are currently unused. + * @example 4 + */ + News?: number; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-density": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0:comfortable, 1:compact + * @example 0 + */ + Density?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-invoicetext": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Maximum 5 lines + * @example Mickey Mouse, Esq. + * Cartoon Law Services + */ + InvoiceText?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-codes": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + /** @description 16 alphanumeric codes, each 8 characters long */ + TwoFactorRecoveryCodes?: string[]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 10041 */ + Code?: number; + /** @example Two Factor confirmation failed */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-2fa-totp": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-totp": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** @example 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 */ + 422: { + 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 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 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 10041 */ + Code?: number; + /** @example Two Factor confirmation failed */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "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; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** + * @description Reset token + * @example A194YN2F9R + */ + Token?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 19502 */ + Code?: number; + /** @example Invalid reset token. Please request another token and try again */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-2fa-register": { + parameters: { + query?: { + /** + * @description If true, it requires a cross-platform authenticator (e.g. Yubikey) and forbids TPMs (e.g. Windows Hello) + * @example true + */ + CrossPlatform?: boolean; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Contains the user's currently registered FIDO2 credentials. */ + RegisteredKeys?: components["schemas"]["Fido2RegisteredKey"][]; + /** + * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. + * @example + */ + RegistrationOptions?: Record; + /** @description Supported attestation formats. */ + AttestationFormats?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-register": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Refer to the definition of PublicKeyCredentialCreationOptions in the WebAuthn spec. Binary data is encoded as Uint8Array. + * @example + */ + RegistrationOptions?: Record; + /** + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** + * @description attestationObject (base64) returned from the client authentication library + * @example + */ + AttestationObject?: string; + /** + * @description An array of transports if known, otherwise an empty array. + * @example usb + */ + Transports?: string; + /** + * @description My FIDO2 key + * @example User defined name for the credential. + */ + Name?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-2fa-{credentialID}-remove": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + credentialID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-2fa-{credentialID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + credentialID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description User defined name for the credential. + * @example My FIDO2 key + */ + Name?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-hide-side-panel": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateHideSidePanelInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-username": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Length <= 40 */ + Username?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-theme": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Theme"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-themetype": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 1 */ + ThemeType?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-weekstart": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description `0`: Locale default, `1`: Monday, `6`: Saturday, `7`: Sunday + * @example 1 + */ + WeekStart?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-dateformat": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Locale default, 1: DD_MM_YYYY, 2: MM_DD_YYYY, 3: YYYY_MM_DD + * @example 1 + */ + DateFormat?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-timeformat": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Locale default, 1: 24H, 2: 12H + * @example 1 + */ + TimeFormat?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-welcome": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2000 */ + Code?: number; + /** @example Unknown client */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-earlyaccess": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Disabled, 1: Enabled + * @example 1 + */ + EarlyAccess?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2000 */ + Code?: number; + /** @example Invalid client */ + Error?: string; + Details?: string[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-flags": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description 0: Disabled, 1: Enabled + * @example 1 + */ + Welcomed?: number; + /** + * @description 0: Disabled, 1: Enabled - Note: requires SettingsFlagsAllowV6OptIn feature flag to be enabled before use + * @example 0 + */ + SupportPgpV6Keys?: number; + /** + * @description 0: Disabled, 1: Enabled - disables easy-device-migration (as of now QR code sign-in) + * @example 0 + */ + EdmOptOut?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-telemetry": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + Telemetry?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-crashreports": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description possible values:
- 0: disable
- 1: enable */ + CrashReports?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description User can't enable High Security (only some users are eligible) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2011 */ + Code: number; + /** @default You do not have an active subscription */ + Error: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-highsecurity": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-breachalerts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description User can't enable Breach Alert (only some users are eligible) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @default 2011 */ + Code: number; + /** @default You do not have an active subscription */ + Error: string; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-breachalerts": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-sessionaccountrecovery": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SessionAccountRecoveryInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-ai-assistant-flags": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AIAssistantFlagsInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + UserSettings?: components["schemas"]["UserSettingsTransformer"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-news-unsubscribe": { + parameters: { + query?: { + News?: number; + Jwt?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_core-{_version}-support-schedulecall": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ScheduleSupportCallOutput"]; + }; + }; + }; + }; + "put_core-{_version}-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": { + 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[]; + /** + * @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"]; + /** + * @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; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "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"]; + }; + VerifyMethods?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-users": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** @example proton.me */ + Domain?: string; + /** @example external@email */ + Email?: string; + /** @example recovery phone number */ + Phone?: string; + /** + * @deprecated + * @example Please use HV headers + */ + Token?: string; + /** + * @deprecated + * @description captcha, email, sms, invite, or payment + * @example Please use HV headers + */ + TokenType?: string; + /** + * @deprecated + * @example identifier + */ + Referrer?: string; + /** @example identifier */ + ReferralIdentifier?: string; + /** + * @description optional field, the encrypted referral ID + * @example + */ + ReferralID?: string; + /** @example 1 */ + Type?: number; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + /** @description optional field, frontend fingerprints */ + Payload?: { + /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ + "random-id-1"?: string; + /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ + "random-id-2"?: string; + /** @example */ + "random-id-3"?: string; + /** @example */ + "random-id-4"?: string; + }; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @example + */ + Salt?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + /** @example 1 */ + Services?: number; + /** @example jason@protonmail.ch */ + Email?: string; + /** @example Jason */ + DisplayName?: string; + Keys?: components["schemas"]["UserKey"][]; + /** + * @description Token for external account creation. If it matches the created email it will be pre-verified + * @example ASD3ldfa.asdfaoa3aw.asdfads + */ + TokenPreVerifiedAddress?: string; + }; + }; + }; + }; + }; + }; + "post_core-{_version}-users-external": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example user_name */ + Username?: string; + /** @example proton.me */ + Domain?: string; + /** @example external@email */ + Email?: string; + /** @example recovery phone number */ + Phone?: string; + /** + * @deprecated + * @example Please use HV headers + */ + Token?: string; + /** + * @deprecated + * @description captcha, email, sms, invite, or payment + * @example Please use HV headers + */ + TokenType?: string; + /** + * @deprecated + * @example identifier + */ + Referrer?: string; + /** @example identifier */ + ReferralIdentifier?: string; + /** + * @description optional field, the encrypted referral ID + * @example + */ + ReferralID?: string; + /** @example 1 */ + Type?: number; + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + /** @description optional field, frontend fingerprints */ + Payload?: { + /** @example ++3dreJ+cHBSeEXvkxjLCRrf1... */ + "random-id-1"?: string; + /** @example Xv5df3dreJ+cHBvkxjSeEXvkx... */ + "random-id-2"?: string; + /** @example */ + "random-id-3"?: string; + /** @example */ + "random-id-4"?: string; + }; + /** + * @deprecated + * @description optional field used together with Android fingerprinting + * @example + */ + Salt?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + User?: components["schemas"]["User"] & { + /** @example 1 */ + Services?: number; + /** @example jason@protonmail.ch */ + Email?: string; + /** @example Jason */ + DisplayName?: string; + Keys?: components["schemas"]["UserKey"][]; + /** + * @description Token for external account creation. If it matches the created email it will be pre-verified + * @example ASD3ldfa.asdfaoa3aw.asdfads + */ + TokenPreVerifiedAddress?: string; + }; + }; + }; + }; + }; + }; + "put_core-{_version}-users-check": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description in case of an invite must be selector:token + * @example + */ + Token?: string; + /** + * @description captcha, email, sms, invite, coupon or payment + * @example captcha + */ + TokenType?: string; + /** + * @description 1 = mail, 2 = VPN + * @example 1 + */ + Type?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-users-availableExternal": { + parameters: { + query?: { + /** + * @description the username + * @example bart + */ + Name?: string; + }; + header?: { + /** + * @description Optional header containing a payment token value. When this value is set and the token is valid, the signup flow is started. + * @example 1234567890abcdefghijklmn + */ + "X-PM-Payment-Info-Token"?: string; + }; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12106 */ + Code?: number; + /** @example Username already used */ + Error?: string; + Details?: { + Suggestions?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-users-available": { + parameters: { + query?: { + /** + * @description the username + * @example bart + */ + Name?: string; + /** + * @description Set to 1 if username is the full email address, otherwise 0 (default) + * @example 1 + */ + ParseDomain?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 12106 */ + Code?: number; + /** @example Username already used */ + Error?: string; + Details?: { + Suggestions?: string[]; + }; + }; + }; + }; + }; + }; + "get_core-{_version}-users-available-{username}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-users-direct": { + parameters: { + query?: { + /** + * @description 1: mail
2: VPN + * @example 1 + */ + Type?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description 1 if enabled, 0 if disabled--client should show invite form + * @example 1 + */ + Direct?: number; + VerifyMethods?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-users-code": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description email or sms + * @example email + * @enum {string} + */ + Type?: "email" | "sms"; + /** + * @description Optional, can use android as well if link support + * @example ios + */ + Platform?: string; + Destination?: { + /** + * @description required if type is email + * @example example@example.com + */ + Address?: string; + /** + * @description required if type is sms + * @example 6176767087 + */ + Phone?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-users-lock": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "put_core-{_version}-users-unlock": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + /** + * @description Token to use when re-authenticating a SSO user + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + SsoReauthToken?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-users-password": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example */ + ClientEphemeral?: string; + /** @example */ + ClientProof?: string; + /** @example */ + SRPSession?: string; + /** + * @description Either this or the FIDO2 object + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** + * @description clientData (base64) returned from the client authentication library + * @example + */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + /** + * @description Token to use when re-authenticating a SSO user + * @example hnnamrzvsgdbxvx74rjadbovyjy63vz4 + */ + SsoReauthToken?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-users-captcha-{token}": { + parameters: { + query?: never; + header?: { + "x-pm-nonce"?: string | null; + }; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-users-disable-{jwt}": { + parameters: { + query?: never; + header?: never; + path: { + /** @example eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc... */ + jwt: string; + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-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": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: string; + }; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example */ + VPNName?: string; + /** @example */ + VPNStatus?: number; + /** + * @description Last VPN login time (unix timestamp) + * @example 1654615966 + */ + LastVPNLogin?: number | null; + ActiveVPNSessions?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + AuthenticationCertificates?: components["schemas"]["VPNAuthenticationCertificateDetailedTransformer"][]; + }; + }; + }; + }; + }; + "put_core-{_version}-members-{enc_id}-vpn": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description the member id + * @example ACXDmTaBub14w== + */ + memberid: string; + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example 2 */ + MaxVPN?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "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": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-nps-submit": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmissionInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "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"][]; + }; + }; + }; + }; + }; + "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; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-info": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Client-specific secret only necessary to access the admin panel + * @example demopass + */ + ClientSecret?: Record; + /** @example user_name */ + Username?: string; + /** + * @description If the intent is to sign into a Proton account, SSO managed account or let the backend decide based on the domain. If Auto and user is SRP, an SRP challenge is always returned; if user is SSO either the SSOChallengeToken is returned directly or a switch to SSO error (HTTP 422 with error code 8100) + * @example auto + * @enum {string} + */ + Intent?: "Proton" | "SSO" | "Auto"; + /** + * @description optional field, to start a testing sso login flow + * @example true + */ + IsTesting?: Record; + /** + * @description optional field, to reauthenticate a SSO user and adding the given scope to the session + * @example locked, password + */ + ReauthScope?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description If Intent SSO or Auto, token to pass to GET /auth/sso/{token} for initiating the SSO flow + * @example a5fd396fcbb + */ + SSOChallengeToken?: string; + } | { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example -----BEGIN PGP SIGNED MESSAGE-----*-----END SIGNATURE----- */ + Modulus?: string; + /** @example */ + ServerEphemeral?: string; + /** @example 4 */ + Version?: number; + /** @example */ + Salt?: string; + /** @example */ + SRPSession?: string; + /** @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 */ + 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; + }; + }; + }; + }; + }; + "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-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-cookies": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @example token */ + ResponseType?: string; + /** @example refresh_token */ + GrantType?: string; + /** @example eaad5a7059835aac32c0bf99c2e208a59b8c1a55 */ + RefreshToken?: string; + /** + * @description defaults to 0 if not present, creates persistent cookies + * @example 1 + */ + Persistent?: number; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @example 6f3c4f52cf499c2066e6c5669a293177c1f43755 */ + UID?: string; + /** @example 0 */ + LocalID?: number; + /** + * @description Do not use this parameter unless you have been instructed to do so. This counts how many refreshes did the session have + * @example 5 + */ + RefreshCounter?: number; + }; + }; + }; + }; + }; + "post_core-{_version}-auth-credentialless": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "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": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + /** @example 1H8EGg3J1Qwk243hf== */ + Salt?: string; + }[]; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-mnemonic": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + MnemonicSalt?: string; + /** @description The new mnemonic SRP verifier */ + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 obect + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "get_core-{_version}-settings-mnemonic-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + /** @example 1H8EGg3J1Qwk243hf== */ + Salt?: string; + }[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-mnemonic-reset": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description The user keys encrypted with the account password */ + UserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + KeysSalt?: string; + /** @description The new account's login password verifier */ + Auth?: { + /** @example 4 */ + Version?: number; + /** @example */ + ModulusID?: string; + /** @example */ + Salt?: string; + /** @example */ + Verifier?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Scopes?: string[]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-mnemonic-disable": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientEphemeral?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + ClientProof?: string; + /** + * @description Optional, for inline re-authentication + * @example + */ + SRPSession?: string; + /** + * @description Optional, for inline re-authentication, either this or the FIDO2 obect + * @example 123456 or recovery code + */ + TwoFactorCode?: string; + /** @description Optional, for inline re-authentication, either this or the TwoFactorCode */ + FIDO2?: { + /** @description The same AuthenticationOptions received as a challenge from the server */ + AuthenticationOptions?: Record; + /** @description clientData (base64) returned from the client authentication library */ + ClientData?: string; + /** @description authenticatorData (base64) returned from the client authentication library */ + AuthenticatorData?: string; + /** @description signature (base64) returned from the client authentication library */ + Signature?: string; + /** @description CredentialID used */ + CredentialID?: Record[]; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** + * @description Present only if inline re-authentication is submitted + * @example + */ + ServerProof?: string; + }; + }; + }; + }; + }; + "put_core-{_version}-settings-mnemonic-reactivate": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + MnemonicUserKeys?: { + /** @example 1H8EGg3J1QpSDL...k0uhrHx6nnGQ== */ + ID?: string; + /** @example -----BEGIN PGP PRIVATE KEY BLOCK ... */ + PrivateKey?: string; + }[]; + /** @example 1H8EGg3J1Qwk243hf== */ + MnemonicSalt?: string; + /** @description The new mnemonic SRP verifier */ + MnemonicAuth?: components["schemas"]["AuthInfoInput"]; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes-active": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "get_core-{_version}-pushes-active-session": { + parameters: { + query?: { + /** + * @description Page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description Page size (max 250) + * @example 100 + */ + PageSize?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: components["schemas"]["PushTransformer"][]; + }; + }; + }; + }; + }; + "delete_core-{_version}-pushes-{enc_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + enc_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Pushes?: string[]; + }; + }; + }; + }; + }; + "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}-load": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-load": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-logs-auth": { + parameters: { + query?: { + /** + * @description the page index using 0-based indexing + * @example 0 + */ + Page?: string; + /** + * @description the page size, maximum 150 + * @example 150 + */ + PageSize?: string; + /** @description skip the given number of results */ + Offset?: string; + /** @description the number of results to return, defaults to page size (1 page), maximum 150 */ + Limit?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + Logs?: components["schemas"]["AuthLogResponse"][]; + /** @example 1 */ + Total?: number; + }; + }; + }; + }; + }; + "delete_core-{_version}-logs-auth": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-metrics": { + parameters: { + query?: { + /** @example signup */ + Category?: string; + /** @example click */ + Action?: string; + /** @example coupon */ + Label?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-metrics": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example encrypted_search + * @enum {string} + */ + Log?: "signup" | "encrypted_search" | "dark_styles"; + /** + * @description Optional title + * @example index + */ + Title?: string; + Data?: { + /** @example you want... */ + whatever?: string; + }; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-settings-recovery-secret": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Base64-encoded secret, decodes to 32 bytes + * @example 1H8EGg3J1...Qwk243hf + */ + RecoverySecret?: string; + /** @example -----BEGIN PGP SIGNATURE... */ + Signature?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-settings-recovery-secret": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-form-{portal_id}-{form_id}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + portal_id: string; + form_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + fields?: Record; + context?: Record; + legalConsentOptions?: Record; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-bug": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** + * @description Client should supply if mobile app, ask user if web app + * @example iOS + */ + OS?: string; + /** + * @description Client should supply if mobile app, ask user if web app + * @example 8.0.3 + */ + OSVersion?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example Safari + */ + Browser?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example 8 + */ + BrowserVersion?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example LastPass + */ + BrowserExtensions?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example 1024x768 + */ + Resolution?: string; + /** + * @description Optional, web app client client should supply, mobile should not use + * @example row + */ + DisplayMode?: string; + /** + * @description Optional, what triggered the bug report modal to show if it was not the user asking explictly + * @example chat-no-agents + */ + Trigger?: string; + /** + * @description Client should supply + * @example Web + */ + Client?: string; + /** + * @description Client should supply + * @example 2.0.0 + */ + ClientVersion?: string; + /** + * @description 1: email, 2: VPN, 3: calendar, 4: drive, 5: pass + * @example 1 + */ + ClientType?: number; + /** @example My issue title */ + Title?: string; + /** + * @description Must be at least 10 characters long + * @example some text here + */ + Description?: string; + /** + * @description If user did not enter this themselves and client is unable to detect it, empty string should be posted + * @example 4w350m3h4x0r + */ + Username?: string; + /** + * @description Outside email, must be a valid email address + * @example derp@gmail.com + */ + Email?: string; + /** + * @description Optional, static web site only. Used for the appeal abuse form. + * @example myaccount@proton.me + */ + DisabledEmail?: string; + /** + * @description Optional, VPN only + * @example CH + */ + Country?: string; + /** + * @description Optional, VPN only + * @example Makedonski Telekom AD-Skopje + */ + ISP?: string; + /** + * @description Optional, VPN only + * @example VPN for Windows + */ + Platform?: string; + /** + * @description Optional + * @example https://search.brave.com/ + */ + Referrer?: string; + /** + * @description Optional + * @example link-footer + */ + ClickOrigin?: string; + /** + * @description Upload attachments asynchronously + * @example 1 + * @enum {integer} + */ + AsyncAttachments?: 0 | 1; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-reports-bug-attachments": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UploadAttachment"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "delete_core-{_version}-reports-bug-{ticketId}": { + parameters: { + query?: { + RequesterID?: number; + CreatedAt?: string; + BrandID?: number | null; + }; + header?: never; + path: { + _version: string; + ticketId: number; + }; + 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-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-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"]; + }; + }; + }; + }; + }; + "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-user-deprecation": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-memoryLeak": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-logger": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-logger-observability": { + parameters: { + query?: { + Level?: number; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-ping": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "get_core-{_version}-tests-version": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-tests-stream": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-update": { + parameters: { + query?: { + /** @example 24m */ + cycle?: string; + }; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-validate-email": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Email address + * @example einstein@pm.me + */ + Email?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"] & { + /** + * @description Email address failed validation + * @default 2050 + */ + Code: unknown; + }; + }; + }; + }; + }; + "post_core-{_version}-validate-phone": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Phone number + * @example +37012345678 + */ + Phone?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"] & { + /** + * @description Phone number failed validation + * @default 2058 + */ + Code: unknown; + }; + }; + }; + }; + }; + "get_core-{_version}-verification-ownership-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-verification-ownership-email-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-email-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-{_version}-verification-ownership-sms-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-sms-{token}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-email-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_core-{_version}-verification-ownership-sms-{token}-{code}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + token: string; + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "get_core-v6-events-{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stream"]; + }; + }; + }; + }; + "get_core-{_version}-events-{id}": { + parameters: { + 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?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventInfo"]; + }; + }; + }; + }; + "get_core-v4-events-{id}": { + parameters: { + query?: { + MessageCounts?: boolean; + ConversationCounts?: boolean; + }; + header?: { + "x-pm-appversion"?: string; + }; + path: { + id: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventInfo"]; + }; + }; + }; + }; + "post_core-{_version}-feedback": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FeedbackVO"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + }; + }; + }; + }; + }; + "post_core-{_version}-checklist-seen-completed-list-{checklistType}": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + checklistType: components["schemas"]["UserChecklistType"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-checklist-get-started-init": { + 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"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_core-{_version}-checklist-paying-user-init": { + 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"]["SuccessfulResponse"]; + }; + }; + }; + }; + "put_core-{_version}-checklist-check-item": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckItemInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "put_core-{_version}-checklist-update-display": { + parameters: { + query?: never; + header?: never; + path: { + _version: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateDisplayInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "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"][]; + }; + }; + }; + }; + }; + "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": components["schemas"]["ConnectionInformationResponse"]; + }; + }; + /** @description Connection information cannot be provided. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "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": { + 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"]["Label2"]; + }; + }; + }; + }; + }; + }; + "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?: { + /** + * @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"]["Label2"][]; + }; + }; + }; + }; + }; + "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"]["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 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"]["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; + }; + }; + }; + }; + }; + "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; + }; + }; + }; + }; + }; + "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: { + /** + * @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; + }; + }; + }; + }; + }; + "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/driveTypes.ts b/js/sdk/src/internal/apiService/driveTypes.ts new file mode 100644 index 00000000..b48551dc --- /dev/null +++ b/js/sdk/src/internal/apiService/driveTypes.ts @@ -0,0 +1,13696 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/drive/photos/volumes/{volumeID}/albums/{linkID}/add-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add photos to an album */ + post: operations["post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current user albums */ + get: operations["get_drive-photos-volumes-{volumeID}-albums"]; + put?: never; + /** Create an album */ + post: operations["post_drive-photos-volumes-{volumeID}-albums"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a photo volume + * @description Also, creates : + * + root folder for the new Photo Volume + * + Photo share for the new Photo Volume + * + Adds ShareMember with given Address ID + */ + post: operations["post_drive-photos-volumes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update an album */ + put: operations["put_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + post?: never; + /** Delete an album */ + delete: operations["delete_drive-photos-volumes-{volumeID}-albums-{linkID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums/{linkID}/duplicates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 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; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/albums/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List photos in album */ + get: operations["get_drive-photos-volumes-{volumeID}-albums-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + 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": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_drive-photos-albums-shared-with-me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + 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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create ShareURL Bookmark + * @description It creates a bookmark for the user in an already existing ShareURL. The bookmark would be stored for the current user if the password is encrypted with his/her addressKey + */ + post: operations["post_drive-v2-urls-{token}-bookmark"]; + /** + * Delete ShareURL Bookmark + * @description It soft deletes the bookmark share url, that would be GC later. The user should be the owner of the bookmark. + */ + delete: operations["delete_drive-v2-urls-{token}-bookmark"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shared-bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all Bookmarks + * @description This endpoint would only show active bookmarks from the user doing the request + */ + get: operations["get_drive-v2-shared-bookmarks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List devices + * @description Gives a list of devices for current user, ordered by creationTime DESC + */ + get: operations["get_drive-devices"]; + put?: never; + /** Create a Device */ + post: operations["post_drive-devices"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/devices/{deviceID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update device */ + put: operations["put_drive-devices-{deviceID}"]; + post?: never; + /** Delete a device */ + delete: operations["delete_drive-devices-{deviceID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List devices (v2) */ + get: operations["get_drive-v2-devices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/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-v2-volumes-{volumeID}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create document + * @deprecated + * @description Create a new proton document. + */ + post: operations["post_drive-shares-{shareID}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/events/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get latest share event + * @deprecated + * @description Get latest EventID for a given share. Deprecated: Use events per volume instead. + */ + get: operations["get_drive-shares-{shareID}-events-latest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/events/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get latest volume event + * @description Get latest EventID for a given volume. + */ + get: operations["get_drive-volumes-{volumeID}-events-latest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/events/{eventID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List share events + * @deprecated + * @description Get new events for given share since eventID. Deprecated: Use events per volume instead. + */ + get: operations["get_drive-shares-{shareID}-events-{eventID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/events/{eventID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volume events + * @description Get new events for given volume since eventID. + */ + get: operations["get_drive-volumes-{volumeID}-events-{eventID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/events/{eventID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volume events (v2) + * @description Get new events for given volume since eventID. + * RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0054-light-events/ + */ + get: operations["get_drive-v2-volumes-{volumeID}-events-{eventID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder + * @deprecated + * @description Create a new folder in a given share, under a given folder link. + */ + post: operations["post_drive-shares-{shareID}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder (v2) + * @description Create a new folder under a given parent folder. + */ + post: operations["post_drive-v2-volumes-{volumeID}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete 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 + * @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; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List folder children (v2) + * @description List children IDs of a given folder. + */ + get: operations["get_drive-v2-volumes-{volumeID}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/folders/{linkID}/trash_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trash children from folder + * @deprecated + */ + post: operations["post_drive-shares-{shareID}-folders-{linkID}-trash_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @description Filter unavailable hashes out of a list of hashes under a given parent folder. + * + * Pending hashes from drafts are also listed. They can be filtered with a list of ClientUID. + */ + post: operations["post_drive-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @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-shares-{shareID}-links-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/{linkID}/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Copy a node to a volume + * @description Copy a single file to a volume, providing the new parent link ID. + */ + post: operations["post_drive-volumes-{volumeID}-links-{linkID}-copy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch links in share + * @deprecated + */ + post: operations["post_drive-shares-{shareID}-links-fetch_metadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch links in volume */ + post: operations["post_drive-volumes-{volumeID}-links-fetch_metadata"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get link data + * @deprecated + * @description Retrieve individual link information. + */ + get: operations["get_drive-shares-{shareID}-links-{linkID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/sanitization/mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List folders with missing hash keys + * @deprecated + */ + get: operations["get_drive-sanitization-mhk"]; + put?: never; + /** + * List folders with missing hash keys + * @deprecated + */ + post: operations["post_drive-sanitization-mhk"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Load links details + * @description Usage on Photo Volumes of this endpoint is DEPRECATED + */ + post: operations["post_drive-v2-volumes-{volumeID}-links"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/links/move-multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Move a batch of files, folders or photos. */ + put: operations["put_drive-volumes-{volumeID}-links-move-multiple"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + 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 + * 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"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move link (v2) + * @description Move a file or folder. Client must provide new values for fields encrypted with parent NodeKey. + * Clients moving a file or folder MUST reuse the existing session keys + * for the name and passphrase as these are also used by shares pointing + * to the link. The passphrase should NOT be changed,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; + 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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename link + * @description Rename a file or folder. Client must provide new values for fields linked to name. + * + * Clients renaming a file or folder MUST reuse the existing session key + * for the name as it is also used by shares pointing to the link. + * + * 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; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename link + * @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. + * + * 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; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get revision + * @description Get detailed revision information. + */ + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + /** + * Commit a revision + * @description The revision becomes the current active one and the updated file content become available for reading. + * + * If NO `BlockNumber` parameter is passed when creating a new revision, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete an obsolete/draft revision + * @description Only the volume owner can delete obsolete revisions. Members with write permission can only delete drafts. + * This will return an error code 2511 INCOMPATIBLE_STATE if the revision is active. Create or revert to + * another revision first. You cannot delete a draft revision for a draft link. Delete the link instead. + */ + delete: operations["delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get revision + * @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, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete an obsolete/draft revision + * @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. + */ + delete: operations["delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new draft file */ + post: operations["post_drive-v2-volumes-{volumeID}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a new draft file + * @deprecated + */ + post: operations["post_drive-shares-{shareID}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List revisions */ + get: operations["get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + put?: never; + /** + * Create revision + * @description Create a new revision on an existing link. + * Only one draft can be created at a time. A draft can be deleted using the DELETE revision endpoint if the new + * draft should be created regardless. The error code indicates the reason for failure. + * + * Client unique ID can be used to track revision ownership to improve concurrency control. + * It can be a single persistent client ID generated by the client and stored locally, + * or it can be specific to the revision. + * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. + */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List revisions + * @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. + * + * Client unique ID can be used to track revision ownership to improve concurrency control. + * It can be a single persistent client ID generated by the client and stored locally, + * or it can be specific to the revision. + * The client can use it to identify revisions in case it failed to receive the confirmation of the revision creation. + */ + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get revision thumbnail + * @deprecated + */ + get: operations["get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Restore a revision */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/files/{linkID}/revisions/{revisionID}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore a revision + * @deprecated + */ + post: operations["post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @deprecated + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/trash/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete items from trash + * @description Permanently delete 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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete items from trash + * @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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List share trash + * @deprecated + * @description List all trashed items of a given share. + * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + * + * CANNOT be used on Photo-Volume -> use volume-trash + */ + get: operations["get_drive-shares-{shareID}-trash"]; + put?: never; + post?: never; + /** + * Empty share trash + * @deprecated + * @description Permanently delete all links from trash of a given share. + * Only used by clients that don’t show photos and devices. Going forward, the volume-based route should be used instead. + * + * CANNOT be used on Photo-Volume -> use volume-trash + */ + delete: operations["delete_drive-shares-{shareID}-trash"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List volume trash + * @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; + /** + * 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; + head?: never; + 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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Restore items from trash + * @description Restore list of links from trash to original location. + * 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; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/trash/restore_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Restore items from trash + * @deprecated + * @description Restore list of links from trash to original location. + * 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; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/trash_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Trash multiple (v2) + * @description Send multiple links to the trash + */ + post: operations["post_drive-v2-volumes-{volumeID}-trash_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload + * @description Request upload URLs for a set of blocks/thumbnails of a given draft revision. + */ + post: operations["post_drive-blocks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload small file */ + post: operations["post_drive-v2-volumes-{volumeID}-files-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload small revision */ + post: operations["post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/health/hash-check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get hash check + * @description Tells the client if the user is potentially impacted by Windows-Drive January 2026 incident and client should check hashes + */ + get: operations["get_drive-health-hash-check"]; + put?: never; + /** + * Report hash check progress + * @description Inform the backend of the hash-check progress for a given client. Only report twice, once when starting with 0-counts and once when finished (be it successfully or with failures) + */ + post: operations["post_drive-health-hash-check"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/me/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Ping active user + * @description Endpoint that can be pinged by clients to mark a user as an active user + */ + get: operations["get_drive-me-active"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/report/url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Report Share URL */ + post: operations["post_drive-report-url"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/onboarding/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": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get onboarding checklist */ + get: operations["get_drive-v2-checklist-get-started"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_drive-v2-onboarding"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/checklist/get-started/seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark completed checklist as seen */ + post: operations["post_drive-v2-checklist-get-started-seen-completed-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get entitlements + * @description Get the current entitlements and their value for the logged-in user. + */ + get: operations["get_drive-entitlements"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/photos/volumes/{volumeID}/links/{linkID}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add tags to existing photo */ + 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"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos/share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * DEPRECATED: Create photo share + * @deprecated + */ + post: operations["post_drive-volumes-{volumeID}-photos-share"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos/share/{shareID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete empty photo share + * @description Can only delete Photo Shares that are empty. + */ + delete: operations["delete_drive-volumes-{volumeID}-photos-share-{shareID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Find duplicates */ + post: operations["post_drive-volumes-{volumeID}-photos-duplicates"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/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 + * @deprecated + */ + 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"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/photos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List photos sorted by capture time + * @description When paginating to secondary pages, the PreviousPageLastLinkID must be provided. + */ + get: operations["get_drive-volumes-{volumeID}-photos"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/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}/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; + 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}/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}/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; + header?: never; + path?: never; + cookie?: never; + }; + /** Initiate shared by URL session with SRP */ + get: operations["get_drive-urls-{token}-info"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + 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"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Commit a revision + * @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, + * ALL blocks after the greatest index in the submitted block list will be + * truncated for this revision. All blocks for the new revision should be + * submitted. If `BlockNumber` is submitted, all previous blocks + * 1...BlockNumber will be preserved if they are not overridden by a new block + * BlockNumber+1... will be discarded. + */ + put: operations["put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}"]; + post?: never; + /** + * Delete a draft revision. + * @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}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create anonymous document. + * @deprecated + * @description Create a new anonymous proton document. + */ + post: operations["post_drive-urls-{token}-documents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create file. + * @deprecated + * @description Create a new file. + */ + post: operations["post_drive-urls-{token}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder. + * @deprecated + * @description Create a new folder in a given share, under a given folder link. + */ + post: operations["post_drive-urls-{token}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders/{linkID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete children + * @deprecated + * @description Permanently delete children from folder, skipping trash. + */ + post: operations["post_drive-urls-{token}-folders-{linkID}-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/fetch_metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch links metadata using token + * @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"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/{linkID}/rename": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename entry + * @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"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload. + * @deprecated + * @description Request upload information for a set of blocks. + */ + post: operations["post_drive-urls-{token}-blocks"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/links/{linkID}/revisions/{revisionID}/verification": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @deprecated + * @description Get data to verify encryption of the revision before committing. + */ + get: operations["get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Shared File Information. + * @deprecated + */ + get: operations["get_drive-urls-{token}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List shared folder's children. + * @deprecated + */ + get: operations["get_drive-urls-{token}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/files/{linkID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Shared File & Revision Metadata. + * @deprecated + */ + get: operations["get_drive-urls-{token}-files-{linkID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/urls/{token}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Shared File Information. + * @deprecated + */ + post: operations["post_drive-urls-{token}-file"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/urls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ShareURLs in a volume */ + get: operations["get_drive-volumes-{volumeID}-urls"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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 */ + post: operations["post_drive-shares-{shareID}-urls"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls/{urlID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a Share 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}"]; + post?: never; + /** Delete a Share URL */ + delete: operations["delete_drive-shares-{shareID}-urls-{urlID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/urls/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete multiple Share URLs */ + post: operations["post_drive-shares-{shareID}-urls-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check available hashes + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/checkAvailableHashes for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-checkAvailableHashes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: 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 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/unauth/v2/volumes/{volumeID}/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: 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/unauth/v2/volumes/{volumeID}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a new draft file + * @description See /drive/v2/volumes/{volumeID}/files for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a folder (v2) + * @description See /drive/v2/volumes/{volumeID}/folders for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-folders"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/files/{linkID}/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create revision + * @description See /drive/v2/volumes/{volumeID}/files/{linkID}/revisions for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/delete_multiple": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete drafts + * @description See /drive/v2/volumes/{volumeID}/delete_multiple for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-delete_multiple"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/volumes/{volumeID}/thumbnails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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/unauth/v2/volumes/{volumeID}/folders/{linkID}/children": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List folder children (v2) + * @description See /drive/v2/volumes/{volumeID}/folders/{linkID}/children for full documentation + */ + get: operations["get_drive-unauth-v2-volumes-{volumeID}-folders-{linkID}-children"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/v2/volumes/{volumeID}/links": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Load links details + * @description See /drive/v2/volumes/{volumeID}/links for full documentation + */ + post: operations["post_drive-unauth-v2-volumes-{volumeID}-links"]; + delete?: never; + options?: never; + head?: never; + 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; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Rename link + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/rename for full documentation + */ + put: operations["put_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-rename"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/unauth/blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Request block upload + * @description See /drive/blocks for full documentation + */ + post: operations["post_drive-unauth-blocks"]; + delete?: never; + options?: never; + head?: never; + 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; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get verification data. + * @description See /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification for full documentation + */ + get: operations["get_drive-unauth-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/map": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search map + * @deprecated + * @description Used only for search on web that does not scale. Should be replaced by better version in the future. + */ + get: operations["get_drive-shares-{shareID}-map"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/my-files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Bootstrap my files */ + get: operations["get_drive-v2-shares-my-files"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/photos": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Bootstrap photos section */ + get: operations["get_drive-v2-shares-photos"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get share bootstrap */ + get: operations["get_drive-shares-{shareID}"]; + put?: never; + post?: never; + /** + * Delete a 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/volumes/{volumeID}/links/{linkID}/context": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get context share + * @description Gets the highest share, meaning closest to the root, for a link + */ + get: operations["get_drive-volumes-{volumeID}-links-{linkID}-context"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List shares + * @description List shares available to current user. + * + * The results can be restricted to a single address by providing the AddressID query parameter. + * By default, only active shares are shown. + * Passing the ShowAll=1 query parameter will show locked and disabled shares also. + */ + get: operations["get_drive-shares"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/shares/{shareID}/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/migrations/shareaccesswithnode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Migrate legacy Shares */ + post: operations["post_drive-migrations-shareaccesswithnode"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/migrations/shareaccesswithnode/unmigrated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List unmigrated shares + * @description List shares that have not been migrated to the new PassphraseNodeKeyPacket yet. + * Will throw a 422 with Code 2000 if the current user doesn't have any active Volume. + */ + get: operations["get_drive-migrations-shareaccesswithnode-unmigrated"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/volumes/{volumeID}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a standard share + * @description Cannot create two shares on the same link. Throws 422 with code 2500 in case a share already exists. + */ + post: operations["post_drive-volumes-{volumeID}-shares"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/volumes/{volumeID}/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Shared by me + * @description List Collaborative Shares in the given volume that are not abandoned, i.e. they either have members, invitations or URLs attached. + */ + get: operations["get_drive-v2-volumes-{volumeID}-shares"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/sharedwithme": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Shared with me + * @description List Collaborative Shares the user has access to as a non-owner + */ + get: operations["get_drive-v2-sharedwithme"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update an external invitation + * @description Only permissions can be changed. They can be changed when the external invitation is pending or accepted. + * After the external invitation has been accepted, the invitation's permissions can be edited. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + post?: never; + /** + * Delete an external invitation + * @description The current user must have admin permission on the share. + */ + delete: operations["delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external invitations in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-external-invitations"]; + put?: never; + /** + * Invite an external user to a share + * @description The current user must have admin permission on the share. The share can be Standard or the RootShare of an Org. Volume (Organization Share) + */ + post: operations["post_drive-v2-shares-{shareID}-external-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/external-invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external invitations of a user + * @description List the UserRegistered external invitations where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-external-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/external-invitations/{invitationID}/sendemail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send the external invitation email to the invitee + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Accept an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-accept"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update an invitation + * @description Only permissions can be changed. They can be changed when the invitation is pending and when it has been rejected. + * The owner should not be aware of rejection. After the invitation has been accepted, membership permissions can be edited. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + post?: never; + /** + * Delete an invitation + * @description The current user must have admin permission on the share. + */ + delete: operations["delete_drive-v2-shares-{shareID}-invitations-{invitationID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List invitations in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-invitations"]; + put?: never; + /** + * Invite a Proton user to a share + * @description The current user must have admin permission on the share. The share can be Standard or the RootShare of an Org. Volume (Organization Share) + */ + post: operations["post_drive-v2-shares-{shareID}-invitations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List invitations of a user + * @description List the pending invitations where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-invitations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}/reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reject an invitation */ + post: operations["post_drive-v2-shares-invitations-{invitationID}-reject"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/invitations/{invitationID}/sendemail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send the invitation email to the invitee + * @description The current user must have admin permission on the share. + */ + post: operations["post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/invitations/{invitationID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return invitation information + * @description Get the information about a pending invitation where the current user is the invitee. + */ + get: operations["get_drive-v2-shares-invitations-{invitationID}"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/user-link-access": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List link accesses for a share url. + * @description RFC: https://drive.gitlab-pages.protontech.ch/documentation/rfcs/0031-share-resolution-from-copied-url/ + */ + get: operations["get_drive-v2-user-link-access"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List members in a share + * @description The current user must have admin permission on the share. + */ + get: operations["get_drive-v2-shares-{shareID}-members"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/members/{memberID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update a member + * @description Only permissions can be changed. They can be changed when the member is active. + * The current user must have admin permission on the share. + */ + put: operations["put_drive-v2-shares-{shareID}-members-{memberID}"]; + post?: never; + /** + * Remove a share member + * @description If the current user is an admin of the share they can remove other members. + * If the current user is not an admin they can only remove themselves. + */ + delete: operations["delete_drive-v2-shares-{shareID}-members-{memberID}"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/drive/v2/shares/{shareID}/security": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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/organization/volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: 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 + * @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; + 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 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/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; + 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 + * @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?: 69632; + /** @constant */ + DraftRevisionLifetimeInSec?: 14400; + /** @constant */ + ExtendedAttributesMaxSizeInBytes?: 65535; + /** @constant */ + UploadTokenExpirationTimeInSec?: 10800; + /** @constant */ + DownloadTokenExpirationTimeInSec?: 1800; + }; + /** @description An encrypted ID */ + Id: string; + /** @description An armored PGP Message */ + PGPMessage: string; + /** @description An armored PGP Signature */ + PGPSignature: string; + /** + * Format: email + * @description Address Email + */ + AddressEmail: string; + AlbumPhotoLinkDataDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name Hash */ + Hash: 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; + /** @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; + }; + AddPhotosToAlbumRequestDto: { + AlbumData: components["schemas"]["AlbumPhotoLinkDataDto"][]; + }; + /** @description An armored PGP Private Key */ + PGPPrivateKey: string; + AlbumLinkDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description Album name Hash */ + 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: components["schemas"]["PGPMessage"]; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: components["schemas"]["PGPMessage"] | null; + }; + CreateAlbumRequestDto: { + Locked: boolean; + Link: components["schemas"]["AlbumLinkDto"]; + }; + AlbumLinkResponseDto: { + LinkID: components["schemas"]["Id"]; + }; + AlbumShortResponseDto: { + Link: components["schemas"]["AlbumLinkResponseDto"]; + }; + CreateAlbumResponseDto: { + Album: components["schemas"]["AlbumShortResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** @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"]["LongId"] | null; + }; + LinkDataDto: { + /** @description Root folder name */ + Name: components["schemas"]["PGPMessage"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeHashKey: components["schemas"]["PGPMessage"]; + }; + CreatePhotoShareRequestDto: { + Share: components["schemas"]["ShareDataDto"]; + Link: components["schemas"]["LinkDataDto"]; + }; + /** + * @description
See values descriptions
ValueDescription
1Active
3Locked
+ * @enum {integer} + */ + VolumeState: 1 | 3; + ShareReferenceResponseDto: { + ShareID: components["schemas"]["Id"]; + /** @description Deprecated, use `ShareID` instead */ + 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"]; + /** @description Main share of the volume */ + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType"]; + }; + GetPhotoVolumeResponseDto: { + Volume: components["schemas"]["PhotoVolumeResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + SuccessfulResponse: { + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FindDuplicatesInput: { + /** @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; + /** @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; + /** @description LinkID, null if deleted */ + LinkID: components["schemas"]["Id"] | null; + /** @description RevisionID, null if deleted */ + RevisionID: components["schemas"]["Id"] | null; + }; + FindDuplicatesOutputCollection: { + DuplicateHashes: components["schemas"]["FoundDuplicate"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AlbumResponseDto: { + Locked: boolean; + LastActivityTime: number; + PhotoCount: number; + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"] | null; + CoverLinkID: components["schemas"]["Id"] | null; + }; + ListAlbumsResponseDto: { + Albums: components["schemas"]["AlbumResponseDto"][]; + AnchorID: string | null; + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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; + /** + * @default Captured + * @enum {string} + */ + Sort: "Captured" | "Added"; + /** @default true */ + Desc: boolean; + /** @default null */ + Tag: components["schemas"]["TagType"] | null; + /** @default false */ + OnlyChildren: boolean; + /** @default false */ + IncludeTrashed: boolean; + }; + 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 */ + Tags: number[]; + }; + ListPhotosAlbumResponseDto: { + Photos: components["schemas"]["ListPhotosAlbumItemResponseDto"][]; + AnchorID: string | null; + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + TransferPhotoLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @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"][]; + /** + * 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"][]; + }; + 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"]; + }; + SharedWithMeResponseDto: { + Albums: components["schemas"]["AlbumResponseDto"][]; + AnchorID: string | null; + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AlbumLinkUpdateDto: { + Name?: components["schemas"]["PGPMessage"] | null; + Hash?: string | null; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + */ + NameSignatureEmail?: components["schemas"]["AddressEmail"] | null; + OriginalHash?: string | null; + /** @description Extended attributes encrypted with link key */ + XAttr?: components["schemas"]["PGPMessage"] | 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"]["LongId"]; + }; + CreateBookmarkShareURLRequestDto: { + BookmarkShareURL: components["schemas"]["BookmarkShareURLRequestDto"]; + }; + /** + * @description
See values descriptions
ValueDescription
1Active
3Deleted
+ * @enum {integer} + */ + BookmarkShareURLState: 1 | 3; + BookmarkShareURLResponseDto: { + UserID: components["schemas"]["LongId"]; + Token: string; + ShareURLID: components["schemas"]["Id"]; + EncryptedUrlPassword: components["schemas"]["PGPMessage"] | null; + State: components["schemas"]["BookmarkShareURLState"]; + CreateTime: number; + ModifyTime: number; + }; + CreateBookmarkShareURLResponseDto: { + BookmarkShareURL: components["schemas"]["BookmarkShareURLResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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: { + /** + * @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; + }; + TokenResponseDto: { + /** + * @description Url Token + * @example YTZZRH7DA8 + */ + Token: string; + /** @description Types: Folder - 1, File - 2} */ + 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; + NodeHashKey: components["schemas"]["PGPMessage"] | 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. */ + 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?: components["schemas"]["LongId"] | null; + /** + * @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"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"]["DeviceDataDto3"] | null; + /** + * @deprecated + * @default 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"]; + /** + * @description Unencrypted signature of the content session key (plain text of the ContentKeyPacket), signed with the NodeKey. + * @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 */ + Hash: string; + 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: components["schemas"]["AddressEmail"] | null; + }; + DocumentDetailsDto: { + VolumeID: components["schemas"]["Id"]; + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + }; + CreateDocumentResponseDto: { + Document: components["schemas"]["DocumentDetailsDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** @description An encrypted ID */ + ShortId: string; + LatestEventIDResponseDto: { + EventID: components["schemas"]["ShortId"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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; + /** + * @deprecated + * @description Deprecated: Clients persist exif information in xAttr instead + */ + Exif?: string | null; + /** @description Photo content hash, Hashmac of content using parent folder's hash key */ + ContentHash: string | null; + /** @description LinkIDs of related Photos if there are any */ + RelatedPhotosLinkIDs: string[]; + }; + /** Link */ + LinkTransformer: { + LinkID: string; + ParentLinkID: string | null; + VolumeID: string; + /** + * @description Node type (1=folder, 2=file) + * @enum {integer} + */ + Type: 1 | 2; + /** + * @description Link name + * @example ----BEGIN PGP MESSAGE----... + */ + Name: string; + /** + * Format: email + * @description Link name signature email (signed since 1st January 2021) + */ + NameSignatureEmail: string; + /** @description Name Hash */ + Hash: string | null; + /** + * @description State (0=draft, 1=active, 2=trashed) + * @enum {integer} + */ + State: 0 | 1 | 2; + /** + * @deprecated + * @description [Deprecated] ExpirationTime (always null) + */ + ExpirationTime: number | null; + /** + * @deprecated + * @description Encrypted size (for files of active revisions, better to use FileProperties > ActiveRevision > Size) + */ + Size: number; + /** @description Encrypted size of Node (all active and obsolete revisions for files) */ + TotalSize: number; + /** + * @description Mime type + * @example application/ms-xls + */ + MIMEType: string; + /** + * @deprecated + * @description Always returns 1 + * @enum {integer} + */ + Attributes: 1; + /** + * @deprecated + * @description Always returns 7, read+write+execute + */ + Permissions: number; + /** + * @description Node Key + * @example -----BEGIN PGP PRIVATE KEY BLOCK-----... + */ + NodeKey: string; + /** + * @description Node passphrase + * @example ----BEGIN PGP MESSAGE-----... + */ + NodePassphrase: string; + /** + * @description Node passphrase signature + * @example -----BEGIN PGP SIGNATURE-----... + */ + NodePassphraseSignature: string; + /** + * Format: email + * @description Signature email address used for passphrase, should be the user's address associated with the Share. + */ + SignatureEmail: string; + /** + * Format: email + * @deprecated + * @description [Deprecated] Signature email address used for passphrase + */ + SignatureAddress: string; + /** @description Creation timestamp */ + CreateTime: number; + /** @description Last modification timestamp (on API, real modify date is stored in XAttr) */ + ModifyTime: number; + /** @description Timestamp, time at which the file was trashed, null if file is not trashed. */ + Trashed: number | null; + }; + /** Link */ + ExtendedLinkTransformer: components["schemas"]["LinkTransformer"] & { + /** + * @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; + /** @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 + * @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"]["ShortId"]; + EventType: components["schemas"]["EventType"]; + /** @description Event creation timestamp */ + CreateTime: number; + Link: { + LinkID: components["schemas"]["Id"]; + } | components["schemas"]["ExtendedLinkTransformer"]; + /** + * @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: components["schemas"]["Id"] | null; + /** + * @description If a file was moved to a different context share, this shows the old, origin share + * @default null + */ + FromContextShareID: components["schemas"]["Id"]; + /** + * @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; + }; + }; + 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"]["ShortId"]; + /** + * @description 1 if there is more to pull, i.e. there are more events than returned in one call + * @enum {integer} + */ + More: 0 | 1; + /** + * @description 1 if client needs to refresh from scratch as their provided event does not exist anymore, i.e. too much time passed since the last event sync + * @enum {integer} + */ + Refresh: 0 | 1; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + EventLinkDataDto: { + LinkID: components["schemas"]["Id"]; + ParentLinkID?: components["schemas"]["Id"] | null; + IsShared: boolean; + IsTrashed: boolean; + }; + EventV2ResponseDto: { + EventID: components["schemas"]["ShortId"]; + 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 */ + 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 + * @enum {integer} + */ + Code: 1000; + }; + CreateFolderRequestDto: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: components["schemas"]["PGPMessage"]; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: components["schemas"]["PGPMessage"] | 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 + * @description Signature email address used to sign passphrase and name + * @default null + */ + SignatureAddress: components["schemas"]["AddressEmail"] | null; + }; + FolderResponseDto: { + /** @description Link ID */ + ID: components["schemas"]["Id"]; + }; + CreateFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateFolderRequestDto2: { + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: components["schemas"]["PGPMessage"]; + /** + * @description Extended attributes encrypted with link key + * @default null + */ + XAttr: components["schemas"]["PGPMessage"] | 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 + * @description Signature email address used to sign passphrase and name + * @default null + */ + SignatureEmail: components["schemas"]["AddressEmail"] | null; + }; + /** @description An encrypted ID */ + EncryptedId: string; + 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"]["Id"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID: components["schemas"]["Id"] | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CheckAvailableHashesRequestDto: { + Hashes: string[]; + /** + * @description Client UID list to filter pending drafts with. If not provided, all conflicting draft hashes will be returned in `PendingHashes` + * @default null + */ + ClientUID: string[] | null; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + RelatedPhotoDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + 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)) ] */ + 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: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @description Name hash */ + Hash: string; + /** @description Volume ID to copy to. */ + TargetVolumeID: components["schemas"]["Id"]; + /** @description New parent link ID to copy to. */ + TargetParentLinkID: components["schemas"]["Id"]; + /** + * Format: email + * @description Signature email address used for signing name. + */ + 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: components["schemas"]["PGPMessage"] | null; + }; + CopyLinkResponseDto: { + LinkID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + FetchLinksMetadataRequestDto: { + /** + * @deprecated + * @description Get thumbnail download URLs + * @default 0 + * @enum {integer} + */ + Thumbnails: 0 | 1; + LinkIDs: components["schemas"]["EncryptedId"][]; + }; + FetchLinksMetadataResponseDto: { + Links: components["schemas"]["ExtendedLinkTransformer"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + ShareID: components["schemas"]["Id"]; + }; + ListMissingHashKeyResponseDto: { + NodesWithMissingNodeHashKey: components["schemas"]["ListMissingHashKeyItemDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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; + OwnedByDto: { + /** + * Format: email + * @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. Always null in public-sharing context. */ + Organization: string | null; + }; + 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; + OwnedBy: components["schemas"]["OwnedByDto"]; + 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. 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; + 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; + /** @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"][]; + /** 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"]["ShortId"]; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @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: components["schemas"]["BinaryString"]; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + InviterSharePassphraseKeyPacketSignature: components["schemas"]["PGPSignature"]; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + InviteeSharePassphraseSessionKeySignature: components["schemas"]["PGPSignature"]; + }; + 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; + /** @default null */ + Album: 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 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; + /** @default null */ + Album: 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 */ + Sharing: components["schemas"]["SharingDto"] | null; + /** @default null */ + Membership: components["schemas"]["MembershipDto"] | null; + /** @default null */ + File: null; + /** @default null */ + Folder: null; + }; + LoadLinkDetailsResponseDto: { + Links: (components["schemas"]["FileDetailsDto"] | components["schemas"]["FolderDetailsDto"] | components["schemas"]["AlbumDetailsDto"])[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + MoveLinkInBatchRequestDto: { + LinkID: components["schemas"]["Id"]; + /** @description Name, reusing same session key as previously. */ + Name: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @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; + }; + 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: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @description Name hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + /** + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @default null + */ + NameSignatureEmail: string | null; + /** + * Format: email + * @deprecated + * @description [DEPRECATED] since only the name is signed, use `NameSignatureEmail`. Signature email address used for the name. + * @default null + */ + SignatureAddress: string | null; + /** + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null + */ + OriginalHash: string | null; + /** + * @deprecated + * @description Deprecated: Target ShareID (for move between shares/devices). Determined on the backend automatically + * @default null + */ + NewShareID: components["schemas"]["Id"] | null; + /** + * @description Optional, except when moving a Photo-Link. Photo content hash, hmacsha256 of sha1 content using parent folder's hash key [ hmacSha256(folder hash key, sha1(plain content)) ] + * @default null + */ + ContentHash: string | null; + /** + * @description Node Passphrase Signature. Required when moving an anonymous Link. It must be signed by the SignatureEmail address. + * @default null + */ + NodePassphraseSignature: components["schemas"]["PGPSignature"] | null; + /** + * Format: email + * @description Signature email address used for the NodePassphraseSignature. + * @default null + */ + SignatureEmail: string | null; + }; + MoveLinkRequestDto2: { + /** @description Name, reusing same session key as previously. */ + Name: components["schemas"]["PGPMessage"]; + /** @description Node passphrase, reusing same session key as previously. */ + NodePassphrase: components["schemas"]["PGPMessage"]; + /** @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: components["schemas"]["PGPMessage"]; + /** @description Name hash; ignored/nullable for root-links */ + Hash?: string | null; + /** + * Format: email + * @description Signature email address used for signing name; Required when not passing `SignatureAddress` + * @default null + */ + NameSignatureEmail: 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: components["schemas"]["AddressEmail"] | null; + /** + * @description Current name hash before move operation. Used to prevent race conditions. + * @default null + */ + OriginalHash: string | null; + /** + * @description MIME type, optional, only on files. + * @default null + * @example text/plain + */ + MIMEType: string | null; + }; + UpdateMissingHashKeyItemDto: { + LinkID: components["schemas"]["Id"]; + VolumeID: components["schemas"]["Id"]; + PGPArmoredEncryptedNodeHashKey: components["schemas"]["PGPMessage"]; + }; + UpdateMissingHashKeyRequestDto: { + NodesWithMissingNodeHashKey: components["schemas"]["UpdateMissingHashKeyItemDto"][]; + }; + CommitRevisionPhotoDto: { + /** @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; + /** + * @description Main photo LinkID reference. Pass null if none. + * @default null + */ + MainPhotoLinkID: components["schemas"]["Id"] | 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; + }; + 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 + * @default null + */ + XAttr: components["schemas"]["PGPMessage"] | 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; + /** + * @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 */ + 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: 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 + */ + 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; + 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: components["schemas"]["AddressEmail"] | null; + }; + FileResponseDto: { + /** @description Link ID */ + ID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + ClientUID: string | null; + }; + CreateDraftFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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; + }; + RevisionResponseDto: { + /** @description Revision ID */ + ID: components["schemas"]["Id"]; + }; + CreateDraftRevisionResponseDto: { + Revision: components["schemas"]["RevisionResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetRevisionQueryParameters: { + /** + * @description Number of blocks + * @default null + */ + PageSize: number | null; + /** + * @description Block index from which to fetch block list + * @default null + */ + FromBlockIndex: number | null; + /** + * @description Do not generate download URLs for blocks + * @default false + */ + NoBlockUrls: boolean; + }; + /** + * @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"][]; + /** @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; + /** + * 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; + }; + ListRevisionsResponseDto: { + Revisions: components["schemas"]["RevisionResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RestoreRevisionAcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + VerificationData: { + VerificationCode: components["schemas"]["BinaryString"]; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + EmptyTrashAcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + VolumeTrashListV2: { + TrashedLinkIDs: components["schemas"]["Id"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + Verifier: { + /** @description Derived from verificationCode from GET /verification endpoint: base64(xor(verificationCode, padWithZeros(dataPacket, 32))) */ + 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 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: components["schemas"]["PGPMessage"] | null; + /** + * @deprecated + * @description Block size in bytes + * @default null + */ + Size: number | null; + /** + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded + */ + Hash?: components["schemas"]["BinaryString"] | null; + }; + RequestUploadThumbnailInput: { + Type: components["schemas"]["ThumbnailType"]; + /** + * @deprecated + * @description Block size in bytes. WARNING: when type is NOT 2=HDPreview(1920) then the max size is 69632 + * @default null + */ + Size: number | null; + /** + * @deprecated + * @description sha256 hash of encrypted block, base64 encoded + */ + Hash?: components["schemas"]["BinaryString"] | null; + }; + RequestUploadInput: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + AddressID?: components["schemas"]["AddressID"] | null; + /** @default null */ + VolumeID: components["schemas"]["Id"] | null; + /** + * @deprecated + * @description Deprecated, pass VolumeID instead + * @default null + */ + ShareID: components["schemas"]["Id"] | null; + /** + * @deprecated + * @description Request for thumbnail upload + * @default 0 + */ + Thumbnail: number | null; + /** + * @deprecated + * @description sha256 hash of thumbnail contents + * @default null + */ + ThumbnailHash: components["schemas"]["BinaryString"] | null; + /** + * @deprecated + * @description Size of thumbnail contents + * @default 0 + */ + ThumbnailSize: number | null; + /** @default [] */ + BlockList: components["schemas"]["RequestUploadBlockInput"][]; + /** @default [] */ + ThumbnailList: components["schemas"]["RequestUploadThumbnailInput"][]; + }; + BlockURL: { + BareURL: string; + Token: string; + /** @deprecated */ + URL: string; + Index: number; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + SmallUploadResponseDto: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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"]; + }; + /** @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; + /** + * @description Reported ShareURL, complete including fragment + * @example https://drive.proton.me/urls/1F9BKXYDMA#yF7d7bn01GMM + */ + ShareURL: string; + AbuseCategory: components["schemas"]["AbuseDtoCategory"]; + /** + * @description Full password, including custom part, as string, escaped for JSON + * @default + */ + Password: string; + /** + * Format: email + * @description Reporter's email if provided + * @default null + */ + ReporterEmail: string | null; + /** + * @description User message about the report. Required for copyright or leak reports. + * @default null + * @example This is malware + */ + ReporterMessage: string | null; + /** @default null */ + VolumeID: components["schemas"]["Id"] | null; + /** @default null */ + LinkID: components["schemas"]["Id"] | null; + /** @default null */ + RevisionID: components["schemas"]["Id"] | null; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + ChecklistResponseDto: { + /** @description Array of completed checklist items */ + Items: string[]; + CreatedAt: number | null; + ExpiresAt: number | null; + /** @description User already has reward quota */ + UserWasRewarded: boolean; + /** @description Client has displayed completed checklist */ + Seen: boolean; + /** @description Client has completed checklist */ + Completed: boolean; + /** + * Format: float + * @description Amount of storage GB completion reward + */ + RewardInGB: number; + /** @description Checklist should be visible to user */ + Visible: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + OnboardingResponseDto: { + /** @description `true` if the user has pending/rejected invitations or user_registered external invitation */ + HasPendingInvitations: boolean; + IsFreshAccount: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + AddTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; + }; + FavoritePhotoDataDto: { + /** @description Name Hash */ + Hash: 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; + /** @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"][]; + }; + 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 + * @enum {integer} + */ + Code: 1000; + }; + GetMigrationStatusResponseDto: { + OldVolumeID: components["schemas"]["Id"]; + NewVolumeID: components["schemas"]["Id"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + AcceptedResponse: { + /** + * ProtonResponseCode + * @example 1002 + * @enum {integer} + */ + Code: 1002; + }; + ListPhotosParameters: { + /** @default true */ + Desc: boolean; + /** @default 500 */ + PageSize: number; + /** + * @description The link ID of the last photo from the previous page when requesting secondary pages + * @default null + */ + PreviousPageLastLinkID: components["schemas"]["Id"] | null; + /** + * @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) + * @default null + */ + MinimumCaptureTime: number | null; + /** @default null */ + Tag: components["schemas"]["TagType"] | null; + }; + 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 */ + Tags: number[]; + RelatedPhotos: components["schemas"]["PhotoListingRelatedItemResponse"][]; + }; + PhotoListingResponse: { + Photos: components["schemas"]["PhotoListingItemResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ActivePhotoRevisionDto: { + 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"][]; + /** Format: email */ + SignatureEmail?: string | null; + }; + PhotoAlbumDto: { + AlbumLinkID: components["schemas"]["Id"]; + Hash: string; + ContentHash: string; + AddedTime: number; + }; + PhotoFileDto: { + ActiveRevision: components["schemas"]["ActivePhotoRevisionDto"] | null; + /** @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"][]; + /** @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 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; + }; + 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; + }; + 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; + /** @default null */ + Album: null; + }; + LoadPhotoVolumeLinkDetailsResponseDto: { + Links: (components["schemas"]["PhotoDetailsDto"] | components["schemas"]["PhotoAlbumDetailsDto"] | components["schemas"]["PhotoRootFolderDetailsDto"])[]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RemoveTagsRequestDto: { + Tags: components["schemas"]["TagType"][]; + }; + UpdatePhotoCaptureTimeRequestDto: { + /** @description Unix timestamp used to determine position in timeline */ + CaptureTime: number; + }; + 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: components["schemas"]["PGPMessage"]; + }; + 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"]; + /** + * @description Permission bitfield of the share URL + * @enum {integer} + */ + PublicPermissions: 4 | 6; + }; + AuthShareTokenResponseDto: { + /** @description Session UID */ + UID: string; + ServerProof: components["schemas"]["BinaryString"]; + Share: components["schemas"]["AuthShareDataResponseDto"]; + /** @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; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ParentEncryptedLinkIDsResponseDto: { + ParentLinkIDs: components["schemas"]["Id"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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 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; + }; + 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 */ + DirectAccess: components["schemas"]["DirectAccessResponseDto"] | null; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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: 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"]; + /** @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; + /** @description Document=1, Sheet=2 */ + DocumentType?: components["schemas"]["DocumentType"]; + }; + CreateAnonymousDocumentResponseDto: { + Document: components["schemas"]["DocumentDetailsDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateAnonymousFileRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @example text/plain */ + MIMEType: string; + ContentKeyPacket: components["schemas"]["BinaryString"]; + /** + * Format: email + * @description Signature email address used to sign passphrase and name + * @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; + /** + * @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; + }; + CreateAnonymousFileResponseDto: { + File: components["schemas"]["FileResponseDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + CreateAnonymousFolderRequestDto: { + Name: components["schemas"]["PGPMessage"]; + /** @description File/folder name Hash */ + Hash: string; + ParentLinkID: components["schemas"]["Id"]; + NodePassphrase: components["schemas"]["PGPMessage"]; + NodePassphraseSignature: components["schemas"]["PGPSignature"]; + NodeKey: components["schemas"]["PGPPrivateKey"]; + /** @description Node hash key (random bytes encoded in base64 format), encrypted and signed. */ + NodeHashKey: components["schemas"]["PGPMessage"]; + /** + * 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: components["schemas"]["PGPMessage"] | null; + }; + CreateAnonymousFolderResponseDto: { + Folder: components["schemas"]["FolderResponseDto"]; + AuthorizationToken: string; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + LinkWithAuthorizationTokenDto: { + LinkID: components["schemas"]["Id"]; + /** @default null */ + AuthorizationToken: string | null; + }; + DeleteChildrenRequestDto: { + Links: components["schemas"]["LinkWithAuthorizationTokenDto"][]; + }; + RenameAnonymousLinkRequestDto: { + /** @description Name, reusing same session key as previously. */ + Name: components["schemas"]["PGPMessage"]; + /** @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: components["schemas"]["AddressEmail"] | null; + /** + * @description MIME type, optional, only on files. + * @default null + * @example text/plain + */ + MIMEType: string | null; + /** @default null */ + AuthorizationToken: string | null; + }; + RequestAnonymousUploadRequestDto: { + LinkID: components["schemas"]["Id"]; + RevisionID: components["schemas"]["Id"]; + /** + * Format: email + * @description Signature email address used to sign the blocks content + * @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} + */ + Code: 1000; + }; + BlockResponseDto: { + Index: number; + Hash: components["schemas"]["BinaryString"]; + Token: string | null; + /** @deprecated */ + 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; + }; + PhotoResponseDto: { + LinkID: components["schemas"]["Id"]; + /** @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 */ + 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; + }; + 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; + /** + * @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"][]; + /** @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; + /** + * 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; + }; + 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"]; + }; + 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; + }; + GetSharedFileInfoResponseDto: { + ServerProof: components["schemas"]["BinaryString"]; + Payload: components["schemas"]["GetSharedFileInfoPayloadDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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"]; + }; + ShareURLContext: { + /** @description Share ID of the share highest in the tree with permissions */ + ContextShareID: components["schemas"]["Id"]; + ShareURLs: components["schemas"]["ShareURLResponseDto"][]; + /** @description Related link IDs and ancestors up to the share. */ + LinkIDs: components["schemas"]["Id"][]; + }; + 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"]["ShareURLResponseDto"][]; + /** + * @deprecated + * @description Unused and deprecated. Always empty. + * @default [] + */ + Links: Record; + /** + * 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: components["schemas"]["PGPMessage"]; + /** @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: 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. */ + 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?: components["schemas"]["PGPMessage"] | null; + /** + * @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"][]; + }; + ThumbnailIDsListInput: { + /** @description List of encrypted ThumbnailIDs. Maximum 30. */ + ThumbnailIDs: components["schemas"]["Id"][]; + }; + ThumbnailResponse: { + ThumbnailID: components["schemas"]["Id"]; + BareURL: string; + Token: string; + }; + ThumbnailErrorResponse: { + ThumbnailID: components["schemas"]["Id"]; + Error: string; + Code: number; + }; + 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; + }; + 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; + }; + LinkMapResponse: { + SessionName: string; + More: number; + Total: number; + Links: components["schemas"]["LinkMapItemResponse"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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"]["LongId"]; + InviterSharePassphraseKeyPacketSignature?: components["schemas"]["PGPSignature"] | null; + InviteeSharePassphraseSessionKeySignature?: components["schemas"]["PGPSignature"] | null; + }; + PrimaryRootShareResponseDto: { + Volume: components["schemas"]["VolumeDto"]; + Share: components["schemas"]["ShareDto"]; + Link: components["schemas"]["FolderDetailsDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @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} + */ + ShareType: 1 | 2 | 3 | 4 | 5; + /** + * @description

1=Active, 3=Restored

See values descriptions
ValueDescription
1Active
2Deleted
3Restored
6Locked
+ * @enum {integer} + */ + 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

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} + */ + ShareMemberState: 1 | 2 | 3; + MemberResponseDto: { + MemberID: components["schemas"]["ShortId"]; + ShareID: components["schemas"]["Id"]; + AddressID: components["schemas"]["LongId"]; + AddressKeyID: components["schemas"]["LongId"]; + /** 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: components["schemas"]["BinaryString"]; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: components["schemas"]["PGPSignature"] | null; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: components["schemas"]["PGPSignature"] | null; + /** @description 1=active, 3=locked */ + 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"]["LongId"]; + AddressKeyID: components["schemas"]["LongId"]; + KeyPacket: components["schemas"]["BinaryString"]; + /** @description 1=active, 3=locked */ + State: components["schemas"]["ShareMemberState"]; + /** + * @deprecated + * @description Deprecated and always null + * @default null + */ + Unlockable: boolean | null; + }; + 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; + CreateTime: number; + ModifyTime: number; + LinkID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime: number; + /** @deprecated */ + PermissionsMask: number; + /** @description 1=folder, 2=file */ + 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: 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"]["LongId"] | null; + /** @description Your own memberships */ + Memberships: components["schemas"]["MemberResponseDto"][]; + /** + * @deprecated + * @description Deprecated, use `Memberships` instead + */ + PossibleKeyPackets: components["schemas"]["KeyPacketResponseDto"][]; + RootLinkRecoveryPassphrase: components["schemas"]["PGPMessage"] | null; + /** @description Indicates if editor members of this share could reshare it or not */ + EditorsCanShare: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + GetHighestContextForDocumentResponse: { + /** @description Context shareID of the highest level that the user is granted permission */ + ContextShareID: components["schemas"]["Id"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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; + CreateTime: number; + ModifyTime: number; + LinkID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated: Use `CreateTime` + */ + CreationTime: number; + /** @deprecated */ + PermissionsMask: number; + /** @deprecated */ + LinkType: number; + /** @deprecated */ + Flags: number; + /** @deprecated */ + BlockSize: number; + /** @deprecated */ + VolumeSoftDeleted: boolean; + }; + ListSharesResponseDto: { + Shares: components["schemas"]["ShareResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateShareEditorsCanShareRequestDto: { + /** @description Indicates if editor members of this share could reshare it or not */ + Value: boolean; + }; + ShareKPMigrationData: { + /** @description Share to migrate. Can only be Active (State=1) Shares of Type=2 */ + ShareID: components["schemas"]["Id"]; + /** @description Key packet to decrypt the share passphrase, encrypted with the node key, base64 encoded */ + PassphraseNodeKeyPacket: components["schemas"]["BinaryString"]; + }; + 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"][]; + }; + /** @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"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UnmigratedSharesResponseDto: { + /** @description ShareIDs that can be migrated */ + ShareIDs: components["schemas"]["Id"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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: components["schemas"]["PGPMessage"]; + SharePassphraseSignature: components["schemas"]["PGPSignature"]; + /** @description Key packet for passphrase of referenced link's node key passphrase */ + PassphraseKeyPacket: components["schemas"]["BinaryString"]; + NameKeyPacket: components["schemas"]["BinaryString"]; + /** + * @deprecated + * @default null + */ + 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"]; + 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: components["schemas"]["Id"] | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + 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"]; + /** @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: components["schemas"]["Id"] | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ExternalInvitationRequestDto: { + InviterAddressID: components["schemas"]["AddressID"]; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: components["schemas"]["BinaryString"]; + }; + 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"]["ShortId"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description Base64 signature of "inviteemail|base64(share passphrase session key)" signed with the admin's address key and the signature context `drive.share-member.external-invitation` */ + ExternalInvitationSignature: components["schemas"]["BinaryString"]; + State: components["schemas"]["ExternalInvitationState"]; + CreateTime: number; + }; + InviteExternalUserResponseDto: { + ExternalInvitation: components["schemas"]["ExternalInvitationResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareExternalInvitationsResponseDto: { + ExternalInvitations: components["schemas"]["ExternalInvitationResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UserRegisteredExternalInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: 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"]["ShortId"] | null; + /** @description Indicates if there is a next page of results */ + More: boolean; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateExternalInvitationRequestDto: { + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + AcceptInvitationRequestDto: { + /** @description Signature of the share passphrase's session key with the private key of the user (invitee) and the signature context `drive.share-member.member`, base64 encoded */ + SessionKeySignature: components["schemas"]["BinaryString"]; + }; + 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: 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: components["schemas"]["BinaryString"]; + /** @default null */ + ExternalInvitationID: components["schemas"]["ShortId"] | null; + }; + InviteUserRequestDto: { + Invitation: components["schemas"]["InvitationRequestDto"]; + /** @default null */ + EmailDetails: components["schemas"]["InvitationEmailDetailsRequestDto"] | null; + }; + InvitationResponseDto: { + InvitationID: components["schemas"]["ShortId"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + InviteeEmail: string; + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: components["schemas"]["BinaryString"]; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: components["schemas"]["BinaryString"]; + CreateTime: number; + }; + InviteUserResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListShareInvitationsResponseDto: { + Invitations: components["schemas"]["InvitationResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + /** + * @description
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; + ListPendingInvitationQueryParameters: { + AnchorID?: components["schemas"]["Id"] | null; + /** @default 150 */ + PageSize: number; + /** @default null */ + ShareTargetTypes: components["schemas"]["TargetType2"][] | null; + }; + PendingInvitationItemDto: { + VolumeID: components["schemas"]["Id"]; + ShareID: 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"]; + }; + ListPendingInvitationResponseDto: { + Invitations: components["schemas"]["PendingInvitationItemDto"][]; + /** @description Used for pagination, pass to the next call to get the next page of results */ + AnchorID: components["schemas"]["ShortId"] | 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 */ + 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; + }; + PendingInvitationResponseDto: { + Invitation: components["schemas"]["InvitationResponseDto"]; + Share: components["schemas"]["ShareResponseDto2"]; + Link: components["schemas"]["LinkResponseDto"]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateInvitationRequestDto: { + /** + * @description Permission bitfield, valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + 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"]["ShortId"]; + /** Format: email */ + InviterEmail: string; + /** Format: email */ + Email: string; + /** + * @description Permission bitfield, cannot exceed the inviter's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + /** @description base64 encoded key packet, encrypting the share passphrase's session key with the invitee's address key */ + KeyPacket: components["schemas"]["BinaryString"]; + /** @description PGP signature of the member key packet (encrypted) by inviter */ + KeyPacketSignature: components["schemas"]["BinaryString"]; + /** @description Signature of the share passphrase's session key with the private key of the user (invitee). */ + SessionKeySignature: components["schemas"]["BinaryString"]; + CreateTime: number; + }; + ListShareMembersResponseDto: { + Members: components["schemas"]["MemberResponseDto2"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + UpdateShareMemberRequestDto: { + /** + * @description Permission bitfield, cannot exceed the current user's permissions. Valid permissions: + * - 4: read access + * - 6: read + write access + * - 22: read + write + admin access + * + * @enum {integer} + */ + Permissions: 4 | 6 | 22; + }; + 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
ValueDescription
0List
1Grid
+ * @enum {integer} + */ + LayoutSetting: 0 | 1; + /** + * @description
See values descriptions
ValueDescription
-4ModifiedDesc
-2SizeDesc
-1NameDesc
1NameAsc
2SizeAsc
4ModifiedAsc
+ * @enum {integer} + */ + SortSetting: -4 | -2 | -1 | 1 | 2 | 4; + /** + * @description

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

See values descriptions
ValueDescription
0DAYS_0
7DAYS_7
30DAYS_30
180DAYS_180
365DAYS_365
3650DAYS_3650
+ * @enum {integer} + */ + RevisionRetentionDays: 0 | 7 | 30 | 180 | 365 | 3650; + UserSettings: { + /** + * @deprecated + * @description [DEPRECATED] Always NULL + */ + 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; + /** @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

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} + */ + RevisionRetentionDays2: 0 | 7 | 30 | 180 | 365 | 3650; + Defaults: { + /** + * @deprecated + * @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; + /** @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[]; + }; + 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 + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + 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; + /** @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: components["schemas"]["LongId"]; + 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"]["LongId"]; + /** @description Name of the org. volume. It's plain text so that name can be displayed in UI menu */ + VolumeName: string; + }; + VolumeResponseDto: { + /** @description Deprecated, use `VolumeID` instead */ + ID: components["schemas"]["Id"]; + /** + * @deprecated + * @description Deprecated, use `CreateTime` instead + */ + CreationTime: number; + /** + * @deprecated + * @default 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"]; + /** @description Main share of the volume */ + Share: components["schemas"]["ShareReferenceResponseDto"]; + Type: components["schemas"]["VolumeType"]; + }; + 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?: components["schemas"]["LongId"] | null; + /** + * @deprecated + * @default null + */ + VolumeName: string | null; + /** + * @deprecated + * @default null + */ + ShareName: string | null; + }; + OrgVolumeResponseDto: { + ShareID: components["schemas"]["ShortId"]; + VolumeID: components["schemas"]["ShortId"]; + /** @description Name of the org. volume */ + Name: string; + /** @description Membership creation time */ + CreateTime: number; + }; + ListOrgVolumesResponseDto: { + Volumes: components["schemas"]["OrgVolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + OrgVolumeForAdminResponseDto: { + VolumeID: components["schemas"]["ShortId"]; + /** @description Name of the org. volume */ + Name: string; + }; + ListOrgVolumesForAdminResponseDto: { + Volumes: components["schemas"]["OrgVolumeForAdminResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + ListVolumesResponseDto: { + Volumes: components["schemas"]["VolumeResponseDto"][]; + /** + * ProtonResponseCode + * @example 1000 + * @enum {integer} + */ + Code: 1000; + }; + RestoreMainShareDto: { + /** @description ShareID of the existing, locked main share */ + LockedShareID: components["schemas"]["Id"]; + /** @description Folder name as armored PGP message */ + Name: components["schemas"]["PGPMessage"]; + /** @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: components["schemas"]["PGPMessage"] | null; + }; + RestoreRootShareDto: { + /** @description ShareID of the existing share on the old volume */ + 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: components["schemas"]["BinaryString"]; + /** @description Signed with new key as armored PGP signature */ + PassphraseSignature: components["schemas"]["PGPSignature"]; + }; + 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?: components["schemas"]["LongId"] | null; + }; + AddPhotoToAlbumWithLinkIDResponseDto: Record; + MultiResponsesPerLinkFactory: { + /** @enum {integer} */ + Code: 1001; + Responses: { + LinkID: string; + Response: components["schemas"]["ProtonSuccess"] | components["schemas"]["ProtonError"]; + }[]; + }; + RemovePhotoFromAlbumWithLinkIDResponseDto: Record; + ConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; + /** + * @description A conflicting Revision in Active state. + * @default null + */ + ConflictRevisionID: components["schemas"]["Id"] | null; + /** + * @description A conflicting Revision in Draft state. + * @default null + */ + ConflictDraftRevisionID: components["schemas"]["Id"] | null; + /** + * @description ClientUID of conflicting Revision if in Draft state. + * @default null + */ + ConflictDraftClientUID: string | null; + /** + * @deprecated + * @description [DEPRECATED] for backwards compatibility on create revision, same value as ConflictDraftRevisionID + * @default null + */ + RevisionID: components["schemas"]["Id"] | null; + }; + ConflictErrorResponseDto: { + Details: components["schemas"]["ConflictErrorDetailsDto"]; + Error: string; + Code: number; + }; + ShareConflictErrorDetailsDto: { + ConflictLinkID: components["schemas"]["Id"]; + /** @description A conflicting Share on the Link. */ + ConflictShareID: components["schemas"]["Id"]; + }; + /** @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?: components["schemas"]["PGPSignature"] | null; + ManifestSignature: components["schemas"]["PGPSignature"]; + ContentBlockVerificationToken?: components["schemas"]["BinaryString"] | null; + /** + * @description Extended attributes encrypted with link key + * @default 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: 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"]; + /** + * 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: 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: { + /** @description Plain success response without additional information */ + ProtonSuccessResponse: { + headers: { + /** @description The same as the body code */ + "X-Pm-Code"?: 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonSuccess"]; + }; + }; + /** @description General Error */ + ProtonErrorResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-add-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddPhotosToAlbumRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["AddPhotoToAlbumWithLinkIDResponseDto"][]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The album does not exist. + * - 200300: Album has reached the limit of photos. + * - 2000: All main photos have to be sent with related photos. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-photos-volumes-{volumeID}-albums": { + parameters: { + query?: { + AnchorID?: string | null; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAlbumsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: a photo share does not exist for this volume + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-volumes-{volumeID}-albums": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAlbumRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAlbumResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: Limit of albums per volume reached + * - 2501: a photo share does not exist for this volume + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreatePhotoShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetPhotoVolumeResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2500: A volume is already active + * - 2001: Invalid PGP message + * - 200501: Operation failed: Make sure you are using the latest version of Proton Drive and retry + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-photos-volumes-{volumeID}-albums-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateAlbumRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: a photo share does not exist for this volume + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-photos-volumes-{volumeID}-albums-{linkID}": { + parameters: { + query?: { + /** @description Whether or not to delete the album even with direct children. */ + DeleteAlbumPhotos?: 0 | 1 | null; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200302: Album is not empty. Delete operation would result in data loss. + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-photos-volumes-{volumeID}-albums-{linkID}-duplicates": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FindDuplicatesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + }; + }; + }; + }; + "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"]; + 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 Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @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-volumes-{volumeID}-albums-{linkID}-remove-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RemovePhotosFromAlbumRequestDto"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {integer} */ + Code?: 1001; + Responses?: components["schemas"]["RemovePhotoFromAlbumWithLinkIDResponseDto"][]; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2500: A volume is already active + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-photos-albums-shared-with-me": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedWithMeResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: Insufficient permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-volumes-{volumeID}-links-transfer-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "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; + }; + }; + }; + }; + }; + "put_drive-photos-volumes-{volumeID}-links-transfer-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TransferPhotoLinksRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "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; + }; + }; + }; + }; + }; + "post_drive-v2-urls-{token}-bookmark": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateBookmarkShareURLRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateBookmarkShareURLResponseDto"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2001: the token format is invalid + * */ + Code: number; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200001: You have reached the maximum number of items you can save. + * - 2501: Item link not found + * - 2500: This item is already saved in your drive + * - 200501: Operation failed: Make sure you are using the latest version of Proton Drive and retry + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-urls-{token}-bookmark": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2001: the token format is invalid + * */ + Code: number; + }; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Item link not found + * - 2501: Item not found + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shared-bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBookmarksOfUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Item link not found + * - 2501: item not found + * - 2501: Invalid Link ID + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListDevicesResponseDto"]; + }; + }; + }; + }; + "post_drive-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDeviceRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDeviceResponseDto"]; + }; + }; + }; + }; + "put_drive-devices-{deviceID}": { + parameters: { + query?: never; + header?: never; + path: { + deviceID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateDeviceRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_drive-devices-{deviceID}": { + parameters: { + query?: never; + header?: never; + path: { + deviceID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-v2-devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListDevicesResponseDto2"]; + }; + }; + }; + }; + "post_drive-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": { + /** + * @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": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateDocumentDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDocumentResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: the user does not have permissions to create a file in this share + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestEventIDResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-events-latest": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestEventIDResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-events-{eventID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + eventID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEventsResponseDto"]; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-events-{eventID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + eventID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEventsResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-events-{eventID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + eventID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListEventsV2ResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-folders": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-folders": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2511: the link targeted is a photo link + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * */ + Code?: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-folders-{linkID}-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Field to sort by */ + Sort?: "MIMEType" | "Size" | "ModifyTime" | "CreateTime" | "Type"; + /** @description Sort order */ + Desc?: 0 | 1; + /** @description Show all files including those in non-active (drafts) state. */ + ShowAll?: 0 | 1; + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Allow sorting of items in folder */ + AllowSorting: boolean; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-folders-{linkID}-children": { + parameters: { + query?: { + /** @description Link ID use to indicate where to start the next page */ + AnchorID?: string & (components["schemas"]["Id"] | null); + /** @description Show folders only */ + FoldersOnly?: 0 | 1; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListChildrenResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2032: sharing is temporarily disabled and the user is not the volume owner. + * - 2011: The user does not have permission to access this folder. + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-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; + 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"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-links-{linkID}-checkAvailableHashes": { + parameters: { + query?: never; + header?: never; + path: { + shareID: 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"]; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-links-{linkID}-copy": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + 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; + header?: never; + path: { + volumeID: 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-shares-{shareID}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + }; + }; + responses: { + /** @description Links */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + Parents: components["schemas"]["ExtendedLinkTransformer"][]; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FetchLinksMetadataRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-links-{linkID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Link */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Link: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + "get_drive-sanitization-mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListMissingHashKeyResponseDto"]; + }; + }; + }; + }; + "post_drive-sanitization-mhk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateMissingHashKeyRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-links": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; + }; + }; + }; + }; + "put_drive-volumes-{volumeID}-links-move-multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkBatchRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "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; + }; + }; + }; + }; + }; + "put_drive-shares-{shareID}-links-{linkID}-move": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 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}-move": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MoveLinkRequestDto2"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2511: cannot move favorite photos from a share + * - 2501: parent folder was not found + * */ + Code?: number; + /** @description Error message */ + Error?: string; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "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 Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeID}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "put_drive-shares-{shareID}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-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: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetRevisionResponseDto"]; + }; + }; + }; + }; + "put_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: { + /** @description 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; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetRevisionResponseDto"]; + }; + }; + }; + }; + "put_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2011: the current user does not have permission to delete the revision + * - 2511: if the revision is active - create or revert to another revision first + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-files": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + 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"]["ListRevisionsResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateRevisionRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; + }; + }; + /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListRevisionsResponseDto"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateRevisionRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; + }; + }; + /** @description Conflict, the submitted revision is no longer up to date or another draft is open. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"] | components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200700: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-thumbnail": { + parameters: { + query?: { + /** @description Type of Thumbnail to fetch */ + Type?: 1 | 2 | 3; + }; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + /** @description Thumbnail download link */ + ThumbnailLink: string; + /** + * @deprecated + * @description Bare Thumbnail download link + */ + ThumbnailBareURL?: string; + /** @description Thumbnail download token */ + ThumbnailToken: string; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}-restore": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision restore queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-shares-{shareID}-files-{linkID}-revisions-{revisionID}-restore": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revision restore queued for async processing */ + 202: { + headers: { + "x-pm-code": 1002; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RestoreRevisionAcceptedResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-v2-volumes-{volumeID}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-trash-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "post_drive-shares-{shareID}-trash-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-trash": { + parameters: { + query?: { + /** + * @deprecated + * @description Get thumbnail download URLs + */ + Thumbnails?: 0 | 1; + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code: components["schemas"]["ResponseCodeSuccess"]; + Links: components["schemas"]["ExtendedLinkTransformer"][]; + /** @description Dictionary of ancestors of trashed links. */ + Parents: { + [key: string]: components["schemas"]["ExtendedLinkTransformer"]; + }; + }; + }; + }; + }; + }; + "delete_drive-shares-{shareID}-trash": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @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": { + parameters: { + query?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VolumeTrashList"]; + }; + }; + }; + }; + "delete_drive-volumes-{volumeID}-trash": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 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; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmptyTrashAcceptedResponse"]; + }; + }; + }; + }; + "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; + header?: never; + path: { + volumeID: 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"]; + }; + }; + }; + }; + "put_drive-shares-{shareID}-trash-restore_multiple": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-trash_multiple": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + }; + }; + "post_drive-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestUploadInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example POST /drive/v2/volumes/{volumeID}/files/small + * Content-Type: multipart/form-data; boundary=[SOME_BOUNDARY] + * Content-Length: [ACTUAL_CONTENT_LENGTH] + * + * --[SOME_BOUNDARY] + * Content-Type: application/json + * Content-Disposition: form-data; name="Metadata" + * + * { + * "Name": "string", + * "NameHash": "string", + * "ParentLinkID": "string", + * "MIMEType": "string", + * // ... remaining metadata, see SmallFileUploadMetadataRequestDto schema + * } + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ContentBlock" + * + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_1" + * + * + * --[SOME_BOUNDARY]-- + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_2" + * + * + * --[SOME_BOUNDARY]-- */ + "multipart/form-data": { + Metadata: components["schemas"]["SmallFileUploadMetadataRequestDto"]; + /** + * Format: binary + * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. + */ + ContentBlock?: string; + /** + * Format: binary + * @description The encrypted binary data for the Preview thumbnail. This is optional. + */ + ThumbnailBlockType_1?: string; + /** + * Format: binary + * @description The encrypted binary data for the HDPreview thumbnail. This is optional. + */ + ThumbnailBlockType_2?: string; + /** + * Format: binary + * @description The encrypted binary data for the MachineLearning thumbnail. This is optional. + */ + ThumbnailBlockType_3?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + /** @description Bad request, the metadata does not pass validation. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Conflict, there is a name hash collision with another link in the same folder. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The parent link does not exist or is trashed + * - 2011: The user does not have write permission on the link + * - 200003: 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 + * - 2511: A photo link is missing photo metadata + * - 200300: max folder size reached + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-volumes-{volumeID}-files-{linkID}-revisions-small": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** @example POST /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/small + * Content-Type: multipart/form-data; boundary=[SOME_BOUNDARY] + * Content-Length: [ACTUAL_CONTENT_LENGTH] + * + * --[SOME_BOUNDARY] + * Content-Type: application/json + * Content-Disposition: form-data; name="Metadata" + * + * { + * "CurrentRevisionID": string, + * "SignatureEmail": "string", + * "ManifestSignature": "string", + * "BlockEncSignature": "string", + * // ... remaining metadata, see SmallRevisionUploadMetadataRequestDto schema + * } + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ContentBlock" + * + * + * --[SOME_BOUNDARY] + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_1" + * + * + * --[SOME_BOUNDARY]-- + * Content-Type: application/octet-stream + * Content-Disposition: form-data; name="ThumbnailBlockType_2" + * + * + * --[SOME_BOUNDARY]-- */ + "multipart/form-data": { + Metadata: components["schemas"]["SmallRevisionUploadMetadataRequestDto"]; + /** + * Format: binary + * @description The encrypted binary data of the file content. This is optional as 0-byte files do not have a block. + */ + ContentBlock?: string; + /** + * Format: binary + * @description The encrypted binary data for the Preview thumbnail. This is optional. + */ + ThumbnailBlockType_1?: string; + /** + * Format: binary + * @description The encrypted binary data for the HDPreview thumbnail. This is optional. + */ + ThumbnailBlockType_2?: string; + /** + * Format: binary + * @description The encrypted binary data for the MachineLearning thumbnail. This is optional. + */ + ThumbnailBlockType_3?: string; + }; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmallUploadResponseDto"]; + }; + }; + /** @description Bad request, the metadata does not pass validation. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Conflict, the passed CurrentRevisionID is no longer up to date. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link does not exist or is trashed + * - 2011: The user does not have write permission on the link + * - 200003: 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 + * - 2511: A photo link cannot have multiple revisions + * */ + Code: number; + }; + }; + }; + }; + }; + "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; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active User */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + Code?: components["schemas"]["ResponseCodeSuccess"]; + /** @enum {boolean} */ + Active?: true; + }; + }; + }; + }; + }; + "post_drive-report-url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AbuseReportDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-v2-onboarding-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; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FreshAccountResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-checklist-get-started": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChecklistResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OnboardingResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-checklist-get-started-seen-completed-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "get_drive-entitlements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetEntitlementResponseDto"]; + }; + }; + }; + }; + "post_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AddTagsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * - 2500: One of the tags is already assigned to the photo. + * - 2011: Only the owner can assign tags to photos. + * - 2000: Cannot assign favorite tag on this endpoint. Please use a dedicated favouring photos endpoint. + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-photos-volumes-{volumeID}-links-{linkID}-tags": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RemoveTagsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: The link or volume does not exist. + * - 2011: Only the owner can assign tags to photos. + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-photos-share": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + 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: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "post_drive-photos-volumes-{volumeID}-links-{linkID}-favorite": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FavoritePhotoRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "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; + }; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-photos-duplicates": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["FindDuplicatesInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FindDuplicatesOutputCollection"]; + }; + }; + }; + }; + "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?: never; + responses: { + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: Share not found + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-photos": { + parameters: { + query?: { + Desc?: components["schemas"]["ListPhotosParameters"]["Desc"]; + PageSize?: components["schemas"]["ListPhotosParameters"]["PageSize"]; + /** @description The link ID of the last photo from the previous page when requesting secondary pages */ + PreviousPageLastLinkID?: components["schemas"]["ListPhotosParameters"]["PreviousPageLastLinkID"]; + /** @description The minimum capture time of photos as UNIX timestamp (to filter out older photos) */ + MinimumCaptureTime?: components["schemas"]["ListPhotosParameters"]["MinimumCaptureTime"]; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PhotoListingResponse"]; + }; + }; + }; + }; + "post_drive-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}-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; + 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; + }; + }; + }; + }; + }; + "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}-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; + 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; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CheckAvailableHashesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableHashesResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitAnonymousRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions. + * - 200303: Cannot commit related photo with main already in album + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "delete_drive-urls-{token}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link (must be active or trashed) or revision does not exist in the volume + * - 2511: if the revision not in draft + * - 200700: if the link is a proton doc (revisions are not used for docs) + * */ + Code?: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-documents": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousDocumentDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousDocumentResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * + * @enum {integer} + */ + Code: 200300 | 2500 | 2501 | 2011; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + /** @description Failed dependency */ + 424: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description Potential codes and their meaning: + * - 2032: Blocked due to feature being disabled, clients are encouraged to refetch feature flags + * + * @enum {integer} + */ + Code: 2032; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-files": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousFileRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousFileResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * - 200701: A document type cannot create a revision + * */ + Code: number; + } | components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-urls-{token}-folders": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAnonymousFolderRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAnonymousFolderResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 200300: max folder size reached + * - 200301: max folder depth reached + * - 2500: file or folder with same name already exists + * - 2501: parent folder was not found + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-folders-{linkID}-delete_multiple": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DeleteChildrenRequestDto"]; + }; + }; + responses: { + /** @description Multi responses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MultiResponsesPerLinkFactory"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-links-fetch_metadata": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ShareURL Token */ + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FetchLinksMetadataResponseDto"]; + }; + }; + /** @description Unprocessable entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: This file was not found, token invalid. */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-urls-{token}-links-{linkID}-rename": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameAnonymousLinkRequestDto"]; + }; + }; + responses: { + 200: components["responses"]["ProtonSuccessResponse"]; + /** @description Conflict, a file or folder with the new name already exists in the current folder. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConflictErrorResponseDto"]; + }; + }; + }; + }; + "post_drive-urls-{token}-blocks": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestAnonymousUploadRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-urls-{token}-links-{linkID}-revisions-{revisionID}-verification": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: The current ShareURL does not have read+write permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-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?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["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: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "put_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CommitRevisionDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "delete_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions-{revisionID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + }; + }; + "post_drive-unauth-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"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-files": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFileDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftFileResponseDto"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-folders": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateFolderRequestDto2"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateFolderResponseDto"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-files-{linkID}-revisions": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateRevisionRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateDraftRevisionResponseDto"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-delete_multiple": { + 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; + }; + }; + }; + "post_drive-unauth-volumes-{volumeID}-thumbnails": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ThumbnailIDsListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListThumbnailsResponse"]; + }; + }; + }; + }; + "get_drive-unauth-v2-volumes-{volumeID}-folders-{linkID}-children": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + FoldersOnly?: number; + }; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListChildrenResponseDto"]; + }; + }; + }; + }; + "post_drive-unauth-v2-volumes-{volumeID}-links": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LinkIDsRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoadLinkDetailsResponseDto"]; + }; + }; + }; + }; + "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; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RenameLinkRequestDto"]; + }; + }; + responses: { + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + "post_drive-unauth-blocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RequestUploadInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResponse"]; + }; + }; + }; + }; + "post_drive-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; + header?: never; + path: { + volumeID: string; + linkID: components["schemas"]["Id"]; + revisionID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerificationData"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}-map": { + 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"]["PrimaryRootShareResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-shares-photos": { + 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"]["PrimaryRootShareResponseDto"]; + }; + }; + }; + }; + "get_drive-shares-{shareID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapShareResponseDto"]; + }; + }; + }; + }; + "delete_drive-shares-{shareID}": { + parameters: { + query?: { + /** @description Forces the deletion of the share along with attached members and urls */ + Force?: 0 | 1; + }; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2005: the share still has members, a public URL or invitations attached and Force=1 has not been used */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-volumes-{volumeID}-links-{linkID}-context": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + linkID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetHighestContextForDocumentResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description 2501: Requested data does not exist or you do not have permission to access it + * + * @enum {integer} + */ + Code: 2501; + }; + }; + }; + }; + }; + "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 | 5; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListSharesResponseDto"]; + }; + }; + }; + }; + "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-migrations-shareaccesswithnode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["MigrateSharesRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MigrateSharesResponseDto"]; + }; + }; + }; + }; + "get_drive-migrations-shareaccesswithnode-unmigrated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnmigratedSharesResponseDto"]; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-shares": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateShareRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateStandardShareResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ShareConflictErrorResponseDto"] | { + /** @description Potential codes and their meaning: + * - 2501: the link does not exist in the volume + * - 2011: the current user does not have admin permission on this share + * - 2001: the PGP message is not correct + * - 200601: The user has too many shares already. + * */ + Code?: number; + }; + }; + }; + }; + }; + "get_drive-v2-volumes-{volumeID}-shares": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedByMeResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-sharedwithme": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharedWithMeResponseDto2"]; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateExternalInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or accepted + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a new member + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-external-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exist, is not pending or accepted + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-external-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareExternalInvitationsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-external-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InviteExternalUserRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InviteExternalUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have admin permission on this share + * - 2500: an external invitation for this user on this share already exists + * - 2026: trying to grant permissions you do not have to a new member + * - 2001: the inviter address does not belong to a Proton account or does not belong to the current user + * - 200502: external invitation signature is invalid + * - 200600: maximum number of invitations and members reached for current share + * - 2008: inviter email is not the same as the one from the context share */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-external-invitations": { + parameters: { + query?: { + AnchorID?: components["schemas"]["Id"] | null; + PageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListUserRegisteredExternalInvitationResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-external-invitations-{invitationID}-sendemail": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-invitations-{invitationID}-accept": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["AcceptInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share or the invitation was not found or was not pending + * - 2011: the invitee email doesn't belong to the current user + * - 2032: sharing is temporarily disabled. + * - 200602: The user has joined too many shares already. + * - 200201: the user is already member of a share in this volume with another address + * - 200502: session key signature is invalid + * - 2000: the address or address key couldn't be found to the invitee email address and user + * */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateInvitationRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a new member + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareInvitationsResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-invitations": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["InviteUserRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InviteUserResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the external invitation does not exists or is still pending + * - 2011: the current user does not have admin permission on this share + * - 2500: an invitation for this user on this share already exists + * - 2001: invitee email doesn't belong to a Proton account or you try to invite yourself + * - 2008: inviter email is not the same as the one from the context share + * - 2032: sharing is temporarily disabled. + * - 2026: trying to grant permissions you do not have to a new member + * - 200501: key packet is invalid + * - 200502: key packet signature is invalid + * - 200600: maximum number of invitations and members reached for current share + * - 200202: Sharing with groups is not available yet. + * - 2011: You do not have permission to invite this group. Please reach out to the group admin. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-invitations": { + parameters: { + query?: { + AnchorID?: components["schemas"]["ListPendingInvitationQueryParameters"]["AnchorID"]; + PageSize?: components["schemas"]["ListPendingInvitationQueryParameters"]["PageSize"]; + ShareTargetTypes?: components["schemas"]["ListPendingInvitationQueryParameters"]["ShareTargetTypes"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPendingInvitationResponseDto"]; + }; + }; + }; + }; + "post_drive-v2-shares-invitations-{invitationID}-reject": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist or is not pending + * - 2011: the invitee email doesn't belong to the current user + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-invitations-{invitationID}-sendemail": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist, is not pending or rejected + * - 2011: the current user does not have admin permission on this share + * - 2032: sharing is temporarily disabled. + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-shares-invitations-{invitationID}": { + parameters: { + query?: never; + header?: never; + path: { + invitationID: components["schemas"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PendingInvitationResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the invitation does not exist or is not pending, or the link/share/volume for it is gone + * - 2011: the invitee email doesn't belong to the current user + * */ + Code: number; + }; + }; + }; + }; + }; + "get_drive-v2-user-link-access": { + parameters: { + query?: { + LinkID?: components["schemas"]["Id"]; + VolumeID?: components["schemas"]["Id"] | null; + ShareID?: components["schemas"]["Id"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LinkAccessesResponseDto"]; + }; + }; + }; + }; + "get_drive-v2-shares-{shareID}-members": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListShareMembersResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the share does not exist + * - 2011: the current user does not have admin permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "put_drive-v2-shares-{shareID}-members-{memberID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + memberID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateShareMemberRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2501: the member does not exist or is removed. + * - 2011: the current user does not have admin permission on this share + * - 2026: trying to grant permissions you do not have to a member or only the owner can modify their own permissions + * */ + Code: number; + }; + }; + }; + }; + }; + "delete_drive-v2-shares-{shareID}-members-{memberID}": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + memberID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the user does not have enough permission to remove another member + * - 2501: the user is not a member of the share + * - 2026: Cannot remove the owner of the file or folder + * */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-v2-shares-{shareID}-security": { + parameters: { + query?: never; + header?: never; + path: { + shareID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SecurityRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecurityResponseDto"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Potential codes and their meaning: + * - 2011: the current user does not have read permission on this share */ + Code: number; + }; + }; + }; + }; + }; + "post_drive-urls-{token}-security": { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SecurityRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecurityResponseDto"]; + }; + }; + /** @description Code 2028 if feature is disabled, rate-limited or blocked because of abuse. Code 9001 for HV captcha. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProtonError"]; + }; + }; + }; + }; + "post_drive-volumes-{volumeID}-thumbnails": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ThumbnailIDsListInput"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListThumbnailsResponse"]; + }; + }; + }; + }; + "get_drive-me-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingsResponse"]; + }; + }; + }; + }; + "put_drive-me-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserSettingsRequest"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingsResponse"]; + }; + }; + }; + }; + "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; + 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?: { + PageSize?: components["schemas"]["OffsetPagination"]["PageSize"] & unknown; + Page?: components["schemas"]["OffsetPagination"]["Page"] & unknown; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListVolumesResponseDto"]; + }; + }; + }; + }; + "post_drive-volumes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateVolumeRequestDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetVolumeResponseDto"]; + }; + }; + }; + }; + "put_drive-volumes-{volumeID}-delete_locked": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "get_drive-volumes-{volumeID}": { + parameters: { + query?: never; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetVolumeResponseDto"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; + "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; + header?: never; + path: { + volumeID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RestoreVolumeDto"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + "x-pm-code": 1000; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessfulResponse"]; + }; + }; + 422: components["responses"]["ProtonErrorResponse"]; + }; + }; +} diff --git a/js/sdk/src/internal/apiService/errorCodes.ts b/js/sdk/src/internal/apiService/errorCodes.ts new file mode 100644 index 00000000..1956b260 --- /dev/null +++ b/js/sdk/src/internal/apiService/errorCodes.ts @@ -0,0 +1,41 @@ +export const enum HTTPErrorCode { + OK = 200, + UNAUTHORIZED = 401, + NOT_FOUND = 404, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, +} + +export function isCodeOk(code: number): boolean { + return code === ErrorCode.OK || code === ErrorCode.OK_MANY || code === ErrorCode.OK_ASYNC; +} + +export function isCodeOkAsync(code: number): boolean { + return code === ErrorCode.OK_ASYNC; +} + +export const enum ErrorCode { + OK = 1000, + OK_MANY = 1001, + OK_ASYNC = 1002, + INVALID_REQUIREMENTS = 2000, + INVALID_VALUE = 2001, + NOT_ENOUGH_PERMISSIONS = 2011, + NOT_ENOUGH_PERMISSIONS_TO_GRANT_PERMISSIONS = 2026, + // Following codes takes name from the API documentation. + ALREADY_EXISTS = 2500, + NOT_EXISTS = 2501, + INSUFFICIENT_QUOTA = 200001, + INSUFFICIENT_SPACE = 200002, + MAX_FILE_SIZE_FOR_FREE_USER = 200003, + MAX_PUBLIC_EDIT_MODE_FOR_FREE_USER = 200004, + INSUFFICIENT_VOLUME_QUOTA = 200100, + INSUFFICIENT_DEVICE_QUOTA = 200101, + ALREADY_MEMBER_OF_SHARE_IN_VOLUME_WITH_ANOTHER_ADDRESS = 200201, + TOO_MANY_CHILDREN = 200300, + NESTING_TOO_DEEP = 200301, + INSUFFICIENT_INVITATION_QUOTA = 200600, + INSUFFICIENT_SHARE_QUOTA = 200601, + INSUFFICIENT_SHARE_JOINED_QUOTA = 200602, + INSUFFICIENT_BOOKMARKS_QUOTA = 200800, +} diff --git a/js/sdk/src/internal/apiService/errors.test.ts b/js/sdk/src/internal/apiService/errors.test.ts new file mode 100644 index 00000000..c2d40400 --- /dev/null +++ b/js/sdk/src/internal/apiService/errors.test.ts @@ -0,0 +1,84 @@ +import { AbortError } from '../../errors'; +import { ErrorCode } from './errorCodes'; +import * as errors from './errors'; +import { apiErrorFactory } from './errors'; + +function mockAPIResponseAndResult(options: { + httpStatusCode?: number; + httpStatusText?: string; + code: number; + message?: string; +}) { + const { httpStatusCode = 422, httpStatusText = 'Unprocessable Entity', code, message = 'API error' } = options; + + const result = { Code: code, Error: message }; + const response = new Response(JSON.stringify(result), { status: httpStatusCode, statusText: httpStatusText }); + + return { response, result }; +} + +describe('apiErrorFactory should return', () => { + it('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 }); + expect(error).toBeInstanceOf(errors.APIHTTPError); + 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', () => { + 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); + expect(error.message).toBe('Path not found'); + expect((error as errors.APIHTTPError).statusCode).toBe(404); + }); + + it('generic APICodeError when there is body even if wrong', () => { + const result = {}; + const response = new Response('', { status: 422 }); + const error = apiErrorFactory({ response, result }); + expectAPICodeError(error, 0, 'Unknown error'); + }); + + it('generic APICodeError when there is body but not specific handle', () => { + const error = apiErrorFactory(mockAPIResponseAndResult({ code: 42, message: 'General error' })); + expectAPICodeError(error, 42, 'General error'); + }); + + it('NotFoundAPIError when code is ErrorCode.NOT_EXISTS', () => { + const error = apiErrorFactory(mockAPIResponseAndResult({ code: ErrorCode.NOT_EXISTS, message: 'Not found' })); + expect(error).toBeInstanceOf(errors.NotFoundAPIError); + expect(error.message).toBe('Not found'); + expect((error as errors.NotFoundAPIError).code).toBe(ErrorCode.NOT_EXISTS); + }); +}); + +function expectAPICodeError(error: Error, code: number, message: string) { + expect(error).toBeInstanceOf(errors.APICodeError); + expect(error.message).toBe(message); + expect((error as errors.APICodeError).code).toBe(code); +} diff --git a/js/sdk/src/internal/apiService/errors.ts b/js/sdk/src/internal/apiService/errors.ts new file mode 100644 index 00000000..aa2a0658 --- /dev/null +++ b/js/sdk/src/internal/apiService/errors.ts @@ -0,0 +1,121 @@ +import { c } from 'ttag'; + +import { AbortError, ServerError, ValidationError } from '../../errors'; +import { ErrorCode, HTTPErrorCode } from './errorCodes'; + +export function apiErrorFactory({ + response, + result, + error, +}: { + response: Response; + 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. + 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); + apiHttpError.cause = error; + return apiHttpError; + } + + const typedResult = result as { + Code?: number; + Error?: string; + Details?: object; + Exception?: string; + message?: string; + file?: string; + line?: number; + trace?: object; + }; + + const [code, message, details] = [ + typedResult.Code || 0, + typedResult.Error || c('Error').t`Unknown error`, + typedResult.Details, + ]; + + const debug = typedResult.Exception + ? { + details: typedResult.Details, + exception: typedResult.Exception, + message: typedResult.message, + file: typedResult.file, + line: typedResult.line, + trace: typedResult.trace, + } + : undefined; + + switch (code) { + case ErrorCode.NOT_EXISTS: + return new NotFoundAPIError(message, code, 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 + // 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: + case ErrorCode.ALREADY_EXISTS: + case ErrorCode.INSUFFICIENT_QUOTA: + case ErrorCode.INSUFFICIENT_SPACE: + case ErrorCode.MAX_FILE_SIZE_FOR_FREE_USER: + case ErrorCode.MAX_PUBLIC_EDIT_MODE_FOR_FREE_USER: + case ErrorCode.INSUFFICIENT_VOLUME_QUOTA: + case ErrorCode.INSUFFICIENT_DEVICE_QUOTA: + case ErrorCode.ALREADY_MEMBER_OF_SHARE_IN_VOLUME_WITH_ANOTHER_ADDRESS: + case ErrorCode.TOO_MANY_CHILDREN: + case ErrorCode.NESTING_TOO_DEEP: + case ErrorCode.INSUFFICIENT_INVITATION_QUOTA: + case ErrorCode.INSUFFICIENT_SHARE_QUOTA: + case ErrorCode.INSUFFICIENT_SHARE_JOINED_QUOTA: + case ErrorCode.INSUFFICIENT_BOOKMARKS_QUOTA: + return new ValidationError(message, code, details); + default: + return new APICodeError(message, code, debug || details); + } +} + +export class APIHTTPError extends ServerError { + name = 'APIHTTPError'; + + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + +export class APICodeError extends ServerError { + name = 'APICodeError'; + + public readonly code: number; + + public readonly debug?: object; + + constructor(message: string, code: number, debug?: object) { + super(message); + this.code = code; + this.debug = debug; + } +} + +export class NotFoundAPIError extends ValidationError { + name = 'NotFoundAPIError'; +} + +export class InvalidRequirementsAPIError extends ValidationError { + name = 'InvalidRequirementsAPIError'; +} diff --git a/js/sdk/src/internal/apiService/index.ts b/js/sdk/src/internal/apiService/index.ts new file mode 100644 index 00000000..77575d05 --- /dev/null +++ b/js/sdk/src/internal/apiService/index.ts @@ -0,0 +1,7 @@ +export { DriveAPIService } from './apiService'; +export type { paths as corePaths } from './coreTypes'; +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/observerStream.ts b/js/sdk/src/internal/apiService/observerStream.ts new file mode 100644 index 00000000..ec5c7330 --- /dev/null +++ b/js/sdk/src/internal/apiService/observerStream.ts @@ -0,0 +1,10 @@ +export class ObserverStream extends TransformStream { + constructor(fn?: (chunk: Uint8Array) => void) { + super({ + transform(chunk, controller) { + fn?.(chunk); + controller.enqueue(chunk); + }, + }); + } +} diff --git a/js/sdk/src/internal/apiService/transformers.ts b/js/sdk/src/internal/apiService/transformers.ts new file mode 100644 index 00000000..52eab0a4 --- /dev/null +++ b/js/sdk/src/internal/apiService/transformers.ts @@ -0,0 +1,63 @@ +import { Logger, MemberRole, NodeType } from '../../interface'; + +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; + default: + logger.warn(`Unknown node type: ${nodeTypeNumber}`); + return NodeType.File; + } +} + +export function permissionsToMemberRole(logger: Logger, permissionsNumber?: number): MemberRole { + switch (permissionsNumber) { + case undefined: + return MemberRole.Inherited; + case 4: + return MemberRole.Viewer; + case 6: + return MemberRole.Editor; + case 22: + return MemberRole.Admin; + default: + // User have access to the data, thus at minimum it can view. + logger.warn(`Unknown sharing permissions: ${permissionsNumber}`); + return MemberRole.Viewer; + } +} + +export function memberRoleToPermission(memberRole: MemberRole): 4 | 6 | 22 { + if (memberRole === MemberRole.Inherited) { + // This is developer error. + throw new Error('Cannot convert inherited role to permission'); + } + switch (memberRole) { + case MemberRole.Viewer: + return 4; + case MemberRole.Editor: + return 6; + case MemberRole.Admin: + return 22; + } +} diff --git a/js/sdk/src/internal/asyncIteratorMap.test.ts b/js/sdk/src/internal/asyncIteratorMap.test.ts new file mode 100644 index 00000000..d8885ead --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorMap.test.ts @@ -0,0 +1,162 @@ +import { AbortError } from '../errors'; +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(195); // We had failures with 199ms - JS is not precise. + 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]); + }); + + 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 new file mode 100644 index 00000000..0a866d42 --- /dev/null +++ b/js/sdk/src/internal/asyncIteratorMap.ts @@ -0,0 +1,72 @@ +import { c } from 'ttag'; + +import { AbortError } from '../errors'; + +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, + signal?: AbortSignal, +): 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) { + if (signal?.aborted) { + throw new AbortError(c('Error').t`Operation aborted`); + } + 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/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/batchLoading.test.ts b/js/sdk/src/internal/batchLoading.test.ts new file mode 100644 index 00000000..feeba855 --- /dev/null +++ b/js/sdk/src/internal/batchLoading.test.ts @@ -0,0 +1,191 @@ +import { AbortError, ProtonDriveError } from '../errors'; +import { BatchLoading } from './batchLoading'; + +describe('BatchLoading', () => { + let batchLoading: BatchLoading; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load in batches with loadItems', async () => { + const loadItems = jest.fn((items: string[]) => Promise.resolve(items.map((item) => `loaded:${item}`))); + + batchLoading = new BatchLoading({ loadItems, batchSize: 2 }); + + const result = []; + for (const item of ['a', 'b', 'c', 'd', 'e']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + + expect(result).toEqual(['loaded:a', 'loaded:b', 'loaded:c', 'loaded:d', 'loaded:e']); + expect(loadItems).toHaveBeenCalledTimes(3); + expect(loadItems).toHaveBeenNthCalledWith(1, ['a', 'b']); + expect(loadItems).toHaveBeenNthCalledWith(2, ['c', 'd']); + expect(loadItems).toHaveBeenNthCalledWith(3, ['e']); + }); + + it('should load in batches with iterateItems', async () => { + const iterateItems = jest.fn(async function* (items: string[]) { + for (const item of items) { + yield `loaded:${item}`; + } + }); + + batchLoading = new BatchLoading({ iterateItems, batchSize: 2 }); + + const result = []; + for (const item of ['a', 'b', 'c', 'd', 'e']) { + for await (const loadedItem of batchLoading.load(item)) { + result.push(loadedItem); + } + } + for await (const loadedItem of batchLoading.loadRest()) { + result.push(loadedItem); + } + + expect(result).toEqual(['loaded:a', 'loaded:b', 'loaded:c', 'loaded:d', 'loaded:e']); + expect(iterateItems).toHaveBeenCalledTimes(3); + expect(iterateItems).toHaveBeenNthCalledWith(1, ['a', 'b']); + expect(iterateItems).toHaveBeenNthCalledWith(2, ['c', 'd']); + expect(iterateItems).toHaveBeenNthCalledWith(3, ['e']); + }); + + 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 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')) { + 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 new file mode 100644 index 00000000..a3ddcc0a --- /dev/null +++ b/js/sdk/src/internal/batchLoading.ts @@ -0,0 +1,93 @@ +import { c } from 'ttag'; + +import { AbortError, ProtonDriveError } from '../errors'; + +const DEFAULT_BATCH_LOADING = 10; + +/** + * Helper class for batch loading items. + * + * The class is responsible for fetching items in batches. Any call to + * `load` will add the item to the batch (without fetching anything), + * and if the batch reaches the limit, it will fetch the items and yield + * them transparently to the caller. + * + * Example: + * + * ```typescript + * const batchLoading = new BatchLoading({ loadItems: loadNodesCallback }); + * for (const nodeUid of nodeUids) { + * for await (const node of batchLoading.load(nodeUid)) { + * console.log(node); + * } + * } + * for await (const node of batchLoading.loadRest()) { + * console.log(node); + * } + * ``` + */ +export class BatchLoading { + private batchSize = DEFAULT_BATCH_LOADING; + private iterateItems: (ids: ID[]) => AsyncGenerator; + + private itemsToFetch: ID[]; + + private errors: unknown[] = []; + + constructor(options: { + loadItems?: (ids: ID[]) => Promise; + iterateItems?: (ids: ID[]) => AsyncGenerator; + batchSize?: number; + }) { + this.itemsToFetch = []; + + if (options.loadItems) { + const loadItems = options.loadItems; + this.iterateItems = async function* (ids: ID[]) { + for (const item of await loadItems(ids)) { + yield item; + } + }; + } else if (options.iterateItems) { + this.iterateItems = options.iterateItems; + } else { + // This is developer error. + throw new Error('Either loadItems or iterateItems must be provided'); + } + + if (options.batchSize) { + this.batchSize = options.batchSize; + } + } + + async *load(nodeUid: ID) { + this.itemsToFetch.push(nodeUid); + + if (this.itemsToFetch.length >= this.batchSize) { + yield* this.iterateItemsWithErrorHandling(this.itemsToFetch); + this.itemsToFetch = []; + } + } + + async *loadRest() { + if (this.itemsToFetch.length > 0) { + yield* this.iterateItemsWithErrorHandling(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) { + if (error instanceof AbortError) { + throw error; + } + this.errors.push(error); + } + } +} diff --git a/js/sdk/src/internal/devices/apiService.ts b/js/sdk/src/internal/devices/apiService.ts new file mode 100644 index 00000000..f6aa9c57 --- /dev/null +++ b/js/sdk/src/internal/devices/apiService.ts @@ -0,0 +1,149 @@ +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< + drivePaths['/drive/devices']['post']['requestBody'], + { content: object } +>['content']['application/json']; +type PostCreateDeviceResponse = drivePaths['/drive/devices']['post']['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. + */ +export class DevicesAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async getDevices(signal?: AbortSignal): Promise { + const response = await this.apiService.get('drive/devices', signal); + return response.Devices.map((device) => ({ + uid: makeDeviceUid(device.Device.VolumeID, device.Device.DeviceID), + type: deviceTypeNumberToEnum(device.Device.Type), + rootFolderUid: makeNodeUid(device.Device.VolumeID, device.Share.LinkID), + 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, + })); + } + + /** + * Originally the device name was on the share of the device. + * This was changed to be on the root node of the device instead. + * Old devices will still have the name on the share and when + * the client renames the device, it must be removed on the device. + */ + async removeNameFromDevice(deviceUid: string): Promise { + const { deviceId } = splitDeviceUid(deviceUid); + await this.apiService.put< + // Web clients do not update Device fields, that is only for desktop clients. + Omit, + PutUpdateDeviceResponse + >(`drive/devices/${deviceId}`, { + Share: { Name: '' }, + }); + } + + async createDevice( + device: { + volumeId: string; + type: DeviceType; + }, + share: { + addressId: string; + addressKeyId: string; + armoredKey: string; + armoredSharePassphrase: string; + armoredSharePassphraseSignature: string; + }, + node: { + encryptedName: string; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + armoredHashKey: string; + }, + ): Promise { + const response = await this.apiService.post( + 'drive/devices', + { + // @ts-expect-error VolumeID is deprecated. + Device: { + Type: deviceTypeEnumToNumber(device.type), + SyncState: 0, + }, + // @ts-expect-error Name is deprecated. + Share: { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + Key: share.armoredKey, + Passphrase: share.armoredSharePassphrase, + PassphraseSignature: share.armoredSharePassphraseSignature, + }, + Link: { + Name: node.encryptedName, + NodeKey: node.armoredKey, + NodePassphrase: node.armoredNodePassphrase, + NodePassphraseSignature: node.armoredNodePassphraseSignature, + NodeHashKey: node.armoredHashKey, + }, + }, + ); + + return { + uid: makeDeviceUid(device.volumeId, response.Device.DeviceID), + type: device.type, + rootFolderUid: makeNodeUid(device.volumeId, response.Device.LinkID), + creationTime: new Date(), + hasDeprecatedName: false, + shareId: response.Device.ShareID, + }; + } + + async deleteDevice(deviceUid: string): Promise { + const { deviceId } = splitDeviceUid(deviceUid); + await this.apiService.delete(`drive/devices/${deviceId}`); + } +} + +function deviceTypeNumberToEnum(deviceType: 1 | 2 | 3): DeviceType { + switch (deviceType) { + case 1: + return DeviceType.Windows; + case 2: + return DeviceType.MacOS; + case 3: + return DeviceType.Linux; + default: + throw new Error(`Unknown device type: ${deviceType}`); + } +} + +function deviceTypeEnumToNumber(deviceType: DeviceType): 1 | 2 | 3 { + switch (deviceType.toLowerCase()) { + case DeviceType.Windows.toLowerCase(): + return 1; + case DeviceType.MacOS.toLowerCase(): + return 2; + case DeviceType.Linux.toLowerCase(): + return 3; + default: + throw new Error(`Unknown device type: ${deviceType}`); + } +} diff --git a/js/sdk/src/internal/devices/cryptoService.ts b/js/sdk/src/internal/devices/cryptoService.ts new file mode 100644 index 00000000..d6f88808 --- /dev/null +++ b/js/sdk/src/internal/devices/cryptoService.ts @@ -0,0 +1,70 @@ +import { DriveCrypto } from '../../crypto'; +import { SharesService } from './interface'; + +/** + * Provides crypto operations for devices. + */ +export class DevicesCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private sharesService: SharesService, + ) { + this.driveCrypto = driveCrypto; + this.sharesService = sharesService; + } + + async createDevice(deviceName: string): Promise<{ + address: { + addressId: string; + addressKeyId: string; + }; + shareKey: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + node: { + key: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + encryptedName: string; + armoredHashKey: string; + }; + }> { + const address = await this.sharesService.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 { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); + + return { + address: { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + }, + shareKey: { + armoredKey: shareKey.encrypted.armoredKey, + armoredPassphrase: shareKey.encrypted.armoredPassphrase, + armoredPassphraseSignature: shareKey.encrypted.armoredPassphraseSignature, + }, + node: { + key: { + armoredKey: rootNodeKey.encrypted.armoredKey, + armoredPassphrase: rootNodeKey.encrypted.armoredPassphrase, + armoredPassphraseSignature: rootNodeKey.encrypted.armoredPassphraseSignature, + }, + encryptedName: armoredNodeName, + armoredHashKey, + }, + }; + } +} diff --git a/js/sdk/src/internal/devices/index.ts b/js/sdk/src/internal/devices/index.ts new file mode 100644 index 00000000..574b736b --- /dev/null +++ b/js/sdk/src/internal/devices/index.ts @@ -0,0 +1,38 @@ +import { DriveCrypto } from '../../crypto'; +import { ProtonDriveTelemetry } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { NodesManagementService, NodesService, SharesService } from './interface'; +import { DevicesManager } from './manager'; + +/** + * Provides facade for the whole devices module. + * + * The devices module is responsible for handling devices metadata, including + * API communication, encryption, decryption, caching, and event handling. + * + * This facade provides internal interface that other modules can use to + * interact with the devices. + */ +export function initDevicesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + sharesService: SharesService, + nodesService: NodesService, + nodesManagementService: NodesManagementService, +) { + const api = new DevicesAPIService(apiService); + const cryptoService = new DevicesCryptoService(driveCrypto, sharesService); + const manager = new DevicesManager( + telemetry.getLogger('devices'), + api, + cryptoService, + sharesService, + nodesService, + nodesManagementService, + ); + + return manager; +} diff --git a/js/sdk/src/internal/devices/interface.ts b/js/sdk/src/internal/devices/interface.ts new file mode 100644 index 00000000..c1ede277 --- /dev/null +++ b/js/sdk/src/internal/devices/interface.ts @@ -0,0 +1,37 @@ +import { PrivateKey } from '../../crypto'; +import { DeviceType, MissingNode } from '../../interface'; +import { DecryptedNode } from '../nodes'; + +export type DeviceMetadata = { + uid: string; + type: DeviceType; + rootFolderUid: string; + creationTime: Date; + lastSyncTime?: Date; + hasDeprecatedName: boolean; + shareId: string; +}; + +export interface SharesService { + getRootIDs(): Promise<{ volumeId: string }>; + getMyFilesShareMemberEmailKey(): Promise<{ + addressId: string; + email: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; +} + +export interface NodesService { + iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator; +} + +export interface NodesManagementService { + renameNode( + nodeUid: string, + newName: string, + options: { + allowRenameRootNode: boolean; + }, + ): Promise; +} diff --git a/js/sdk/src/internal/devices/manager.test.ts b/js/sdk/src/internal/devices/manager.test.ts new file mode 100644 index 00000000..8f62802b --- /dev/null +++ b/js/sdk/src/internal/devices/manager.test.ts @@ -0,0 +1,160 @@ +import { ValidationError } from '../../errors'; +import { Device, DeviceType, Logger } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { DeviceMetadata, NodesManagementService, NodesService, SharesService } from './interface'; +import { DevicesManager } from './manager'; + +describe('DevicesManager', () => { + let logger: Logger; + let apiService: jest.Mocked; + let cryptoService: jest.Mocked; + let sharesService: jest.Mocked; + let nodesService: jest.Mocked; + let nodesManagementService: jest.Mocked; + let manager: DevicesManager; + + beforeEach(() => { + logger = getMockLogger(); + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createDevice: jest.fn(), + getDevices: jest.fn(), + removeNameFromDevice: jest.fn(), + deleteDevice: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + createDevice: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getRootIDs: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesService = {}; + nodesManagementService = { + renameNode: jest.fn(), + }; + + manager = new DevicesManager( + logger, + apiService, + cryptoService, + sharesService, + nodesService, + nodesManagementService, + ); + }); + + it('creates device', async () => { + const volumeId = 'volume123'; + const name = 'Test Device'; + const deviceType = DeviceType.Linux; + const address = { addressId: 'address123', addressKeyId: 'key123' }; + const shareKey = { + armoredKey: 'armoredKey', + armoredPassphrase: 'passphrase', + armoredPassphraseSignature: 'signature', + }; + const node = { + encryptedName: 'encryptedName', + key: { + armoredKey: 'nodeKey', + armoredPassphrase: 'nodePassphrase', + armoredPassphraseSignature: 'nodeSignature', + }, + armoredHashKey: 'hashKey', + }; + const createdDevice = { + uid: 'device123', + rootFolderUid: 'rootFolder123', + type: deviceType, + shareId: 'shareid', + } as DeviceMetadata; + + sharesService.getRootIDs.mockResolvedValue({ volumeId }); + cryptoService.createDevice.mockResolvedValue({ address, shareKey, node }); + apiService.createDevice.mockResolvedValue(createdDevice); + + const result = await manager.createDevice(name, deviceType); + + expect(sharesService.getRootIDs).toHaveBeenCalled(); + expect(cryptoService.createDevice).toHaveBeenCalledWith(name); + expect(apiService.createDevice).toHaveBeenCalledWith( + { volumeId, type: deviceType }, + { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + armoredKey: shareKey.armoredKey, + armoredSharePassphrase: shareKey.armoredPassphrase, + armoredSharePassphraseSignature: shareKey.armoredPassphraseSignature, + }, + { + encryptedName: node.encryptedName, + armoredKey: node.key.armoredKey, + armoredNodePassphrase: node.key.armoredPassphrase, + armoredNodePassphraseSignature: node.key.armoredPassphraseSignature, + armoredHashKey: node.armoredHashKey, + }, + ); + expect(result).toEqual({ ...createdDevice, name: { ok: true, value: name } }); + }); + + it('renames device with deprecated name', async () => { + const deviceUid = 'device123'; + const name = 'New Device Name'; + const device = { + uid: deviceUid, + rootFolderUid: 'rootFolder123', + hasDeprecatedName: true, + shareId: 'shareid', + } as DeviceMetadata; + + apiService.getDevices.mockResolvedValue([device]); + + const result = await manager.renameDevice(deviceUid, name); + + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).toHaveBeenCalledWith(deviceUid); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); + expect(result).toEqual({ ...device, name: { ok: true, value: name } }); + }); + + it('renames device without deprecated name', async () => { + const deviceUid = 'device123'; + const name = 'New Device Name'; + const device = { + uid: deviceUid, + rootFolderUid: 'rootFolder123', + hasDeprecatedName: false, + shareId: 'shareid', + } as DeviceMetadata; + + apiService.getDevices.mockResolvedValue([device]); + + const result = await manager.renameDevice(deviceUid, name); + + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); + expect(nodesManagementService.renameNode).toHaveBeenCalledWith(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); + expect(result).toEqual({ ...device, name: { ok: true, value: name } }); + }); + + it('renames non-existing device', async () => { + const deviceUid = 'nonexistentDevice'; + const name = 'New Device Name'; + + apiService.getDevices.mockResolvedValue([]); + + await expect(manager.renameDevice(deviceUid, name)).rejects.toThrow(ValidationError); + expect(apiService.getDevices).toHaveBeenCalled(); + expect(apiService.removeNameFromDevice).not.toHaveBeenCalled(); + expect(nodesManagementService.renameNode).not.toHaveBeenCalled(); + }); +}); diff --git a/js/sdk/src/internal/devices/manager.ts b/js/sdk/src/internal/devices/manager.ts new file mode 100644 index 00000000..2845930e --- /dev/null +++ b/js/sdk/src/internal/devices/manager.ts @@ -0,0 +1,127 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +import { Device, DeviceType, Logger, resultOk } from '../../interface'; +import { DevicesAPIService } from './apiService'; +import { DevicesCryptoService } from './cryptoService'; +import { DeviceMetadata, NodesManagementService, NodesService, SharesService } from './interface'; + +export class DevicesManager { + constructor( + private logger: Logger, + private apiService: DevicesAPIService, + private cryptoService: DevicesCryptoService, + private sharesService: SharesService, + private nodesService: NodesService, + private nodesManagementService: NodesManagementService, + ) { + this.logger = logger; + this.apiService = apiService; + this.cryptoService = cryptoService; + this.sharesService = sharesService; + this.nodesService = nodesService; + this.nodesManagementService = nodesManagementService; + } + + async 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); + + const nodeUidToDevice = new Map(); + for (const device of devices) { + nodeUidToDevice.set(device.rootFolderUid, device); + } + + for await (const node of this.nodesService.iterateNodes(Array.from(nodeUidToDevice.keys()), signal)) { + if ('missingUid' in node) { + continue; + } + + const device = nodeUidToDevice.get(node.uid); + if (device) { + yield { + ...device, + name: node.name, + }; + } + } + } + + async createDevice(name: string, deviceType: DeviceType): Promise { + const { volumeId } = await this.sharesService.getRootIDs(); + const { address, shareKey, node } = await this.cryptoService.createDevice(name); + + const device = await this.apiService.createDevice( + { + volumeId, + type: deviceType, + }, + { + addressId: address.addressId, + addressKeyId: address.addressKeyId, + armoredKey: shareKey.armoredKey, + armoredSharePassphrase: shareKey.armoredPassphrase, + armoredSharePassphraseSignature: shareKey.armoredPassphraseSignature, + }, + { + encryptedName: node.encryptedName, + armoredKey: node.key.armoredKey, + armoredNodePassphrase: node.key.armoredPassphrase, + armoredNodePassphraseSignature: node.key.armoredPassphraseSignature, + armoredHashKey: node.armoredHashKey, + }, + ); + return { + ...device, + name: resultOk(name), + }; + } + + async renameDevice(deviceUid: string, name: string): Promise { + const device = await this.getDeviceMetadata(deviceUid); + + if (device.hasDeprecatedName) { + this.logger.info('Removing deprecated name from device'); + try { + await this.apiService.removeNameFromDevice(deviceUid); + } catch (error: unknown) { + this.logger.error('Failed to remove name from device', error); + } + } + + await this.nodesManagementService.renameNode(device.rootFolderUid, name, { + allowRenameRootNode: true, + }); + + return { + ...device, + name: resultOk(name), + }; + } + + private async getDeviceMetadata(deviceUid: string): Promise { + const devices = await this.apiService.getDevices(); + const device = devices.find((device) => device.uid === deviceUid); + if (!device) { + throw new ValidationError(c('Error').t`Device not found`); + } + return device; + } + + async deleteDevice(deviceUid: string): Promise { + await this.apiService.deleteDevice(deviceUid); + } +} diff --git a/js/sdk/src/internal/download/apiService.ts b/js/sdk/src/internal/download/apiService.ts new file mode 100644 index 00000000..2760acac --- /dev/null +++ b/js/sdk/src/internal/download/apiService.ts @@ -0,0 +1,169 @@ +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']; + +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) + > { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + while (true) { + if (signal?.aborted) { + break; + } + + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?PageSize=${BLOCKS_PAGE_SIZE}&FromBlockIndex=${fromBlockIndex}`, + signal, + ); + + if (fromBlockIndex === 1) { + yield { + type: 'manifestSignature', + armoredManifestSignature: result.Revision.ManifestSignature || undefined, + }; + + if (result.Revision.Thumbnails.length > 0) { + for (const block of result.Revision.Thumbnails) { + yield { + type: 'thumbnail', + base64sha256Hash: block.Hash, + }; + } + } + } + + if (result.Revision.Blocks.length === 0) { + break; + } + + for (const block of result.Revision.Blocks) { + yield { + type: 'block', + ...transformBlock(block), + }; + fromBlockIndex = block.Index + 1; + } + } + } + + async getRevisionBlockToken( + nodeRevisionUid: string, + blockIndex: number, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?PageSize=1&FromBlockIndex=${blockIndex}`, + signal, + ); + + const block = result.Revision.Blocks[0]; + return transformBlock(block); + } + + async downloadBlock( + baseUrl: string, + token: string, + onProgress?: (downloadedBytes: number) => void, + signal?: AbortSignal, + ): Promise> { + const rawBlockStream = await this.apiService.getBlockStream(baseUrl, token, signal); + const progressStream = new ObserverStream((value) => { + onProgress?.(value.length); + }); + const blockStream = rawBlockStream.pipeThrough(progressStream); + const encryptedBlock = new Uint8Array(await new Response(blockStream).arrayBuffer()); + return encryptedBlock; + } + + 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(); + for (const { volumeId, thumbnailId, nodeId } of splitedThumbnailsIds) { + if (!thumbnailIdsByVolumeId.has(volumeId)) { + thumbnailIdsByVolumeId.set(volumeId, []); + } + thumbnailIdsByVolumeId.get(volumeId)?.push({ volumeId, thumbnailId, nodeId }); + } + + for (const [volumeId, thumbnailIds] of thumbnailIdsByVolumeId.entries()) { + 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, + }; + } + + 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, + }; + } + } + } + } +} + +function transformBlock(block: GetRevisionResponse['Revision']['Blocks'][0]): BlockMetadata { + return { + index: block.Index, + bareUrl: block.BareURL as string, + token: block.Token as string, + base64sha256Hash: block.Hash, + signatureEmail: block.SignatureEmail || undefined, + }; +} 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/controller.ts b/js/sdk/src/internal/download/controller.ts new file mode 100644 index 00000000..1a9f65f8 --- /dev/null +++ b/js/sdk/src/internal/download/controller.ts @@ -0,0 +1,43 @@ +import { AbortError } from '../../errors'; +import { waitForCondition } from '../wait'; + +export class DownloadController { + private paused = false; + public promise?: Promise; + private _isDownloadCompleteWithSignatureIssues = false; + + 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 { + this.paused = true; + } + + resume(): void { + this.paused = false; + } + + 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 new file mode 100644 index 00000000..94001797 --- /dev/null +++ b/js/sdk/src/internal/download/cryptoService.ts @@ -0,0 +1,124 @@ +import { c } from 'ttag'; + +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'; + +export class DownloadCryptoService { + constructor( + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + ) { + this.account = account; + this.driveCrypto = driveCrypto; + } + + async getRevisionKeys( + nodeKey: { key: PrivateKey; contentKeyPacketSessionKey: SessionKey }, + revision: Revision, + ): Promise { + const verificationKeys = await this.getRevisionVerificationKeys(revision, nodeKey.key); + return { + ...nodeKey, + verificationKeys, + }; + } + + async decryptBlock(encryptedBlock: Uint8Array, revisionKeys: RevisionKeys): Promise> { + let decryptedBlock; + try { + // We do not verify signatures on blocks. We only verify + // the signature on the revision content key packet and + // the manifest of the revision. + // We plan to drop signatures of individual blocks + // completely in the future. Any issue on the blocks + // should be considered serious integrity issue. + decryptedBlock = await this.driveCrypto.decryptBlock( + encryptedBlock, + revisionKeys.contentKeyPacketSessionKey, + ); + } catch (error: unknown) { + const message = getErrorMessage(error); + throw new DecryptionError(c('Error').t`Failed to decrypt block: ${message}`, { cause: error }); + } + + return decryptedBlock; + } + + async decryptThumbnail(thumbnail: Uint8Array, contentKeyPacketSessionKey: SessionKey): Promise> { + let decryptedBlock; + try { + const result = await this.driveCrypto.decryptThumbnailBlock( + thumbnail, + contentKeyPacketSessionKey, + [], // We ignore verification for thumbnails. + ); + decryptedBlock = result.decryptedThumbnail; + } catch (error: unknown) { + const message = getErrorMessage(error); + throw new DecryptionError(c('Error').t`Failed to decrypt thumbnail: ${message}`, { cause: error }); + } + + return decryptedBlock; + } + + async verifyBlockIntegrity(encryptedBlock: Uint8Array, base64sha256Hash: string): Promise { + 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`, { + expectedHash, + actualHash: base64sha256Hash, + }); + } + } + + async verifyManifest( + revision: Revision, + nodeKey: PrivateKey, + allBlockHashes: Uint8Array[], + armoredManifestSignature?: string, + ): Promise { + 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, + verificationKeys, + ); + + if (verified !== VERIFICATION_STATUS.SIGNED_AND_VALID) { + throw new SignatureVerificationError(c('Error').t`Data integrity check failed`, { + verificationErrors, + }); + } + } + + 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) : [nodeKey]; + } +} diff --git a/js/sdk/src/internal/download/fileDownloader.test.ts b/js/sdk/src/internal/download/fileDownloader.test.ts new file mode 100644 index 00000000..4a3c1b99 --- /dev/null +++ b/js/sdk/src/internal/download/fileDownloader.test.ts @@ -0,0 +1,498 @@ +import { IntegrityError } from '../..'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; +import { DecryptedRevision } from '../nodes'; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { FileDownloader } from './fileDownloader'; +import { SignatureVerificationError } from './interface'; +import { DownloadTelemetry } from './telemetry'; + +function mockBlockDownload(_: string, token: string, onProgress: (downloadedBytes: number) => void) { + const index = parseInt(token.slice(5, 6)); + const array = new Uint8Array(index); + for (let i = 0; i < index; i++) { + array[i] = i; + } + + onProgress(array.length); + return array; +} + +describe('FileDownloader', () => { + let telemetry: DownloadTelemetry; + let apiService: DownloadAPIService; + let cryptoService: DownloadCryptoService; + let nodeKey: { key: object; contentKeyPacketSessionKey: string }; + let revision: DecryptedRevision; + + 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(), + }), + downloadInitFailed: jest.fn(), + downloadFailed: jest.fn(), + downloadFinished: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + apiService = { + iterateRevisionBlocks: jest.fn().mockImplementation(async function* () { + yield { type: 'manifestSignature', armoredManifestSignature: 'manifestSignature' }; + yield { type: 'thumbnail', base64sha256Hash: 'aGFzaDA=' }; + yield { type: 'block', index: 1, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 2, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 3, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + }), + getRevisionBlockToken: jest.fn().mockImplementation(async (_, blockIndex: number) => ({ + index: blockIndex, + bareUrl: 'url', + token: `token${blockIndex}-refreshed`, + base64sha256Hash: `hash${blockIndex}`, + })), + // By default, return a block of length equal to the index number. + downloadBlock: jest.fn().mockImplementation(mockBlockDownload), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + getRevisionKeys: jest.fn().mockImplementation(async () => ({ + key: 'privateKey', + contentKeyPacketSessionKey: 'contentSessionKey', + verificationKeys: 'verificationKeys', + })), + decryptBlock: jest.fn().mockImplementation(async (encryptedBlock) => encryptedBlock), + verifyBlockIntegrity: jest.fn().mockResolvedValue(undefined), + verifyManifest: jest.fn().mockResolvedValue(undefined), + }; + + nodeKey = { + key: { _idx: 32131 }, + contentKeyPacketSessionKey: 'sessionKey', + }; + + revision = { + uid: 'revisionUid', + claimedSize: 1024, + claimedBlockSizes: [16, 16, 16, 16], + } as DecryptedRevision; + }); + + describe('downloadToStream', () => { + let onProgress: (downloadedBytes: number) => void; + let onFinish: () => void; + + let downloader: FileDownloader; + let writer: WritableStreamDefaultWriter>; + let stream: WritableStream>; + + const verifySuccess = async ( + fileProgress: number = 6, // 3 blocks of length 1, 2, 3 + ) => { + const controller = downloader.downloadToStream(stream, onProgress); + await controller.completion(); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(cryptoService.verifyManifest).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); + 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.downloadToStream(stream, onProgress); + + await expect(controller.completion()).rejects.toThrow(error); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(telemetry.downloadFinished).not.toHaveBeenCalled(); + expect(telemetry.downloadFailed).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFailed).toHaveBeenCalledWith( + 'revisionUid', + new Error(error), + downloadedBytes === undefined ? expect.anything() : downloadedBytes, + revision.claimedSize, + ); + expect(onFinish).toHaveBeenCalledTimes(1); + + return controller; + }; + + const verifyOnProgress = async (downloadedBytes: number[]) => { + expect(onProgress).toHaveBeenCalledTimes(downloadedBytes.length); + let fileProgress = 0; + for (let i = 0; i < downloadedBytes.length; i++) { + fileProgress += downloadedBytes[i]; + expect(onProgress).toHaveBeenNthCalledWith(i + 1, fileProgress); + } + }; + + beforeEach(() => { + onProgress = jest.fn(); + onFinish = jest.fn(); + + // @ts-expect-error Mocking WritableStreamDefaultWriter + writer = { + write: jest.fn(), + }; + // @ts-expect-error Mocking WritableStream + stream = { + getWriter: () => writer, + }; + downloader = new FileDownloader( + telemetry, + apiService, + cryptoService, + nodeKey as any, + revision, + undefined, + onFinish, + ); + }); + + it('should reject two download starts', async () => { + 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 () => { + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + // Use over MAX_DOWNLOAD_BLOCK_SIZE blocks to test that the downloader is not stuck in a loop. + it('should start a download and write to the stream with random order', async () => { + let count = 0; + // Keep first block with high timeout to make sure it is not finished first. + const timeouts = [90, 50, 40, 80, 70, 60, 30, 20, 10, 90, 10]; + + apiService.iterateRevisionBlocks = jest.fn().mockImplementation(async function* () { + yield { type: 'manifestSignature', armoredManifestSignature: 'manifestSignature' }; + yield { type: 'thumbnail', base64sha256Hash: 'aGFzaDA=' }; + yield { type: 'block', index: 1, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 2, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 3, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 4, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 5, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 6, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 7, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 8, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + yield { type: 'block', index: 9, bareUrl: 'url', token: 'token3', base64sha256Hash: 'aGFzaDM=' }; + yield { type: 'block', index: 10, bareUrl: 'url', token: 'token1', base64sha256Hash: 'aGFzaDE=' }; + yield { type: 'block', index: 11, bareUrl: 'url', token: 'token2', base64sha256Hash: 'aGFzaDI=' }; + }); + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + await new Promise((resolve) => setTimeout(resolve, timeouts[count++])); + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(21); // Progress is 1 + 2 + 3 + 1 + 2 + 3 + 1 + 2 + 3 + 1 + 2 = 21 + expect(apiService.downloadBlock).toHaveBeenCalledTimes(11); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(11); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(11); + expect(writer.write).toHaveBeenNthCalledWith(1, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(2, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(3, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(4, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(5, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(6, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(7, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(8, new Uint8Array([0, 1])); + expect(writer.write).toHaveBeenNthCalledWith(9, new Uint8Array([0, 1, 2])); + expect(writer.write).toHaveBeenNthCalledWith(10, new Uint8Array([0])); + expect(writer.write).toHaveBeenNthCalledWith(11, new Uint8Array([0, 1])); + }); + + it('should handle failure when iterating blocks', async () => { + apiService.iterateRevisionBlocks = jest.fn().mockImplementation(async function* () { + throw new Error('Failed to iterate blocks'); + }); + + await verifyFailure('Failed to iterate blocks', 0); + }); + + it('should handle failure when downloading block', async () => { + apiService.downloadBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to download block'); + }); + + await verifyFailure('Failed to download block', 0); + }); + + it('should handle one time-off failure when downloading block', async () => { + let count = 0; + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + if (count === 0) { + count++; + onProgress?.(1); // Simulate the failure happens after some progress. + throw new Error('Failed to download block'); + } + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle expired token when downloading block', async () => { + let count = 0; + apiService.downloadBlock = jest.fn().mockImplementation(async function (bareUrl, token, onProgress) { + if (count === 0) { + count++; + throw new APIHTTPError('Expired token', HTTPErrorCode.NOT_FOUND); + } + return mockBlockDownload(bareUrl, token, onProgress); + }); + + await verifySuccess(); + expect(apiService.getRevisionBlockToken).toHaveBeenCalledTimes(1); + expect(apiService.getRevisionBlockToken).toHaveBeenCalledWith('revisionUid', 1, undefined); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + it('should handle failure when verifying block', async () => { + cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { + throw new Error('Failed to verify block'); + }); + + await verifyFailure('Failed to verify block', undefined); + }); + + it('should handle one time-off failure when verifying block', async () => { + let count = 0; + cryptoService.verifyBlockIntegrity = jest.fn().mockImplementation(async function () { + if (count === 0) { + count++; + throw new Error('Failed to verify block'); + } + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(4); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle failure when decrypting block', async () => { + cryptoService.decryptBlock = jest.fn().mockImplementation(async function () { + throw new Error('Failed to decrypt block'); + }); + + await verifyFailure('Failed to decrypt block', undefined); + }); + + it('should handle one time-off failure when decrypting block', async () => { + let count = 0; + cryptoService.decryptBlock = jest.fn().mockImplementation(async function (encryptedBlock) { + if (count === 0) { + count++; + throw new Error('Failed to decrypt block'); + } + return encryptedBlock; + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(4); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(4); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(4); + await verifyOnProgress([1, -1, 1, 2, 3]); + }); + + it('should handle failure when writing to the stream', async () => { + writer.write = jest.fn().mockImplementation(async function () { + throw new Error('Failed to write data'); + }); + + await verifyFailure('Failed to write data', undefined); + }); + + it('should handle one time-off failure when writing to the stream', async () => { + let count = 0; + writer.write = jest.fn().mockImplementation(async function () { + if (count === 0) { + count++; + throw new Error('Failed to write data'); + } + }); + + await verifySuccess(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + await verifyOnProgress([1, 2, 3]); + }); + + it('should handle failure when 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 SignatureVerificationError('Failed to verify manifest'); + }); + + const controller = await verifyFailure('Failed to verify manifest', 6); // All blocks of length 1, 2, 3. + expect(controller.isDownloadCompleteWithSignatureIssues()).toBe(true); + }); + }); + + describe('unsafeDownloadToStream', () => { + let onProgress: (downloadedBytes: number) => void; + let onFinish: () => void; + + let downloader: FileDownloader; + let writer: WritableStreamDefaultWriter>; + let stream: WritableStream>; + + beforeEach(() => { + onProgress = jest.fn(); + onFinish = jest.fn(); + + // @ts-expect-error Mocking WritableStreamDefaultWriter + writer = { + write: jest.fn(), + close: jest.fn(), + abort: jest.fn(), + }; + // @ts-expect-error Mocking WritableStream + stream = { + getWriter: () => writer, + }; + downloader = new FileDownloader( + telemetry, + apiService, + cryptoService, + nodeKey as any, + revision, + undefined, + onFinish, + ); + }); + + it('should skip verification steps', async () => { + const controller = downloader.unsafeDownloadToStream(stream, onProgress); + await controller.completion(); + + expect(apiService.iterateRevisionBlocks).toHaveBeenCalledWith('revisionUid', undefined); + expect(cryptoService.verifyManifest).not.toHaveBeenCalled(); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.verifyBlockIntegrity).not.toHaveBeenCalled(); + expect(cryptoService.decryptBlock).toHaveBeenCalledTimes(3); + expect(telemetry.downloadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.downloadFinished).toHaveBeenCalledWith('revisionUid', 6); // 3 blocks of length 1, 2, 3. + expect(telemetry.downloadFailed).not.toHaveBeenCalled(); + 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 new file mode 100644 index 00000000..716448e0 --- /dev/null +++ b/js/sdk/src/internal/download/fileDownloader.ts @@ -0,0 +1,404 @@ +import { c } from 'ttag'; + +import { PrivateKey, SessionKey } from '../../crypto'; +import { AbortError, IntegrityError } from '../../errors'; +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, SignatureVerificationError } from './interface'; +import { BufferedSeekableStream } from './seekableStream'; +import { DownloadTelemetry } from './telemetry'; + +/** + * Maximum number of blocks that can be downloaded at the same time + * for a single file. This is to prevent downloading too many blocks + * at the same time and running out of memory. + */ +const MAX_DOWNLOAD_BLOCK_SIZE = 10; + +export class FileDownloader { + private logger: Logger; + + private controller: DownloadController; + private nextBlockIndex = 1; + 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 revision: DecryptedRevision, + private signal?: AbortSignal, + private onFinish?: () => void, + private ignoreManifestVerification = false, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLoggerForRevision(revision.uid); + this.apiService = apiService; + this.cryptoService = cryptoService; + this.nodeKey = nodeKey; + this.revision = revision; + this.signal = signal; + this.onFinish = onFinish; + this.ignoreManifestVerification = ignoreManifestVerification; + this.controller = new DownloadController(this.signal); + } + + getClaimedSizeInBytes(): number | undefined { + 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 | Error | undefined> { + 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}`, { cause: error }); + } + } + + downloadToStream(stream: WritableStream, onProgress?: (downloadedBytes: number) => void): DownloadController { + if (this.controller.promise) { + throw new Error(`Download already started`); + } + this.controller.promise = this.internalDownloadToStream(stream, onProgress); + return this.controller; + } + + 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.internalDownloadToStream(stream, onProgress, ignoreIntegrityErrors); + return this.controller; + } + + private async internalDownloadToStream( + stream: WritableStream, + onProgress?: (downloadedBytes: number) => void, + ignoreIntegrityErrors = false, + ): Promise { + const writer = stream.getWriter(); + const cryptoKeys = await this.cryptoService.getRevisionKeys(this.nodeKey, this.revision); + + // File progress is tracked for telemetry - to track at what + // point the download failed. + let fileProgress = 0; + + // Collection of all block hashes for manifest verification. + // This includes both thumbnail and regular blocks. + const allBlockHashes: Uint8Array[] = []; + let armoredManifestSignature: string | undefined; + + try { + this.logger.info(`Starting download`); + for await (const blockMetadata of this.apiService.iterateRevisionBlocks(this.revision.uid, this.signal)) { + if (blockMetadata.type === 'manifestSignature') { + armoredManifestSignature = blockMetadata.armoredManifestSignature; + continue; + } + + allBlockHashes.push(Uint8Array.fromBase64(blockMetadata.base64sha256Hash)); + if (blockMetadata.type === 'thumbnail') { + continue; + } + + await this.controller.waitWhilePaused(); + + const downloadPromise = this.downloadBlock( + blockMetadata, + ignoreIntegrityErrors, + cryptoKeys, + (downloadedBytes) => { + fileProgress += downloadedBytes; + onProgress?.(fileProgress); + }, + ); + this.ongoingDownloads.set(blockMetadata.index, { downloadPromise }); + + await this.waitForDownloadCapacity(); + 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(async (chunk) => { + await writer.write(chunk); + }); + + if (this.ongoingDownloads.size > 0) { + this.logger.error(`Some blocks were not downloaded: ${this.ongoingDownloads.keys()}`); + // This is a bug in the algorithm. + throw new Error(`Some blocks were not downloaded`); + } + + if (ignoreIntegrityErrors || this.ignoreManifestVerification) { + this.logger.warn('Skipping manifest check'); + } else { + this.logger.debug(`Verifying manifest`); + await this.cryptoService.verifyManifest( + this.revision, + this.nodeKey.key, + allBlockHashes, + armoredManifestSignature, + ); + } + + void this.telemetry.downloadFinished(this.revision.uid, fileProgress); + this.logger.info(`Download succeeded`); + 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) { + 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()); + throw error; + } finally { + this.logger.debug(`Download cleanup`); + this.onFinish?.(); + } + } + + private async downloadBlock( + blockMetadata: BlockMetadata, + ignoreIntegrityErrors: boolean, + 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`); + + let blockProgress = 0; + let decryptedBlock: Uint8Array | null = null; + let retries = 0; + + while (!decryptedBlock) { + logger.debug(`Downloading`); + await this.controller.waitWhilePaused(); + try { + const encryptedBlock = await this.apiService.downloadBlock( + blockMetadata.bareUrl, + blockMetadata.token, + (downloadedBytes) => { + blockProgress += downloadedBytes; + onProgress?.(downloadedBytes); + }, + this.signal, + ); + + if (ignoreIntegrityErrors) { + logger.warn('Skipping hash check'); + } else { + logger.debug(`Verifying hash`); + await this.cryptoService.verifyBlockIntegrity(encryptedBlock, blockMetadata.base64sha256Hash); + } + + logger.debug(`Decrypting`); + decryptedBlock = await this.cryptoService.decryptBlock(encryptedBlock, cryptoKeys); + } catch (error) { + if (this.signal?.aborted) { + throw new AbortError(c('Error').t`Operation aborted`); + } + + if (blockProgress !== 0) { + onProgress?.(-blockProgress); + blockProgress = 0; + } + + if (error instanceof APIHTTPError && error.statusCode === HTTPErrorCode.NOT_FOUND) { + logger.warn(`Token expired, fetching new token and retrying`); + blockMetadata = await this.apiService.getRevisionBlockToken( + this.revision.uid, + blockMetadata.index, + this.signal, + ); + continue; + } + + // Download can fail for various reasons, for example integrity + // can fail due to bitflips. We want to retry and solve the issue + // seamlessly for the user. We retry only once, because we don't + // want to get stuck in a loop. + if (retries === 0) { + logger.error(`Download failed, retrying`, error); + retries++; + continue; + } + + logger.error(`Download failed`, error); + throw error; + } + } + + logger.info(`Downloaded`); + return decryptedBlock; + } + + private async waitForDownloadCapacity() { + if (this.ongoingDownloads.size >= MAX_DOWNLOAD_BLOCK_SIZE) { + this.logger.info(`Download limit reached, waiting for next block to be finished`); + + // We need to ensure the next block is downloaded, otherwise the + // buffer will still be full. + while (!this.isNextBlockDownloaded) { + // Promise.race never finishes if the passed array is empty. + // It shouldn't happen if at least next block is still not downloaded, + // also JS is single threaded, so it should be impossible to change + // the ongoing downloads in the middle of the loop. It is handled + // just in case something is changed that would affect this part + // without noticing. + const ongoingDownloadPromises = Array.from(this.ongoingDownloadPromises); + if (ongoingDownloadPromises.length === 0) { + break; + } + + // Promise.race is used to ensure if any block fails, the error is + // thrown up the chain and we dont end up in stuck loop here waiting + // for the next block to be ready. + // We wait only for the ongoing downloads as if we use all promises, + // some block can be finished and it would result in inifinite loop. + await Promise.race(ongoingDownloadPromises); + } + } + } + + 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 write(decryptedBlock); + } catch (error) { + this.logger.error(`Failed to write block, retrying once`, error); + await write(decryptedBlock); + } + this.ongoingDownloads.delete(this.nextBlockIndex); + this.nextBlockIndex++; + } + } + + private get downloadPromises() { + return this.ongoingDownloads.values().map(({ downloadPromise }) => downloadPromise); + } + + private get ongoingDownloadPromises() { + return this.ongoingDownloads + .values() + .filter((value) => value.decryptedBufferedBlock === undefined) + .map((value) => value.downloadPromise); + } + + private get isNextBlockDownloaded() { + return !!this.ongoingDownloads.get(this.nextBlockIndex)?.decryptedBufferedBlock; + } +} diff --git a/js/sdk/src/internal/download/index.ts b/js/sdk/src/internal/download/index.ts new file mode 100644 index 00000000..4c45d780 --- /dev/null +++ b/js/sdk/src/internal/download/index.ts @@ -0,0 +1,125 @@ +import { c } from 'ttag'; + +import { DriveCrypto } from '../../crypto'; +import { ValidationError } from '../../errors'; +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 { FileDownloader } from './fileDownloader'; +import { NodesService, RevisionsService, SharesService } from './interface'; +import { DownloadQueue } from './queue'; +import { DownloadTelemetry } from './telemetry'; +import { ThumbnailDownloader } from './thumbnailDownloader'; + +export function initDownloadModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + account: ProtonDriveAccount, + sharesService: SharesService, + nodesService: NodesService, + revisionsService: RevisionsService, + ignoreManifestVerification = false, +) { + const queue = new DownloadQueue(); + const api = new DownloadAPIService(apiService); + const cryptoService = new DownloadCryptoService(driveCrypto, account); + const downloadTelemetry = new DownloadTelemetry(telemetry, sharesService); + + async function getFileDownloader(nodeUid: string, signal?: AbortSignal): Promise { + await queue.waitForCapacity(signal); + + let node, nodeKey; + try { + node = await nodesService.getNode(nodeUid); + nodeKey = await nodesService.getNodeKeys(nodeUid); + + if (node.type === NodeType.Folder) { + throw new ValidationError(c('Error').t`Cannot download a folder`); + } + if (!nodeKey.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`File has no content key`); + } + if (!node.activeRevision?.ok || !node.activeRevision.value) { + throw new ValidationError(c('Error').t`File has no active revision`); + } + } catch (error: unknown) { + queue.releaseCapacity(); + void downloadTelemetry.downloadInitFailed(nodeUid, error); + throw error; + } + + const onFinish = () => queue.releaseCapacity(); + + return new FileDownloader( + downloadTelemetry, + api, + cryptoService, + { + key: nodeKey.key, + contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, + }, + node.activeRevision.value, + signal, + onFinish, + ignoreManifestVerification, + ); + } + + async function getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal): Promise { + await queue.waitForCapacity(signal); + + const nodeUid = makeNodeUidFromRevisionUid(nodeRevisionUid); + + let node, nodeKey, revision; + try { + node = await nodesService.getNode(nodeUid); + nodeKey = await nodesService.getNodeKeys(nodeUid); + revision = await revisionsService.getRevision(nodeRevisionUid); + + if (node.type === NodeType.Folder) { + throw new ValidationError(c('Error').t`Cannot download a folder`); + } + if (!nodeKey.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`File has no content key`); + } + } catch (error: unknown) { + queue.releaseCapacity(); + void downloadTelemetry.downloadInitFailed(nodeUid, error); + throw error; + } + + const onFinish = () => queue.releaseCapacity(); + + return new FileDownloader( + downloadTelemetry, + api, + cryptoService, + { + key: nodeKey.key, + contentKeyPacketSessionKey: nodeKey.contentKeyPacketSessionKey, + }, + revision, + signal, + onFinish, + ignoreManifestVerification, + ); + } + + async function* iterateThumbnails( + nodeUids: string[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { + const thumbnailDownloader = new ThumbnailDownloader(telemetry, nodesService, api, cryptoService); + yield* thumbnailDownloader.iterateThumbnails(nodeUids, thumbnailType, signal); + } + + return { + getFileDownloader, + getFileRevisionDownloader, + iterateThumbnails, + }; +} diff --git a/js/sdk/src/internal/download/interface.ts b/js/sdk/src/internal/download/interface.ts new file mode 100644 index 00000000..f0f7612d --- /dev/null +++ b/js/sdk/src/internal/download/interface.ts @@ -0,0 +1,52 @@ +import { PrivateKey, PublicKey, SessionKey } from '../../crypto'; +import { IntegrityError } from '../../errors'; +import { MetricVolumeType, MissingNode, NodeType, Result } from '../../interface'; +import { DecryptedNode, DecryptedRevision } from '../nodes'; + +export type BlockMetadata = { + index: number; + bareUrl: string; + token: string; + base64sha256Hash: string; + signatureEmail?: string; +}; + +export type RevisionKeys = { + key: PrivateKey; + contentKeyPacketSessionKey: SessionKey; + verificationKeys?: PublicKey[]; +}; + +export interface SharesService { + getVolumeMetricContext(volumeId: string): Promise; +} + +export interface NodesService { + 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; +} + +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'; +} diff --git a/js/sdk/src/internal/download/queue.ts b/js/sdk/src/internal/download/queue.ts new file mode 100644 index 00000000..2b1bb009 --- /dev/null +++ b/js/sdk/src/internal/download/queue.ts @@ -0,0 +1,30 @@ +import { waitForCondition } from '../wait'; + +/** + * A queue that limits the number of concurrent downloads. + * + * This is used to limit the number of concurrent downloads to avoid + * overloading the server, or get rate limited. + * + * Each file download consumes memory and is limited by the number of + * concurrent block downloads for each file. + * + * This queue is straitforward and does not have any priority mechanism + * or other features, such as limiting total number of blocks being + * downloaded. That is something we want to add in the future to be + * more performant for many small file downloads. + */ +const MAX_CONCURRENT_DOWNLOADS = 5; + +export class DownloadQueue { + private capacity = 0; + + async waitForCapacity(signal?: AbortSignal) { + await waitForCondition(() => this.capacity < MAX_CONCURRENT_DOWNLOADS, signal); + this.capacity++; + } + + releaseCapacity() { + this.capacity--; + } +} diff --git a/js/sdk/src/internal/download/seekableStream.test.ts b/js/sdk/src/internal/download/seekableStream.test.ts new file mode 100644 index 00000000..10625bf0 --- /dev/null +++ b/js/sdk/src/internal/download/seekableStream.test.ts @@ -0,0 +1,312 @@ +import { BufferedSeekableStream, SeekableReadableStream, UnderlyingSeekableSource } 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; + let seekableSource: UnderlyingSeekableSource; + + 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++; + }); + + // 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', () => { + 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 }); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..5e0bc63b --- /dev/null +++ b/js/sdk/src/internal/download/seekableStream.ts @@ -0,0 +1,203 @@ +export 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 || position < this.currentPosition) { + this.buffer = new Uint8Array(0); + this.bufferPosition = 0; + + 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.currentPosition = position; + } +} diff --git a/js/sdk/src/internal/download/telemetry.test.ts b/js/sdk/src/internal/download/telemetry.test.ts new file mode 100644 index 00000000..7a0f5dcf --- /dev/null +++ b/js/sdk/src/internal/download/telemetry.test.ts @@ -0,0 +1,149 @@ +import { DecryptionError, IntegrityError, RateLimitedError, ValidationError } from '../../errors'; +import { ProtonDriveTelemetry } from '../../interface'; +import { APIHTTPError } from '../apiService'; +import { SharesService } from './interface'; +import { DownloadTelemetry } from './telemetry'; + +describe('DownloadTelemetry', () => { + let mockTelemetry: jest.Mocked; + let sharesService: jest.Mocked; + let downloadTelemetry: DownloadTelemetry; + + const nodeUid = 'volumeId~nodeId'; + const revisionUid = 'volumeId~nodeId~revisionId'; + + beforeEach(() => { + mockTelemetry = { + recordMetric: jest.fn(), + getLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), + } as unknown as jest.Mocked; + + sharesService = { + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), + }; + + downloadTelemetry = new DownloadTelemetry(mockTelemetry, sharesService); + }); + + it('should log failure during init (excludes file size)', async () => { + const error = new Error('Failed'); + await downloadTelemetry.downloadInitFailed(nodeUid, error); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'download', + volumeType: 'own_volume', + downloadedSize: 0, + approximateDownloadedSize: 0, + error: 'unknown', + originalError: error, + }); + }); + + it('should log failure download', async () => { + const error = new Error('Failed'); + await downloadTelemetry.downloadFailed(revisionUid, error, 123, 456); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'download', + volumeType: 'own_volume', + downloadedSize: 123, + approximateDownloadedSize: 4095, + claimedFileSize: 456, + approximateClaimedFileSize: 4095, + error: 'unknown', + originalError: error, + }); + }); + + it('should log successful download (excludes error)', async () => { + await downloadTelemetry.downloadFinished(revisionUid, 500); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'download', + volumeType: 'own_volume', + downloadedSize: 500, + approximateDownloadedSize: 4095, + claimedFileSize: 500, + approximateClaimedFileSize: 4095, + }); + }); + + describe('detect error category', () => { + const verifyErrorCategory = (error: string) => { + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith( + expect.objectContaining({ + error, + }), + ); + }; + + it('should detect "validation_error" for ValidationError', async () => { + const error = new ValidationError('file not found'); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('validation_error'); + }); + + it('should ignore AbortError', async () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('should detect "rate_limited" error for RateLimitedError', async () => { + const error = new RateLimitedError('Rate limited'); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('rate_limited'); + }); + + it('should detect "decryption_error" for DecryptionError', async () => { + const error = new DecryptionError('Decryption failed'); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('decryption_error'); + }); + + it('should detect "integrity_error" for IntegrityError', async () => { + const error = new IntegrityError('Integrity check failed'); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('integrity_error'); + }); + + it('should detect "4xx" error for APIHTTPError with 4xx status code', async () => { + const error = new APIHTTPError('Client error', 404); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('4xx'); + }); + + it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { + const error = new APIHTTPError('Server error', 500); + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('server_error'); + }); + + it('should detect "server_error" for TimeoutError', async () => { + const error = new Error('Timeout'); + error.name = 'TimeoutError'; + await downloadTelemetry.downloadFailed(revisionUid, error, 100, 200); + verifyErrorCategory('server_error'); + }); + + it('should detect "network_error" for NetworkError', async () => { + const error = new Error('Network error'); + error.name = 'NetworkError'; + 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 new file mode 100644 index 00000000..518b9ca1 --- /dev/null +++ b/js/sdk/src/internal/download/telemetry.ts @@ -0,0 +1,129 @@ +import { DecryptionError, IntegrityError, RateLimitedError, ValidationError } from '../../errors'; +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'; + +export class DownloadTelemetry { + private logger: Logger; + + constructor( + private telemetry: ProtonDriveTelemetry, + private sharesService: SharesService, + ) { + this.telemetry = telemetry; + this.logger = this.telemetry.getLogger('download'); + this.sharesService = sharesService; + } + + getLoggerForRevision(revisionUid: string) { + return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); + } + + async downloadInitFailed(nodeUid: string, error: unknown) { + const { volumeId } = splitNodeUid(nodeUid); + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + await this.sendTelemetry(volumeId, { + downloadedSize: 0, + error: errorCategory, + originalError: error, + }); + } + + async downloadFailed(revisionUid: string, error: unknown, downloadedSize: number, claimedFileSize?: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + await this.sendTelemetry(volumeId, { + downloadedSize, + claimedFileSize, + error: errorCategory, + originalError: error, + }); + } + + async downloadFinished(revisionUid: string, downloadedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + await this.sendTelemetry(volumeId, { + downloadedSize, + claimedFileSize: downloadedSize, + }); + } + + private async sendTelemetry( + volumeId: string, + options: { + downloadedSize: number; + claimedFileSize?: number; + error?: MetricsDownloadErrorType; + originalError?: unknown; + }, + ) { + 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: 'download', + volumeType, + approximateDownloadedSize: reduceSizePrecision(options.downloadedSize), + approximateClaimedFileSize: options.claimedFileSize + ? reduceSizePrecision(options.claimedFileSize) + : undefined, + ...options, + }); + } +} + +function getErrorCategory(error: unknown): MetricsDownloadErrorType | undefined { + if (error instanceof ValidationError) { + return 'validation_error'; + } + if (error instanceof RateLimitedError) { + return 'rate_limited'; + } + if (error instanceof DecryptionError) { + return 'decryption_error'; + } + if (error instanceof IntegrityError) { + return 'integrity_error'; + } + if (error instanceof APIHTTPError) { + if (error.statusCode >= 400 && error.statusCode < 500) { + return '4xx'; + } + if (error.statusCode >= 500) { + return 'server_error'; + } + } + if (error instanceof Error) { + if (error.name === 'TimeoutError') { + return 'server_error'; + } + if (isNetworkError(error)) { + return 'network_error'; + } + if (error.name === 'AbortError') { + return undefined; + } + } + return 'unknown'; +} diff --git a/js/sdk/src/internal/download/thumbnailDownloader.test.ts b/js/sdk/src/internal/download/thumbnailDownloader.test.ts new file mode 100644 index 00000000..488804b6 --- /dev/null +++ b/js/sdk/src/internal/download/thumbnailDownloader.test.ts @@ -0,0 +1,231 @@ +import { ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DownloadAPIService } from './apiService'; +import { DownloadCryptoService } from './cryptoService'; +import { NodesService } from './interface'; +import { ThumbnailDownloader } from './thumbnailDownloader'; + +describe('ThumbnailDownloader', () => { + let telemetry: ProtonDriveTelemetry; + let nodesService: NodesService; + let apiService: DownloadAPIService; + let cryptoService: DownloadCryptoService; + let downloader: ThumbnailDownloader; + + beforeEach(() => { + telemetry = getMockTelemetry(); + + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + iterateNodes: jest.fn().mockImplementation(async function* (nodeUids: string[]) { + for (const nodeUid of nodeUids) { + yield { + uid: nodeUid, + type: 'file', + activeRevision: { + ok: true, + value: { + thumbnails: [{ type: 1, uid: `thumb-${nodeUid}` }], + }, + }, + }; + } + }), + getNodeKeys: jest.fn().mockReturnValue({ + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + }), + } as NodesService; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + iterateThumbnails: jest.fn().mockImplementation(async function* (thumbnailUids: string[]) { + for (const thumbnailUid of thumbnailUids) { + yield { + uid: thumbnailUid, + ok: true, + bareUrl: `url-${thumbnailUid}`, + token: `token-${thumbnailUid}`, + }; + } + }), + downloadBlock: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + } as DownloadAPIService; + + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + decryptThumbnail: jest.fn().mockImplementation(async (thumbnail: Uint8Array) => thumbnail), + } as DownloadCryptoService; + + downloader = new ThumbnailDownloader(telemetry, nodesService, apiService, cryptoService); + }); + + it('should handle all success cases', async () => { + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1', 'node2', 'node3'])); + + expect(results).toEqual([ + { nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + { nodeUid: 'node2', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + { nodeUid: 'node3', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }, + ]); + expect(nodesService.iterateNodes).toHaveBeenCalledWith(['node1', 'node2', 'node3'], undefined); + expect(apiService.iterateThumbnails).toHaveBeenCalledWith( + ['thumb-node1', 'thumb-node2', 'thumb-node3'], + undefined, + ); + expect(nodesService.getNodeKeys).toHaveBeenCalledTimes(3); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptThumbnail).toHaveBeenCalledTimes(3); + expect(cryptoService.decryptThumbnail).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]), + 'contentKeyPacketSessionKey', + ); + }); + + it('should handle no requested node', async () => { + const results = await Array.fromAsync(downloader.iterateThumbnails([])); + + expect(results).toEqual([]); + expect(nodesService.iterateNodes).not.toHaveBeenCalled(); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle failure when requesting nodes', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(() => { + throw new Error('Failed to fetch nodes'); + }); + + const results = Array.fromAsync(downloader.iterateThumbnails(['node1'])); + await expect(results).rejects.toThrow('Failed to fetch nodes'); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle missing node', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { missingUid: 'node1' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node not found' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle node that is not a file', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { uid: 'node1', type: 'folder' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node has no thumbnail' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle node without requested thumbnail', async () => { + nodesService.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { uid: 'node1', type: 'file', activeRevision: { ok: true, value: { thumbnails: [] } } }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Node has no thumbnail' }]); + expect(apiService.iterateThumbnails).not.toHaveBeenCalled(); + }); + + it('should handle API failure to provide token for thumbnail', async () => { + apiService.iterateThumbnails = jest.fn().mockImplementation(async function* () { + yield { uid: 'thumb-node1', ok: false, error: 'Failed to fetch token' }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to fetch token' }]); + expect(apiService.downloadBlock).not.toHaveBeenCalled(); + }); + + it('should handle API providing unexpected thumbnail', async () => { + apiService.iterateThumbnails = jest.fn().mockImplementation(async function* () { + yield { uid: 'thumb-unexpected', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }; + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Thumbnail not found' }]); + expect(apiService.downloadBlock).not.toHaveBeenCalled(); + }); + + it('should handle failure when downloading block', async () => { + apiService.downloadBlock = jest.fn().mockRejectedValue(new Error('Failed to download thumbnail')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to download thumbnail' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when downloading block', async () => { + let callCount = 0; + apiService.downloadBlock = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to download block')); + } + return Promise.resolve(new Uint8Array([1, 2, 3])); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle failure when getting node keys', async () => { + nodesService.getNodeKeys = jest.fn().mockRejectedValue(new Error('Failed to get node keys')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to get node keys' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when getting node keys', async () => { + let callCount = 0; + nodesService.getNodeKeys = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to get node keys')); + } + return Promise.resolve({ contentKeyPacketSessionKey: 'contentKeyPacketSessionKey' }); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); + + it('should handle failure when decrypting block', async () => { + cryptoService.decryptThumbnail = jest.fn().mockRejectedValue(new Error('Failed to decrypt thumbnail')); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: false, error: 'Failed to decrypt thumbnail' }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(3); + }); + + it('should handle one-off failure when decrypting block', async () => { + let callCount = 0; + cryptoService.decryptThumbnail = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Failed to decrypt thumbnail')); + } + return Promise.resolve(new Uint8Array([1, 2, 3])); + }); + + const results = await Array.fromAsync(downloader.iterateThumbnails(['node1'])); + + expect(results).toEqual([{ nodeUid: 'node1', ok: true, thumbnail: new Uint8Array([1, 2, 3]) }]); + expect(apiService.downloadBlock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/js/sdk/src/internal/download/thumbnailDownloader.ts b/js/sdk/src/internal/download/thumbnailDownloader.ts new file mode 100644 index 00000000..db59b851 --- /dev/null +++ b/js/sdk/src/internal/download/thumbnailDownloader.ts @@ -0,0 +1,253 @@ +import { c } from 'ttag'; + +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'; + +/** + * Maximum number of thumbnails that can be downloaded at the same time. + */ +const MAX_DOWNLOAD_THUMBNAILS = 10; + +/** + * Maximum number of retries for thumbnail download and decryption. + */ +const MAX_THUMBNAIL_DOWNLOAD_ATTEMPTS = 2; + +export class ThumbnailDownloader { + private logger: Logger; + + private batchThumbnailToNodeUids = new Map(); + private ongoingDownloads = new Map>(); + private bufferedThumbnails: ( + | { nodeUid: string; ok: true; thumbnail: Uint8Array } + | { nodeUid: string; ok: false; error: string } + )[] = []; + + constructor( + telemetry: ProtonDriveTelemetry, + private nodesService: NodesService, + private apiService: DownloadAPIService, + private cryptoService: DownloadCryptoService, + ) { + this.logger = telemetry.getLogger('download'); + this.nodesService = nodesService; + this.apiService = apiService; + this.cryptoService = cryptoService; + } + + async *iterateThumbnails( + nodeUids: string[], + thumbnailType = ThumbnailType.Type1, + signal?: AbortSignal, + ): AsyncGenerator { + if (nodeUids.length === 0) { + return; + } + + for await (const result of this.iterateThumbnailUids(nodeUids, thumbnailType, signal)) { + if (!result.ok) { + yield result; + continue; + } + + this.batchThumbnailToNodeUids.set(result.thumbnailUid, result.nodeUid); + if (this.batchThumbnailToNodeUids.size >= MAX_DOWNLOAD_THUMBNAILS) { + await this.requestBatchedThumbnailDownloads(signal); + } + + while (this.ongoingDownloads.size >= MAX_DOWNLOAD_THUMBNAILS) { + await Promise.race(this.ongoingDownloads.values()); + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + } + + await this.requestBatchedThumbnailDownloads(signal); + + while (this.ongoingDownloads.size > 0) { + await Promise.race(this.ongoingDownloads.values()); + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + + yield* this.bufferedThumbnails; + this.bufferedThumbnails = []; + } + + private async *iterateThumbnailUids( + nodeUids: string[], + thumbnailType: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator< + { nodeUid: string; ok: true; thumbnailUid: string } | { nodeUid: string; ok: false; error: string } + > { + for await (const node of this.nodesService.iterateNodes(nodeUids, signal)) { + if ('missingUid' in node) { + yield { + nodeUid: node.missingUid, + ok: false, + error: c('Error').t`Node not found`, + }; + continue; + } + + let thumbnail; + if (node.activeRevision?.ok) { + thumbnail = node.activeRevision.value.thumbnails?.find((t) => t.type === thumbnailType); + } + if (!thumbnail) { + yield { + nodeUid: node.uid, + ok: false, + error: c('Error').t`Node has no thumbnail`, + }; + continue; + } + + yield { + nodeUid: node.uid, + ok: true, + thumbnailUid: thumbnail.uid, + }; + } + } + + private async requestBatchedThumbnailDownloads(signal?: AbortSignal) { + if (this.batchThumbnailToNodeUids.size === 0) { + return; + } + + this.logger.debug(`Downloading thumbnail batch of size ${this.batchThumbnailToNodeUids.size}`); + + for await (const downloadResult of this.iterateThumbnailDownloads(signal)) { + if (!downloadResult.ok) { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: false, + error: downloadResult.error, + }); + continue; + } + + this.ongoingDownloads.set( + downloadResult.nodeUid, + downloadResult.downloadPromise + .then((thumbnail) => { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: true, + thumbnail, + }); + }) + .catch((error) => { + this.bufferedThumbnails.push({ + nodeUid: downloadResult.nodeUid, + ok: false, + error: getErrorMessage(error), + }); + }) + .finally(() => { + this.ongoingDownloads.delete(downloadResult.nodeUid); + }), + ); + } + + this.batchThumbnailToNodeUids.clear(); + } + + private async *iterateThumbnailDownloads( + signal?: AbortSignal, + ): AsyncGenerator< + | { nodeUid: string; ok: true; downloadPromise: Promise> } + | { nodeUid: string; ok: false; error: string } + > { + const missingThumbnailUids = new Set(this.batchThumbnailToNodeUids.keys()); + + for await (const result of this.apiService.iterateThumbnails( + Array.from(this.batchThumbnailToNodeUids.keys()), + signal, + )) { + const nodeUid = this.batchThumbnailToNodeUids.get(result.uid); + if (!nodeUid) { + this.logger.warn(`Unexpected thumbnail UID ${result.uid} returned from API`); + continue; + } + + missingThumbnailUids.delete(result.uid); + + if (!result.ok) { + yield { + nodeUid, + ok: false, + error: result.error, + }; + continue; + } + + yield { + nodeUid, + ok: true, + downloadPromise: this.downloadThumbnail(nodeUid, result.bareUrl, result.token, signal), + }; + } + + for (const uid of missingThumbnailUids) { + const nodeUid = this.batchThumbnailToNodeUids.get(uid)!; + this.logger.warn(`Thumbnail UID ${uid} not found in API response`); + yield { + nodeUid, + ok: false, + error: c('Error').t`Thumbnail not found`, + }; + } + } + + private async downloadThumbnail( + nodeUid: string, + bareUrl: string, + token: string, + signal?: AbortSignal, + ): Promise> { + const logger = new LoggerWithPrefix(this.logger, `thumbnail ${token}`); + + let decryptedBlock: Uint8Array | null = null; + let attempt = 0; + + while (!decryptedBlock) { + logger.debug(`Downloading`); + attempt++; + + try { + const [nodeKeys, encryptedBlock] = await Promise.all([ + this.nodesService.getNodeKeys(nodeUid), + this.apiService.downloadBlock(bareUrl, token, undefined, signal), + ]); + + if (!nodeKeys.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`File has no content key`); + } + + logger.debug(`Decrypting`); + decryptedBlock = await this.cryptoService.decryptThumbnail( + encryptedBlock, + nodeKeys.contentKeyPacketSessionKey, + ); + } catch (error: unknown) { + if (attempt <= MAX_THUMBNAIL_DOWNLOAD_ATTEMPTS) { + logger.warn(`Thumbnail download failed #${attempt}, retrying: ${getErrorMessage(error)}`); + continue; + } + + logger.error(`Thumbnail download failed`, error); + throw error; + } + } + + return decryptedBlock; + } +} diff --git a/js/sdk/src/internal/errors.test.ts b/js/sdk/src/internal/errors.test.ts new file mode 100644 index 00000000..e0b164e8 --- /dev/null +++ b/js/sdk/src/internal/errors.test.ts @@ -0,0 +1,116 @@ +import { VERIFICATION_STATUS } from '../crypto'; +import { AbortError, ConnectionError, RateLimitedError, ValidationError } from '../errors'; +import { getVerificationMessage, isNotApplicationError } from './errors'; + +describe('getVerificationMessage', () => { + 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, errors, type, notAvailable, expected] of testCases) { + it(`returns correct message for status ${status} with type ${type} and notAvailable ${notAvailable}`, () => { + expect(getVerificationMessage(status, errors, type, notAvailable)).toBe(expected); + }); + } +}); + +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 new file mode 100644 index 00000000..01f05809 --- /dev/null +++ b/js/sdk/src/internal/errors.ts @@ -0,0 +1,123 @@ +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`; +} + +/** + * @param signatureType - Must be translated before calling this function. + */ +export function getVerificationMessage( + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + 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`; + } + + if (notAvailableVerificationKeys) { + return signatureType + ? c('Error').t`Verification keys for ${signatureType} are not available` + : 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`; +} + +/** + * 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; +} + +export function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if ( + 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, + )) + ) { + 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' + ); +} diff --git a/js/sdk/src/internal/events/apiService.ts b/js/sdk/src/internal/events/apiService.ts new file mode 100644 index 00000000..fd503f37 --- /dev/null +++ b/js/sdk/src/internal/events/apiService.ts @@ -0,0 +1,109 @@ +import { corePaths, DriveAPIService, drivePaths } from '../apiService'; +import { makeNodeUid } from '../uids'; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType, NodeEvent, NodeEventType } from './interface'; + +type GetCoreLatestEventResponse = + corePaths['/core/{_version}/events/latest']['get']['responses']['200']['content']['application/json']; +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 = + drivePaths['/drive/v2/volumes/{volumeID}/events/{eventID}']['get']['responses']['200']['content']['application/json']; + +interface VolumeEventTypeMap { + [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. + * + * 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) { + this.apiService = apiService; + } + + async getCoreLatestEventId(): Promise { + const result = await this.apiService.get(`core/v4/events/latest`); + return result.EventID as string; + } + + async getCoreEvents(eventId: string): Promise { + // TODO: Switch to v6 endpoint? + const result = await this.apiService.get(`core/v5/events/${eventId}`); + const driveEvents = EventsAPIService.getDriveEventsFromCoreEvent(result); + // in core/v5/events, refresh is always all apps, value 255 + const refresh = result.Refresh > 0; + return { + latestEventId: result.EventID, + more: result.More === 1, + refresh, + convertibleExternalInvitationLinkIds: [], + 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`, + ); + return result.EventID; + } + + 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, + 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 = { + nodeUid: makeNodeUid(volumeId, event.Link.LinkID), + parentNodeUid: event.Link.ParentLinkID ? makeNodeUid(volumeId, event.Link.ParentLinkID) : undefined, + }; + return { + type, + ...uids, + isTrashed: event.Link.IsTrashed, + isShared: event.Link.IsShared, + eventId: event.EventID, + treeEventScopeId: volumeId, + }; + }), + }; + } +} 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..b8644077 --- /dev/null +++ b/js/sdk/src/internal/events/coreEventManager.test.ts @@ -0,0 +1,97 @@ +import { getMockLogger } from '../../tests/logger'; +import { EventsAPIService } from './apiService'; +import { CoreEventManager } from './coreEventManager'; +import { DriveEvent, DriveEventsListWithStatus, DriveEventType } from './interface'; + +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 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, + convertibleExternalInvitationLinkIds: [], + events: [mockEvent1, mockEvent2], + }; + mockApiService.getCoreEvents.mockResolvedValue(mockEvents); + + 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, + convertibleExternalInvitationLinkIds: [], + 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 new file mode 100644 index 00000000..9afbeffe --- /dev/null +++ b/js/sdk/src/internal/events/coreEventManager.ts @@ -0,0 +1,47 @@ +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 + * core events. Core events are events that are not specific to any volume. + * At this moment, Drive listenes only to shares with me updates from core + * events. Such even indicates that user was invited to the new share or + * that user's membership was removed from existing one and lost access. + * + * The client might be already using own core events, thus this service + * is here only in case the client is not connected to the Proton services + * with own implementation. + */ +export class CoreEventManager implements EventManagerInterface { + constructor( + private logger: Logger, + private apiService: EventsAPIService, + ) { + this.apiService = apiService; + + this.logger = new LoggerWithPrefix(logger, `core`); + } + + async getLatestEventId(): Promise { + return await this.apiService.getCoreLatestEventId(); + } + + async *getEvents(eventId: string): AsyncIterable { + const events = await this.apiService.getCoreEvents(eventId); + if (events.events.length === 0 && events.latestEventId !== eventId) { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'core', + eventId: events.latestEventId, + }; + return; + } + yield* events.events; + } + + 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 new file mode 100644 index 00000000..c333bd23 --- /dev/null +++ b/js/sdk/src/internal/events/eventManager.test.ts @@ -0,0 +1,278 @@ +import { getMockLogger } from '../../tests/logger'; +import { EventManager } from './eventManager'; +import { DriveEvent, DriveEventType, EventSubscription, UnsubscribeFromEventsSourceError } from './interface'; + +jest.useFakeTimers(); + +const POLLING_INTERVAL = 1; + +describe('EventManager', () => { + let manager: EventManager; + + const listenerMock = jest.fn(); + const subscriptions: EventSubscription[] = []; + const mockEventManager = { + getLogger: () => getMockLogger(), + getLatestEventId: jest.fn(), + getEvents: jest.fn(), + }; + + beforeEach(() => { + manager = new EventManager(mockEventManager, POLLING_INTERVAL, null); + 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 start polling when started', async () => { + mockEventManager.getLatestEventId.mockResolvedValue('EventId1'); + + const mockEvents: DriveEvent[][] = [ + [ + { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId2', + }, + ], + [ + { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'EventId3', + }, + ], + ]; + + mockEventManager.getEvents + .mockImplementationOnce(async function* () { + yield* mockEvents[0]; + }) + .mockImplementationOnce(async function* () { + yield* mockEvents[1]; + }) + .mockImplementationOnce(async function* () {}); + + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(0); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(0); + + expect(await manager.start()).toBeUndefined(); + await jest.runOnlyPendingTimersAsync(); + + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(1); + expect(mockEventManager.getEvents).toHaveBeenCalledWith('EventId1'); + + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(2); + expect(mockEventManager.getEvents).toHaveBeenCalledWith('EventId2'); + }); + + it('should stop polling when stopped', async () => { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + mockEventManager.getEvents.mockImplementation(async function* () { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'eventId1', + }; + }); + + await manager.start(); + await jest.runOnlyPendingTimersAsync(); + + const callsBeforeStop = mockEventManager.getEvents.mock.calls.length; + await manager.stop(); + await jest.runOnlyPendingTimersAsync(); + + // Should not have made additional calls after stopping + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(callsBeforeStop); + }); + + it('should notify all listeners when getting events', async () => { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + + const mockEvents: DriveEvent[] = [ + { + type: DriveEventType.NodeCreated, + nodeUid: 'node1', + parentNodeUid: 'parent1', + isTrashed: false, + isShared: false, + treeEventScopeId: 'volume1', + eventId: 'eventId2', + }, + ]; + + mockEventManager.getEvents + .mockImplementationOnce(async function* () { + yield* mockEvents; + }) + .mockImplementation(async function* () {}); + + expect(await manager.start()).toBeUndefined(); + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); + }); + + it('should propagate unsubscription errors', async () => { + mockEventManager.getLatestEventId.mockImplementation(() => { + throw new UnsubscribeFromEventsSourceError('Not found'); + }); + + await expect(manager.start()).rejects.toThrow(UnsubscribeFromEventsSourceError); + + expect(mockEventManager.getLatestEventId).toHaveBeenCalledTimes(1); + expect(listenerMock).toHaveBeenCalledTimes(0); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(0); + }); + + it('should continue processing multiple events', async () => { + mockEventManager.getLatestEventId.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', + }, + ]; + + mockEventManager.getEvents + .mockImplementationOnce(async function* () { + yield* mockEvents; + }) + .mockImplementation(async function* () { + // Empty generator for subsequent calls + }); + + await manager.start(); + await jest.runOnlyPendingTimersAsync(); + + expect(listenerMock).toHaveBeenCalledTimes(2); + expect(listenerMock).toHaveBeenNthCalledWith(1, mockEvents[0]); + expect(listenerMock).toHaveBeenNthCalledWith(2, mockEvents[1]); + + mockEventManager.getEvents.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 () => { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + + let callCount = 0; + mockEventManager.getEvents.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(); + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(1); + expect(manager['retryIndex']).toEqual(1); + + await jest.runOnlyPendingTimersAsync(); + expect(mockEventManager.getEvents).toHaveBeenCalledTimes(2); + expect(manager['retryIndex']).toEqual(2); + + await jest.runOnlyPendingTimersAsync(); + expect(manager['retryIndex']).toEqual(3); + + expect(listenerMock).toHaveBeenCalledTimes(0); + + await jest.runOnlyPendingTimersAsync(); + expect(listenerMock).toHaveBeenCalledTimes(1); + // After success, retry index should reset + expect(manager['retryIndex']).toEqual(0); + }); + + it('should stop polling when stopped immediately', async () => { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + mockEventManager.getEvents.mockImplementation(async function* () { + yield { + type: DriveEventType.FastForward, + treeEventScopeId: 'volume1', + eventId: 'eventId1', + }; + }); + + expect(await manager.start()).toBeUndefined(); + 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(mockEventManager.getEvents).toHaveBeenCalledTimes(1); + }); + + it('should handle empty event streams', async () => { + mockEventManager.getLatestEventId.mockResolvedValue('eventId1'); + + mockEventManager.getEvents.mockImplementation(async function* () { + // Empty generator - no events + }); + + await manager.start(); + await jest.runOnlyPendingTimersAsync(); + + 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 new file mode 100644 index 00000000..5e54ff88 --- /dev/null +++ b/js/sdk/src/internal/events/eventManager.ts @@ -0,0 +1,134 @@ +import { Logger } from '../../interface'; +import { Event, EventManagerInterface, 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. + * + * 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. + */ +export class EventManager { + private logger: Logger; + private latestEventId?: string; + private timeoutHandle?: ReturnType; + private processPromise?: Promise; + private listeners: Listener[] = []; + private retryIndex: number = 0; + + constructor( + private specializedEventManager: EventManagerInterface, + private pollingIntervalInSeconds: number, + latestEventId: string | null, + ) { + if (latestEventId !== null) { + this.latestEventId = latestEventId; + } + this.logger = specializedEventManager.getLogger(); + } + + 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(); + } + + addListener(callback: Listener): EventSubscription { + this.listeners.push(callback); + return { + dispose: (): void => { + const index = this.listeners.indexOf(callback); + this.listeners.splice(index, 1); + }, + getLatestEventId: () => this.latestEventId ?? null, + }; + } + + 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 { + 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.scheduleNextPoll(); + } + + private scheduleNextPoll() { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + } + this.timeoutHandle = setTimeout(() => { + this.processPromise = this.processEvents(); + }, this.nextPollTimeout); + } + + /** + * Polling timeout is using exponential backoff with Fibonacci sequence. + */ + private get nextPollTimeout(): number { + const retryIndex = Math.min(this.retryIndex, FIBONACCI_LIST.length - 1); + // FIXME jitter + return this.pollingIntervalInSeconds * 1000 * FIBONACCI_LIST[retryIndex]; + } +} 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 new file mode 100644 index 00000000..9f020641 --- /dev/null +++ b/js/sdk/src/internal/events/index.test.ts @@ -0,0 +1,70 @@ +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { CoreApiEvent } from './apiService'; +import { DriveEventsService } from './index'; +import { DriveEvent, DriveEventType, InternalDriveEvent } from './interface'; + +describe('DriveEventsService', () => { + describe('processCoreEvent', () => { + function createService( + cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [], + ) { + const telemetry = getMockTelemetry(); + const apiService = {} as unknown as DriveAPIService; + const sharesService = { isOwnVolume: jest.fn(), getRootIDs: 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<(event: DriveEvent | InternalDriveEvent) => Promise> = + 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 new file mode 100644 index 00000000..2c75ea11 --- /dev/null +++ b/js/sdk/src/internal/events/index.ts @@ -0,0 +1,223 @@ +import { Logger, ProtonDriveTelemetry } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { CoreApiEvent, EventsAPIService } from './apiService'; +import { CoreEventManager } from './coreEventManager'; +import { EventManager } from './eventManager'; +import { EventScheduler } from './eventScheduler'; +import { + DriveEvent, + DriveEventType, + DriveListener, + EventSubscription, + InternalDriveEvent, + isInternalDriveEvent, + LatestEventIdProvider, + SharesService, +} from './interface'; +import { VolumeEventManager } from './volumeEventManager'; + +export type { CoreApiEvent } from './apiService'; +export type { EventScheduler } from './eventScheduler'; +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; +const OTHER_VOLUME_POLLING_INTERVAL = 60; +const CORE_POLLING_INTERVAL = 30; + +/** + * Service for listening to drive events. The service is responsible for + * managing the subscriptions to the events and notifying the listeners + * about the new events. + */ +export class DriveEventsService { + private apiService: EventsAPIService; + private coreEventManager?: EventManager; + private volumeEventManagers: { [volumeId: string]: EventManager }; + private logger: Logger; + + constructor( + private telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + private sharesService: SharesService, + private cacheEventListeners: ((event: DriveEvent | InternalDriveEvent) => Promise)[] = [], + private latestEventIdProvider?: LatestEventIdProvider, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('events'); + this.apiService = new EventsAPIService(apiService); + this.volumeEventManagers = {}; + } + + /** + * @deprecated Use `processCoreEvent` instead. + */ + async subscribeToCoreEvents(callback: DriveListener): Promise { + let manager = this.coreEventManager; + const started = !!manager; + + if (manager === undefined) { + manager = await this.createCoreEventManager(); + this.coreEventManager = manager; + } + + const eventSubscription = manager.addListener((event) => { + if (isInternalDriveEvent(event)) { + return Promise.resolve(); + } + return callback(event); + }); + if (!started) { + await manager.start(); + } + return eventSubscription; + } + + private async createCoreEventManager() { + if (!this.latestEventIdProvider) { + throw new Error( + 'Cannot subscribe to events without passing a latestEventIdProvider in ProtonDriveClient initialization', + ); + } + + const coreEventManager = new CoreEventManager(this.logger, this.apiService); + const latestEventId = await this.latestEventIdProvider.getLatestEventId('core'); + const eventManager = new EventManager( + coreEventManager, + CORE_POLLING_INTERVAL, + latestEventId, + ); + + for (const listener of this.cacheEventListeners) { + eventManager.addListener(listener); + } + + 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; + } + + /** + * 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; + } + for await (const event of volumeEventManager.getEvents(lastEventId, signal)) { + for (const listener of this.cacheEventListeners) { + await listener(event); + } + if (!isInternalDriveEvent(event)) { + yield event; + } + } + } + + /** + * 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; + let manager = this.volumeEventManagers[volumeId]; + const started = !!manager; + + if (manager === undefined) { + manager = await this.createVolumeEventManager(volumeId); + this.volumeEventManagers[volumeId] = manager; + } + + 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(); + } + return eventSubscription; + } + + private async createVolumeEventManager(volumeId: string): Promise> { + 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.sharesService.isOwnVolume(volumeId); + const pollingInterval = this.getDefaultVolumePollingInterval(isOwnVolume); + const latestEventId = await this.latestEventIdProvider.getLatestEventId(volumeId); + const eventManager = new EventManager( + volumeEventManager, + pollingInterval, + latestEventId, + ); + + for (const listener of this.cacheEventListeners) { + eventManager.addListener(listener); + } + + return eventManager; + } + + private getDefaultVolumePollingInterval(isOwnVolume: boolean): number { + return isOwnVolume ? OWN_VOLUME_POLLING_INTERVAL : OTHER_VOLUME_POLLING_INTERVAL; + } + + private sendNumberOfVolumeSubscriptionsToTelemetry() { + this.telemetry.recordMetric({ + eventName: 'volumeEventsSubscriptionsChanged', + numberOfVolumeSubscriptions: Object.keys(this.volumeEventManagers).length, + }); + } +} diff --git a/js/sdk/src/internal/events/interface.ts b/js/sdk/src/internal/events/interface.ts new file mode 100644 index 00000000..90f72a15 --- /dev/null +++ b/js/sdk/src/internal/events/interface.ts @@ -0,0 +1,154 @@ +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 = (event: DriveEvent) => Promise; + +export interface Event { + eventId: string; +} + +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 { + getLatestEventId(treeEventScopeId: string): Promise; +} + +/** + * Generic internal event interface representing a list of events + * with metadata about the last event ID, whether there are more + * events to fetch, or whether the listener should refresh its state. + */ +export type EventsListWithStatus = { + latestEventId: string; + more: boolean; + refresh: boolean; + events: T[]; +}; + +/** + * Internal event interface representing a list of specific Drive events. + */ +export type DriveEventsListWithStatus = EventsListWithStatus & { + convertibleExternalInvitationLinkIds: string[]; +}; + +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 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 + | 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', +} + +/** + * 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. + */ +export class UnsubscribeFromEventsSourceError extends Error {} + +export interface EventManagerInterface { + getLatestEventId(): Promise; + getEvents(eventId: string): AsyncIterable; + getLogger(): Logger; +} + +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 new file mode 100644 index 00000000..757605b4 --- /dev/null +++ b/js/sdk/src/internal/events/volumeEventManager.test.ts @@ -0,0 +1,250 @@ +import { getMockLogger } from '../../tests/logger'; +import { NotFoundAPIError } from '../apiService'; +import { EventsAPIService } from './apiService'; +import { DriveEventsListWithStatus, DriveEventType } from './interface'; +import { VolumeEventManager } from './volumeEventManager'; + +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, + convertibleExternalInvitationLinkIds: [], + 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', undefined); + }); + + it('should continue fetching when more events are available', async () => { + const firstResponse: DriveEventsListWithStatus = { + latestEventId: 'eventId2', + more: true, + refresh: false, + convertibleExternalInvitationLinkIds: [], + 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, + convertibleExternalInvitationLinkIds: [], + 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', + undefined, + ); + expect(mockEventsAPIService.getVolumeEvents).toHaveBeenNthCalledWith(2, volumeId, 'eventId2', undefined); + }); + + it('should yield TreeRefresh event when refresh is true', async () => { + const mockEventsResponse: DriveEventsListWithStatus = { + latestEventId: 'eventId789', + more: false, + refresh: true, + convertibleExternalInvitationLinkIds: [], + 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, + convertibleExternalInvitationLinkIds: [], + 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, + convertibleExternalInvitationLinkIds: [], + 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 new file mode 100644 index 00000000..54c985cb --- /dev/null +++ b/js/sdk/src/internal/events/volumeEventManager.ts @@ -0,0 +1,99 @@ +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'; + +/** + * 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, + ) { + this.apiService = apiService; + this.volumeId = volumeId; + this.logger = new LoggerWithPrefix(logger, `volume ${volumeId}`); + } + + getLogger(): Logger { + return this.logger; + } + + 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, + 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; + } + } + + 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/apiService.test.ts b/js/sdk/src/internal/nodes/apiService.test.ts new file mode 100644 index 00000000..be456277 --- /dev/null +++ b/js/sdk/src/internal/nodes/apiService.test.ts @@ -0,0 +1,865 @@ +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; +import { MemberRole, NodeType } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { DriveAPIService, ErrorCode, InvalidRequirementsAPIError } from '../apiService'; +import { groupNodeUidsByVolumeAndIteratePerBatch, NodeAPIService } from './apiService'; +import { NodeOutOfSyncError } from './errors'; + +function generateAPIFileNode(linkOverrides = {}, overrides = {}, fileOverrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 2, + ...linkOverrides, + }, + File: { + MediaType: 'text', + ContentKeyPacket: 'contentKeyPacket', + ContentKeyPacketSignature: 'contentKeyPacketSig', + TotalEncryptedSize: 42, + ActiveRevision: { + RevisionID: 'revisionId', + CreateTime: 1234567890, + SignatureEmail: 'revSigEmail', + XAttr: '{file}', + EncryptedSize: 12, + }, + ...fileOverrides, + }, + ...overrides, + }; +} + +function generateAPIFolderNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 1, + ...linkOverrides, + }, + Folder: { + XAttr: '{folder}', + NodeHashKey: 'nodeHashKey', + }, + ...overrides, + }; +} + +function generateAPIAlbumNode(linkOverrides = {}, overrides = {}) { + const node = generateAPINode(); + return { + Link: { + ...node.Link, + Type: 3, + ...linkOverrides, + }, + ...overrides, + }; +} + +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', + + OwnedBy: { + Email: 'ownerByEmail', + Organization: null, + }, + }, + SharingSummary: null, + }; +} + +function generateFileNode(overrides = {}, encryptedCryptoOverrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.File, + mediaType: 'text', + totalStorageSize: 42, + encryptedCrypto: { + ...node.encryptedCrypto, + file: { + base64ContentKeyPacket: 'contentKeyPacket', + armoredContentKeyPacketSignature: 'contentKeyPacketSig', + }, + activeRevision: { + uid: 'volumeId~linkId~revisionId', + state: 'active', + creationTime: new Date(1234567890000), + storageSize: 12, + signatureEmail: 'revSigEmail', + armoredExtendedAttributes: '{file}', + thumbnails: [], + }, + ...encryptedCryptoOverrides, + }, + ...overrides, + }; +} + +function generateFolderNode(overrides = {}, encryptedCryptoOverrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.Folder, + encryptedCrypto: { + ...node.encryptedCrypto, + folder: { + armoredHashKey: 'nodeHashKey', + armoredExtendedAttributes: '{folder}', + }, + ...encryptedCryptoOverrides, + }, + ...overrides, + }; +} + +function generateAlbumNode(overrides = {}) { + const node = generateNode(); + return { + ...node, + type: NodeType.Album, + ...overrides, + }; +} + +function generateNode() { + return { + hash: 'nameHash', + encryptedName: 'encName', + + uid: 'volumeId~linkId', + parentUid: 'volumeId~parentLinkId', + creationTime: new Date(123456789000), + modificationTime: new Date(1234567890000), + trashTime: undefined, + + shareId: undefined, + isShared: false, + isSharedPublicly: false, + directRole: MemberRole.Admin, + membership: undefined, + ownedBy: { + email: 'ownerByEmail', + organization: undefined, + }, + + encryptedCrypto: { + armoredKey: 'nodeKey', + armoredNodePassphrase: 'nodePass', + armoredNodePassphraseSignature: 'nodePassSig', + nameSignatureEmail: 'nameSigEmail', + signatureEmail: 'sigEmail', + membership: undefined, + }, + }; +} + +describe('nodeAPIService', () => { + let apiMock: DriveAPIService; + let api: NodeAPIService; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error Mocking for testing purposes + apiMock = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + }; + + api = new NodeAPIService(getMockLogger(), apiMock, 'clientUid'); + }); + + 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 + apiMock.post = jest.fn(async () => + Promise.resolve({ + Links: [mockedLink], + }), + ); + + const nodes = await Array.fromAsync(api.iterateNodes(['volumeId~nodeId'], ownVolumeId)); + expect(nodes).toStrictEqual(expectedNode ? [expectedNode] : []); + } + + it('should get folder node', async () => { + await testIterateNodes(generateAPIFolderNode(), generateFolderNode()); + }); + + it('should get root folder node', async () => { + await testIterateNodes( + generateAPIFolderNode({ ParentLinkID: null }), + generateFolderNode({ parentUid: undefined }), + ); + }); + + it('should get file node', async () => { + 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()); + }); + + it('should get shared node', async () => { + await testIterateNodes( + generateAPIFolderNode( + {}, + { + Sharing: { + ShareID: 'shareId', + }, + Membership: { + Permissions: 22, + InviteTime: 1234567890, + InviterEmail: 'inviterEmail', + MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + InviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + InviteeSharePassphraseSessionKeySignature: 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + generateFolderNode( + { + isShared: true, + isSharedPublicly: false, + shareId: 'shareId', + directRole: MemberRole.Admin, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + }, + }, + { + membership: { + inviterEmail: 'inviterEmail', + base64MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + armoredInviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + armoredInviteeSharePassphraseSessionKeySignature: + 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + ); + }); + + it('should get shared node with unknown permissions', async () => { + await testIterateNodes( + generateAPIFolderNode( + {}, + { + Sharing: { + ShareID: 'shareId', + }, + Membership: { + Permissions: 42, + InviteTime: 1234567890, + InviterEmail: 'inviterEmail', + MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + InviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + InviteeSharePassphraseSessionKeySignature: 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + generateFolderNode( + { + isShared: true, + isSharedPublicly: false, + shareId: 'shareId', + directRole: MemberRole.Viewer, + membership: { + role: MemberRole.Viewer, + inviteTime: new Date(1234567890000), + }, + }, + { + membership: { + inviterEmail: 'inviterEmail', + base64MemberSharePassphraseKeyPacket: 'memberSharePassphraseKeyPacket', + armoredInviterSharePassphraseKeyPacketSignature: 'inviterSharePassphraseKeyPacketSignature', + armoredInviteeSharePassphraseSessionKeySignature: + 'inviteeSharePassphraseSessionKeySignature', + }, + }, + ), + 'myVolumeId', + ); + }); + + 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({ + TrashTime: 123456, + }), + generateFileNode({ + 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(), + ], + }), + ); + + const generator = api.iterateNodes(['volumeId~nodeId'], 'volumeId'); + + const node1 = await generator.next(); + expect(node1.value).toStrictEqual(generateFolderNode()); + + // Second node is actually third, second is skipped and throwed at the end. + const node2 = await generator.next(); + expect(node2.value).toStrictEqual(generateFileNode()); + + const node3 = generator.next(); + await expect(node3).rejects.toThrow('Failed to load some nodes'); + try { + await node3; + } catch (error: any) { + expect(error.cause).toEqual([new Error('Unknown node type: 42'), new Error('Unknown node type: 43')]); + } + }); + + 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'), + ); + expect(nodes).toStrictEqual([ + generateFolderNode({ + uid: 'volumeId1~nodeId1', + parentUid: 'volumeId1~parentNodeId1', + directRole: MemberRole.Admin, + }), + generateFolderNode({ + uid: 'volumeId2~nodeId2', + parentUid: 'volumeId2~parentNodeId2', + directRole: MemberRole.Inherited, + }), + ]); + }); + + 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', () => { + 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', + }, + }, + ], + }), + ); + + 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' }, + ]); + }); + + 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', () => { + 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, + }, + }, + ], + }), + ); + + 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' }, + { uid: 'volumeId~nodeId3', ok: false, error: 'Unknown error 2000' }, + ]); + }); + + 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('deleteTrashedNodes', () => { + it('should delete trashed 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', + }, + }, + ], + }), + ); + + 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 trashed 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.deleteTrashedNodes(['volumeId1~nodeId1', 'volumeId2~nodeId2'])); + expect(result).toEqual([ + { uid: 'volumeId1~nodeId1', ok: true }, + { uid: 'volumeId2~nodeId2', ok: true }, + ]); + }); + }); + + 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( + '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')); + }); + }); +}); + +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 new file mode 100644 index 00000000..7c5fe1d9 --- /dev/null +++ b/js/sdk/src/internal/nodes/apiService.ts @@ -0,0 +1,884 @@ +import { c } from 'ttag'; + +import { NodeWithSameNameExistsValidationError, ProtonDriveError, ValidationError } from '../../errors'; +import { AnonymousUser, Logger, MemberRole, NodeResult, RevisionState } from '../../interface'; +import { + DriveAPIService, + drivePaths, + ErrorCode, + InvalidRequirementsAPIError, + isCodeOk, + nodeTypeNumberToNodeType, + permissionsToMemberRole, +} from '../apiService'; +import { asyncIteratorRace } from '../asyncIteratorRace'; +import { batch } from '../batch'; +import { makeNodeRevisionUid, makeNodeThumbnailUid, makeNodeUid, splitNodeRevisionUid, splitNodeUid } from '../uids'; +import { NodeOutOfSyncError } from './errors'; +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; + +// 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 } +>['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 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 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 } +>['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 PostDeleteTrashedNodesRequest = Extract< + drivePaths['/drive/v2/volumes/{volumeID}/trash/delete_multiple']['post']['requestBody'], + { content: object } +>['content']['application/json']; +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 } +>['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 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. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export abstract class NodeAPIServiceBase< + T extends EncryptedNode = EncryptedNode, + TMetadataResponseLink extends { Link: { LinkID: string } } = { Link: { LinkID: string } }, +> { + constructor( + 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 | undefined, signal?: AbortSignal): Promise { + 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`); + } + await nodesGenerator.return('finish'); + return result.value; + } + + async *iterateNodes( + nodeUids: string[], + ownVolumeId: string | undefined, + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { + const allNodeIds = nodeUids.map(splitNodeUid); + + const nodeIdsByVolumeId = new Map(); + for (const { volumeId, nodeId } of allNodeIds) { + if (!nodeIdsByVolumeId.has(volumeId)) { + nodeIdsByVolumeId.set(volumeId, []); + } + nodeIdsByVolumeId.get(volumeId)?.push(nodeId); + } + + // If the API returns node that is not recognised, it is returned as + // an error, but first all nodes that are recognised are yielded. + // Thus we capture all errors and throw them at the end of iteration. + const errors: 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, + filterOptions, + signal, + ); + if (errorsPerVolume.length) { + errors.push(...errorsPerVolume); + } + })(); + } + }; + + 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 }); + } + } + + protected async *iterateNodesPerVolume( + volumeId: string, + nodeIds: string[], + isOwnVolumeId: boolean, + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { + const errors: unknown[] = []; + + for (const nodeIdsBatch of batch(nodeIds, API_NODES_BATCH_SIZE)) { + const responseLinks = await this.fetchNodeMetadata(volumeId, nodeIdsBatch, signal); + + for (const link of responseLinks) { + try { + const encryptedNode = this.linkToEncryptedNode(volumeId, link, isOwnVolumeId); + if (!encryptedNode || (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); + } + } + } + + return errors; + } + + protected abstract fetchNodeMetadata( + volumeId: string, + linkIds: string[], + signal?: AbortSignal, + ): Promise; + + /** + * 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( + 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?${queryParams.toString()}`, + signal, + ); + for (const linkID of response.LinkIDs) { + yield makeNodeUid(volumeId, linkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } + + // Improvement requested: load next page sooner before all IDs are yielded. + async *iterateTrashedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { + let page = 0; + while (true) { + 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. + // The new API endpoint should not split per shares anymore and adopt + // the new pagination model with More/Anchor. For now, this is not + // the most efficient way, but should be with us only for a short time. + let hasItems = false; + + for (const linksPerShare of response.Trash) { + for (const linkId of linksPerShare.LinkIDs) { + yield makeNodeUid(volumeId, linkId); + hasItems = true; + } + } + + if (!hasItems) { + break; + } + page++; + } + } + + async renameNode( + nodeUid: string, + originalNode: { + hash?: string; + }, + newNode: { + encryptedName: string; + nameSignatureEmail: string | AnonymousUser; + hash?: string; + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + + 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( + nodeUid: string, + oldNode: { + hash: string; + }, + newNode: { + parentUid: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; + signatureEmail?: string | AnonymousUser; + encryptedName: string; + nameSignatureEmail?: string | AnonymousUser; + hash: string; + contentHash?: string; + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { nodeId: newParentNodeId } = splitNodeUid(newNode.parentUid); + + 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( + nodeUid: string, + newNode: { + parentUid: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; + signatureEmail?: string | AnonymousUser; + encryptedName: string; + nameSignatureEmail?: string | AnonymousUser; + hash: string; + }, + signal?: AbortSignal, + ): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { volumeId: parentVolumeId, nodeId: parentNodeId } = splitNodeUid(newNode.parentUid); + + 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); + } + + async *trashNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + 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(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } + } + + 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( + `drive/v2/volumes/${volumeId}/trash/restore_multiple`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); + + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } + } + + 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`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); + + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } + } + + 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}/remove-mine`, + { + LinkIDs: batchNodeIds, + }, + signal, + ); + + // TODO: remove `as` when backend fixes OpenAPI schema. + yield* handleResponseErrors(batchNodeUids, volumeId, response.Responses as LinkResponse[]); + } + } + + async createFolder( + parentUid: string, + newNode: { + armoredKey: string; + armoredHashKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string | AnonymousUser; + encryptedName: string; + hash: string; + armoredExtendedAttributes?: string; + }, + ): Promise { + const { volumeId, nodeId: parentId } = splitNodeUid(parentUid); + + 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) { + handleNodeWithSameNameExistsValidationError(volumeId, error); + throw error; + } + + return makeNodeUid(volumeId, response.Folder.ID); + } + + async getRevision(nodeRevisionUid: string, signal?: AbortSignal): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}?NoBlockUrls=true`, + signal, + ); + return transformRevisionResponse(volumeId, nodeId, response.Revision); + } + + async getRevisions(nodeUid: string, signal?: AbortSignal): Promise { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/files/${nodeId}/revisions`, + signal, + ); + return response.Revisions.filter( + (revision) => revision.State === APIRevisionState.Active || revision.State === APIRevisionState.Obsolete, + ).map((revision) => transformRevisionResponse(volumeId, nodeId, revision)); + } + + async restoreRevision(nodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid); + + 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}`, + ); + } + + 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, + })), + }; + } +} + +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 | undefined { + return linkToEncryptedNode(this.logger, volumeId, link, isOwnVolumeId); + } +} + +type LinkResponse = { + LinkID: string; + Response: { + Code?: number; + Error?: string; + }; +}; + +function* handleResponseErrors( + nodeUids: string[], + volumeId: string, + responses: LinkResponse[] = [], +): Generator { + const errors = new Map(); + + 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}`); + } + }); + + for (const uid of nodeUids) { + const error = errors.get(uid); + if (error) { + yield { uid, ok: false, error }; + } else { + yield { uid, ok: true }; + } + } +} + +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); + } + } +} + +export function linkToEncryptedNode( + logger: Logger, + volumeId: string, + link: Pick, + isAdmin: boolean, +): EncryptedNode | undefined { + const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( + logger, + volumeId, + link, + isAdmin, + ); + + if (link.Link.Type === 1 && link.Folder) { + return { + ...baseNodeMetadata, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + armoredExtendedAttributes: link.Folder.XAttr || undefined, + armoredHashKey: link.Folder.NodeHashKey as string, + }, + }, + }; + } + + if (link.Link.Type === 2 && link.File) { + if (!link.File.ActiveRevision) { + logger.warn(`Requested draft file node, skipping from the result`); + return undefined; + } + + return { + ...baseNodeMetadata, + totalStorageSize: link.File.TotalEncryptedSize, + mediaType: link.File.MediaType || undefined, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + file: { + base64ContentKeyPacket: link.File.ContentKeyPacket, + armoredContentKeyPacketSignature: link.File.ContentKeyPacketSignature || undefined, + }, + activeRevision: { + uid: makeNodeRevisionUid(volumeId, link.Link.LinkID, link.File.ActiveRevision.RevisionID), + state: RevisionState.Active, + 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), + ) || [], + }, + }, + }; + } + + // 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, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + }, + }; + } + + 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, + ownedBy: { + email: link.Link.OwnedBy?.Email || undefined, + organization: link.Link.OwnedBy?.Organization || 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[] }> { + 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, + revision: GetRevisionResponse['Revision'] | GetRevisionsResponse['Revisions'][0], +): EncryptedRevision { + 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, + armoredExtendedAttributes: revision.XAttr || undefined, + thumbnails: revision.Thumbnails?.map((thumbnail) => transformThumbnail(volumeId, nodeId, thumbnail)) || [], + sha1Verified: revision.ChecksumVerified, + }; +} + +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 new file mode 100644 index 00000000..b5640c71 --- /dev/null +++ b/js/sdk/src/internal/nodes/cache.test.ts @@ -0,0 +1,301 @@ +import { MemoryCache } from '../../cache'; +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'; + +function generateNode( + uid: string, + parentUid = 'root', + params: Partial & { volumeId?: string } = {}, +): DecryptedNode { + return { + uid: `${params.volumeId || 'volumeId'}~:${uid}`, + parentUid: `${params.volumeId || 'volumeId'}~:${parentUid}`, + directRole: MemberRole.Admin, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(), + sharedBy: resultOk('test@example.com'), + }, + type: NodeType.File, + mediaType: 'text', + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: new Date(), + trashTime: undefined, + volumeId: 'volumeId', + isStale: false, + activeRevision: undefined, + folder: undefined, + ...params, + } as DecryptedNode; +} + +async function generateTreeStructure(cache: NodesCache) { + for (const node of [ + generateNode('node1', 'root'), + generateNode('node1a', 'node1'), + generateNode('node1b', 'node1', { trashTime: new Date() }), + generateNode('node1c', 'node1'), + generateNode('node1c-alpha', 'node1c'), + generateNode('node1c-beta', 'node1c', { trashTime: new Date() }), + + generateNode('node2', 'root'), + generateNode('node2a', 'node2'), + generateNode('node2b', 'node2', { trashTime: new Date() }), + + generateNode('node3', 'root'), + + generateNode('root-otherVolume', '', { volumeId: 'volume2' }), + ]) { + await cache.setNode(node); + } +} + +async function verifyNodesCache(cache: NodesCache, expectedNodes: string[], expectedMissingNodes: string[]) { + for (const nodeUid of expectedNodes) { + try { + await cache.getNode(`volumeId~:${nodeUid}`); + } catch (error) { + throw new Error(`${nodeUid} should be in the cache: ${error}`); + } + } + + for (const nodeUid of expectedMissingNodes) { + try { + await cache.getNode(`volumeId~:${nodeUid}`); + throw new Error(`${nodeUid} should not be in the cache`); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + } +} + +describe('nodesCache', () => { + let memoryCache: MemoryCache; + let cache: NodesCache; + + beforeEach(async () => { + memoryCache = new MemoryCache(); + await memoryCache.setEntity('node-volumeId~:root', JSON.stringify(generateNode('root', ''))); + await memoryCache.setEntity('node-badObject', 'aaa', [`${CACHE_TAG_KEYS.ParentUid}:root`]); + + cache = new NodesCache(getMockLogger(), memoryCache); + }); + + it('should store and retrieve node', async () => { + const node = generateNode('node1', ''); + + await cache.setNode(node); + const result = await cache.getNode(node.uid); + + expect(result).toStrictEqual(node); + }); + + it('should 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'), + claimedModificationTime: new Date('2021-02-01'), + claimedSize: 100, + claimedDigests: { + sha1: 'hash', + sha1Verified: true, + }, + 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 }); + + 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'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a corrupted node and remove the node from the cache', async () => { + try { + await cache.getNode('badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe( + 'Error: Failed to deserialise node: Unexpected token \'a\', \"aaa\" is not valid JSON', + ); + } + + try { + await memoryCache.getEntity('nodes-badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should remove node without children', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['volumeId~:node3']); + await verifyNodesCache( + cache, + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node2', 'node2a', 'node2b'], + ['node3'], + ); + }); + + it('should remove node and its children', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['volumeId~:node2']); + await verifyNodesCache( + cache, + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta', 'node3'], + ['node2', 'node2a', 'node2b'], + ); + }); + + it('should remove node and its children recursively', async () => { + await generateTreeStructure(cache); + await cache.removeNodes(['volumeId~:node1']); + await verifyNodesCache( + cache, + ['node2', 'node2a', 'node2b', 'node3'], + ['node1', 'node1a', 'node1b', 'node1c', 'node1c-alpha', 'node1c-beta'], + ); + }); + + it('should iterate requested nodes', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateNodes(['volumeId~:node1', 'volumeId~:node2'])); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['volumeId~:node1', 'volumeId~:node2']); + }); + + it('should iterate children without trashed items', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateChildren('volumeId~:node1')); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['volumeId~:node1a', 'volumeId~:node1c']); + }); + + it('should iterate children and silently remove a corrupted node', async () => { + await generateTreeStructure(cache); + // badObject has root as parent. + const result = await Array.fromAsync(cache.iterateChildren('volumeId~:root')); + const nodeUids = result.map(({ uid }) => uid); + 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', + ], + ['badObject'], + ); + }); + + it('should iterate trashed nodes', async () => { + await generateTreeStructure(cache); + const result = await Array.fromAsync(cache.iterateTrashedNodes()); + const nodeUids = result.map(({ uid }) => uid); + expect(nodeUids).toStrictEqual(['volumeId~:node1b', 'volumeId~:node1c-beta', 'volumeId~:node2b']); + }); + + it('should set and unset children loaded state', async () => { + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(false); + + await cache.setFolderChildrenLoaded('volumeId~:node1'); + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(true); + + await cache.resetFolderChildrenLoaded('volumeId~:node1'); + expect(await cache.isFolderChildrenLoaded('volumeId~:node1')).toBe(false); + }); + + it('should set nodes from the volume as stale', async () => { + await generateTreeStructure(cache); + await cache.setNodesStaleFromVolume('volumeId'); + + const staleNodeUids = [ + 'node1', + 'node1a', + 'node1b', + 'node1c', + 'node1c-alpha', + 'node1c-beta', + 'node2', + 'node2a', + 'node2b', + 'node3', + ].map((uid) => `volumeId~:${uid}`); + const result = await Array.fromAsync(cache.iterateNodes([...staleNodeUids, 'volume2~:root-otherVolume'])); + const got = result.map((item) => ({ uid: item.uid, isStale: item.ok ? item.node.isStale : item.error })); + const expected = [ + ...staleNodeUids.map((uid) => ({ uid, isStale: true })), + { uid: 'volume2~:root-otherVolume', isStale: false }, + ]; + expect(got).toEqual(expected); + }); +}); diff --git a/js/sdk/src/internal/nodes/cache.ts b/js/sdk/src/internal/nodes/cache.ts new file mode 100644 index 00000000..50ec7127 --- /dev/null +++ b/js/sdk/src/internal/nodes/cache.ts @@ -0,0 +1,333 @@ +import { EntityResult } from '../../cache'; +import { Logger, ProtonDriveEntitiesCache, Result, resultOk } from '../../interface'; +import { splitNodeUid } from '../uids'; +import { DecryptedNode, DecryptedRevision } from './interface'; + +export enum CACHE_TAG_KEYS { + ParentUid = 'nodeParentUid', + Trashed = 'nodeTrashed', + Roots = 'nodeRoot', +} + +type DecryptedNodeResult = + | { uid: string; ok: true; node: T } + | { uid: string; ok: false; error: string }; + +/** + * 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 abstract class NodesCacheBase { + constructor( + private logger: Logger, + private driveCache: ProtonDriveEntitiesCache, + ) { + this.logger = logger; + this.driveCache = driveCache; + } + + async setNode(node: T): Promise { + const key = getCacheUid(node.uid); + const nodeData = this.serialiseNode(node); + const { volumeId } = splitNodeUid(node.uid); + + 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}`); + } + + await this.driveCache.setEntity(key, nodeData, tags); + } + + async getNode(nodeUid: string): Promise { + const key = getCacheUid(nodeUid); + const nodeData = await this.driveCache.getEntity(key); + try { + 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}`, { + cause: error, + }); + } + } + + 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 + * which nodes were up-to-date anymore. + */ + async setNodesStaleFromVolume(volumeId: string): Promise { + for await (const result of this.driveCache.iterateEntitiesByTag(`volume:${volumeId}`)) { + const node = await this.convertCacheResult(result); + if (node && node.ok) { + node.node.isStale = true; + await this.setNode(node.node); + } + } + + // Force all calls to children UIDs to be re-fetched. + for await (const result of this.driveCache.iterateEntitiesByTag(`children-volume:${volumeId}`)) { + await this.driveCache.removeEntities([result.key]); + } + } + + /** + * 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 + * 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 { + this.logger.error(`Removing corrupted nodes from the cache`, corruptionError); + try { + if (nodeUid) { + await this.removeNodes([nodeUid]); + } else if (cacheUid) { + await this.driveCache.removeEntities([cacheUid]); + } + } catch (removingError: unknown) { + // The node will not be returned, thus SDK will re-fetch + // and re-cache it. Setting it again should then fix the + // problem. + this.logger.warn( + `Failed to remove corrupted node from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); + } + } + + async removeNodes(nodeUids: string[]): Promise { + const cacheUids = nodeUids.map(getCacheUid); + await this.driveCache.removeEntities(cacheUids); + for (const nodeUid of nodeUids) { + try { + const childrenCacheUids = await this.getRecursiveChildrenCacheUids(nodeUid); + // Reverse the order to remove children first. + // Crucial to not leave any children without parent + // if removing nodes fails. + childrenCacheUids.reverse(); + await this.driveCache.removeEntities(childrenCacheUids); + } catch (error: unknown) { + this.logger.error(`Failed to remove children from the cache`, error); + } + } + } + + private async getRecursiveChildrenCacheUids(parentNodeUid: string): Promise { + const cacheUids = []; + 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); + } + return cacheUids; + } + + 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); + if (node) { + yield node; + } + } + } + + async *iterateChildren(parentNodeUid: string): AsyncGenerator> { + for await (const result of this.driveCache.iterateEntitiesByTag( + `${CACHE_TAG_KEYS.ParentUid}:${parentNodeUid}`, + )) { + const node = await this.convertCacheResult(result); + if (node && (!node.ok || !node.node.trashTime)) { + yield node; + } + } + } + + 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); + if (node) { + yield node; + } + } + } + + /** + * 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 | null> { + let nodeUid; + try { + nodeUid = getNodeUid(result.key); + } catch (error: unknown) { + await this.removeCorruptedNode({ cacheUid: result.key }, error); + return null; + } + if (result.ok) { + let node; + try { + node = this.deserialiseNode(result.value); + } catch (error: unknown) { + await this.removeCorruptedNode({ nodeUid }, error); + return null; + } + return { + uid: nodeUid, + ok: true, + node, + }; + } else { + return { + ...result, + uid: nodeUid, + }; + } + } + + async setFolderChildrenLoaded(nodeUid: string): Promise { + const { volumeId } = splitNodeUid(nodeUid); + await this.driveCache.setEntity(`node-children-${nodeUid}`, 'loaded', [`children-volume:${volumeId}`]); + } + + async resetFolderChildrenLoaded(nodeUid: string): Promise { + await this.driveCache.removeEntities([`node-children-${nodeUid}`]); + } + + async isFolderChildrenLoaded(nodeUid: string): Promise { + try { + await this.driveCache.getEntity(`node-children-${nodeUid}`); + return true; + } catch { + return false; + } + } +} + +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}`; +} + +function getNodeUid(cacheUid: string) { + if (!cacheUid.startsWith('node-')) { + throw new Error(`Unexpected cached node uid "${cacheUid}"`); + } + return cacheUid.substring(5); +} + +export function serialiseNode(node: DecryptedNode) { + return JSON.stringify(node); +} + +// TODO: use better deserialisation with validation +export function deserialiseNode(nodeData: string): DecryptedNode { + const node = JSON.parse(nodeData); + if ( + !node || + typeof node !== 'object' || + !node.uid || + typeof node.uid !== '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) || + 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) + ) { + throw new Error(`Invalid node data: ${nodeData}`); + } + 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 + ? { + ...node.membership, + inviteTime: new Date(node.membership.inviteTime), + } + : 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), + claimedModificationTime: revision.value.claimedModificationTime + ? new Date(revision.value.claimedModificationTime) + : undefined, + }); + } + + return revision; +} diff --git a/js/sdk/src/internal/nodes/cryptoCache.test.ts b/js/sdk/src/internal/nodes/cryptoCache.test.ts new file mode 100644 index 00000000..e391c066 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoCache.test.ts @@ -0,0 +1,109 @@ +import { MemoryCache } from '../../cache'; +import { PrivateKey, SessionKey } from '../../crypto'; +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; + }; + + const generateSessionKey = (name: string) => { + return name as unknown as SessionKey; + }; + + beforeEach(async () => { + memoryCache = new MemoryCache(); + await memoryCache.setEntity('nodeKeys-missingProperties', {} as any); + + cache = new NodesCryptoCache(getMockLogger(), memoryCache); + }); + + it('should store and retrieve keys', async () => { + const nodeId = 'newNodeId'; + const keys = { + passphrase: 'pass', + key: generatePrivateKey('privateKey'), + passphraseSessionKey: generateSessionKey('sessionKey'), + hashKey: undefined, + }; + + await cache.setNodeKeys(nodeId, keys); + const result = await cache.getNodeKeys(nodeId); + + expect(result).toStrictEqual(keys); + }); + + it('should replace and retrieve new keys', async () => { + const nodeId = 'newNodeId'; + const keys1 = { + passphrase: 'pass', + key: generatePrivateKey('privateKey1'), + 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); + const result = await cache.getNodeKeys(nodeId); + + expect(result).toStrictEqual(keys2); + }); + + it('should remove keys', async () => { + const nodeId = 'newNodeId'; + const keys = { + passphrase: 'pass', + key: generatePrivateKey('privateKey'), + passphraseSessionKey: generateSessionKey('sessionKey'), + hashKey: undefined, + }; + + await cache.setNodeKeys(nodeId, keys); + await cache.removeNodeKeys([nodeId]); + + try { + await cache.getNodeKeys(nodeId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const nodeId = 'newNodeId'; + + try { + await cache.getNodeKeys(nodeId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad keys and remove the key', async () => { + try { + await cache.getNodeKeys('missingProperties'); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Failed to deserialize node keys'); + } + + try { + 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 new file mode 100644 index 00000000..18c00f01 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoCache.ts @@ -0,0 +1,51 @@ +import { Logger, ProtonDriveCryptoCache } 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, + ) { + this.logger = logger; + this.driveCache = driveCache; + } + + async setNodeKeys(nodeUid: string, keys: DecryptedNodeKeys): Promise { + const cacheUid = getCacheKey(nodeUid); + await this.driveCache.setEntity(cacheUid, { + nodeKeys: keys, + }); + } + + async getNodeKeys(nodeUid: string): Promise { + const nodeKeysData = await this.driveCache.getEntity(getCacheKey(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 node keys from the cache: ${removingError instanceof Error ? removingError.message : removingError}`, + ); + } + throw new Error(`Failed to deserialize node keys`); + } + return nodeKeysData.nodeKeys; + } + + async removeNodeKeys(nodeUids: string[]): Promise { + const cacheUids = nodeUids.map(getCacheKey); + await this.driveCache.removeEntities(cacheUids); + } +} + +function getCacheKey(nodeUid: string) { + return `nodeKeys-${nodeUid}`; +} diff --git a/js/sdk/src/internal/nodes/cryptoReporter.ts b/js/sdk/src/internal/nodes/cryptoReporter.ts new file mode 100644 index 00000000..c5ab1712 --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoReporter.ts @@ -0,0 +1,148 @@ +import { VERIFICATION_STATUS } from '../../crypto'; +import { + AnonymousUser, + Author, + Logger, + MetricsDecryptionErrorField, + MetricVerificationErrorField, + MetricVolumeType, + ProtonDriveTelemetry, + resultError, + resultOk, +} from '../../interface'; +import { getVerificationMessage, isNotApplicationError } 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 | AnonymousUser, + 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 | AnonymousUser, + ) { + if (this.reportedVerificationErrors.has(node.uid)) { + return; + } + this.reportedVerificationErrors.add(node.uid); + + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); + + let addressMatchingDefaultShare, + volumeType = MetricVolumeType.Unknown; + 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 (isNotApplicationError(error)) { + return; + } + + if (this.reportedDecryptionErrors.has(node.uid)) { + return; + } + + const fromBefore2024 = node.creationTime < new Date('2024-01-01'); + + let volumeType = MetricVolumeType.Unknown; + 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 | AnonymousUser, + 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 new file mode 100644 index 00000000..a00f714d --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoService.test.ts @@ -0,0 +1,1610 @@ +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, + DecryptedUnparsedNode, + EncryptedNode, + NodeSigningKeys, + SharesService, +} from './interface'; + +describe('nodesCryptoService', () => { + let telemetry: ProtonDriveTelemetry; + let driveCrypto: DriveCrypto; + let account: ProtonDriveAccount; + let sharesService: SharesService; + + let cryptoService: NodesCryptoService; + + const publicAddressKey = { _idx: 21312 } as PublicKey; + const ownPrivateAddressKey = { id: 'id', key: 'key' as unknown as PrivateKey }; + + beforeEach(() => { + jest.clearAllMocks(); + + 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', + }), + ), + // @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, + }), + ), + }; + // @ts-expect-error No need to implement all methods for mocking + account = { + 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 = { + getRootIDs: jest.fn(async () => ({ + volumeId: 'volumeId', + rootNodeId: 'rootNodeId', + })), + getMyFilesShareMemberEmailKey: jest.fn(async () => ({ + email: 'email', + addressKey: 'key' as unknown as PrivateKey, + })), + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), + }; + + const nodesCryptoReporter = new NodesCryptoReporter(telemetry, sharesService); + cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, nodesCryptoReporter); + }); + + const parentKey = 'parentKey' as unknown as PrivateKey; + + function verifyLogEventVerificationError(options = {}) { + 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.recordMetric).toHaveBeenCalledTimes(1); + expect(telemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: 'own_volume', + fromBefore2024: false, + uid: 'volumeId~nodeId', + ...options, + }); + } + + describe('folder node', () => { + let encryptedNode: EncryptedNode; + + beforeEach(() => { + encryptedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + }, + 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 }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, + folder: { + extendedAttributes: '{}', + }, + membership: { + role: MemberRole.Admin, + inviteTime: new Date(1234567890000), + sharedBy: { ok: true, value: 'inviterEmail' }, + }, + activeRevision: undefined, + errors: undefined, + ...expectedNode, + }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: new Uint8Array(), + ...expectedKeys, + }, + }), + }); + } + + describe('should decrypt successfuly', () => { + it('same author everywhere', async () => { + encryptedNode.encryptedCrypto.nameSignatureEmail = 'signatureEmail'; + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'signatureEmail' }, + }); + + 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(3); // signatureEmail, nameSignatureEmail, inviterEmail + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('inviterEmail'); + expect(telemetry.recordMetric).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, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + error: 'verification error', + }); + }); + + it('on node name', async () => { + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { + ok: false, + error: { + claimedAuthor: 'nameSignatureEmail', + error: 'Signature verification for name failed: verification error', + }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + error: 'verification error', + }); + }); + + 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({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Signature verification for hash key failed: verification error', + }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeHashKey', + error: 'verification error', + }); + }); + + 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({ + passphrase: 'pass', + 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 () => + Promise.resolve({ + hashKey: new Uint8Array(), + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + error: 'verification error', + }); + }); + + it('on folder extended attributes', async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => + Promise.resolve({ + extendedAttributes: '{}', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + 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', + }); + }); + }); + + describe('should decrypt with decryption issues', () => { + it('on node key', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt node key: Decryption error', + }, + }, + errors: [new Error('Decryption error')], + folder: undefined, + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeKey', + error, + }); + }); + + it('on node name', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + name: { ok: false, error }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Decryption error' }, + }, + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeName', + error, + }); + }); + + it('on hash key', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptNodeHashKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + errors: [error], + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeHashKey', + error, + }); + }); + + it('on folder extended attributes', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + folder: undefined, + errors: [error], + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeExtendedAttributes', + 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 () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error('Failed to load keys')); + + const result = cryptoService.decryptNode(encryptedNode, parentKey); + await expect(result).rejects.toThrow('Failed to load keys'); + }); + }); + + describe('file node', () => { + const encryptedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', + creationTime: new Date('2026-01-01'), + encryptedCrypto: { + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'nameSignatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + file: { + base64ContentKeyPacket: 'base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature', + }, + activeRevision: { + uid: 'revisionUid', + state: 'active', + signatureEmail: 'revisionSignatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', + }, + }, + } as EncryptedNode; + + function verifyResult( + result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, + folder: undefined, + activeRevision: { + ok: true, + value: { + uid: 'revisionUid', + state: RevisionState.Active, + creationTime: undefined, + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'revisionSignatureEmail' }, + }, + }, + errors: undefined, + ...expectedNode, + }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: undefined, + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + ...expectedKeys, + }, + }), + }); + } + + describe('should decrypt successfuly', () => { + it('same author everywhere', async () => { + const encryptedNode = { + encryptedCrypto: { + signatureEmail: 'signatureEmail', + nameSignatureEmail: 'signatureEmail', + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + file: { + base64ContentKeyPacket: 'base64ContentKeyPacket', + }, + activeRevision: { + uid: 'revisionUid', + state: 'active', + signatureEmail: 'signatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', + }, + }, + } as EncryptedNode; + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'signatureEmail' }, + activeRevision: { + ok: true, + value: { + uid: 'revisionUid', + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + creationTime: undefined, + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'signatureEmail' }, + }, + }, + }); + + expect(account.getPublicKeys).toHaveBeenCalledTimes(2); // node + revision + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + 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(3); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('nameSignatureEmail'); + expect(account.getPublicKeys).toHaveBeenCalledWith('revisionSignatureEmail'); + expect(telemetry.recordMetric).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, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { claimedAuthor: 'signatureEmail', error: 'Missing signature for key' }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeKey', + error: 'verification error', + }); + }); + + it('on node name', async () => { + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + nameAuthor: { + ok: false, + error: { + claimedAuthor: 'nameSignatureEmail', + error: 'Signature verification for name failed: verification error', + }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + error: 'verification error', + }); + }); + + it('on folder extended attributes', async () => { + driveCrypto.decryptExtendedAttributes = jest.fn(async () => + Promise.resolve({ + extendedAttributes: '{}', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + verificationErrors: [new Error('verification error')], + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + activeRevision: { + ok: true, + value: { + uid: 'revisionUid', + extendedAttributes: '{}', + state: RevisionState.Active, + // @ts-expect-error Ignore mocked data. + creationTime: undefined, + contentAuthor: { + ok: false, + error: { + claimedAuthor: 'revisionSignatureEmail', + error: 'Signature verification for attributes failed: verification error', + }, + }, + }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeExtendedAttributes', + error: 'verification error', + }); + }); + + it('on content key packet without fallback verification', 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, + creationTime: new Date('2026-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', + }); + }); + + 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() + .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(account.getOwnAddresses).toHaveBeenCalled(); + 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', () => { + it('on node key', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt node key: Decryption error', + }, + }, + activeRevision: { ok: false, error: new Error('Failed to decrypt node key: Decryption error') }, + errors: [new Error('Decryption error')], + folder: undefined, + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeKey', + error, + }); + }); + + it('on node name', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptNodeName = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + name: { ok: false, error }, + nameAuthor: { + ok: false, + error: { claimedAuthor: 'nameSignatureEmail', error: 'Decryption error' }, + }, + }, + 'noKeys', + ); + verifyLogEventDecryptionError({ + field: 'nodeName', + error, + }); + }); + + it('on file extended attributes', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptExtendedAttributes = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + activeRevision: { + ok: false, + error: new Error('Failed to decrypt active revision: Decryption error'), + }, + }); + verifyLogEventDecryptionError({ + field: 'nodeExtendedAttributes', + error, + }); + }); + + it('on content key packet', async () => { + const error = new Error('Decryption error'); + driveCrypto.decryptAndVerifySessionKey = jest.fn(async () => Promise.reject(error)); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult( + result, + { + keyAuthor: { + ok: false, + error: { + claimedAuthor: 'signatureEmail', + error: 'Failed to decrypt content key: Decryption error', + }, + }, + errors: [error], + }, + { + contentKeyPacketSessionKey: undefined, + }, + ); + verifyLogEventDecryptionError({ + field: 'nodeContentKey', + error, + }); + }); + }); + + it('should fail when keys cannot be loaded', async () => { + account.getPublicKeys = jest.fn().mockRejectedValue(new Error('Failed to load keys')); + + const result = cryptoService.decryptNode(encryptedNode, parentKey); + await expect(result).rejects.toThrow('Failed to load keys'); + }); + }); + + describe('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.recordMetric).not.toHaveBeenCalled(); + }); + }); + + describe('anonymous node', () => { + const encryptedNode = { + uid: 'volumeId~nodeId', + parentUid: 'volumeId~parentId', + encryptedCrypto: { + signatureEmail: undefined, + nameSignatureEmail: undefined, + armoredKey: 'armoredKey', + armoredNodePassphrase: 'armoredNodePassphrase', + armoredNodePassphraseSignature: 'armoredNodePassphraseSignature', + file: { + base64ContentKeyPacket: 'base64ContentKeyPacket', + }, + activeRevision: { + uid: 'revisionUid', + state: 'active', + signatureEmail: 'revisionSignatureEmail', + armoredExtendedAttributes: 'encryptedExtendedAttributes', + }, + }, + } as EncryptedNode; + + const encryptedNodeWithoutParent = { + ...encryptedNode, + parentUid: undefined, + }; + + function verifyResult( + result: { node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }, + expectedNode: Partial = {}, + expectedKeys: Partial | 'noKeys' = {}, + ) { + expect(result).toMatchObject({ + node: { + name: { ok: true, value: 'name' }, + keyAuthor: { ok: true, value: 'signatureEmail' }, + nameAuthor: { ok: true, value: 'nameSignatureEmail' }, + folder: undefined, + activeRevision: { + ok: true, + value: { + uid: 'revisionUid', + state: RevisionState.Active, + creationTime: undefined, + extendedAttributes: '{}', + contentAuthor: { ok: true, value: 'revisionSignatureEmail' }, + }, + }, + errors: undefined, + ...expectedNode, + }, + ...(expectedKeys === 'noKeys' + ? {} + : { + keys: { + passphrase: 'pass', + key: 'decryptedKey', + passphraseSessionKey: 'passphraseSessionKey', + hashKey: undefined, + contentKeyPacketSessionKey: 'contentKeyPacketSessionKey', + ...expectedKeys, + }, + }), + }); + } + + describe('should decrypt with verification issues', () => { + it('on node key and name with access to parent node', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + + const result = await cryptoService.decryptNode(encryptedNode, parentKey); + verifyResult(result, { + keyAuthor: { + ok: false, + error: { claimedAuthor: undefined, error: 'Signature verification for key failed' }, + }, + nameAuthor: { + ok: false, + error: { claimedAuthor: undefined, error: 'Signature verification for name failed' }, + }, + }); + verifyLogEventVerificationError({ + field: 'nodeName', + addressMatchingDefaultShare: undefined, + }); + expect(driveCrypto.decryptKey).toHaveBeenCalledWith( + encryptedNode.encryptedCrypto.armoredKey, + encryptedNode.encryptedCrypto.armoredNodePassphrase, + encryptedNode.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + [parentKey], + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith(encryptedNode.encryptedName, parentKey, [ + parentKey, + ]); + }); + + it('on anonymous node key and name without access to parent node', async () => { + driveCrypto.decryptKey = jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + passphraseSessionKey: 'passphraseSessionKey' as unknown as SessionKey, + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + driveCrypto.decryptNodeName = jest.fn(async () => + Promise.resolve({ + name: 'name', + verified: VERIFICATION_STATUS.SIGNED_AND_INVALID, + }), + ); + + const result = await cryptoService.decryptNode(encryptedNodeWithoutParent, parentKey); + verifyResult(result, { + keyAuthor: { ok: true, value: null }, + nameAuthor: { ok: true, value: null }, + }); + expect(telemetry.recordMetric).not.toHaveBeenCalled(); + expect(driveCrypto.decryptKey).toHaveBeenCalledWith( + encryptedNode.encryptedCrypto.armoredKey, + encryptedNode.encryptedCrypto.armoredNodePassphrase, + encryptedNode.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + [], + ); + expect(driveCrypto.decryptNodeName).toHaveBeenCalledWith(encryptedNode.encryptedName, parentKey, []); + }); + }); + }); + + 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', () => { + let node: DecryptedNode; + let keys: any; + let parentKeys: any; + + beforeEach(() => { + node = { + name: { ok: true, value: 'testFile.txt' }, + } as DecryptedNode; + keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + parentKeys = { + key: 'newParentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({ + armoredNodeName: 'encryptedNodeName', + }); + driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash'); + driveCrypto.encryptPassphrase = jest.fn().mockResolvedValue({ + 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.name, + keys as any, + parentKeys, + signingKeys, + ); + + expect(result).toEqual({ + encryptedName: 'encryptedNodeName', + hash: 'newHash', + armoredNodePassphrase: 'encryptedPassphrase', + armoredNodePassphraseSignature: 'passphraseSignature', + signatureEmail: 'test@example.com', + nameSignatureEmail: 'test@example.com', + }); + + expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith( + 'testFile.txt', + keys.nameSessionKey, + parentKeys.key, + 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.name, + 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], + signingKeys.nodeKey, + ); + }); + + it('should throw error when moving to non-folder', async () => { + const node = { + name: { ok: true, value: 'testFile.txt' }, + } as DecryptedNode; + const keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + const parentKeys = { + key: 'newParentKey' as any, + hashKey: undefined, + } as any; + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + await expect( + cryptoService.encryptNodeWithNewParent(node.name, keys as any, parentKeys, signingKeys), + ).rejects.toThrow('Moving item to a non-folder is not allowed'); + }); + + it('should throw error when node has invalid name', async () => { + const node = { + name: { ok: false, error: 'Invalid name' }, + } as any; + const keys = { + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + nameSessionKey: 'nameSessionKey' as any, + }; + const parentKeys = { + key: 'newParentKey' as any, + hashKey: new Uint8Array([1, 2, 3]), + }; + const signingKeys: NodeSigningKeys = { + type: 'userAddress', + email: 'test@example.com', + addressId: 'addressId', + key: 'addressKey' as any, + }; + + await expect( + 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 new file mode 100644 index 00000000..69c36e9a --- /dev/null +++ b/js/sdk/src/internal/nodes/cryptoService.ts @@ -0,0 +1,782 @@ +import { c } from 'ttag'; + +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { ValidationError } from '../../errors'; +import { + AnonymousUser, + Author, + Logger, + Membership, + MetricsDecryptionErrorField, + MetricVerificationErrorField, + ProtonDriveAccount, + ProtonDriveTelemetry, + Result, + resultError, + resultOk, +} from '../../interface'; +import { getErrorMessage } from '../errors'; +import { splitNodeUid } from '../uids'; +import { + DecryptedNode, + DecryptedNodeKeys, + DecryptedUnparsedNode, + DecryptedUnparsedRevision, + EncryptedNode, + EncryptedNodeFileCrypto, + EncryptedNodeFolderCrypto, + EncryptedRevision, + NodeSigningKeys, + SharesService, +} from './interface'; + +export interface NodesCryptoReporter { + handleClaimedAuthor( + node: NodesCryptoReporterNode, + field: MetricVerificationErrorField, + signatureType: string, + verified: VERIFICATION_STATUS, + verificationErrors?: Error[], + claimedAuthor?: string | AnonymousUser, + 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. + * + * The node crypto service is responsible for decrypting and encrypting node + * metadata. It should export high-level actions only, such as "decrypt node" + * instead of low-level operations like "decrypt node key". Low-level operations + * should be kept private to the module. + * + * The service owns the logic to switch between old and new crypto model. + */ +export class NodesCryptoService { + private logger: Logger; + + protected allowContentKeyPacketFallbackVerification = true; + + constructor( + telemetry: ProtonDriveTelemetry, + protected driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + private sharesService: Pick, + private reporter: NodesCryptoReporter, + ) { + this.logger = telemetry.getLogger('nodes-crypto'); + this.driveCrypto = driveCrypto; + this.account = account; + this.reporter = reporter; + } + + async decryptNode( + node: EncryptedNode, + parentKey: PrivateKey, + ): Promise<{ node: DecryptedUnparsedNode; keys?: DecryptedNodeKeys }> { + const start = Date.now(); + + const commonNodeMetadata = { + ...node, + encryptedCrypto: undefined, + }; + + const signatureEmailKeys = node.encryptedCrypto.signatureEmail + ? await this.account.getPublicKeys(node.encryptedCrypto.signatureEmail) + : []; + + // Parent key is node or share key. Anonymous files are signed with + // the node parent key. If the anonymous file is shared directly, + // there is no access to the parent key. In that case, the verification + // is skipped. + const nodeParentKeys = node.parentUid ? [parentKey] : []; + + // Anonymous uploads (without signature email set) use parent key instead. + const keyVerificationKeys = node.encryptedCrypto.signatureEmail ? signatureEmailKeys : nodeParentKeys; + + let nameVerificationKeys; + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; + if (nameSignatureEmail === node.encryptedCrypto.signatureEmail) { + nameVerificationKeys = keyVerificationKeys; + } else { + nameVerificationKeys = nameSignatureEmail + ? await this.account.getPublicKeys(nameSignatureEmail) + : nodeParentKeys; + } + + // 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 keyPromise; + passphrase = keyResult.passphrase; + key = keyResult.key; + passphraseSessionKey = keyResult.passphraseSessionKey; + keyAuthor = keyResult.author; + } catch (error: unknown) { + 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; + const membership = await membershipPromise; + return { + node: { + ...commonNodeMetadata, + name, + keyAuthor: resultError({ + claimedAuthor: getClaimedAuthor( + node.encryptedCrypto.signatureEmail, + keyVerificationKeys.length === 0, + ), + error: errorMessage, + }), + nameAuthor, + membership, + activeRevision: + 'file' in node.encryptedCrypto + ? resultError(new Error(errorMessage, { cause: error })) + : undefined, + folder: undefined, + errors: [error], + }, + }; + } + + const errors = []; + + let hashKey; + let hashKeyAuthor; + 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 hashKeyPromise; + hashKey = hashKeyResult.hashKey; + hashKeyAuthor = hashKeyResult.author; + } catch (error: unknown) { + void this.reporter.reportDecryptionError(node, 'nodeHashKey', error); + errors.push(error); + } + + try { + const extendedAttributesResult = await folderExtendedAttributesPromise; + folder = { + extendedAttributes: extendedAttributesResult.extendedAttributes, + }; + folderExtendedAttributesAuthor = extendedAttributesResult.author; + } catch (error: unknown) { + void this.reporter.reportDecryptionError(node, 'nodeExtendedAttributes', error); + errors.push(error); + } + } + + let activeRevision: Result | undefined; + let contentKeyPacketSessionKey; + let contentKeyPacketAuthor; + let contentKeyPacket: Uint8Array | undefined; + if ('file' in node.encryptedCrypto) { + 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), + ]; + + try { + activeRevision = resultOk(await activeRevisionPromise); + } catch (error: unknown) { + 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, { cause: error })); + } + + try { + const keySessionKeyResult = await contentKeyPacketSessionKeyPromise; + contentKeyPacketSessionKey = keySessionKeyResult.sessionKey; + contentKeyPacketAuthor = + keySessionKeyResult.verified !== undefined && + (await this.reporter.handleClaimedAuthor( + node, + 'nodeContentKey', + c('Property').t`content key`, + keySessionKeyResult.verified, + keySessionKeyResult.verificationErrors, + node.encryptedCrypto.signatureEmail, + )); + } catch (error: unknown) { + void this.reporter.reportDecryptionError(node, 'nodeContentKey', error); + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt content key: ${message}`; + contentKeyPacketAuthor = resultError({ + claimedAuthor: node.encryptedCrypto.signatureEmail, + error: errorMessage, + }); + errors.push(error); + } + } + + // If key signature verificaiton failed, prefer returning error from + // the key directly. If key signature is ok but not hash or folder + // extended attributes, return that error instead. Only if all the + // signatures using the same signature email are ok, return OK. + let finalKeyAuthor; + if (!keyAuthor.ok) { + finalKeyAuthor = keyAuthor; + } + if (!finalKeyAuthor && contentKeyPacketAuthor && !contentKeyPacketAuthor.ok) { + finalKeyAuthor = contentKeyPacketAuthor; + } + if (!finalKeyAuthor && hashKeyAuthor && !hashKeyAuthor.ok) { + finalKeyAuthor = hashKeyAuthor; + } + if (!finalKeyAuthor && folderExtendedAttributesAuthor && !folderExtendedAttributesAuthor.ok) { + finalKeyAuthor = folderExtendedAttributesAuthor; + } + if (!finalKeyAuthor) { + 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, + name, + keyAuthor: finalKeyAuthor, + nameAuthor, + membership, + activeRevision, + folder, + errors: errors.length ? errors : undefined, + }, + keys: { + passphrase, + key, + passphraseSessionKey, + contentKeyPacket, + contentKeyPacketSessionKey, + hashKey, + }, + }; + } + + 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, + node.encryptedCrypto.armoredNodePassphraseSignature, + [parentKey], + verificationKeys, + ); + + return { + passphrase: key.passphrase, + key: key.key, + passphraseSessionKey: key.passphraseSessionKey, + author: await this.reporter.handleClaimedAuthor( + node, + 'nodeKey', + c('Property').t`key`, + key.verified, + key.verificationErrors, + node.encryptedCrypto.signatureEmail, + verificationKeys.length === 0, + ), + }; + } + + private async decryptName( + node: EncryptedNode, + parentKey: PrivateKey, + verificationKeys: PublicKey[], + ): Promise<{ + name: Result; + author: Author; + }> { + const nameSignatureEmail = node.encryptedCrypto.nameSignatureEmail; + + try { + 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), + author: await this.reporter.handleClaimedAuthor( + node, + 'nodeName', + c('Property').t`name`, + verified, + verificationErrors, + nameSignatureEmail, + verificationKeys.length === 0, + ), + }; + } catch (error: unknown) { + void this.reporter.reportDecryptionError(node, 'nodeName', error); + const errorMessage = getErrorMessage(error); + return { + name: resultError(new Error(errorMessage, { cause: error })), + author: resultError({ + claimedAuthor: getClaimedAuthor(nameSignatureEmail, verificationKeys.length === 0), + error: errorMessage, + }), + }; + } + } + + async getNameSessionKey(node: { encryptedName: string }, parentKey: PrivateKey): Promise { + 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, + { armored: node.encryptedCrypto.membership.armoredInviterSharePassphraseKeyPacketSignature }, + inviterEmailKeys || [], + ); + + sharedBy = await this.reporter.handleClaimedAuthor( + node, + 'membershipInviter', + c('Property').t`membership`, + verified, + verificationErrors, + node.encryptedCrypto.membership.inviterEmail, + ); + } catch (error: unknown) { + void this.reporter.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, + addressKeys: PublicKey[], + ): Promise<{ + hashKey: Uint8Array; + author: Author; + }> { + if (!('folder' in node.encryptedCrypto)) { + // This is developer error. + throw new Error('Node is not a folder'); + } + + 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, + author: await this.reporter.handleClaimedAuthor( + node, + 'nodeHashKey', + c('Property').t`hash key`, + verified, + verificationErrors, + node.encryptedCrypto.signatureEmail, + ), + }; + } + + 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( + { uid: nodeUid, creationTime: encryptedRevision.creationTime }, + encryptedRevision.armoredExtendedAttributes, + nodeKey, + verificationKeys, + encryptedRevision.signatureEmail, + ); + + return { + uid: encryptedRevision.uid, + state: encryptedRevision.state, + creationTime: encryptedRevision.creationTime, + storageSize: encryptedRevision.storageSize, + contentAuthor, + extendedAttributes, + thumbnails: encryptedRevision.thumbnails, + sha1Verified: encryptedRevision.sha1Verified, + }; + } + + 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; + } + + if (!this.allowContentKeyPacketFallbackVerification) { + 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)); + + 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, + nodeKey: PrivateKey, + addressKeys: PublicKey[], + signatureEmail?: string | AnonymousUser, + ): Promise<{ + extendedAttributes?: string; + author: Author; + }> { + if (!encryptedExtendedAttributes) { + return { + author: resultOk(signatureEmail) as Author, + }; + } + + const { extendedAttributes, verified, verificationErrors } = await this.driveCrypto.decryptExtendedAttributes( + encryptedExtendedAttributes, + nodeKey, + addressKeys, + ); + + return { + extendedAttributes, + author: await this.reporter.handleClaimedAuthor( + node, + 'nodeExtendedAttributes', + c('Property').t`attributes`, + verified, + verificationErrors, + signatureEmail, + ), + }; + } + + async createFolder( + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + signingKeys: NodeSigningKeys, + name: string, + extendedAttributes?: string, + ): Promise<{ + encryptedCrypto: Omit & { + signatureEmail: string | AnonymousUser; + nameSignatureEmail: string | AnonymousUser; + armoredNodePassphraseSignature: string; + encryptedName: string; + hash: string; + }; + keys: DecryptedNodeKeys; + }> { + 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], 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, + extendedAttributesSigningKey, + ) + : { armoredExtendedAttributes: undefined }; + + return { + encryptedCrypto: { + encryptedName: armoredNodeName, + hash, + armoredKey: nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: nodeKeys.encrypted.armoredPassphraseSignature, + folder: { + armoredExtendedAttributes: armoredExtendedAttributes, + armoredHashKey, + }, + signatureEmail: email, + nameSignatureEmail: email, + }, + keys: { + passphrase: nodeKeys.decrypted.passphrase, + key: nodeKeys.decrypted.key, + passphraseSessionKey: nodeKeys.decrypted.passphraseSessionKey, + hashKey, + }, + }; + } + + async encryptNewName( + parentKeys: { key: PrivateKey; hashKey?: Uint8Array }, + nodeNameSessionKey: SessionKey, + signingKeys: NodeSigningKeys, + newName: string, + ): Promise<{ + signatureEmail: string | AnonymousUser; + armoredNodeName: string; + hash?: string; + }> { + 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, + nameSigningKey, + ); + + const hash = parentKeys.hashKey + ? await this.driveCrypto.generateLookupHash(newName, parentKeys.hashKey) + : undefined; + return { + signatureEmail: email, + armoredNodeName, + hash, + }; + } + + async encryptNodeWithNewParent( + nodeName: DecryptedNode['name'], + keys: { passphrase: string; passphraseSessionKey: SessionKey; nameSessionKey: SessionKey }, + parentKeys: { key: PrivateKey; hashKey: Uint8Array }, + signingKeys: NodeSigningKeys, + ): Promise<{ + encryptedName: string; + hash: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature: string; + signatureEmail: string | AnonymousUser; + nameSignatureEmail: string | AnonymousUser; + }> { + if (!parentKeys.hashKey) { + throw new ValidationError('Moving item to a non-folder is not allowed'); + } + if (!nodeName.ok) { + throw new ValidationError('Cannot move item without a valid name, please rename the item first'); + } + + 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( + nodeName.value, + keys.nameSessionKey, + parentKeys.key, + nameAndPassprhaseSigningKey, + ); + const hash = await this.driveCrypto.generateLookupHash(nodeName.value, parentKeys.hashKey); + const { armoredPassphrase, armoredPassphraseSignature } = await this.driveCrypto.encryptPassphrase( + keys.passphrase, + keys.passphraseSessionKey, + [parentKeys.key], + nameAndPassprhaseSigningKey, + ); + + return { + encryptedName: armoredNodeName, + hash, + armoredNodePassphrase: armoredPassphrase, + armoredNodePassphraseSignature: armoredPassphraseSignature, + signatureEmail: email, + 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( + claimedAuthor?: string | AnonymousUser, + notAvailableVerificationKeys = false, +): string | AnonymousUser | undefined { + if (!claimedAuthor && notAvailableVerificationKeys) { + return null as AnonymousUser; + } + + return claimedAuthor; +} 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..a68671b1 --- /dev/null +++ b/js/sdk/src/internal/nodes/debouncer.test.ts @@ -0,0 +1,141 @@ +import { ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { NodesDebouncer } from './debouncer'; + +describe('NodesDebouncer', () => { + let debouncer: NodesDebouncer; + let mockTelemetry: ReturnType; + + beforeEach(() => { + mockTelemetry = getMockTelemetry(); + debouncer = new NodesDebouncer(mockTelemetry); + + 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(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 () => { + const nodeUid = 'test-node-1'; + debouncer.loadingNode(nodeUid); + + jest.advanceTimersByTime(6000); + expect(mockTelemetry.mockLogger.warn).toHaveBeenCalledWith(`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(mockTelemetry.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(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 new file mode 100644 index 00000000..75cf6287 --- /dev/null +++ b/js/sdk/src/internal/nodes/debouncer.ts @@ -0,0 +1,109 @@ +import { Logger, ProtonDriveTelemetry } from '../../interface'; + +/** + * 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; + +/** + * 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. + * + * 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 logger: Logger; + + private promises: Map< + string, + { + promise: Promise; + resolve: () => void; + timeout: NodeJS.Timeout; + } + > = new Map(); + + constructor(private telemetry: ProtonDriveTelemetry) { + this.logger = telemetry.getLogger('nodes-debouncer'); + this.telemetry = telemetry; + } + + 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; + } + + const metricTimeout = setTimeout(() => { + this.telemetry.recordMetric({ + eventName: 'debounceLongWait', + }); + }, DEBOUNCE_LONG_WAIT_TIMEOUT); + + this.logger.debug(`Wait for: ${nodeUid}`); + await result.promise; + + clearTimeout(metricTimeout); + } + + clear() { + for (const result of this.promises.values()) { + clearTimeout(result.timeout); + result.resolve(); + } + this.promises.clear(); + } +} 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/events.test.ts b/js/sdk/src/internal/nodes/events.test.ts new file mode 100644 index 00000000..de4bd9e3 --- /dev/null +++ b/js/sdk/src/internal/nodes/events.test.ts @@ -0,0 +1,87 @@ +import { getMockLogger } from '../../tests/logger'; +import { DriveEvent, DriveEventType } from '../events'; +import { NodesCache } from './cache'; +import { NodesEventsHandler } from './events'; +import { DecryptedNode } from './interface'; + +describe('NodesEventsHandler', () => { + const logger = getMockLogger(); + let cache: NodesCache; + let nodesEventsNodesEventsHandler: NodesEventsHandler; + + beforeEach(() => { + jest.clearAllMocks(); + + // @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), + ), + setNode: jest.fn(), + removeNodes: jest.fn(), + resetFolderChildrenLoaded: jest.fn(), + }; + nodesEventsNodesEventsHandler = new NodesEventsHandler(logger, cache); + }); + + 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, + treeEventScopeId: 'volume1', + }; + await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); + + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledTimes(1); + expect(cache.resetFolderChildrenLoaded).toHaveBeenCalledWith('parentUid'); + expect(cache.setNode).toHaveBeenCalledTimes(0); + }); + + 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', + isTrashed: false, + isShared: false, + 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, + }), + ); + }); + + it('should remove node from cache', async () => { + const event: DriveEvent = { + type: DriveEventType.NodeDeleted, + eventId: 'event1', + nodeUid: 'nodeUid123', + parentNodeUid: 'parentUid', + treeEventScopeId: 'volume1', + }; + + await nodesEventsNodesEventsHandler.updateNodesCacheOnEvent(event); + + expect(cache.removeNodes).toHaveBeenCalledTimes(1); + expect(cache.removeNodes).toHaveBeenCalledWith([event.nodeUid]); + }); +}); diff --git a/js/sdk/src/internal/nodes/events.ts b/js/sdk/src/internal/nodes/events.ts new file mode 100644 index 00000000..71c9db15 --- /dev/null +++ b/js/sdk/src/internal/nodes/events.ts @@ -0,0 +1,64 @@ +import { Logger } from '../../interface'; +import { DriveEvent, DriveEventType, InternalDriveEvent } from '../events'; +import { NodesCacheBase } from './cache'; + +/** + * Provides internal event handling. + * + * The service is responsible for handling events regarding node metadata + * from the DriveEventsService. + */ +export class NodesEventsHandler { + constructor( + private logger: Logger, + private cache: NodesCacheBase, + ) {} + + async updateNodesCacheOnEvent(event: DriveEvent | InternalDriveEvent): Promise { + try { + if (event.type === DriveEventType.TreeRefresh) { + await this.cache.setNodesStaleFromVolume(event.treeEventScopeId); + return; + } + if (event.type === DriveEventType.TreeRemove) { + await this.cache.removeVolume(event.treeEventScopeId); + return; + } + 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; + } + if (event.type === DriveEventType.NodeUpdated) { + let node; + try { + node = await this.cache.getNode(event.nodeUid); + } catch { + return; + } + 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); + } + } catch (error: unknown) { + this.logger.error(`Failed to update node cache for event: ${event.eventId}`, error); + } + } +} diff --git a/js/sdk/src/internal/nodes/extendedAttributes.test.ts b/js/sdk/src/internal/nodes/extendedAttributes.test.ts new file mode 100644 index 00000000..ecdc361b --- /dev/null +++ b/js/sdk/src/internal/nodes/extendedAttributes.test.ts @@ -0,0 +1,256 @@ +import { getMockLogger } from '../../tests/logger'; +import { + FileExtendedAttributesParsed, + FolderExtendedAttributes, + generateFileExtendedAttributes, + generateFolderExtendedAttributes, + parseFileExtendedAttributes, + parseFolderExtendedAttributes, +} from './extendedAttributes'; + +describe('extended attrbiutes', () => { + describe('should generate folder attributes', () => { + const testCases: [Date | undefined, string | undefined][] = [ + [undefined, undefined], + [new Date(1234567890000), '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z"}}'], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should generate ${input}`, () => { + const output = generateFolderExtendedAttributes(input); + expect(output).toBe(expectedAttributes); + }); + }); + }); + + describe('should parse folder attributes', () => { + const testCases: [string, FolderExtendedAttributes][] = [ + ['', {}], + ['{}', {}], + ['a', {}], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000"}}', + { + claimedModificationTime: new Date(1234567890000), + }, + ], + ['{"Common": {"ModificationTime": "aa"}}', {}], + [ + '{"Common": {"ModificationTime": "2009-02-13T23:31:30+0000", "Size": 123}}', + { + claimedModificationTime: new Date(1234567890000), + }, + ], + ['{"Common": {"Whatever": 123}}', {}], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should parse ${input}`, () => { + const output = parseFolderExtendedAttributes(getMockLogger(), input); + expect(output).toMatchObject(expectedAttributes); + }); + }); + }); + + describe('should generate file attributes without additional metadata', () => { + const testCases: [any, string | undefined][] = [ + [ + { 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"}}}', + ], + [ + { + modificationTime: new Date(1234567890000), + size: 1234, + blockSizes: [4, 4, 4, 2], + digests: { sha1: 'abcdef' }, + }, + '{"Common":{"ModificationTime":"2009-02-13T23:31:30.000Z","Size":1234,"BlockSizes":[4,4,4,2],"Digests":{"SHA1":"abcdef"}}}', + ], + ]; + testCases.forEach(([input, expectedAttributes]) => { + it(`should generate ${JSON.stringify(input)}`, () => { + const output = generateFileExtendedAttributes(input); + expect(output).toBe(expectedAttributes); + }); + }); + }); + + describe('should generate file attributes with additional metadata', () => { + 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, blockSizes: [], digests: { sha1: 'abcdef' } }, + { 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'), '', {}], + [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, + }, + ], + [ + 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, + claimedSize: undefined, + claimedDigests: undefined, + 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(([creationTime, input, expectedAttributes]) => { + it(`should parse ${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 new file mode 100644 index 00000000..13ea24ba --- /dev/null +++ b/js/sdk/src/internal/nodes/extendedAttributes.ts @@ -0,0 +1,220 @@ +import { Logger } from '../../interface'; + +interface FolderExtendedAttributesSchema { + Common?: { + ModificationTime?: string; + }; +} + +interface FileExtendedAttributesSchema { + Common?: { + ModificationTime?: string; + Size?: number; + BlockSizes?: number[]; + Digests?: { + SHA1: string; + }; + }; + Location?: { + Latitude?: number; + Longitude?: number; + }; + Camera?: { + CaptureTime?: string; + Device?: string; + Orientation?: number; + SubjectCoordinates?: { + Top?: number; + Left?: number; + Bottom?: number; + Right?: number; + }; + }; + Media?: { + Width?: number; + Height?: number; + Duration?: number; + }; +} + +export interface FolderExtendedAttributes { + claimedModificationTime?: Date; +} + +export interface FileExtendedAttributesParsed { + claimedSize?: number; + claimedModificationTime?: Date; + claimedDigests?: { + sha1?: string; + }; + claimedAdditionalMetadata?: object; + claimedBlockSizes?: number[]; +} + +export function generateFolderExtendedAttributes(claimedModificationTime?: Date): string | undefined { + if (!claimedModificationTime) { + return undefined; + } + return JSON.stringify({ + Common: { + ModificationTime: dateToIsoString(claimedModificationTime), + }, + }); +} + +function dateToIsoString(date: Date) { + const isDateValid = !Number.isNaN(date.getTime()); + return isDateValid ? date.toISOString() : undefined; +} + +export function parseFolderExtendedAttributes(logger: Logger, extendedAttributes?: string): FolderExtendedAttributes { + if (!extendedAttributes) { + return {}; + } + + try { + const parsed = JSON.parse(extendedAttributes) as FolderExtendedAttributesSchema; + return { + claimedModificationTime: parseModificationTime(logger, parsed), + }; + } catch (error: unknown) { + logger.error(`Failed to parse extended attributes`, error); + return {}; + } +} + +export function generateFileExtendedAttributes( + common: { + modificationTime?: Date; + size: number; + blockSizes: number[]; + digests: { + sha1: string; + }; + }, + additionalMetadata?: object, +): string { + if (additionalMetadata && 'Common' in additionalMetadata) { + throw new Error('Common attributes are not allowed in additional metadata'); + } + + const commonAttributes: FileExtendedAttributesSchema['Common'] = {}; + if (common.modificationTime) { + commonAttributes.ModificationTime = dateToIsoString(common.modificationTime); + } + 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 } : {}), + }); +} + +export function parseFileExtendedAttributes( + logger: Logger, + creationTime: Date, + extendedAttributes?: string, +): FileExtendedAttributesParsed { + if (!extendedAttributes) { + return {}; + } + + try { + const parsed = JSON.parse(extendedAttributes) as FolderExtendedAttributesSchema; + + const claimedAdditionalMetadata = { ...parsed }; + delete claimedAdditionalMetadata.Common; + + return { + claimedSize: parseSize(logger, parsed), + claimedModificationTime: parseModificationTime(logger, parsed), + claimedDigests: parseDigests(logger, parsed), + claimedAdditionalMetadata: Object.keys(claimedAdditionalMetadata).length + ? claimedAdditionalMetadata + : undefined, + claimedBlockSizes: parseBlockSizes(logger, creationTime, parsed), + }; + } catch (error: unknown) { + logger.error(`Failed to parse extended attributes`, error); + return {}; + } +} + +function parseSize(logger: Logger, xattr?: FileExtendedAttributesSchema): number | undefined { + const size = xattr?.Common?.Size; + if (size === undefined) { + return undefined; + } + if (typeof size !== 'number') { + logger.warn(`XAttr file size "${size}" is not valid`); + return undefined; + } + return size; +} + +function parseModificationTime( + logger: Logger, + xattr?: FolderExtendedAttributesSchema | FolderExtendedAttributesSchema, +): Date | undefined { + const modificationTime = xattr?.Common?.ModificationTime; + if (modificationTime === undefined) { + return undefined; + } + const modificationDate = new Date(modificationTime); + // This is the best way to check if date is "Invalid Date". :shrug: + if (JSON.stringify(modificationDate) === 'null') { + logger.warn(`XAttr modification time "${modificationTime}" is not valid`); + return undefined; + } + return modificationDate; +} + +function parseDigests(logger: Logger, xattr?: FileExtendedAttributesSchema): { sha1: string } | undefined { + const digests = xattr?.Common?.Digests; + if (digests === undefined || digests.SHA1 === undefined) { + return undefined; + } + + const sha1 = digests.SHA1; + if (typeof sha1 !== 'string') { + logger.warn(`XAttr digest SHA1 "${sha1}" is not valid`); + return undefined; + } + + return { + 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 "${JSON.stringify(blockSizes)}" is not valid`); + return undefined; + } + if (blockSizes.some((size) => typeof size !== 'number' || size <= 0)) { + logger.warn(`XAttr block sizes "${JSON.stringify(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/index.test.ts b/js/sdk/src/internal/nodes/index.test.ts new file mode 100644 index 00000000..0736909a --- /dev/null +++ b/js/sdk/src/internal/nodes/index.test.ts @@ -0,0 +1,136 @@ +import { MemoryCache } from '../../cache'; +import { DriveCrypto } from '../../crypto'; +import { + MemberRole, + NodeType, + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, +} from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { DriveEventType } from '../events'; +import { makeNodeUid } from '../uids'; +import { NodesCache } from './cache'; +import { initNodesModule } from './index'; +import { DecryptedNode, SharesService } from './interface'; + +function generateNode(uid: string, parentUid = 'volumeId~root', params: Partial = {}): DecryptedNode { + return { + uid, + parentUid, + directRole: MemberRole.Admin, + type: NodeType.File, + mediaType: 'text', + isShared: false, + isSharedPublicly: false, + creationTime: new Date(), + modificationTime: 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 = { + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + }; + + nodesModule = initNodesModule( + getMockTelemetry(), + apiService, + driveEntitiesCache, + driveCryptoCache, + account, + driveCrypto, + sharesService, + 'clientUid', + ); + + 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); + }); +}); diff --git a/js/sdk/src/internal/nodes/index.ts b/js/sdk/src/internal/nodes/index.ts new file mode 100644 index 00000000..48c4e507 --- /dev/null +++ b/js/sdk/src/internal/nodes/index.ts @@ -0,0 +1,58 @@ +import { DriveCrypto } from '../../crypto'; +import { + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, + ProtonDriveTelemetry, +} from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { NodeAPIService } from './apiService'; +import { NodesCache } from './cache'; +import { NodesCryptoCache } from './cryptoCache'; +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'; + +export { generateFileExtendedAttributes } from './extendedAttributes'; +export type { DecryptedNode, DecryptedRevision } from './interface'; + +/** + * Provides facade for the whole nodes module. + * + * The nodes module is responsible for handling node metadata, including + * API communication, encryption, decryption, caching, and event handling. + * + * This facade provides internal interface that other modules can use to + * interact with the nodes. + */ +export function initNodesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + account: ProtonDriveAccount, + driveCrypto: DriveCrypto, + sharesService: SharesService, + clientUid: string | undefined, +) { + 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); + 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); + const nodesRevisions = new NodesRevisons(telemetry.getLogger('nodes'), api, cryptoService, nodesAccess); + + return { + access: nodesAccess, + management: nodesManagement, + revisions: nodesRevisions, + eventHandler: nodesEventHandler, + }; +} diff --git a/js/sdk/src/internal/nodes/interface.ts b/js/sdk/src/internal/nodes/interface.ts new file mode 100644 index 00000000..b274ae3b --- /dev/null +++ b/js/sdk/src/internal/nodes/interface.ts @@ -0,0 +1,214 @@ +import { PrivateKey, SessionKey } from '../../crypto'; +import { + AnonymousUser, + Author, + InvalidNameError, + MemberRole, + MetricVolumeType, + NodeEntity, + NodeType, + Result, + Revision, + RevisionState, + ThumbnailType, +} from '../../interface'; + +export type FilterOptions = { + type?: NodeType; +}; + +/** + * Internal common node interface for both encrypted or decrypted node. + */ +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 + uid: string; + parentUid?: string; + type: NodeType; + mediaType?: string; + creationTime: Date; // created on the server + modificationTime: Date; // modified on server + trashTime?: Date; + totalStorageSize?: number; + + // Share node metadata + shareId?: string; + isShared: boolean; + isSharedPublicly: boolean; + directRole: MemberRole; + membership?: { + role: MemberRole; + inviteTime: Date; + // TODO: acceptedBy: Author; + }; + ownedBy: { + email?: string; + organization?: string; + }; +} + +/** + * Interface used only internaly in the nodes module. + * + * Outside of the module, the decrypted node interface should be used. + */ +export interface EncryptedNode extends BaseNode { + encryptedCrypto: EncryptedNodeFolderCrypto | EncryptedNodeFileCrypto | EncryptedNodeAlbumCrypto; +} + +export interface EncryptedNodeCrypto { + signatureEmail?: string | AnonymousUser; + nameSignatureEmail?: string | AnonymousUser; + armoredKey: string; + armoredNodePassphrase: string; + armoredNodePassphraseSignature?: string; + membership?: { + inviterEmail: string; + base64MemberSharePassphraseKeyPacket: string; + armoredInviterSharePassphraseKeyPacketSignature: string; + armoredInviteeSharePassphraseSessionKeySignature: string; + }; +} + +export interface EncryptedNodeFileCrypto extends EncryptedNodeCrypto { + file: { + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature?: string; + }; + activeRevision: EncryptedRevision; +} + +export interface EncryptedNodeFolderCrypto extends EncryptedNodeCrypto { + folder: { + armoredExtendedAttributes?: string; + armoredHashKey: string; + }; +} + +// 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. + * + * Outside of the module, the decrypted node interface should be used. + * + * This interface is holding decrypted node metadata that is not yet parsed, + * such as extended attributes. + */ +export interface DecryptedUnparsedNode extends Omit { + keyAuthor: Author; + nameAuthor: Author; + membership?: { + role: MemberRole; + inviteTime: Date; + sharedBy: Author; + }; + name: Result; + activeRevision?: Result; + folder?: { + extendedAttributes?: string; + }; + errors?: unknown[]; +} + +/** + * Interface holding decrypted node metadata. + */ +export interface DecryptedNode + extends Omit, + Omit { + // Internal metadata + isStale: boolean; + name: Result; + + activeRevision?: Result; + folder?: { + claimedModificationTime?: Date; + }; +} + +/** + * Interface holding decrypted node key, including session key, and hash key. + * + * These keys are cached as they are needed for various actions on the node. + * + * Passphrase, for example, might be removed at some point. It is needed as + * at this moment the move requires both node key passphrase and the session + * key. + */ +export interface DecryptedNodeKeys { + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + contentKeyPacket?: Uint8Array; + contentKeyPacketSessionKey?: SessionKey; + hashKey?: Uint8Array; +} + +interface BaseRevision { + uid: string; + state: RevisionState; + creationTime: Date; // created on the server + storageSize: number; + thumbnails: Thumbnail[]; +} + +export type Thumbnail = { + uid: string; + type: ThumbnailType; +}; + +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 { + thumbnails?: Thumbnail[]; + claimedBlockSizes?: number[]; +} + +/** + * Interface describing the dependencies to the shares module. + */ +export interface SharesService { + getRootIDs(): Promise<{ volumeId: string; rootNodeId: string }>; + getSharePrivateKey(shareId: string): Promise; + getMyFilesShareMemberEmailKey(): Promise<{ + email: string; + }>; + getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; + getVolumeMetricContext(volumeId: string): Promise; +} diff --git a/js/sdk/src/internal/nodes/mediaTypes.ts b/js/sdk/src/internal/nodes/mediaTypes.ts new file mode 100644 index 00000000..5ea1b57a --- /dev/null +++ b/js/sdk/src/internal/nodes/mediaTypes.ts @@ -0,0 +1,13 @@ +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'; + +export function isProtonDocument(mediaType?: string) { + return mediaType === PROTON_DOC_MEDIA_TYPE; +} + +export function isProtonSheet(mediaType?: string) { + return mediaType === PROTON_SHEET_MEDIA_TYPE; +} diff --git a/js/sdk/src/internal/nodes/nodeName.test.ts b/js/sdk/src/internal/nodes/nodeName.test.ts new file mode 100644 index 00000000..b0a21886 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodeName.test.ts @@ -0,0 +1,57 @@ +import { joinNameAndExtension, splitExtension } 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/nodesAccess.test.ts b/js/sdk/src/internal/nodes/nodesAccess.test.ts new file mode 100644 index 00000000..0b0f5c40 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesAccess.test.ts @@ -0,0 +1,821 @@ +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'; + +describe('nodesAccess', () => { + let apiService: NodeAPIService; + let cache: NodesCache; + let cryptoCache: NodesCryptoCache; + let cryptoService: NodesCryptoService; + let shareService: SharesService; + let access: NodesAccess; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + getNode: jest.fn(), + iterateNodes: jest.fn().mockImplementation(async function* (uids: string[]) { + 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(), + setNode: jest.fn(), + iterateChildren: jest.fn().mockImplementation(async function* () {}), + isFolderChildrenLoaded: jest.fn().mockResolvedValue(false), + setFolderChildrenLoaded: jest.fn(), + removeNodes: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoCache = { + 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 = { + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + getSharePrivateKey: jest.fn(), + }; + + access = new NodesAccess(getMockTelemetry(), apiService, cache, cryptoCache, cryptoService, shareService); + }); + + describe('getNode', () => { + it('should get node from cache', async () => { + const node = { uid: 'volumeId~nodeId', isStale: false } as DecryptedNode; + cache.getNode = jest.fn(() => Promise.resolve(node)); + + const result = await access.getNode('volumeId~nodeId'); + expect(result).toBe(node); + expect(apiService.getNode).not.toHaveBeenCalled(); + }); + + 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: '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('volumeId~nodeId'); + expect(result).toEqual(decryptedNode); + 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('volumeId~nodeId', decryptedKeys); + }); + + 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 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.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => + Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys }), + ); + + const result = await access.getNode('volumeId~nodeId'); + expect(result).toEqual(decryptedNode); + 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('volumeId~nodeId', decryptedKeys); + }); + + 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: '' }, + } as DecryptedUnparsedNode; + const decryptedNode = { + ...decryptedUnparsedNode, + name: { ok: false, error: { name: '', error: "Name must not be empty" } }, + treeEventScopeId: 'volumeId', + } as DecryptedNode; + const decryptedKeys = { key: 'key' } as any as DecryptedNodeKeys; + + cache.getNode = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.resolve(encryptedNode)); + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + cryptoService.decryptNode = jest.fn(() => + Promise.resolve({ node: decryptedUnparsedNode, keys: decryptedKeys }), + ); + + const result = await access.getNode('volumeId~nodeId'); + expect(result).toMatchObject(decryptedNode); + }); + }); + + 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 + .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', () => { + 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); + }); + + it('should serve fully from cache', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(true); + cache.iterateChildren = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: true, node: node2 }; + yield { ok: true, node: node3 }; + yield { ok: true, node: node4 }; + }); + + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); + expect(result).toMatchObject([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).not.toHaveBeenCalled(); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); + }); + + it('should serve children from cache and load stale nodes only', async () => { + cache.isFolderChildrenLoaded = jest.fn().mockResolvedValue(true); + cache.iterateChildren = jest.fn().mockImplementation(async function* () { + yield { ok: true, uid: node1.uid, node: node1 }; + yield { ok: true, uid: node2.uid, node: { ...node2, isStale: true } }; + yield { ok: true, uid: node3.uid, node: { ...node3, isStale: true } }; + yield { ok: true, uid: node4.uid, node: node4 }; + }); + + 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, // filterOptions + undefined, // signal + ); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(2); + expect(cache.setNode).toHaveBeenCalledTimes(2); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(2); + }); + + it('should load children uids and serve nodes from cache', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield node1.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('volumeId~parentNodeid')); + expect(result).toMatchObject([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith( + 'volumeId~parentNodeid', + false, // onlyFolders + undefined, // signal + ); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); + expect(cache.setFolderChildrenLoaded).toHaveBeenCalledWith('volumeId~parentNodeid'); + }); + + it('should load from API', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield node1.uid; + yield node2.uid; + yield node3.uid; + yield node4.uid; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); + expect(result).toMatchObject([node1, node2, node3, node4]); + expect(apiService.iterateChildrenNodeUids).toHaveBeenCalledWith( + 'volumeId~parentNodeid', + false, // onlyFolders + undefined, // signal + ); + expect(apiService.iterateNodes).toHaveBeenCalledWith( + ['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4'], + 'volumeId', + undefined, // filterOptions + undefined, // signal + ); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); + expect(cache.setNode).toHaveBeenCalledTimes(4); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); + 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.uid; + yield node2.uid; + yield node3.uid; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + 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); + }); + + const result = await Array.fromAsync(access.iterateFolderChildren('volumeId~parentNodeid')); + expect(result).toMatchObject([node2, node3]); + expect(cache.removeNodes).toHaveBeenCalledWith([node1.uid]); + }); + + it('should yield all decryptable children before throwing error', async () => { + apiService.iterateChildrenNodeUids = jest.fn().mockImplementation(async function* () { + yield 'volumeId~node1'; + yield 'volumeId~node2'; + yield 'volumeId~node3'; + }); + cache.getNode = jest.fn().mockImplementation((uid: string) => { + if (uid === parentNode.uid) { + return parentNode; + } + throw new Error('Entity not found'); + }); + cryptoService.decryptNode = jest.fn().mockImplementation((encryptedNode: EncryptedNode) => { + if (encryptedNode.uid === 'volumeId~node2') { + throw new DecryptionError('Decryption failed'); + } + return Promise.resolve({ + node: { + uid: encryptedNode.uid, + isStale: false, + name: { ok: true, value: 'name' }, + } as DecryptedNode, + keys: { key: 'key' } as any as DecryptedNodeKeys, + }); + }); + + const generator = access.iterateFolderChildren('volumeId~parentNodeid'); + const node1 = await generator.next(); + expect(node1.value).toMatchObject({ uid: 'volumeId~node1' }); + const node2 = await generator.next(); + expect(node2.value).toMatchObject({ uid: 'volumeId~node3' }); + const node3 = generator.next(); + await expect(node3).rejects.toThrow('Failed to load some items'); + try { + await node3; + } catch (error: any) { + expect(error.cause).toEqual([new ProtonDriveError('Failed to load some nodes')]); + expect(error.cause[0].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('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', () => { + const volumeId = 'volumeId'; + 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.getRootIDs = jest.fn().mockResolvedValue({ volumeId }); + apiService.iterateTrashedNodeUids = jest.fn().mockImplementation(async function* () { + yield node1.uid; + yield node2.uid; + yield node3.uid; + yield node4.uid; + }); + }); + + it('should load trashed nodes and serve nodes from cache', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => ({ uid, isStale: false })); + + const result = await Array.fromAsync(access.iterateTrashedNodes()); + expect(result).toMatchObject([node1, node2, node3, node4]); + expect(apiService.iterateTrashedNodeUids).toHaveBeenCalledWith(volumeId, undefined); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); + }); + + it('should load from API', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => { + throw new Error('Entity not found'); + }); + + 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, // filterOptions + undefined, // signal + ); + expect(cryptoService.decryptNode).toHaveBeenCalledTimes(4); + expect(cache.setNode).toHaveBeenCalledTimes(4); + expect(cryptoCache.setNodeKeys).toHaveBeenCalledTimes(4); + }); + + it('should remove from cache if missing on API', async () => { + cache.getNode = jest.fn().mockImplementation((uid: string) => { + throw new Error('Entity not found'); + }); + apiService.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); + }); + + const result = await Array.fromAsync(access.iterateTrashedNodes()); + expect(result).toMatchObject([node2, node3, node4]); + expect(cache.removeNodes).toHaveBeenCalledWith(['volumeId~node1']); + }); + }); + + describe('iterateNodes', () => { + 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: 'volumeId~node4', isStale: false, treeEventScopeId: 'volumeId' } as DecryptedNode; + + it('should serve fully from cache', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: true, node: node2 }; + yield { ok: true, node: node3 }; + yield { ok: true, node: node4 }; + }); + + const result = await Array.fromAsync( + access.iterateNodes(['volumeId~node1', 'volumeId~node2', 'volumeId~node3', 'volumeId~node4']), + ); + expect(result).toMatchObject([node1, node2, node3, node4]); + expect(apiService.iterateNodes).not.toHaveBeenCalled(); + }); + + it('should load from API', async () => { + cache.iterateNodes = jest.fn().mockImplementation(async function* () { + yield { ok: true, node: node1 }; + yield { ok: false, uid: 'volumeId~node2' }; + yield { ok: false, uid: 'volumeId~node3' }; + yield { ok: true, node: 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, // filterOptions + undefined, // signal + ); + }); + + 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: '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: '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' }]); + 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: '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) => { + const parentUid = uid.replace('node', 'parentOfNode'); + return { + uid, + parentUid, + encryptedCrypto, + } as EncryptedNode; + }); + }); + const decryptionError = new DecryptionError('Parent cannot be decrypted'); + jest.spyOn(access, 'getParentKeys').mockImplementation(async ({ parentUid }) => { + if (parentUid === 'volumeId~parentOfNode1') { + throw decryptionError; + } + return { + key: { _idx: 32132 }, + } as any; + }); + + 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 }, + }, + errors: [decryptionError], + }, + { + ...node2, + name: { ok: true, value: 'name' }, + folder: undefined, + activeRevision: undefined, + }, + { + ...node3, + name: { ok: true, value: 'name' }, + folder: undefined, + activeRevision: undefined, + }, + ]); + }); + }); + }); + + describe('getParentKeys', () => { + it('should get share parent keys', async () => { + shareService.getSharePrivateKey = jest.fn(() => Promise.resolve('shareKey' as any as PrivateKey)); + + const result = await access.getParentKeys({ + uid: 'volumeId~nodeId', + shareId: 'shareId', + parentUid: undefined, + }); + expect(result).toEqual({ key: 'shareKey' }); + expect(cryptoCache.getNodeKeys).not.toHaveBeenCalled(); + }); + + it('should get node parent keys', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + + const result = await access.getParentKeys({ + uid: 'volumeId~nodeId', + shareId: undefined, + parentUid: 'volumeId~parentNodeid', + }); + expect(result).toEqual({ key: 'parentKey' }); + expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); + }); + + it('should get node parent keys even if share is set', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.resolve({ key: 'parentKey' } as any as DecryptedNodeKeys)); + + const result = await access.getParentKeys({ + uid: 'volume1~nodeId', + shareId: 'shareId', + parentUid: 'volume1~parentNodeid', + }); + expect(result).toEqual({ key: 'parentKey' }); + expect(shareService.getSharePrivateKey).not.toHaveBeenCalled(); + }); + }); + + describe('getNodeKeys', () => { + it('should load node if not cached', async () => { + cryptoCache.getNodeKeys = jest.fn(() => Promise.reject(new Error('Entity not found'))); + apiService.getNode = jest.fn(() => Promise.reject(new Error('API called'))); + + try { + await access.getNodeKeys('volumeId~nodeId'); + throw new Error('Expected error'); + } catch (error: unknown) { + expect(`${error}`).toBe('Error: API called'); + } + }); + }); + + describe('getNodePrivateAndSessionKeys', () => { + it('should return all node keys and session keys', async () => { + const nodeUid = 'nodeUid'; + const node = { + uid: nodeUid, + parentUid: 'volume1~parentNodeid', + encryptedName: 'encryptedName', + } as DecryptedNode; + + jest.spyOn(access, 'getNode').mockResolvedValue(node); + jest.spyOn(access, 'getParentKeys').mockResolvedValue({ key: 'parentKey' } as any); + jest.spyOn(access, 'getNodeKeys').mockResolvedValue({ + key: 'nodeKey', + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + contentKeyPacketSessionKey: 'nodeContentKeyPacketSessionKey', + } as any); + cryptoService.getNameSessionKey = jest.fn().mockResolvedValue('nameSessionKey'); + + const result = await access.getNodePrivateAndSessionKeys(nodeUid); + + expect(result).toEqual({ + key: 'nodeKey', + passphrase: 'nodePassphrase', + passphraseSessionKey: 'nodePassphraseSessionKey', + contentKeyPacketSessionKey: 'nodeContentKeyPacketSessionKey', + nameSessionKey: 'nameSessionKey', + }); + expect(access.getNode).toHaveBeenCalledWith(nodeUid); + expect(access.getParentKeys).toHaveBeenCalledWith(node); + expect(access.getNodeKeys).toHaveBeenCalledWith(nodeUid); + expect(cryptoService.getNameSessionKey).toHaveBeenCalledWith(node, 'parentKey'); + }); + }); + + describe('getNodeUrl', () => { + const nodeUid = 'volumeId~nodeId'; + + it('should return node URL of document', async () => { + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ mediaType: 'application/vnd.proton.doc' } as any as DecryptedNode), + ); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://docs.proton.me/doc?type=doc&mode=open&volumeId=volumeId&linkId=nodeId'); + }); + + it('should return node URL of sheet', async () => { + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ mediaType: 'application/vnd.proton.sheet' } as any as DecryptedNode), + ); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://docs.proton.me/doc?type=sheet&mode=open&volumeId=volumeId&linkId=nodeId'); + }); + + it('should return node URL of image', async () => { + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ type: NodeType.File } as any as DecryptedNode), + ); + jest.spyOn(access as any, 'getRootNode').mockReturnValue( + Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode), + ); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://drive.proton.me/shareId/file/nodeId'); + }); + + it('should return node URL of folder', async () => { + jest.spyOn(access, 'getNode').mockReturnValue( + Promise.resolve({ type: NodeType.Folder } as any as DecryptedNode), + ); + jest.spyOn(access as any, 'getRootNode').mockReturnValue( + Promise.resolve({ shareId: 'shareId', type: NodeType.Folder } as any as DecryptedNode), + ); + + const result = await access.getNodeUrl(nodeUid); + expect(result).toBe('https://drive.proton.me/shareId/folder/nodeId'); + }); + }); + + 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 new file mode 100644 index 00000000..9d0b6e74 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesAccess.ts @@ -0,0 +1,613 @@ +import { c } from 'ttag'; + +import { PrivateKey, SessionKey } from '../../crypto'; +import { DecryptionError, ProtonDriveError } from '../../errors'; +import { + InvalidNameError, + Logger, + MissingNode, + NodeType, + ProtonDriveTelemetry, + Result, + resultError, + resultOk, +} from '../../interface'; +import { asyncIteratorMap } from '../asyncIteratorMap'; +import { BatchLoading } from '../batchLoading'; +import { getErrorMessage } from '../errors'; +import { makeNodeUid, splitNodeUid } from '../uids'; +import { NodeAPIServiceBase } from './apiService'; +import { NodesCacheBase } from './cache'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; +import { NodesDebouncer } from './debouncer'; +import { parseFileExtendedAttributes, parseFolderExtendedAttributes } from './extendedAttributes'; +import { + DecryptedNode, + DecryptedNodeKeys, + DecryptedUnparsedNode, + EncryptedNode, + FilterOptions, + NodeSigningKeys, + SharesService, +} from './interface'; +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. +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 = 30; + +/** + * Provides access to node metadata. + * + * The node access module is responsible for fetching, decrypting and caching + * nodes metadata. + */ +export abstract class NodesAccessBase< + TEncryptedNode extends EncryptedNode = EncryptedNode, + TDecryptedNode extends DecryptedNode = DecryptedNode, + TCryptoService extends NodesCryptoService = NodesCryptoService, +> { + protected logger: Logger; + protected debouncer: NodesDebouncer; + + constructor( + protected telemetry: ProtonDriveTelemetry, + protected apiService: NodeAPIServiceBase, + protected cache: NodesCacheBase, + protected cryptoCache: NodesCryptoCache, + protected cryptoService: TCryptoService, + protected shareService: Pick< + SharesService, + 'getRootIDs' | 'getSharePrivateKey' | 'getContextShareMemberEmailKey' + >, + ) { + 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.telemetry); + } + + async getVolumeRootFolder() { + const { volumeId, rootNodeId } = await this.shareService.getRootIDs(); + const nodeUid = makeNodeUid(volumeId, rootNodeId); + return this.getNode(nodeUid); + } + + async getNode(nodeUid: string): Promise { + let cachedNode; + try { + await this.debouncer.waitForLoadingNode(nodeUid); + cachedNode = await this.cache.getNode(nodeUid); + } catch {} + + if (cachedNode && !cachedNode.isStale) { + return cachedNode; + } + + this.logger.debug(`Node ${nodeUid} is ${cachedNode?.isStale ? 'stale' : 'not cached'}`); + + const { node } = await this.loadNode(nodeUid); + 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, + 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, + 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, filterOptions, signal), + batchSize: BATCH_LOADING_SIZE, + }); + + const areChildrenCached = await this.cache.isFolderChildrenLoaded(parentNodeUid); + 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); + } + } + yield* batchLoading.loadRest(); + return; + } + + this.logger.debug(`Folder ${parentNodeUid} children are not cached`); + const onlyFolders = filterOptions?.type === NodeType.Folder; + 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 {} + + 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'}`); + yield* batchLoading.load(nodeUid); + } + } + yield* batchLoading.loadRest(); + + // If some nodes were filtered out, we don't have the folder fully loaded. + if (!filterOptions) { + await this.cache.setFolderChildrenLoaded(parentNodeUid); + } + } + + /** + * @deprecated Use `iterateTrashedNodeUids` instead. + */ + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.shareService.getRootIDs(); + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.loadNodes(nodeUids, undefined, signal), + batchSize: BATCH_LOADING_SIZE, + }); + 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 {} + + if (node && !node.isStale) { + yield node; + } else { + this.logger.debug(`Node ${nodeUid} trom trash is ${node?.isStale ? 'stale' : 'not cached'}`); + yield* batchLoading.load(nodeUid); + } + } + yield* batchLoading.loadRest(); + } + + async *iterateNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator { + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.loadNodesWithMissingReport(nodeUids, undefined, signal), + batchSize: BATCH_LOADING_SIZE, + }); + for await (const result of this.cache.iterateNodes(nodeUids)) { + if (result.ok && !result.node.isStale) { + yield result.node; + } else { + yield* batchLoading.load(result.uid); + } + } + yield* batchLoading.loadRest(); + } + + /** + * 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: TDecryptedNode; keys?: DecryptedNodeKeys }> { + this.debouncer.loadingNode(nodeUid); + try { + const ownVolumeId = await this.getOwnVolumeId(); + const encryptedNode = await this.apiService.getNode(nodeUid, ownVolumeId); + return this.decryptNode(encryptedNode); + } finally { + this.debouncer.finishedLoadingNode(nodeUid); + } + } + + 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; + } + yield result; + } + } + + protected async getOwnVolumeId(): Promise { + const { volumeId } = await this.shareService.getRootIDs(); + return volumeId; + } + + private async *loadNodesWithMissingReport( + nodeUids: string[], + filterOptions?: FilterOptions, + signal?: AbortSignal, + ): AsyncGenerator { + const returnedNodeUids: string[] = []; + const errors = []; + + const ownVolumeId = await this.getOwnVolumeId(); + + const apiNodesIterator = this.apiService.iterateNodes(nodeUids, ownVolumeId, filterOptions, signal); + + const debouncedNodeMapper = async (encryptedNode: TEncryptedNode): Promise => { + this.debouncer.loadingNode(encryptedNode.uid); + return encryptedNode; + }; + const encryptedNodesIterator = asyncIteratorMap(apiNodesIterator, debouncedNodeMapper, 1); + + const decryptNodeMapper = async (encryptedNode: TEncryptedNode): Promise> => { + returnedNodeUids.push(encryptedNode.uid); + try { + const { node } = await this.decryptNode(encryptedNode); + return resultOk(node); + } catch (error: unknown) { + return resultError(error); + } + }; + const decryptedNodesIterator = asyncIteratorMap( + encryptedNodesIterator, + decryptNodeMapper, + DECRYPTION_CONCURRENCY, + signal, + ); + for await (const node of decryptedNodesIterator) { + if (node.ok) { + yield node.value; + } else { + errors.push(node.error); + } + } + + if (errors.length > 0) { + this.logger.error(`Failed to decrypt ${errors.length} nodes`, errors); + throw new ProtonDriveError(c('Error').t`Failed to load some nodes`, { cause: errors }); + } + + const missingNodeUids = nodeUids.filter((nodeUid) => !returnedNodeUids.includes(nodeUid)); + + if (missingNodeUids.length) { + this.logger.debug(`Removing ${missingNodeUids.length} nodes from cache not existing on the API anymore`); + await this.cache.removeNodes(missingNodeUids); + for (const missingNodeUid of missingNodeUids) { + yield { missingUid: missingNodeUid }; + } + } + } + + private async decryptNode( + encryptedNode: TEncryptedNode, + ): Promise<{ node: TDecryptedNode; keys?: DecryptedNodeKeys }> { + let parentKey; + try { + const parentKeys = await this.getParentKeys(encryptedNode); + parentKey = parentKeys.key; + } catch (error: unknown) { + if (error instanceof DecryptionError) { + return { + node: this.getDegradedUndecryptableNode(encryptedNode, error), + }; + } + throw error; + } + + const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey); + const node = this.parseNode(unparsedNode); + try { + await this.cache.setNode(node); + } catch (error: unknown) { + this.logger.error(`Failed to cache node ${node.uid}`, error); + } + if (keys) { + try { + await this.cryptoCache.setNodeKeys(node.uid, keys); + } catch (error: unknown) { + this.logger.error(`Failed to cache node keys ${node.uid}`, error); + } + } + this.debouncer.finishedLoadingNode(node.uid); + 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, + ): Promise> { + if (node.parentUid) { + try { + return await this.getNodeKeys(node.parentUid); + } catch (error: unknown) { + if (error instanceof DecryptionError) { + // Change the error message to be more specific. + // Original error message is referring to node, while here + // it referes to as parent to follow the method context. + throw new DecryptionError(c('Error').t`Parent cannot be decrypted`, { cause: error }); + } + throw error; + } + } + if (node.shareId) { + return { + key: await this.shareService.getSharePrivateKey(node.shareId), + }; + } + // This is bug that should not happen. + // API cannot provide node without parent or share. + throw new Error(`Node has neither parent node nor share: ${node.uid}`); + } + + async getNodeKeys(nodeUid: string): Promise { + try { + await this.debouncer.waitForLoadingNode(nodeUid); + return await this.cryptoCache.getNodeKeys(nodeUid); + } catch { + const { keys } = await this.loadNode(nodeUid); + if (!keys) { + throw new DecryptionError(c('Error').t`Item cannot be decrypted`); + } + return keys; + } + } + + async getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ + key: PrivateKey; + passphrase: string; + passphraseSessionKey: SessionKey; + contentKeyPacketSessionKey?: SessionKey; + nameSessionKey: SessionKey; + }> { + const node = await this.getNode(nodeUid); + const { key: parentKey } = await this.getParentKeys(node); + const { key, passphrase, passphraseSessionKey, contentKeyPacketSessionKey } = await this.getNodeKeys(nodeUid); + const nameSessionKey = await this.cryptoService.getNameSessionKey(node, parentKey); + return { + key, + passphrase, + passphraseSessionKey, + contentKeyPacketSessionKey, + nameSessionKey, + }; + } + + 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; + addressKey: PrivateKey; + addressKeyId: string; + }> { + const rootNode = await this.getRootNode(nodeUid); + if (!rootNode.shareId) { + throw new Error(`Node "${nodeUid}" is not accessible - missing root shareId`); + } + return this.shareService.getContextShareMemberEmailKey(rootNode.shareId); + } + + async getNodeUrl(nodeUid: string): Promise { + const node = await this.getNode(nodeUid); + if (isProtonDocument(node.mediaType) || isProtonSheet(node.mediaType)) { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const type = isProtonDocument(node.mediaType) ? 'doc' : 'sheet'; + return `https://docs.proton.me/doc?type=${type}&mode=open&volumeId=${volumeId}&linkId=${nodeId}`; + } + + const rootNode = await this.getRootNode(nodeUid); + if (!rootNode.shareId) { + throw new ProtonDriveError(c('Error').t`Node is not accessible`); + } + const { nodeId } = splitNodeUid(nodeUid); + const type = node.type === NodeType.File ? 'file' : 'folder'; + + return `https://drive.proton.me/${rootNode.shareId}/${type}/${nodeId}`; + } + + private async getRootNode(nodeUid: string): Promise { + const hierarchy = await this.getNodeHierarchy(nodeUid); + return hierarchy[0]; + } +} + +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 { + 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, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: unparsedNode.activeRevision.value.sha1Verified || false, + }, + }), + 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/nodes/nodesManagement.test.ts b/js/sdk/src/internal/nodes/nodesManagement.test.ts new file mode 100644 index 00000000..c71ec8f8 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesManagement.test.ts @@ -0,0 +1,499 @@ +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; +import { NodeResult, NodeResultWithError } from '../../interface'; +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 { NodesManagement } from './nodesManagement'; + +describe('NodesManagement', () => { + let apiService: NodeAPIService; + let cryptoCache: NodesCryptoCache; + let cryptoService: NodesCryptoService; + let nodesAccess: NodesAccess; + let management: NodesManagement; + + let nodes: { [uid: string]: DecryptedNode }; + + beforeEach(() => { + nodes = { + nodeUid: { + uid: 'nodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'old name' }, + keyAuthor: { ok: true, value: 'keyAauthor' }, + nameAuthor: { ok: true, value: 'nameAuthor' }, + hash: 'hash', + mediaType: 'mediaType', + } as DecryptedNode, + anonymousNodeUid: { + uid: 'anonymousNodeUid', + parentUid: 'parentUid', + name: { ok: true, value: 'old name' }, + keyAuthor: { ok: true, value: null }, + nameAuthor: { ok: true, value: 'nameAuthor' }, + hash: 'hash', + mediaType: 'mediaType', + } as DecryptedNode, + parentUid: { + uid: 'parentUid', + name: { ok: true, value: 'parent' }, + } as DecryptedNode, + newParentUid: { + uid: 'newParentUid', + name: { ok: true, value: 'new parent' }, + } as DecryptedNode, + }; + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + renameNode: jest.fn(), + moveNode: jest.fn(), + copyNode: jest.fn().mockResolvedValue('newCopiedNodeUid'), + 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); + }), + deleteTrashedNodes: jest.fn(async function* (uids) { + 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 = { + setNodeKeys: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + encryptNewName: jest.fn().mockResolvedValue({ + signatureEmail: 'newSignatureEmail', + armoredNodeName: 'newArmoredNodeName', + hash: 'newHash', + }), + 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 = { + 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(({ uid }) => ({ + key: `${nodes[uid].parentUid}-key`, + 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`, + }), + ), + getNodeSigningKeys: jest.fn().mockResolvedValue({ + type: 'userAddress', + email: 'root-email', + addressId: 'root-addressId', + key: 'root-key', + }), + notifyNodeChanged: jest.fn(), + notifyNodeDeleted: jest.fn(), + notifyChildCreated: jest.fn(), + }; + + management = new NodesManagement(apiService, cryptoCache, cryptoService, nodesAccess); + }); + + it('renameNode manages rename and updates cache', async () => { + const newNode = await management.renameNode('nodeUid', 'new name'); + + expect(newNode).toEqual({ + ...nodes.nodeUid, + name: { ok: true, value: 'new name' }, + encryptedName: 'newArmoredNodeName', + nameAuthor: { ok: true, value: 'newSignatureEmail' }, + hash: 'newHash', + }); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ nodeUid: 'nodeUid', parentNodeUid: 'parentUid' }); + expect(cryptoService.encryptNewName).toHaveBeenCalledWith( + { key: 'parentUid-key', hashKey: 'parentUid-hashKey' }, + 'nodeUid-nameSessionKey', + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, + 'new name', + ); + expect(apiService.renameNode).toHaveBeenCalledWith( + nodes.nodeUid.uid, + { hash: nodes.nodeUid.hash }, + { encryptedName: 'newArmoredNodeName', nameSignatureEmail: 'newSignatureEmail', hash: 'newHash' }, + ); + 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', + hash: 'movedHash', + armoredNodePassphrase: 'movedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', + signatureEmail: 'movedSignatureEmail', + nameSignatureEmail: 'movedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.moveNode('nodeUid', 'newParentNodeUid'); + + expect(newNode).toEqual({ + ...nodes.nodeUid, + parentUid: 'newParentNodeUid', + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + keyAuthor: { ok: true, value: 'movedSignatureEmail' }, + nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, + }); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ + nodeUid: 'nodeUid', + parentNodeUid: 'newParentNodeUid', + }); + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + nodes.nodeUid.name, + 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' }, + ); + expect(apiService.moveNode).toHaveBeenCalledWith( + 'nodeUid', + { + hash: nodes.nodeUid.hash, + }, + { + parentUid: 'newParentNodeUid', + ...encryptedCrypto, + armoredNodePassphraseSignature: undefined, + signatureEmail: undefined, + }, + ); + expect(nodesAccess.notifyNodeChanged).toHaveBeenCalledWith('nodeUid', 'newParentNodeUid'); + }); + + it('moveNode manages move of anonymous node', async () => { + const encryptedCrypto = { + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + armoredNodePassphrase: 'movedArmoredNodePassphrase', + armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature', + signatureEmail: 'movedSignatureEmail', + nameSignatureEmail: 'movedNameSignatureEmail', + }; + cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto); + + const newNode = await management.moveNode('anonymousNodeUid', 'newParentNodeUid'); + + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + nodes.anonymousNodeUid.name, + 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' }), + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, + ); + expect(newNode).toEqual({ + ...nodes.anonymousNodeUid, + parentUid: 'newParentNodeUid', + encryptedName: 'movedArmoredNodeName', + hash: 'movedHash', + keyAuthor: { ok: true, value: 'movedSignatureEmail' }, + nameAuthor: { ok: true, value: 'movedNameSignatureEmail' }, + }); + expect(apiService.moveNode).toHaveBeenCalledWith( + 'anonymousNodeUid', + { + hash: nodes.nodeUid.hash, + }, + { + parentUid: 'newParentNodeUid', + ...encryptedCrypto, + }, + ); + }); + + 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', + 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, + uid: 'newCopiedNodeUid', + parentUid: 'newParentNodeUid', + encryptedName: 'copiedArmoredNodeName', + hash: 'copiedHash', + keyAuthor: { ok: true, value: 'copiedSignatureEmail' }, + nameAuthor: { ok: true, value: 'copiedNameSignatureEmail' }, + }); + expect(nodesAccess.getNodeSigningKeys).toHaveBeenCalledWith({ + nodeUid: 'nodeUid', + parentNodeUid: 'newParentNodeUid', + }); + expect(cryptoService.encryptNodeWithNewParent).toHaveBeenCalledWith( + nodes.nodeUid.name, + 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' }, + ); + 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.name, + 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' }), + { type: 'userAddress', email: 'root-email', addressId: 'root-addressId', key: 'root-key' }, + ); + expect(newNode).toEqual({ + ...nodes.anonymousNodeUid, + uid: 'newCopiedNodeUid', + 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('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', ''); + await expect(promise).rejects.toThrow(ValidationError); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..deff50c9 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesManagement.ts @@ -0,0 +1,460 @@ +import { c } from 'ttag'; + +import { AbortError, ValidationError } from '../../errors'; +import { + InvalidNameError, + MemberRole, + NodeResult, + NodeResultWithError, + NodeResultWithNewUid, + NodeType, + resultOk, +} from '../../interface'; +import { createErrorFromUnknown, getErrorMessage } from '../errors'; +import { splitNodeUid } from '../uids'; +import { NodeAPIServiceBase } from './apiService'; +import { NodesCryptoCache } from './cryptoCache'; +import { NodesCryptoService } from './cryptoService'; +import { NodeOutOfSyncError } from './errors'; +import { generateFolderExtendedAttributes } from './extendedAttributes'; +import { DecryptedNode, EncryptedNode } from './interface'; +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; +const AVAILABLE_NAME_LIMIT = 1000; + +/** + * Provides high-level actions for managing nodes. + * + * The manager is responsible for handling nodes metadata, including + * API communication, encryption, decryption, and caching. + * + * This module uses other modules providing low-level operations, such + * as API service, cache, crypto service, etc. + */ +export abstract class NodesManagementBase< + TEncryptedNode extends EncryptedNode = EncryptedNode, + TDecryptedNode extends DecryptedNode = DecryptedNode, + TNodesCryptoService extends NodesCryptoService = NodesCryptoService, +> { + constructor( + protected apiService: NodeAPIServiceBase, + protected cryptoCache: NodesCryptoCache, + protected cryptoService: NodesCryptoService, + protected nodesAccess: NodesAccessBase, + ) { + this.apiService = apiService; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.nodesAccess = nodesAccess; + } + + async renameNode( + nodeUid: string, + newName: string, + options = { allowRenameRootNode: false }, + ): Promise { + validateNodeName(newName); + + const node = await this.nodesAccess.getNode(nodeUid); + const { nameSessionKey: nodeNameSessionKey } = await this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid); + const parentKeys = await this.nodesAccess.getParentKeys(node); + 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`); + } + + const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.encryptNewName( + parentKeys, + nodeNameSessionKey, + signingKeys, + 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'); + } + + 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: TDecryptedNode = { + ...node, + name: resultOk(newName), + encryptedName: armoredNodeName, + nameAuthor: resultOk(signatureEmail || null), + hash, + }; + return newNode; + } + + // Improvement requested: move nodes in parallel + async *moveNodes( + nodeUids: string[], + newParentNodeUid: string, + signal?: AbortSignal, + ): AsyncGenerator { + for (const nodeUid of nodeUids) { + if (signal?.aborted) { + throw new AbortError(c('Error').t`Move operation aborted`); + } + try { + await this.moveNode(nodeUid, newParentNodeUid); + yield { + uid: nodeUid, + ok: true, + }; + } catch (error: unknown) { + yield { + uid: nodeUid, + ok: false, + error: error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }), + }; + } + } + } + + 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); + + const [keys, newParentKeys, signingKeys] = await Promise.all([ + this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), + this.nodesAccess.getNodeKeys(newParentUid), + this.nodesAccess.getNodeSigningKeys({ nodeUid, parentNodeUid: newParentUid }), + ]); + + if (!node.hash) { + throw new ValidationError(c('Error').t`Moving root item is not allowed`); + } + if (!newParentKeys.hashKey) { + throw new ValidationError(c('Error').t`Moving item to a non-folder is not allowed`); + } + + const encryptedCrypto = await this.cryptoService.encryptNodeWithNewParent( + node.name, + keys, + { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, + signingKeys, + ); + + // Node could be uploaded or renamed by anonymous user and thus have + // missing signatures that must be added to the move request. + // Node passphrase and signature email must be passed if and only if + // the the signatures are missing (key author is null). + const anonymousKey = node.keyAuthor.ok && node.keyAuthor.value === null; + const keySignatureProperties = !anonymousKey + ? {} + : { + signatureEmail: encryptedCrypto.signatureEmail, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + }; + await this.apiService.moveNode( + nodeUid, + { + hash: node.hash, + }, + { + ...keySignatureProperties, + parentUid: newParentUid, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + hash: encryptedCrypto.hash, + }, + ); + const newNode: TDecryptedNode = { + ...node, + encryptedName: encryptedCrypto.encryptedName, + parentUid: newParentUid, + hash: encryptedCrypto.hash, + keyAuthor: resultOk(encryptedCrypto.signatureEmail), + nameAuthor: resultOk(encryptedCrypto.nameSignatureEmail), + }; + await this.nodesAccess.notifyNodeChanged(node.uid, newParentUid); + return newNode; + } + + // Improvement requested: copy nodes in parallel using copy_multiple endpoint + async *copyNodes( + nodeUidsOrWithNames: (string | { uid: string; name: string })[], + newParentNodeUid: string, + signal?: AbortSignal, + ): AsyncGenerator { + 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, name); + yield { + uid: nodeUid, + newUid: newNodeUid, + ok: true, + }; + } catch (error: unknown) { + yield { + uid: nodeUid, + ok: false, + error: createErrorFromUnknown(error), + }; + } + } + } + + async copyNode(nodeUid: string, newParentUid: string, name?: string): Promise { + if (name !== undefined) { + validateNodeName(name); + } + + const node = await this.nodesAccess.getNode(nodeUid); + const nodeName = name !== undefined ? resultOk(name) : node.name; + + const [keys, newParentKeys, signingKeys] = await Promise.all([ + this.nodesAccess.getNodePrivateAndSessionKeys(nodeUid), + this.nodesAccess.getNodeKeys(newParentUid), + this.nodesAccess.getNodeSigningKeys({ nodeUid, parentNodeUid: 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( + nodeName, + keys, + { key: newParentKeys.key, hashKey: newParentKeys.hashKey }, + signingKeys, + ); + + // 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, + }; + const newNodeUid = await this.apiService.copyNode(nodeUid, { + ...keySignatureProperties, + parentUid: newParentUid, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + encryptedName: encryptedCrypto.encryptedName, + nameSignatureEmail: encryptedCrypto.nameSignatureEmail, + hash: encryptedCrypto.hash, + }); + const newNode: TDecryptedNode = { + ...node, + name: nodeName, + uid: newNodeUid, + 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) { + await this.nodesAccess.notifyNodeChanged(result.uid); + } + yield result; + } + } + + 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); + } + yield result; + } + } + + 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); + } + yield result; + } + } + + // FIXME create test for create folder + async createFolder(parentNodeUid: string, folderName: string, modificationTime?: Date): Promise { + validateNodeName(folderName); + + const parentNode = await this.nodesAccess.getNode(parentNodeUid); + const parentKeys = await this.nodesAccess.getNodeKeys(parentNodeUid); + if (!parentKeys.hashKey) { + throw new ValidationError(c('Error').t`Creating folders in non-folders is not allowed`); + } + + const signingKeys = await this.nodesAccess.getNodeSigningKeys({ parentNodeUid }); + const extendedAttributes = generateFolderExtendedAttributes(modificationTime); + + const { encryptedCrypto, keys } = await this.cryptoService.createFolder( + { key: parentKeys.key, hashKey: parentKeys.hashKey }, + signingKeys, + folderName, + extendedAttributes, + ); + const nodeUid = await this.apiService.createFolder(parentNodeUid, { + armoredKey: encryptedCrypto.armoredKey, + armoredHashKey: encryptedCrypto.folder.armoredHashKey, + armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase, + armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature, + signatureEmail: encryptedCrypto.signatureEmail, + encryptedName: encryptedCrypto.encryptedName, + hash: encryptedCrypto.hash, + armoredExtendedAttributes: encryptedCrypto.folder.armoredExtendedAttributes, + }); + + await this.nodesAccess.notifyChildCreated(parentNodeUid); + const node = this.generateNodeFolder(parentNode, nodeUid, folderName, encryptedCrypto); + await this.cryptoCache.setNodeKeys(nodeUid, keys); + return node; + } + + protected abstract generateNodeFolder( + parentNode: TDecryptedNode, + nodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): TDecryptedNode; + + protected generateNodeFolderBase( + parentNode: TDecryptedNode, + nodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedNode { + return { + // Internal metadata + hash: encryptedCrypto.hash, + encryptedName: encryptedCrypto.encryptedName, + + // Basic node metadata + uid: nodeUid, + parentUid: parentNode.uid, + type: NodeType.Folder, + mediaType: FOLDER_MEDIA_TYPE, + creationTime: new Date(), + modificationTime: new Date(), + + // Share node metadata + isShared: false, + isSharedPublicly: false, + directRole: MemberRole.Inherited, + ownedBy: parentNode.ownedBy, + + // Decrypted metadata + isStale: false, + keyAuthor: resultOk(encryptedCrypto.signatureEmail || null), + nameAuthor: resultOk(encryptedCrypto.signatureEmail || null), + name: resultOk(name), + treeEventScopeId: splitNodeUid(nodeUid).volumeId, + }; + } + + 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`); + } +} + +export class NodesManagement extends NodesManagementBase { + protected generateNodeFolder( + parentNode: DecryptedNode, + nodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedNode { + return this.generateNodeFolderBase(parentNode, nodeUid, name, encryptedCrypto); + } +} diff --git a/js/sdk/src/internal/nodes/nodesRevisions.ts b/js/sdk/src/internal/nodes/nodesRevisions.ts new file mode 100644 index 00000000..deb7f774 --- /dev/null +++ b/js/sdk/src/internal/nodes/nodesRevisions.ts @@ -0,0 +1,79 @@ +import { Logger } from '../../interface'; +import { makeNodeUidFromRevisionUid } from '../uids'; +import { NodeAPIServiceBase } from './apiService'; +import { NodesCryptoService } from './cryptoService'; +import { parseFileExtendedAttributes } from './extendedAttributes'; +import { DecryptedRevision } from './interface'; +import { NodesAccess } from './nodesAccess'; + +/** + * Provides access to revisions metadata. + */ +export class NodesRevisons { + constructor( + private logger: Logger, + private apiService: NodeAPIServiceBase, + private cryptoService: NodesCryptoService, + private nodesAccess: Pick, + ) { + this.logger = logger; + this.apiService = apiService; + this.cryptoService = cryptoService; + this.nodesAccess = nodesAccess; + } + + 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.creationTime, + revision.extendedAttributes, + ); + return { + ...revision, + ...extendedAttributes, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: revision.sha1Verified || false, + }, + }; + } + + 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.creationTime, + revision.extendedAttributes, + ); + yield { + ...revision, + ...extendedAttributes, + claimedDigests: { + ...extendedAttributes?.claimedDigests, + sha1Verified: revision.sha1Verified || false, + }, + }; + } + } + + 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 { + await this.apiService.deleteRevision(nodeRevisionUid); + } +} diff --git a/js/sdk/src/internal/nodes/validations.ts b/js/sdk/src/internal/nodes/validations.ts new file mode 100644 index 00000000..32f35b8b --- /dev/null +++ b/js/sdk/src/internal/nodes/validations.ts @@ -0,0 +1,23 @@ +import { c, msgid } from 'ttag'; + +import { ValidationError } from '../../errors'; + +const MAX_NODE_NAME_LENGTH = 255; + +/** + * @throws Error if the name is empty or long. + */ +export function validateNodeName(name: string): void { + if (!name) { + throw new ValidationError(c('Error').t`Name must not be empty`); + } + if (name.length > MAX_NODE_NAME_LENGTH) { + throw new ValidationError( + 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, + ), + ); + } +} 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..8d6a1460 --- /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(), + copyPhoto: 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.copyPhoto.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.copyPhoto.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.copyPhoto).toHaveBeenCalledTimes(1); + const params = apiService.copyPhoto.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.copyPhoto).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.copyPhoto).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.copyPhoto.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.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 () => { + // 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.copyPhoto).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..44db056e --- /dev/null +++ b/js/sdk/src/internal/photos/addToAlbum.ts @@ -0,0 +1,234 @@ +import { c } from 'ttag'; + +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 { PhotosNodesAccess } from './nodes'; +import { PhotoTransferPayloadBuilder, TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; + +/** + * 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(); + private readonly payloadBuilder: PhotoTransferPayloadBuilder; + + constructor( + private readonly albumNodeUid: string, + private readonly albumKeys: DecryptedNodeKeys, + private readonly signingKeys: NodeSigningKeys, + private readonly apiService: PhotosAPIService, + 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 { + 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.payloadBuilder.preparePhotoPayloads( + items, + this.albumNodeUid, + this.albumKeys, + this.signingKeys, + this.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.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.payloadBuilder.preparePhotoPayloads( + items, + this.albumNodeUid, + this.albumKeys, + this.signingKeys, + this.signal, + ); + + for (const [uid, error] of errors) { + yield { uid, ok: false, error }; + } + + for (const payload of payloads) { + try { + const newPhotoNodeUid = await this.apiService.copyPhoto( + 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 }), + }; + } + } + } + } + + /** + * 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. + */ +export function* createBatches(payloads: TransferEncryptedPhotoPayload[]): Generator { + let batch: TransferEncryptedPhotoPayload[] = []; + 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/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..41ab25ec --- /dev/null +++ b/js/sdk/src/internal/photos/albumsCrypto.ts @@ -0,0 +1,152 @@ +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'; + +/** + * 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, + }; + } + + 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/albumsManager.test.ts b/js/sdk/src/internal/photos/albumsManager.test.ts new file mode 100644 index 00000000..e3826e9f --- /dev/null +++ b/js/sdk/src/internal/photos/albumsManager.test.ts @@ -0,0 +1,367 @@ +import { ValidationError } from '../../errors'; +import { NodeType } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { AlbumsManager } from './albumsManager'; +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', () => { + let apiService: PhotosAPIService; + let cryptoService: AlbumsCryptoService; + let photoShares: PhotoSharesManager; + let nodesService: PhotosNodesAccess; + let photosService: PhotosManager; + let albums: AlbumsManager; + + 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(), + removePhotosFromAlbum: 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(), + }; + + // @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', () => { + 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('')).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: '' })).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'); + }); + + 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('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* () { + 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(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 new file mode 100644 index 00000000..53910262 --- /dev/null +++ b/js/sdk/src/internal/photos/albumsManager.ts @@ -0,0 +1,259 @@ +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 { 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; + +/** + * Provides access and high-level actions for managing albums. + */ +export class AlbumsManager { + private logger: Logger; + + constructor( + telemetry: ProtonDriveTelemetry, + private apiService: PhotosAPIService, + private cryptoService: AlbumsCryptoService, + private photoShares: PhotoSharesManager, + private nodesService: PhotosNodesAccess, + private photos: PhotosManager, + ) { + this.logger = telemetry.getLogger('albums'); + this.apiService = apiService; + this.cryptoService = cryptoService; + this.photoShares = photoShares; + this.nodesService = nodesService; + } + + async *iterateAlbums(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.photoShares.getRootIDs(); + + 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 *iterateAlbumUids(signal?: AbortSignal): AsyncGenerator { + 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; + } + } + + async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator { + yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal); + } + + 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, + ownedBy: rootNode.ownedBy, + + // 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 !== undefined) { + 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; 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); + } + + 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, + ); + try { + yield* process.execute(photoNodeUids); + } finally { + await this.nodesService.notifyNodeChanged(albumNodeUid); + } + } + + async *removePhotos( + albumNodeUid: string, + photoNodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + try { + for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) { + if (result.ok) { + await this.nodesService.notifyNodeChanged(result.uid); + } + yield result; + } + } finally { + await this.nodesService.notifyNodeChanged(albumNodeUid); + } + } + + 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.test.ts b/js/sdk/src/internal/photos/apiService.test.ts new file mode 100644 index 00000000..df3aba25 --- /dev/null +++ b/js/sdk/src/internal/photos/apiService.test.ts @@ -0,0 +1,388 @@ +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', + 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 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('copyPhoto', () => { + const photoPayloads = [ + { + nodeUid: 'volumeId2~photoNodeId1', + contentHash: 'contentHash1', + nameHash: 'nameHash1', + originalNameHash: 'originalNameHash1', + encryptedName: 'encryptedName1', + nameSignatureEmail: 'nameSignatureEmail1', + nodePassphrase: 'nodePassphrase1', + nodePassphraseSignature: 'nodePassphraseSignature1', + signatureEmail: 'signatureEmail1', + relatedPhotos: [ + { + nodeUid: 'volumeId2~photoNodeId2', + contentHash: 'contentHash2', + nameHash: 'nameHash2', + originalNameHash: 'originalNameHash2', + 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.copyPhoto(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.copyPhoto(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.copyPhoto(albumNodeUid, photoPayloads[0]); + + 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 new file mode 100644 index 00000000..54ea7c9a --- /dev/null +++ b/js/sdk/src/internal/photos/apiService.ts @@ -0,0 +1,653 @@ +import { c } from 'ttag'; + +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 { AlbumContainsPhotosNotInTimelineError, MissingRelatedPhotosError } from './errors'; +import { AlbumItem } from './interface'; +import { TransferEncryptedPhotoPayload } from './photosTransferPayloadBuilder'; + +type GetPhotoShareResponse = + drivePaths['/drive/v2/shares/photos']['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']; + +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 } +>['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 } +>['content']['application/json']; +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 } +>['content']['application/json']; +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']; + +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; + +/** + * 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 getPhotoShare(): Promise { + const response = await this.apiService.get('drive/v2/shares/photos'); + + return { + volumeId: response.Volume.VolumeID, + shareId: response.Share.ShareID, + rootNodeId: response.Link.Link.LinkID, + creatorEmail: response.Share.CreatorEmail, + encryptedCrypto: { + armoredKey: response.Share.Key, + armoredPassphrase: response.Share.Passphrase, + armoredPassphraseSignature: response.Share.PassphraseSignature, + }, + addressId: response.Share.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; + } + } + + 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[], + 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.LinkID || + 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); + } + + 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) { + 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; + } + } + + /** + * Add photos from the same volume to an album. + * + * To add photos from different volumes, use the {@link copyPhoto} method. + * + * In the future, these two methods will be merged into a single one. + */ + async *addPhotosToAlbum( + albumNodeUid: string, + photoPayloads: TransferEncryptedPhotoPayload[], + 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 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 copyPhoto( + targetNodeUid: string, + payload: TransferEncryptedPhotoPayload, + signal?: AbortSignal, + ): Promise { + const { volumeId: sourceVolumeId, nodeId: sourceLinkId } = splitNodeUid(payload.nodeUid); + const { volumeId: targetVolumeId, nodeId: targetNodeId } = splitNodeUid(targetNodeUid); + + try { + const response = await this.apiService.post( + `drive/volumes/${sourceVolumeId}/links/${sourceLinkId}/copy`, + { + TargetVolumeID: targetVolumeId, + TargetParentLinkID: targetNodeId, + 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 { + const { volumeId, nodeId: albumLinkId } = splitNodeUid(albumNodeUid); + + const batchSize = 10; + + for (const photoNodeUidsBatch of batch(photoNodeUids, batchSize)) { + const linkIds = photoNodeUidsBatch.map((nodeUid) => splitNodeUid(nodeUid).nodeId); + + let error: Error | undefined; + try { + await this.apiService.post( + `drive/photos/volumes/${volumeId}/albums/${albumLinkId}/remove-multiple`, + { + LinkIDs: linkIds, + }, + signal, + ); + } 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 (error) { + yield { uid, ok: false, error }; + } else { + yield { uid, ok: true }; + } + } + } + } + + 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, + ); + } + + 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 new file mode 100644 index 00000000..03c57dd6 --- /dev/null +++ b/js/sdk/src/internal/photos/errors.ts @@ -0,0 +1,22 @@ +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. + // 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'; + } +} + +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 new file mode 100644 index 00000000..013c5955 --- /dev/null +++ b/js/sdk/src/internal/photos/index.ts @@ -0,0 +1,193 @@ +import { DriveCrypto } from '../../crypto'; +import { + FeatureFlagProvider, + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, + ProtonDriveTelemetry, +} from '../../interface'; +import { DriveAPIService } from '../apiService'; +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'; +import { SharesCache } from '../shares/cache'; +import { SharesCryptoCache } from '../shares/cryptoCache'; +import { SharesCryptoService } from '../shares/cryptoService'; +import { NodesService as UploadNodesService } from '../upload/interface'; +import { UploadQueue } from '../upload/queue'; +import { UploadTelemetry } from '../upload/telemetry'; +import { AlbumsCryptoService } from './albumsCrypto'; +import { AlbumsManager } from './albumsManager'; +import { PhotosAPIService } from './apiService'; +import { SharesService } from './interface'; +import { PhotosNodesAccess, PhotosNodesAPIService, PhotosNodesCache, PhotosNodesManagement } from './nodes'; +import { PhotosManager } from './photosManager'; +import { PhotoSharesManager } from './shares'; +import { PhotosTimeline } from './timeline'; +import { + PhotoFileUploader, + PhotoUploadAPIService, + PhotoUploadCryptoService, + PhotoUploadManager, + PhotoUploadMetadata, +} from './upload'; + +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]; + +/** + * 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( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + photoShares: PhotoSharesManager, + nodesService: PhotosNodesAccess, +) { + const api = new PhotosAPIService(apiService); + const albumsCryptoService = new AlbumsCryptoService(driveCrypto); + const timeline = new PhotosTimeline( + telemetry.getLogger('photos-timeline'), + api, + driveCrypto, + 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, + albums, + photos, + }; +} + +/** + * 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, + ); +} + +/** + * 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, 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); + 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. + * + * 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, + featureFlagProvider: FeatureFlagProvider, + clientUid?: string, +) { + const api = new PhotoUploadAPIService(apiService, clientUid); + const cryptoService = new PhotoUploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider); + + 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(metadata.expectedSize, signal); + + const onFinish = () => { + queue.releaseCapacity(metadata.expectedSize); + }; + + return new PhotoFileUploader( + uploadTelemetry, + api, + cryptoService, + manager, + parentFolderUid, + name, + metadata, + onFinish, + // Small-file upload is not supported for photos yet. + () => Promise.resolve(false), + signal, + ); + } + + return { + getFileUploader, + }; +} diff --git a/js/sdk/src/internal/photos/interface.ts b/js/sdk/src/internal/photos/interface.ts new file mode 100644 index 00000000..4f404c9c --- /dev/null +++ b/js/sdk/src/internal/photos/interface.ts @@ -0,0 +1,58 @@ +import { PrivateKey } from '../../crypto'; +import { AlbumAttributes, MetricVolumeType, PhotoAttributes, PhotoTag } from '../../interface'; +import { DecryptedNode, DecryptedUnparsedNode, EncryptedNode } from '../nodes/interface'; +import { EncryptedShare } from '../shares'; + +export interface SharesService { + getRootIDs(): 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 type EncryptedPhotoNode = EncryptedNode & { + photo?: EncryptedPhotoAttributes; + album?: AlbumAttributes; +}; + +export type DecryptedUnparsedPhotoNode = DecryptedUnparsedNode & { + photo?: PhotoAttributes; + album?: AlbumAttributes; +}; + +export type DecryptedPhotoNode = DecryptedNode & { + photo?: PhotoAttributes; + album?: AlbumAttributes; +}; + +export type EncryptedPhotoAttributes = Omit & { + contentHash?: string; + albums: (PhotoAttributes['albums'][0] & { + nameHash?: string; + contentHash?: string; + })[]; +}; + +export type TimelineItem = { + nodeUid: string; + captureTime: Date; + tags: PhotoTag[]; +}; + +export type AlbumItem = { + nodeUid: string; + captureTime: Date; +}; 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..b011d2a3 --- /dev/null +++ b/js/sdk/src/internal/photos/nodes.test.ts @@ -0,0 +1,406 @@ +import { MemoryCache } from '../../cache'; +import { MemberRole, NodeType } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { DriveAPIService } from '../apiService'; +import { DecryptedPhotoNode } from './interface'; +import { PhotosNodesAccess, PhotosNodesAPIService, PhotosNodesCache, 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, + Album: { + PhotoCount: 1, + CoverLinkID: 'coverLinkId', + LastActivityTime: 1700002000, + }, + Folder: null, + ...overrides, + }; +} + +function generateAPIPhotoNode(linkOverrides = {}, photoOverrides = {}, 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', + ...photoOverrides, + }, + 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')); + if (expectedType) { + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe(expectedType); + } else { + expect(nodes).toHaveLength(0); + } + + return nodes; + } + + 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 () => { + 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'); + expect(nodes[0].album?.lastActivityTime).toEqual(new Date(1700002000 * 1000)); + }); + + it('should convert photo (type 2) to photo node with photo attributes', async () => { + const nodes = await testIterateNodes(generateAPIPhotoNode(), 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)); + }); + + 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)); + }); + }); +}); + +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', + }, + ], + }, + album: { + photoCount: 1, + coverPhotoNodeUid: 'volumeId~coverLinkId', + lastActivityTime: '2023-11-15T10:33:20.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')); + 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', () => { + 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(); + expect(node.album).toBeUndefined(); + }); + }); +}); + +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('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(); + + // @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: [], + }, + album: { + photoCount: 1, + coverPhotoNodeUid: 'volumeId~coverLinkId', + lastActivityTime: new Date('2023-11-15T10:33:20.000Z'), + }, + }; + + // @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]); + 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 new file mode 100644 index 00000000..9066b783 --- /dev/null +++ b/js/sdk/src/internal/photos/nodes.ts @@ -0,0 +1,324 @@ +import { PrivateKey } from '../../crypto'; +import { DecryptionError } from '../../errors'; +import { NodeType } from '../../interface'; +import { drivePaths } from '../apiService'; +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 { DecryptedPhotoNode, DecryptedUnparsedPhotoNode, EncryptedPhotoNode } 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 { + 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 | undefined { + const { baseNodeMetadata, baseCryptoNodeMetadata } = linkToEncryptedNodeBaseMetadata( + this.logger, + volumeId, + link, + isOwnVolumeId, + ); + + 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 === null || link.Photo.CaptureTime === undefined) { + this.logger.warn(`Requested draft photo node, skipping from the result`); + return undefined; + } + 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 && link.Album) { + return { + ...baseNodeMetadata, + album: { + photoCount: link.Album.PhotoCount, + coverPhotoNodeUid: link.Album.CoverLinkID + ? makeNodeUid(volumeId, link.Album.CoverLinkID) + : undefined, + lastActivityTime: new Date(link.Album.LastActivityTime * 1000), + }, + encryptedCrypto: { + ...baseCryptoNodeMetadata, + folder: { + armoredExtendedAttributes: link.Album.XAttr || undefined, + armoredHashKey: link.Album.NodeHashKey as string, + }, + }, + }; + } + + 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) || + (typeof node.album !== 'object' && node.album !== 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), + })), + }, + album: !node.album + ? undefined + : { + ...node.album, + lastActivityTime: new Date(node.album.lastActivityTime), + }, + } as DecryptedPhotoNode; + } +} + +export class PhotosNodesAccess extends NodesAccessBase { + async getParentKeys( + node: Pick, + ): Promise> { + // 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); + } + + 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); + } + + if (node.shareId) { + return super.getParentKeys(node); + } + + // 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: ${node.uid}`); + } + + 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, + type: NodeType.Photo, + }; + } + + if (unparsedNode.type === NodeType.Album) { + const node = parseNodeBase(this.logger, { + ...unparsedNode, + type: NodeType.Folder, + }); + return { + ...node, + album: unparsedNode.album, + type: NodeType.Album, + }; + } + + return parseNodeBase(this.logger, unparsedNode); + } + + /** + * Update album metadata fields in the cache without invalidating the node. + * Used by iterateAlbumUids to patch fresh API data (photoCount, coverNodeUid, + * lastActivityTime) into already-cached nodes so iterateNodes doesn't re-fetch + * the full node just to get up-to-date album attributes. + */ + async updateAlbumMetadataCache( + albumUid: string, + metadata: { photoCount: number; coverNodeUid?: string; lastActivityTime: Date }, + ): Promise { + 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 { + 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, + }, + }; + } + + if (decryptedNode.node.type === NodeType.Album) { + return { + node: { + ...decryptedNode.node, + album: encryptedNode.album, + }, + }; + } + + return decryptedNode; + } +} + +export class PhotosNodesManagement extends NodesManagementBase< + EncryptedPhotoNode, + DecryptedPhotoNode, + PhotosNodesCryptoService +> { + protected generateNodeFolder( + parentNode: DecryptedPhotoNode, + nodeUid: string, + name: string, + encryptedCrypto: { + hash: string; + encryptedName: string; + signatureEmail: string | null; + }, + ): DecryptedPhotoNode { + return this.generateNodeFolderBase(parentNode, nodeUid, name, encryptedCrypto); + } +} 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..4b9baba0 --- /dev/null +++ b/js/sdk/src/internal/photos/photosManager.test.ts @@ -0,0 +1,343 @@ +import { PhotoTag, resultOk } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { AlbumsCryptoService } from './albumsCrypto'; +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 { + 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; +} + +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< + Pick + >; + let cryptoService: jest.Mocked>; + let nodesService: jest.Mocked< + Pick< + PhotosNodesAccess, + | 'getVolumeRootFolder' + | 'getNodeKeys' + | 'getNodeSigningKeys' + | 'iterateNodes' + | 'getNodePrivateAndSessionKeys' + | 'notifyNodeChanged' + | 'notifyChildCreated' + > + >; + 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), + transferPhotos: jest.fn().mockImplementation(async function* () {}), + copyPhoto: jest.fn().mockResolvedValue('volume1~newPhoto'), + }; + + 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), + notifyChildCreated: 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(); + }); + }); + }); + + 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'); + }); + + 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 new file mode 100644 index 00000000..f1fe96f6 --- /dev/null +++ b/js/sdk/src/internal/photos/photosManager.ts @@ -0,0 +1,243 @@ +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'; + +/** + * The number of photos that are loaded in parallel to prepare the payloads. + */ +const BATCH_LOADING_SIZE = 20; + +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 *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 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 }; + } + + 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 && + 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; + } + } + + // 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 }, + 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..aa3555c4 --- /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 { PhotosNodesAccess } from './nodes'; +import { PhotoTransferPayloadBuilder } from './photosTransferPayloadBuilder'; + +/** + * 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..7d183284 --- /dev/null +++ b/js/sdk/src/internal/photos/photosTransferPayloadBuilder.ts @@ -0,0 +1,205 @@ +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; + originalNameHash: string | undefined; + 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, + originalNameHash: photoNode.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/internal/photos/shares.ts b/js/sdk/src/internal/photos/shares.ts new file mode 100644 index 00000000..85b5ac22 --- /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 getRootIDs(): 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.getRootIDs(); + if (volumeId === myVolumeId) { + return true; + } + return this.sharesService.isOwnVolume(volumeId); + } + + async getVolumeMetricContext(volumeId: string): Promise { + const { volumeId: myVolumeId } = await this.getRootIDs(); + if (volumeId === myVolumeId) { + return MetricVolumeType.OwnPhotoVolume; + } + return this.sharesService.getVolumeMetricContext(volumeId); + } + + async loadEncryptedShare(shareId: string): Promise { + return this.sharesService.loadEncryptedShare(shareId); + } +} 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..80e39ec4 --- /dev/null +++ b/js/sdk/src/internal/photos/timeline.test.ts @@ -0,0 +1,148 @@ +import { DriveCrypto } from '../../crypto'; +import { getMockLogger } from '../../tests/logger'; +import { makeNodeUid } from '../uids'; +import { PhotosAPIService } from './apiService'; +import { PhotosNodesAccess } from './nodes'; +import { PhotoSharesManager } from './shares'; +import { PhotosTimeline } from './timeline'; + +describe('PhotosTimeline', () => { + let logger: ReturnType; + let apiService: PhotosAPIService; + let driveCrypto: DriveCrypto; + let photoShares: PhotoSharesManager; + let nodesService: PhotosNodesAccess; + 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('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.findPhotoDuplicates(name, generateSha1); + + expect(result).toEqual([]); + 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.findPhotoDuplicates(name, generateSha1); + + expect(result).toEqual([]); + 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.findPhotoDuplicates(name, generateSha1); + + 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 new file mode 100644 index 00000000..2d6f8199 --- /dev/null +++ b/js/sdk/src/internal/photos/timeline.ts @@ -0,0 +1,66 @@ +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'; + +/** + * Provides access to the photo timeline. + */ +export class PhotosTimeline { + constructor( + private logger: Logger, + private apiService: PhotosAPIService, + private driveCrypto: DriveCrypto, + private photoShares: PhotoSharesManager, + private nodesService: PhotosNodesAccess, + ) { + this.logger = logger; + this.apiService = apiService; + this.driveCrypto = driveCrypto; + this.photoShares = photoShares; + this.nodesService = nodesService; + } + + async *iterateTimeline(signal?: AbortSignal): AsyncGenerator { + const { volumeId } = await this.photoShares.getRootIDs(); + yield* this.apiService.iterateTimeline(volumeId, signal); + } + + 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); + 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 []; + } + + // 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 []; + } + + const nodeUids = matchingDuplicates.map((duplicate) => duplicate.nodeUid); + this.logger.debug( + `Duplicate photo found: name hash: ${nameHash}, content hash: ${contentHash}, node uids: ${nodeUids}`, + ); + return nodeUids; + } +} diff --git a/js/sdk/src/internal/photos/upload.ts b/js/sdk/src/internal/photos/upload.ts new file mode 100644 index 00000000..70bb6e68 --- /dev/null +++ b/js/sdk/src/internal/photos/upload.ts @@ -0,0 +1,280 @@ +import { DriveCrypto } from '../../crypto'; +import { + AnonymousUser, + FeatureFlagProvider, + PhotoTag, + ProtonDriveTelemetry, + Thumbnail, + UploadMetadata, +} from '../../interface'; +import { DriveAPIService, drivePaths } from '../apiService'; +import { generateFileExtendedAttributes } from '../nodes'; +import { splitNodeRevisionUid, splitNodeUid } 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'; +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; + mainPhotoNodeUid?: string; + tags?: PhotoTag[]; +}; + +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, + shouldUseSmallFileUpload: (expectedSize: number) => Promise, + signal?: AbortSignal, + ) { + super( + telemetry, + apiService, + cryptoService, + manager, + parentFolderUid, + name, + metadata, + onFinish, + shouldUseSmallFileUpload, + 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.controller, + 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, + controller: UploadController, + signal?: AbortSignal, + ) { + 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; + } + + async commitFile(thumbnails: Thumbnail[]) { + const digests = this.digests.digests(); + const integrityInfo = this.verifyIntegrity(thumbnails, digests); + + const extendedAttributes = { + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: this.uploadedBlockSizes, + digests, + }; + + await this.photoUploadManager.commitDraftPhoto( + this.revisionDraft, + await this.getManifest(), + extendedAttributes, + this.photoMetadata, + integrityInfo, + ); + } +} + +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, + integrityInfo: { checksumVerified: boolean }, + ): 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, + mainPhotoNodeUid: uploadMetadata.mainPhotoNodeUid, + tags: uploadMetadata.tags, + }; + await this.photoApiService.commitDraftPhoto( + nodeRevisionDraft.nodeRevisionUid, + { + ...nodeCommitCrypto, + ...integrityInfo, + }, + photo, + ); + await this.notifyNodeUploaded(nodeRevisionDraft); + } +} + +export class PhotoUploadCryptoService extends UploadCryptoService { + constructor( + telemetry: ProtonDriveTelemetry, + driveCrypto: DriveCrypto, + nodesService: NodesService, + featureFlagProvider: FeatureFlagProvider, + ) { + super(telemetry, driveCrypto, nodesService, featureFlagProvider); + } + + 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 | AnonymousUser; + armoredExtendedAttributes?: string; + checksumVerified?: boolean; + }, + photo: { + contentHash: string; + captureTime?: Date; + 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, + PostCommitRevisionResponse + >(`drive/v2/volumes/${volumeId}/files/${nodeId}/revisions/${revisionId}`, { + 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, + MainPhotoLinkID: mainPhotoNodeId, + Tags: photo.tags || [], + Exif: null, // Deprecated field, not used. + }, + }); + } +} diff --git a/js/sdk/src/internal/sdkEvents.test.ts b/js/sdk/src/internal/sdkEvents.test.ts new file mode 100644 index 00000000..f7a42f08 --- /dev/null +++ b/js/sdk/src/internal/sdkEvents.test.ts @@ -0,0 +1,55 @@ +import { SDKEvent } from '../interface'; +import { SDKEvents } from './sdkEvents'; + +describe('SDKEvents', () => { + let sdkEvents: SDKEvents; + let logger: { debug: jest.Mock }; + + beforeEach(() => { + logger = { debug: jest.fn() }; + sdkEvents = new SDKEvents({ getLogger: () => logger } as any); + }); + + it('should log when no listeners are present for an event', () => { + sdkEvents.requestsThrottled(); + + expect(logger.debug).toHaveBeenCalledWith('No listeners for event: requestsThrottled'); + }); + + it('should emit an event to its listeners', () => { + const requestsThrottledListener = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener); + const requestsUnthrottledListener = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsUnthrottled, requestsUnthrottledListener); + + sdkEvents.requestsThrottled(); + + expect(requestsThrottledListener).toHaveBeenCalled(); + expect(requestsUnthrottledListener).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('Emitting event: requestsThrottled'); + }); + + it('should emit an event to multiple listeners', () => { + const requestsThrottledListener1 = jest.fn(); + const requestsThrottledListener2 = jest.fn(); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener1); + sdkEvents.addListener(SDKEvent.RequestsThrottled, requestsThrottledListener2); + + sdkEvents.requestsThrottled(); + + expect(requestsThrottledListener1).toHaveBeenCalled(); + expect(requestsThrottledListener2).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('Emitting event: requestsThrottled'); + }); + + it('should not emit after unsubsribe', () => { + const callback = jest.fn(); + const unsubscribe = sdkEvents.addListener(SDKEvent.RequestsThrottled, callback); + + sdkEvents.requestsThrottled(); + unsubscribe(); + sdkEvents.requestsThrottled(); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/sdk/src/internal/sdkEvents.ts b/js/sdk/src/internal/sdkEvents.ts new file mode 100644 index 00000000..5403c6b9 --- /dev/null +++ b/js/sdk/src/internal/sdkEvents.ts @@ -0,0 +1,44 @@ +import { Logger, ProtonDriveTelemetry, SDKEvent } from '../interface'; + +export class SDKEvents { + private logger: Logger; + private listeners: Map void)[]> = new Map(); + + constructor(telemetry: ProtonDriveTelemetry) { + this.logger = telemetry.getLogger('sdk-events'); + } + + addListener(eventName: SDKEvent, callback: () => void): () => void { + this.listeners.set(eventName, [...(this.listeners.get(eventName) || []), callback]); + + return () => { + this.listeners.set(eventName, this.listeners.get(eventName)?.filter((cb) => cb !== callback) || []); + }; + } + + transfersPaused(): void { + this.emit(SDKEvent.TransfersPaused); + } + + transfersResumed(): void { + this.emit(SDKEvent.TransfersResumed); + } + + requestsThrottled(): void { + this.emit(SDKEvent.RequestsThrottled); + } + + requestsUnthrottled(): void { + this.emit(SDKEvent.RequestsUnthrottled); + } + + private emit(eventName: SDKEvent): void { + if (!this.listeners.get(eventName)?.length) { + this.logger.debug(`No listeners for event: ${eventName}`); + return; + } + + this.logger.debug(`Emitting event: ${eventName}`); + this.listeners.get(eventName)?.forEach((callback) => callback()); + } +} diff --git a/js/sdk/src/internal/shares/apiService.ts b/js/sdk/src/internal/shares/apiService.ts new file mode 100644 index 00000000..d885e698 --- /dev/null +++ b/js/sdk/src/internal/shares/apiService.ts @@ -0,0 +1,190 @@ +import { DriveAPIService, drivePaths } from '../apiService'; +import { makeMemberUid } from '../uids'; +import { EncryptedRootShare, EncryptedShare, 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< + 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. + */ +export class SharesAPIService { + constructor(private apiService: DriveAPIService) { + this.apiService = apiService; + } + + async getMyFiles(): Promise { + const response = await this.apiService.get('drive/v2/shares/my-files'); + return { + volumeId: response.Volume.VolumeID, + shareId: response.Share.ShareID, + rootNodeId: response.Link.Link.LinkID, + creatorEmail: response.Share.CreatorEmail, + encryptedCrypto: { + armoredKey: response.Share.Key, + armoredPassphrase: response.Share.Passphrase, + armoredPassphraseSignature: response.Share.PassphraseSignature, + }, + addressId: response.Share.AddressID, + type: ShareType.Main, + }; + } + + async getVolume(volumeId: string): Promise<{ shareId: string }> { + const response = await this.apiService.get(`drive/volumes/${volumeId}`); + return { + shareId: response.Volume.Share.ShareID, + }; + } + + async getShare(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}`); + return convertSharePayload(response); + } + + /** + * Returns root share with address key. + * + * This function provides access to root shares that provides access + * to node tree via address key. For this reason, caller must use this + * only when it is clear the shareId is root share. + * + * @throws Error when share is not root share. + */ + async getRootShare(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}`); + + if (!response.AddressID) { + throw new Error('Loading share without direct access is not supported'); + } + + return { + ...convertSharePayload(response), + addressId: response.AddressID, + }; + } + + async createVolume( + share: { + addressId: string; + addressKeyId: string; + } & EncryptedShareCrypto, + node: { + encryptedName: string; + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + armoredHashKey: string; + }, + ): Promise<{ volumeId: string; shareId: string; rootNodeId: string }> { + const response = await this.apiService.post< + // Volume & share names are deprecated. + Omit, + PostCreateVolumeResponse + >('drive/volumes', { + AddressID: share.addressId, + AddressKeyID: share.addressKeyId, + ShareKey: share.armoredKey, + SharePassphrase: share.armoredPassphrase, + SharePassphraseSignature: share.armoredPassphraseSignature, + + FolderName: node.encryptedName, + FolderKey: node.armoredKey, + FolderPassphrase: node.armoredPassphrase, + FolderPassphraseSignature: node.armoredPassphraseSignature, + FolderHashKey: node.armoredHashKey, + }); + return { + volumeId: response.Volume.ID, + shareId: response.Volume.Share.ShareID, + rootNodeId: response.Volume.Share.LinkID, + }; + } + + async createShare( + volumeId: string, + share: { + addressId: string; + } & EncryptedShareCrypto, + node: { + nodeId: string; + encryptedName: string; + nameKeyPacket: string; + passphraseKeyPacket: string; + }, + ): Promise<{ shareId: string }> { + const response = await this.apiService.post< + // Share name is deprecated. + Omit, + PostCreateShareResponse + >(`/drive/volumes/${volumeId}/shares`, { + AddressID: share.addressId, + ShareKey: share.armoredKey, + SharePassphrase: share.armoredPassphrase, + SharePassphraseSignature: share.armoredPassphraseSignature, + RootLinkID: node.nodeId, + NameKeyPacket: node.nameKeyPacket, + PassphraseKeyPacket: node.passphraseKeyPacket, + }); + + return { + shareId: response.Share.ID, + }; + } +} + +function convertSharePayload(response: GetShareResponse): EncryptedShare { + return { + volumeId: response.VolumeID, + shareId: response.ShareID, + rootNodeId: response.LinkID, + creatorEmail: response.Creator, + 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, + type: convertShareTypeNumberToEnum(response.Type), + editorsCanShare: response.EditorsCanShare + }; +} + +function convertShareTypeNumberToEnum(type: 1 | 2 | 3 | 4 | 5): ShareType { + switch (type) { + case 1: + return ShareType.Main; + case 2: + return ShareType.Standard; + case 3: + 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/shares/cache.test.ts b/js/sdk/src/internal/shares/cache.test.ts new file mode 100644 index 00000000..1b1709af --- /dev/null +++ b/js/sdk/src/internal/shares/cache.test.ts @@ -0,0 +1,60 @@ +import { MemoryCache } from '../../cache'; +import { getMockLogger } from '../../tests/logger'; +import { SharesCache } from './cache'; + +describe('sharesCache', () => { + let memoryCache: MemoryCache; + let cache: SharesCache; + + beforeEach(async () => { + memoryCache = new MemoryCache(); + await memoryCache.setEntity('volume-badObject', 'aaa'); + + cache = new SharesCache(getMockLogger(), memoryCache); + }); + + it('should store and retrieve volume', async () => { + const volumeId = 'volume1'; + const volume = { + volumeId, + shareId: 'share1', + rootNodeId: 'node1', + creatorEmail: 'email', + addressId: 'address1', + }; + + await cache.setVolume(volume); + const result = await cache.getVolume(volumeId); + + expect(result).toStrictEqual(volume); + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const volumeId = 'newVolumeId'; + + try { + await cache.getVolume(volumeId); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a bad keys and remove the key', async () => { + try { + await cache.getVolume('badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe( + 'Error: Failed to deserialize volume: Unexpected token \'a\', \"aaa\" is not valid JSON', + ); + } + + try { + await memoryCache.getEntity('volumes-badObject'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); +}); diff --git a/js/sdk/src/internal/shares/cache.ts b/js/sdk/src/internal/shares/cache.ts new file mode 100644 index 00000000..6e278642 --- /dev/null +++ b/js/sdk/src/internal/shares/cache.ts @@ -0,0 +1,75 @@ +import { Logger, ProtonDriveEntitiesCache } from '../../interface'; +import { getErrorMessage } from '../errors'; +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, + ) { + this.logger = logger; + this.driveCache = driveCache; + } + + async setVolume(volume: Volume): Promise { + const key = getCacheUid(volume.volumeId); + const shareData = serializeVolume(volume); + await this.driveCache.setEntity(key, shareData); + } + + async getVolume(volumeId: string): Promise { + const key = getCacheUid(volumeId); + const volumeData = await this.driveCache.getEntity(key); + + try { + return deserializeVolume(volumeData); + } catch (error: unknown) { + try { + await this.removeVolume(volumeId); + } catch (removingError: unknown) { + this.logger.error('Failed to remove invalid volume from cache', removingError); + } + throw new Error(`Failed to deserialize volume: ${getErrorMessage(error)}`); + } + } + + async removeVolume(volumeId: string): Promise { + await this.driveCache.removeEntities([getCacheUid(volumeId)]); + } +} + +function getCacheUid(volumeId: string) { + return `volume-${volumeId}`; +} + +function serializeVolume(volume: Volume) { + return JSON.stringify(volume); +} + +function 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' + ) { + throw new Error('Invalid volume data'); + } + return volume; +} diff --git a/js/sdk/src/internal/shares/cryptoCache.test.ts b/js/sdk/src/internal/shares/cryptoCache.test.ts new file mode 100644 index 00000000..92fb4abf --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoCache.test.ts @@ -0,0 +1,77 @@ +import { MemoryCache } from '../../cache'; +import { PrivateKey, SessionKey } from '../../crypto'; +import { CachedCryptoMaterial } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { SharesCryptoCache } from './cryptoCache'; + +describe('sharesCryptoCache', () => { + let memoryCache: MemoryCache; + let cache: SharesCryptoCache; + + const generatePrivateKey = (name: string) => { + return name as unknown as PrivateKey; + }; + + const generateSessionKey = (name: string) => { + return name as unknown as SessionKey; + }; + + beforeEach(() => { + memoryCache = new MemoryCache(); + cache = new SharesCryptoCache(getMockLogger(), memoryCache); + }); + + it('should store and retrieve keys', async () => { + const shareId = 'newShareId'; + const keys = { key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey') }; + + await cache.setShareKey(shareId, keys); + const result = await cache.getShareKey(shareId); + + expect(result).toStrictEqual(keys); + }); + + it('should replace and retrieve new keys', async () => { + const shareId = 'newShareId'; + const keys1 = { + key: generatePrivateKey('privateKey1'), + passphraseSessionKey: generateSessionKey('sessionKey1'), + }; + const keys2 = { + key: generatePrivateKey('privateKey2'), + passphraseSessionKey: generateSessionKey('sessionKey2'), + }; + + await cache.setShareKey(shareId, keys1); + await cache.setShareKey(shareId, keys2); + const result = await cache.getShareKey(shareId); + + expect(result).toStrictEqual(keys2); + }); + + it('should remove keys', async () => { + const shareId = 'newShareId'; + const keys = { key: generatePrivateKey('privateKey'), passphraseSessionKey: generateSessionKey('sessionKey') }; + + await cache.setShareKey(shareId, keys); + await cache.removeShareKeys([shareId]); + + try { + await cache.getShareKey(shareId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); + + it('should throw an error when retrieving a non-existing entity', async () => { + const shareId = 'newShareId'; + + try { + await cache.getShareKey(shareId); + throw new Error('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Entity not found'); + } + }); +}); diff --git a/js/sdk/src/internal/shares/cryptoCache.ts b/js/sdk/src/internal/shares/cryptoCache.ts new file mode 100644 index 00000000..be796ef4 --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoCache.ts @@ -0,0 +1,54 @@ +import { Logger, 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 + * only the root node, thus share cache is not needed. + */ +export class SharesCryptoCache { + constructor( + private logger: Logger, + private driveCache: ProtonDriveCryptoCache, + ) { + this.logger = logger; + this.driveCache = driveCache; + } + + async setShareKey(shareId: string, key: DecryptedShareKey): Promise { + await this.driveCache.setEntity(getCacheKey(shareId), { + shareKey: key, + }); + } + + async getShareKey(shareId: string): Promise { + 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 removeShareKeys(shareIds: string[]): Promise { + await this.driveCache.removeEntities(shareIds.map(getCacheKey)); + } +} + +function getCacheKey(shareId: string) { + return `shareKey-${shareId}`; +} diff --git a/js/sdk/src/internal/shares/cryptoService.test.ts b/js/sdk/src/internal/shares/cryptoService.test.ts new file mode 100644 index 00000000..a683058c --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoService.test.ts @@ -0,0 +1,137 @@ +import { DriveCrypto, PrivateKey, SessionKey, VERIFICATION_STATUS } from '../../crypto'; +import { ProtonDriveAccount, ProtonDriveTelemetry } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { SharesCryptoService } from './cryptoService'; +import { EncryptedRootShare, ShareType } from './interface'; + +describe('SharesCryptoService', () => { + let telemetry: ProtonDriveTelemetry; + let driveCrypto: DriveCrypto; + let account: ProtonDriveAccount; + let cryptoService: SharesCryptoService; + + beforeEach(() => { + telemetry = getMockTelemetry(); + // @ts-expect-error No need to implement all methods for mocking + driveCrypto = { + decryptKey: jest.fn(async () => + Promise.resolve({ + passphrase: 'pass', + key: 'decryptedKey' as unknown as PrivateKey, + 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 }], + })), + 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); + + expect(result).toMatchObject({ + share: { + shareId: 'shareId', + author: { ok: true, value: 'signatureEmail' }, + }, + key: { + key: 'decryptedKey', + passphraseSessionKey: 'sessionKey', + }, + }); + + expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(telemetry.recordMetric).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); + + expect(result).toMatchObject({ + share: { + shareId: 'shareId', + author: { ok: false, error: { claimedAuthor: 'signatureEmail', error: 'Missing signature' } }, + }, + key: { + key: 'decryptedKey', + passphraseSessionKey: 'sessionKey', + }, + }); + + expect(account.getOwnAddress).toHaveBeenCalledWith('addressId'); + expect(account.getPublicKeys).toHaveBeenCalledWith('signatureEmail'); + expect(telemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'verificationError', + volumeType: 'own_volume', + field: 'shareKey', + addressMatchingDefaultShare: undefined, + fromBefore2024: undefined, + uid: 'shareId', + }); + }); + + 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); + + await expect(result).rejects.toThrow(error); + + 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 new file mode 100644 index 00000000..12ac2665 --- /dev/null +++ b/js/sdk/src/internal/shares/cryptoService.ts @@ -0,0 +1,175 @@ +import { DriveCrypto, PrivateKey, VERIFICATION_STATUS } from '../../crypto'; +import { + Logger, + MetricVolumeType, + ProtonDriveAccount, + ProtonDriveTelemetry, + Result, + resultError, + resultOk, + UnverifiedAuthorError, +} from '../../interface'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; +import { + DecryptedRootShare, + DecryptedShareKey, + EncryptedRootShare, + EncryptedShareCrypto, + 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 { + private logger: Logger; + + private reportedDecryptionErrors = new Set(); + private reportedVerificationErrors = new Set(); + + constructor( + private telemetry: ProtonDriveTelemetry, + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('shares-crypto'); + this.driveCrypto = driveCrypto; + this.account = account; + } + + async generateVolumeBootstrap(addressKey: PrivateKey): Promise<{ + shareKey: { encrypted: EncryptedShareCrypto; decrypted: DecryptedShareKey }; + rootNode: { + 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 { armoredHashKey } = await this.driveCrypto.generateHashKey(rootNodeKey.decrypted.key); + return { + shareKey, + rootNode: { + key: rootNodeKey, + encryptedName: armoredNodeName, + armoredHashKey, + }, + }; + } + + 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); + + let key, passphraseSessionKey, verified, verificationErrors; + try { + const result = await this.driveCrypto.decryptKey( + share.encryptedCrypto.armoredKey, + share.encryptedCrypto.armoredPassphrase, + share.encryptedCrypto.armoredPassphraseSignature, + addressKeys.map(({ key }) => key), + addressPublicKeys, + ); + key = result.key; + passphraseSessionKey = result.passphraseSessionKey; + verified = result.verified; + verificationErrors = result.verificationErrors; + } catch (error: unknown) { + this.reportDecryptionError(share, error); + throw error; + } + + const author: Result = + verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: getVerificationMessage(verified, verificationErrors), + }); + + if (!author.ok) { + await this.reportVerificationError(share); + } + + return { + share: { + ...share, + author, + }, + key: { + key, + passphraseSessionKey, + }, + }; + } + + private reportDecryptionError(share: EncryptedRootShare, error?: unknown) { + if (isNotApplicationError(error)) { + return; + } + + if (this.reportedDecryptionErrors.has(share.shareId)) { + return; + } + + 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.recordMetric({ + eventName: 'decryptionError', + volumeType: shareTypeToMetricContext(share.type), + field: 'shareKey', + fromBefore2024, + error, + uid: share.shareId, + }); + this.reportedDecryptionErrors.add(share.shareId); + } + + private async reportVerificationError(share: EncryptedRootShare) { + if (this.reportedVerificationErrors.has(share.shareId)) { + return; + } + + 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.recordMetric({ + eventName: 'verificationError', + volumeType: shareTypeToMetricContext(share.type), + field: 'shareKey', + fromBefore2024, + uid: share.shareId, + }); + this.reportedVerificationErrors.add(share.shareId); + } +} + +function shareTypeToMetricContext(shareType: ShareType): MetricVolumeType { + // SDK doesn't support public sharing yet, also public sharing + // doesn't use a share but shareURL, thus we can simplify and + // ignore this case for now. + switch (shareType) { + case ShareType.Main: + case ShareType.Device: + case ShareType.Photo: + return MetricVolumeType.OwnVolume; + case ShareType.Standard: + return MetricVolumeType.Shared; + } +} diff --git a/js/sdk/src/internal/shares/index.ts b/js/sdk/src/internal/shares/index.ts new file mode 100644 index 00000000..040b0e6c --- /dev/null +++ b/js/sdk/src/internal/shares/index.ts @@ -0,0 +1,48 @@ +import { DriveCrypto } from '../../crypto'; +import { + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, + ProtonDriveTelemetry, +} from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { SharesAPIService } from './apiService'; +import { SharesCache } from './cache'; +import { SharesCryptoCache } from './cryptoCache'; +import { SharesCryptoService } from './cryptoService'; +import { SharesManager } from './manager'; + +export type { EncryptedShare } from './interface'; +export { ShareTargetType } 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. + */ +export function initSharesModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + account: ProtonDriveAccount, + crypto: DriveCrypto, +) { + const api = new SharesAPIService(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); + 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 new file mode 100644 index 00000000..7dceafc6 --- /dev/null +++ b/js/sdk/src/internal/shares/interface.ts @@ -0,0 +1,114 @@ +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. + * + * 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. + */ +export interface VolumeShareNodeIDs { + volumeId: string; + shareId: string; + rootNodeId: string; +} + +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. + */ + creatorEmail: string; + addressId: string; +} & VolumeShareNodeIDs; + +/** + * Internal share interface. + */ +type BaseShare = { + /** + * Address ID is set only when user is member of the share. + * Owner or invitee of share with higher access in the tree + * might not have this field set. + */ + addressId?: string; + creationTime?: Date; + type: ShareType; +} & VolumeShareNodeIDs; + +export enum ShareType { + Main = 'main', + Standard = 'standard', + Device = 'device', + Photo = 'photo', +} + +interface BaseRootShare extends BaseShare { + /** + * Address ID is always available for root shares, in contrast + * to other standard shares that might not have it. See the comment + * for BaseShare. + */ + addressId: string; +} + +/** + * Interface used only internaly in the shares module. + * + * Outside of the module, the decrypted share interface should be used. + */ +export interface EncryptedShare extends BaseShare { + creatorEmail: string; + encryptedCrypto: EncryptedShareCrypto; + membership?: ShareMembership; + editorsCanShare: boolean; +} + +interface ShareMembership { + memberUid: string; +} + +/** + * Interface used only internaly in the shares module. + * + * Outside of the module, the decrypted share interface should be used. + */ +export interface EncryptedRootShare extends BaseRootShare { + creatorEmail: string; + encryptedCrypto: EncryptedShareCrypto; +} + +/** + * Interface holding decrypted share metadata. + */ +export interface DecryptedRootShare extends BaseRootShare { + author: Result; +} + +export interface EncryptedShareCrypto { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; +} + +export interface DecryptedShareKey { + key: PrivateKey; + passphraseSessionKey: SessionKey; +} diff --git a/js/sdk/src/internal/shares/manager.test.ts b/js/sdk/src/internal/shares/manager.test.ts new file mode 100644 index 00000000..1c58b8b2 --- /dev/null +++ b/js/sdk/src/internal/shares/manager.test.ts @@ -0,0 +1,218 @@ +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; + let cryptoService: SharesCryptoService; + let account: ProtonDriveAccount; + + let manager: SharesManager; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + apiService = { + getMyFiles: jest.fn(), + getRootShare: jest.fn(), + getShare: jest.fn(), + getVolume: jest.fn(), + createVolume: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cache = { + setVolume: jest.fn(), + getVolume: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoCache = { + setShareKey: jest.fn(), + getShareKey: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + cryptoService = { + generateVolumeBootstrap: jest.fn(), + decryptRootShare: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + account = { + getOwnPrimaryAddress: jest.fn(), + getOwnAddress: jest.fn(), + }; + + manager = new SharesManager(getMockLogger(), apiService, cache, cryptoCache, cryptoService, account); + }); + + describe('getRootIDs', () => { + const myFilesShare = { + shareId: 'myFilesShareId', + volumeId: 'myFilesVolumeId', + rootNodeId: 'myFilesRootNodeId', + }; + + it('should load My files IDs once', async () => { + const encryptedShare = { + share: myFilesShare, + creatorEmail: 'email', + }; + const key = { + key: 'privateKey', + sessionKey: 'sessionKey', + }; + + apiService.getMyFiles = jest.fn().mockResolvedValue(encryptedShare); + cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ share: myFilesShare, key }); + + // Calling twice to check if it loads only once. + await manager.getRootIDs(); + const result = await manager.getRootIDs(); + + expect(result).toStrictEqual(myFilesShare); + expect(apiService.getMyFiles).toHaveBeenCalledTimes(1); + expect(cryptoService.decryptRootShare).toHaveBeenCalledTimes(1); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith(myFilesShare.shareId, key); + expect(cache.setVolume).toHaveBeenCalledWith({ + ...myFilesShare, + creatorEmail: encryptedShare.creatorEmail, + }); + expect(apiService.createVolume).not.toHaveBeenCalled(); + }); + + it("should create volume when My files section doesn't exist", async () => { + apiService.getMyFiles = jest.fn().mockRejectedValue(new NotFoundAPIError('no active volume', 0)); + account.getOwnPrimaryAddress = jest + .fn() + .mockResolvedValue({ primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); + cryptoService.generateVolumeBootstrap = jest.fn().mockResolvedValue({ + shareKey: { + encrypted: 'encrypted share key', + decrypted: 'decrypted share key', + }, + rootNode: { + key: { + encrypted: 'encrypted root key', + }, + }, + }); + apiService.createVolume = jest.fn().mockResolvedValue(myFilesShare); + + const result = await manager.getRootIDs(); + + expect(result).toStrictEqual(myFilesShare); + expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith('myFilesShareId', 'decrypted share key'); + }); + + it('should throw on unknown error', async () => { + apiService.getMyFiles = jest.fn().mockRejectedValue(new Error('Some error')); + + await expect(manager.getRootIDs()).rejects.toThrow('Some error'); + expect(cryptoService.decryptRootShare).not.toHaveBeenCalled(); + expect(apiService.createVolume).not.toHaveBeenCalled(); + }); + }); + + describe('getSharePrivateKey', () => { + it('should return cached private key', async () => { + cryptoCache.getShareKey = jest.fn().mockResolvedValue({ key: 'cachedPrivateKey' }); + + const result = await manager.getSharePrivateKey('shareId'); + + expect(result).toBe('cachedPrivateKey'); + }); + + it('should load private key if not in cache', async () => { + cryptoCache.getShareKey = jest.fn().mockRejectedValue(new Error('not found')); + apiService.getRootShare = jest.fn().mockResolvedValue({ shareId: 'shareId' }); + cryptoService.decryptRootShare = jest.fn().mockResolvedValue({ key: { key: 'privateKey' } }); + + const result = await manager.getSharePrivateKey('shareId'); + + expect(result).toBe('privateKey'); + expect(cryptoCache.setShareKey).toHaveBeenCalledWith('shareId', { key: 'privateKey' }); + }); + }); + + describe('getMyFilesShareMemberEmailKey', () => { + it('should return cached volume email key', async () => { + jest.spyOn(manager, 'getRootIDs').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', + }); + }); + + it('should load volume email key if not in cache', async () => { + jest.spyOn(manager, 'getRootIDs').mockResolvedValue({ volumeId: 'volumeId' } as VolumeShareNodeIDs); + const share = { + 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.getRootShare = jest.fn().mockResolvedValue(share); + 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', + }); + expect(cache.setVolume).toHaveBeenCalledWith(share); + }); + }); + + describe('getContextShareMemberEmailKey', () => { + it('should load share email key only once', async () => { + const share = { + volumeId: 'volumeId', + shareId: 'shareId', + rootNodeId: 'rootNodeId', + creatorEmail: 'creatorEmail', + addressId: 'addressId', + }; + apiService.getRootShare = jest.fn().mockResolvedValue(share); + account.getOwnAddress = jest + .fn() + .mockResolvedValue({ email: 'email', primaryKeyIndex: 0, keys: [{ key: 'addressKey' }] }); + + const result = await manager.getContextShareMemberEmailKey('shareId'); + + expect(result).toEqual({ + addressId: 'addressId', + email: 'email', + addressKey: 'addressKey', + }); + expect(apiService.getRootShare).toHaveBeenCalledTimes(1); + expect(account.getOwnAddress).toHaveBeenCalledTimes(1); + + const result2 = await manager.getContextShareMemberEmailKey('shareId'); + + expect(result2).toEqual(result); + expect(apiService.getRootShare).toHaveBeenCalledTimes(1); + expect(account.getOwnAddress).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/js/sdk/src/internal/shares/manager.ts b/js/sdk/src/internal/shares/manager.ts new file mode 100644 index 00000000..d2ccded0 --- /dev/null +++ b/js/sdk/src/internal/shares/manager.ts @@ -0,0 +1,217 @@ +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 { EncryptedRootShare, EncryptedShare, VolumeShareNodeIDs } from './interface'; + +/** + * Provides high-level actions for managing shares. + * + * The manager is responsible for handling shares metadata, including + * API communication, encryption, decryption, and caching. + * + * This module uses other modules providing low-level operations, such + * as API service, cache, crypto service, etc. + */ +export class SharesManager { + // Cache for My files IDs. + // Those IDs are required very often, so it is better to keep them in memory. + // The IDs are not cached in the cache module, as we want to always fetch + // them from the API, and not from the this.cache. + private myFilesIds?: VolumeShareNodeIDs; + + private rootShares: Map = new Map(); + + constructor( + private logger: Logger, + private apiService: SharesAPIService, + private cache: SharesCache, + private cryptoCache: SharesCryptoCache, + private cryptoService: SharesCryptoService, + private account: ProtonDriveAccount, + ) { + this.logger = logger; + this.apiService = apiService; + this.cache = cache; + this.cryptoCache = cryptoCache; + this.cryptoService = cryptoService; + this.account = account; + } + + /** + * It returns the IDs of the My files section. + * + * If the default volume or My files section doesn't exist, it creates it. + */ + async getRootIDs(): Promise { + if (this.myFilesIds) { + return this.myFilesIds; + } + + try { + const encryptedShare = await this.apiService.getMyFiles(); + + // Once any place needs IDs for My files, it will most likely + // need also the keys for decrypting the tree. It is better to + // decrypt the share here right away. + const { 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.myFilesIds = { + volumeId: myFilesShare.volumeId, + shareId: myFilesShare.shareId, + rootNodeId: myFilesShare.rootNodeId, + }; + return this.myFilesIds; + } catch (error: unknown) { + if (error instanceof NotFoundAPIError) { + this.logger.warn('Active volume not found, creating a new one'); + return this.createVolume(); + } + this.logger.error('Failed to get active volume', error); + throw error; + } + } + + /** + * 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 { + const address = await this.account.getOwnPrimaryAddress(); + const primaryKey = address.keys[address.primaryKeyIndex]; + const bootstrap = await this.cryptoService.generateVolumeBootstrap(primaryKey.key); + const myFilesIds = await this.apiService.createVolume( + { + addressId: address.addressId, + addressKeyId: primaryKey.id, + ...bootstrap.shareKey.encrypted, + }, + { + ...bootstrap.rootNode.key.encrypted, + encryptedName: bootstrap.rootNode.encryptedName, + armoredHashKey: bootstrap.rootNode.armoredHashKey, + }, + ); + await this.cryptoCache.setShareKey(myFilesIds.shareId, bootstrap.shareKey.decrypted); + return myFilesIds; + } + + /** + * It is a high-level action that retrieves the private key for a share. + * If prefers to use the cache, but if the key is not there, it fetches + * the share from the API, decrypts it, and caches it. + * + * @param shareId - The ID of the share. + * @returns The private key for the share. + * @throws If the share is not found or cannot be decrypted, or cached. + */ + async getSharePrivateKey(shareId: string): Promise { + try { + const { key } = await this.cryptoCache.getShareKey(shareId); + return key; + } catch {} + + const encryptedShare = await this.apiService.getRootShare(shareId); + const { key } = await this.cryptoService.decryptRootShare(encryptedShare); + await this.cryptoCache.setShareKey(shareId, key); + return key.key; + } + + async getMyFilesShareMemberEmailKey(): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }> { + const { volumeId } = await this.getRootIDs(); + + try { + const { addressId } = await this.cache.getVolume(volumeId); + const address = await this.account.getOwnAddress(addressId); + return { + email: address.email, + addressId, + addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, + }; + } catch {} + + const { shareId } = await this.apiService.getVolume(volumeId); + const share = await this.apiService.getRootShare(shareId); + + await this.cache.setVolume({ + volumeId: share.volumeId, + shareId: share.shareId, + rootNodeId: share.rootNodeId, + creatorEmail: share.creatorEmail, + addressId: share.addressId, + }); + + const address = await this.account.getOwnAddress(share.addressId); + return { + email: address.email, + addressId: share.addressId, + addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, + }; + } + + async getContextShareMemberEmailKey(shareId: string): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }> { + let encryptedShare = this.rootShares.get(shareId); + if (!encryptedShare) { + encryptedShare = await this.apiService.getRootShare(shareId); + this.rootShares.set(shareId, encryptedShare); + } + + const address = await this.account.getOwnAddress(encryptedShare.addressId); + + return { + email: address.email, + addressId: encryptedShare.addressId, + addressKey: address.keys[address.primaryKeyIndex].key, + addressKeyId: address.keys[address.primaryKeyIndex].id, + }; + } + + async isOwnVolume(volumeId: string): Promise { + return (await this.getRootIDs()).volumeId === volumeId; + } + + async getVolumeMetricContext(volumeId: string): Promise { + 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 + // ignore this case for now. + if (volumeId === myVolumeId) { + return MetricVolumeType.OwnVolume; + } + return MetricVolumeType.Shared; + } + + async loadEncryptedShare(shareId: string): Promise { + return this.apiService.getShare(shareId); + } +} diff --git a/js/sdk/src/internal/sharing/apiService.ts b/js/sdk/src/internal/sharing/apiService.ts new file mode 100644 index 00000000..41d460f0 --- /dev/null +++ b/js/sdk/src/internal/sharing/apiService.ts @@ -0,0 +1,692 @@ +import { SRPVerifier } from '../../crypto'; +import { Logger, MemberRole, NonProtonInvitationState } from '../../interface'; +import { + DriveAPIService, + drivePaths, + memberRoleToPermission, + nodeTypeNumberToNodeType, + permissionsToMemberRole, +} from '../apiService'; +import { ShareTargetType } from '../shares'; +import { + makeInvitationUid, + makeMemberUid, + makeNodeUid, + makePublicLinkUid, + splitInvitationUid, + splitMemberUid, + splitNodeUid, + splitPublicLinkUid, +} from '../uids'; +import { + EncryptedBookmark, + EncryptedExternalInvitation, + EncryptedExternalInvitationRequest, + EncryptedInvitation, + EncryptedInvitationRequest, + EncryptedInvitationWithNode, + EncryptedMember, + 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 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']; + +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 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']; + +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 PostChangeSharePropertiesRequest = Extract< + drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['requestBody'], + { content: object } +>['content']['application/json']; + +type PostChangeSharePropertiesResponse = + drivePaths['/drive/shares/{shareID}/editors-can-share']['put']['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']; + +/** + * Provides API communication for fetching and managing sharing. + * + * The service is responsible for transforming local objects to API payloads + * and vice versa. It should not contain any business logic. + */ +export class SharingAPIService { + constructor( + private logger: Logger, + private apiService: DriveAPIService, + private shareTargetTypes: ShareTargetType[], + ) { + this.logger = logger; + this.apiService = apiService; + this.shareTargetTypes = shareTargetTypes; + } + + async *iterateSharedNodeUids(volumeId: string, signal?: AbortSignal): AsyncGenerator { + let anchor = ''; + while (true) { + const response = await this.apiService.get( + `drive/v2/volumes/${volumeId}/shares?${anchor ? `AnchorID=${anchor}` : ''}`, + signal, + ); + for (const link of response.Links) { + yield makeNodeUid(volumeId, link.LinkID); + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } + + async *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, + ); + for (const link of response.Links) { + const nodeUid = makeNodeUid(link.VolumeID, link.LinkID); + + if (!this.shareTargetTypes.includes(link.ShareTargetType)) { + this.logger.debug(`Unsupported share target type ${link.ShareTargetType} for node ${nodeUid}`); + continue; + } + + yield nodeUid; + } + + if (!response.More || !response.AnchorID) { + break; + } + 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 { + 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?${params.toString()}`, + signal, + ); + for (const invitation of response.Invitations) { + const invitationUid = makeInvitationUid(invitation.ShareID, invitation.InvitationID); + + if (!this.shareTargetTypes.includes(invitation.ShareTargetType)) { + this.logger.warn( + `Unsupported share target type ${invitation.ShareTargetType} for invitation ${invitationUid}`, + ); + continue; + } + + yield invitationUid; + } + + if (!response.More || !response.AnchorID) { + break; + } + anchor = response.AnchorID; + } + } + + async getInvitation(invitationUid: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + const response = await this.apiService.get( + `drive/v2/shares/invitations/${invitationId}`, + ); + return { + uid: invitationUid, + addedByEmail: response.Invitation.InviterEmail, + inviteeEmail: response.Invitation.InviteeEmail, + base64KeyPacket: response.Invitation.KeyPacket, + base64KeyPacketSignature: response.Invitation.KeyPacketSignature, + invitationTime: new Date(response.Invitation.CreateTime * 1000), + role: permissionsToMemberRole(this.logger, response.Invitation.Permissions), + share: { + armoredKey: response.Share.ShareKey, + armoredPassphrase: response.Share.Passphrase, + creatorEmail: response.Share.CreatorEmail, + }, + node: { + uid: makeNodeUid(response.Share.VolumeID, response.Link.LinkID), + type: nodeTypeNumberToNodeType(this.logger, response.Link.Type, response.Share.ShareTargetType), + mediaType: response.Link.MIMEType || undefined, + encryptedName: response.Link.Name, + }, + }; + } + + async acceptInvitation(invitationUid: string, base64SessionKeySignature: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post( + `drive/v2/shares/invitations/${invitationId}/accept`, + { + SessionKeySignature: base64SessionKeySignature, + }, + ); + } + + async rejectInvitation(invitationUid: string): Promise { + const { invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/invitations/${invitationId}/reject`); + } + + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + const response = await this.apiService.get(`drive/v2/shared-bookmarks`, signal); + for (const bookmark of response.Bookmarks) { + yield { + tokenId: bookmark.Token.Token, + creationTime: new Date(bookmark.CreateTime * 1000), + share: { + armoredKey: bookmark.Token.ShareKey, + armoredPassphrase: bookmark.Token.SharePassphrase, + }, + url: { + encryptedUrlPassword: bookmark.EncryptedUrlPassword || undefined, + base64SharePasswordSalt: bookmark.Token.SharePasswordSalt, + }, + node: { + // 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, + armoredNodePassphrase: bookmark.Token.NodePassphrase, + file: { + base64ContentKeyPacket: bookmark.Token.ContentKeyPacket || undefined, + }, + }, + }; + } + } + + 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`); + } + + async getShareInvitations(shareId: string): Promise { + const response = await this.apiService.get(`drive/v2/shares/${shareId}/invitations`); + return response.Invitations.map((invitation) => { + return this.convertInternalInvitation(shareId, invitation); + }); + } + + async getShareExternalInvitations(shareId: string): Promise { + const response = await this.apiService.get( + `drive/v2/shares/${shareId}/external-invitations`, + ); + return response.ExternalInvitations.map((invitation) => { + return this.convertExternalInvitaiton(shareId, invitation); + }); + } + + async getShareMembers(shareId: string): Promise { + const response = await this.apiService.get(`drive/v2/shares/${shareId}/members`); + return response.Members.map((member) => { + return { + uid: makeMemberUid(shareId, member.MemberID), + addedByEmail: member.InviterEmail, + inviteeEmail: member.Email, + base64KeyPacket: member.KeyPacket, + base64KeyPacketSignature: member.KeyPacketSignature, + invitationTime: new Date(member.CreateTime * 1000), + role: permissionsToMemberRole(this.logger, member.Permissions), + }; + }); + } + + async createStandardShare( + nodeUid: string, + addressId: string, + shareKey: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }, + node: { + base64PassphraseKeyPacket: string; + base64NameKeyPacket: string; + }, + ): Promise<{ shareId: string; editorsCanShare: boolean }> { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + 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 { 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.put( + `drive/shares/${shareId}/editors-can-share`, + { Value: editorsCanShare }, + ); + } + + async inviteProtonUser( + 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`, + { + Invitation: { + InviterEmail: invitation.addedByEmail, + InviteeEmail: invitation.inviteeEmail, + Permissions: memberRoleToPermission(invitation.role), + KeyPacket: invitation.base64KeyPacket, + KeyPacketSignature: invitation.base64KeyPacketSignature, + ExternalInvitationID: externalInvitationId, + }, + EmailDetails: { + Message: emailDetails.message, + ItemName: emailDetails.nodeName, + }, + }, + ); + return this.convertInternalInvitation(shareId, response.Invitation); + } + + async updateInvitation(invitationUid: string, invitation: { role: MemberRole }): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.put( + `drive/v2/shares/${shareId}/invitations/${invitationId}`, + { + Permissions: memberRoleToPermission(invitation.role), + }, + ); + } + + async resendInvitationEmail(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/${shareId}/invitations/${invitationId}/sendemail`); + } + + async deleteInvitation(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/invitations/${invitationId}`); + } + + async inviteExternalUser( + shareId: string, + invitation: EncryptedExternalInvitationRequest, + emailDetails: { message?: string; nodeName?: string } = {}, + ): Promise { + const response = await this.apiService.post( + `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 { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.put( + `drive/v2/shares/${shareId}/external-invitations/${invitationId}`, + { + Permissions: memberRoleToPermission(invitation.role), + }, + ); + } + + async resendExternalInvitationEmail(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.post(`drive/v2/shares/${shareId}/external-invitations/${invitationId}/sendemail`); + } + + async deleteExternalInvitation(invitationUid: string): Promise { + const { shareId, invitationId } = splitInvitationUid(invitationUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/external-invitations/${invitationId}`); + } + + async updateMember(memberUid: string, member: { role: MemberRole }): Promise { + const { shareId, memberId } = splitMemberUid(memberUid); + await this.apiService.put( + `drive/v2/shares/${shareId}/members/${memberId}`, + { + Permissions: memberRoleToPermission(member.role), + }, + ); + } + + async removeMember(memberUid: string): Promise { + const { shareId, memberId } = splitMemberUid(memberUid); + await this.apiService.delete(`drive/v2/shares/${shareId}/members/${memberId}`); + } + + async getPublicLink(shareId: string): Promise { + const response = await this.apiService.get(`drive/shares/${shareId}/urls`); + + if (!response.ShareURLs || response.ShareURLs.length === 0) { + return undefined; + } + if (response.ShareURLs.length > 1) { + this.logger.warn('Multiple share URLs found, using the first one'); + } + const shareUrl = response.ShareURLs[0]; + + return { + uid: makePublicLinkUid(shareUrl.ShareID, shareUrl.ShareURLID), + creationTime: new Date(shareUrl.CreateTime * 1000), + expirationTime: shareUrl.ExpirationTime ? new Date(shareUrl.ExpirationTime * 1000) : undefined, + role: permissionsToMemberRole(this.logger, shareUrl.Permissions), + flags: shareUrl.Flags, + creatorEmail: shareUrl.CreatorEmail, + publicUrl: shareUrl.PublicUrl, + numberOfInitializedDownloads: shareUrl.NumAccesses, + armoredUrlPassword: shareUrl.Password, + urlPasswordSalt: shareUrl.UrlPasswordSalt, + base64SharePassphraseKeyPacket: shareUrl.SharePassphraseKeyPacket, + sharePassphraseSalt: shareUrl.SharePasswordSalt, + }; + } + + 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.'); + } + + const result = await this.apiService.post< + // TODO: Backend type wrongly requires ExpirationDuration (it should be optional) and Name (it is not used). + Omit, + PostShareUrlResponse + >(`drive/shares/${shareId}/urls`, { + CreatorEmail: publicLink.creatorEmail, + ...this.generatePublicLinkRequestPayload(publicLink), + }); + return { + uid: makePublicLinkUid(shareId, result.ShareURL.ShareURLID), + publicUrl: result.ShareURL.PublicUrl, + }; + } + + 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.'); + } + + const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); + + await this.apiService.put< + // TODO: Backend type wrongly requires ExpirationTime (it should be optional) and Name (it is not used). + Omit & { ExpirationTime: number | null }, + PutShareUrlResponse + >(`drive/shares/${shareId}/urls/${publicLinkId}`, this.generatePublicLinkRequestPayload(publicLink)); + } + + private generatePublicLinkRequestPayload(publicLink: { + 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 + ? 3 // Random + custom password set. + : 2, // Random password set. + ExpirationTime: publicLink.expirationTime || null, + + SharePasswordSalt: publicLink.crypto.base64SharePasswordSalt, + SharePassphraseKeyPacket: publicLink.crypto.base64SharePassphraseKeyPacket, + Password: publicLink.crypto.armoredPassword, + + UrlPasswordSalt: publicLink.srp.salt, + SRPVerifier: publicLink.srp.verifier, + SRPModulusID: publicLink.srp.modulusId, + + MaxAccesses: 0, // We don't support setting limit. + }; + } + + async removePublicLink(publicLinkUid: string): Promise { + const { shareId, publicLinkId } = splitPublicLinkUid(publicLinkUid); + await this.apiService.delete(`drive/shares/${shareId}/urls/${publicLinkId}`); + } + + private convertInternalInvitation( + shareId: string, + invitation: GetShareInvitations['Invitations'][0], + ): EncryptedInvitation { + return { + uid: makeInvitationUid(shareId, invitation.InvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitationTime: new Date(invitation.CreateTime * 1000), + role: permissionsToMemberRole(this.logger, invitation.Permissions), + base64KeyPacket: invitation.KeyPacket, + base64KeyPacketSignature: invitation.KeyPacketSignature, + }; + } + + private convertExternalInvitaiton( + shareId: string, + invitation: GetShareExternalInvitations['ExternalInvitations'][0], + ): EncryptedExternalInvitation { + const state = + invitation.State === 1 ? NonProtonInvitationState.Pending : NonProtonInvitationState.UserRegistered; + return { + uid: makeInvitationUid(shareId, invitation.ExternalInvitationID), + addedByEmail: invitation.InviterEmail, + inviteeEmail: invitation.InviteeEmail, + invitationTime: new Date(invitation.CreateTime * 1000), + role: permissionsToMemberRole(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 new file mode 100644 index 00000000..12d63e86 --- /dev/null +++ b/js/sdk/src/internal/sharing/cache.test.ts @@ -0,0 +1,99 @@ +import { MemoryCache } from '../../cache'; +import { SharingCache } from './cache'; + +describe('SharingCache', () => { + let memoryCache: MemoryCache; + let cache: SharingCache; + + beforeEach(() => { + memoryCache = new MemoryCache(); + cache = new SharingCache(memoryCache); + }); + + describe('set and get shared by me nodes', () => { + it('should set node uids', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); + + const result = await cache.getSharedByMeNodeUids(); + + expect(result).toEqual(['nodeUid']); + }); + }); + + describe('addSharedByMeNodeUid', () => { + it('should throw if adding before setting', async () => { + try { + await cache.addSharedByMeNodeUid('nodeUid'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Calling add before setting the loaded items'); + } + }); + + it('should add node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.addSharedByMeNodeUid('newNodeUid'); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual(['nodeUid', 'newNodeUid']); + expect(spy).toHaveBeenCalled(); + }); + + it('should not add duplicate node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.addSharedByMeNodeUid('nodeUid'); + await cache.addSharedByMeNodeUid('nodeUid'); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual(['nodeUid']); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('removeSharedByMeNodeUid', () => { + it('should throw if removing before setting', async () => { + try { + await cache.removeSharedByMeNodeUid('nodeUid'); + fail('Should have thrown an error'); + } catch (error) { + expect(`${error}`).toBe('Error: Calling remove before setting the loaded items'); + } + }); + + it('should remove node uid', async () => { + await cache.setSharedByMeNodeUids(['nodeUid']); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.removeSharedByMeNodeUid('nodeUid'); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual([]); + expect(spy).toHaveBeenCalled(); + }); + + it('should handle removing of missing node uid', async () => { + await cache.setSharedByMeNodeUids([]); + const spy = jest.spyOn(memoryCache, 'setEntity'); + + await cache.removeSharedByMeNodeUid('nodeUid'); + + const result = await cache.getSharedByMeNodeUids(); + expect(result).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('set and get shared with me nodes', () => { + it('should set node uids', async () => { + await cache.setSharedWithMeNodeUids(['nodeUid']); + + const result = await cache.getSharedWithMeNodeUids(); + + expect(result).toEqual(['nodeUid']); + }); + }); +}); diff --git a/js/sdk/src/internal/sharing/cache.ts b/js/sdk/src/internal/sharing/cache.ts new file mode 100644 index 00000000..aea3f0c9 --- /dev/null +++ b/js/sdk/src/internal/sharing/cache.ts @@ -0,0 +1,135 @@ +import { ProtonDriveEntitiesCache } from '../../interface'; +import { SharingType } from './interface'; + +/** + * Provides caching for shared by me and with me listings. + * + * The cache is responsible for serialising and deserialising the node + * UIDs for each sharing type. Also, ensuring that only full lists are + * cached. + */ +export class SharingCache { + /** + * Locally cached data to avoid unnecessary reads from the cache. + */ + private cache: Map = new Map(); + + constructor(private driveCache: ProtonDriveEntitiesCache) { + this.driveCache = driveCache; + } + + async getSharedByMeNodeUids(): Promise { + return this.getNodeUids(SharingType.SharedByMe); + } + + async hasSharedByMeNodeUidsLoaded(): Promise { + try { + await this.getNodeUids(SharingType.SharedByMe); + return true; + } catch { + return false; + } + } + + async addSharedByMeNodeUid(nodeUid: string): Promise { + return this.addNodeUid(SharingType.SharedByMe, nodeUid); + } + + async removeSharedByMeNodeUid(nodeUid: string): Promise { + return this.removeNodeUid(SharingType.SharedByMe, nodeUid); + } + + async setSharedByMeNodeUids(nodeUids: string[] | undefined): Promise { + return this.setNodeUids(SharingType.SharedByMe, nodeUids); + } + + async getSharedWithMeNodeUids(): Promise { + 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); + } + + /** + * @throws Error if the cache is not set yet. First, the cache should be + * set by calling `setNodeUids` after full loading of the list. + */ + private async addNodeUid(type: SharingType, nodeUid: string): Promise { + let nodeUids; + try { + nodeUids = await this.getNodeUids(type); + } catch { + // This is developer error. + throw new Error('Calling add before setting the loaded items'); + } + const set = new Set(nodeUids); + if (set.has(nodeUid)) { + return; + } + set.add(nodeUid); + await this.setNodeUids(type, [...set]); + } + + /** + * @throws Error if the cache is not set yet. First, the cache should be + * set by calling `setNodeUids` after full loading of the list. + */ + private async removeNodeUid(type: SharingType, nodeUid: string): Promise { + let nodeUids; + try { + nodeUids = await this.getNodeUids(type); + } catch { + // This is developer error. + throw new Error('Calling remove before setting the loaded items'); + } + const set = new Set(nodeUids); + if (!set.has(nodeUid)) { + return; + } + set.delete(nodeUid); + await this.setNodeUids(type, [...set]); + } + + private async getNodeUids(type: SharingType): Promise { + let nodeUids = this.cache.get(type); + if (nodeUids) { + return nodeUids; + } + + const nodeUidsString = await this.driveCache.getEntity(`sharing-${type}-nodeUids`); + nodeUids = nodeUidsString.split(','); + this.cache.set(type, nodeUids); + return nodeUids; + } + + /** + * @param nodeUids - Passing `undefined` will remove the cache. + */ + private async setNodeUids(type: SharingType, nodeUids: string[] | undefined): Promise { + if (nodeUids) { + this.cache.set(type, nodeUids); + await this.driveCache.setEntity(`sharing-${type}-nodeUids`, nodeUids.join(',')); + } else { + this.cache.delete(type); + await this.driveCache.removeEntities([`sharing-${type}-nodeUids`]); + } + } +} diff --git a/js/sdk/src/internal/sharing/cryptoService.test.ts b/js/sdk/src/internal/sharing/cryptoService.test.ts new file mode 100644 index 00000000..d089879a --- /dev/null +++ b/js/sdk/src/internal/sharing/cryptoService.test.ts @@ -0,0 +1,383 @@ +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 { SharingCryptoService } from './cryptoService'; +import { SharesService } from './interface'; + +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 }], + })), + getPublicKeys: jest.fn(), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + getMyFilesShareMemberEmailKey: jest.fn().mockResolvedValue({ + addressId: 'addressId', + addressKey: 'addressKey' as unknown as PrivateKey, + addressKeyId: 'keyId', + }), + }; + 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.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); + + 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.recordMetric).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareUrlPassword', + error, + uid: 'tokenId', + }); + }); + + 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.recordMetric).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareKey', + error, + uid: 'tokenId', + }); + }); + + 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.recordMetric).toHaveBeenCalledWith({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'nodeName', + error, + uid: 'tokenId', + }); + }); + + it('should handle invalid node name', async () => { + driveCrypto.decryptNodeName = jest.fn().mockResolvedValue({ + name: '', + }); + + const result = await cryptoService.decryptBookmark(encryptedBookmark); + + expect(result).toMatchObject({ + url: resultOk('https://drive.proton.me/urls/tokenId#urlPassword'), + nodeName: resultError({ + name: '', + 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'; + 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 new file mode 100644 index 00000000..fcbd3e45 --- /dev/null +++ b/js/sdk/src/internal/sharing/cryptoService.ts @@ -0,0 +1,664 @@ +import { c } from 'ttag'; + +import { DriveCrypto, PrivateKey, PublicKey, SessionKey, SRPVerifier, VERIFICATION_STATUS } from '../../crypto'; +import { DecryptionError } from '../../errors'; +import { + Author, + InvalidNameError, + Logger, + Member, + MetricVolumeType, + NonProtonInvitation, + ProtonDriveAccount, + ProtonDriveTelemetry, + ProtonInvitation, + ProtonInvitationWithNode, + Result, + resultError, + resultOk, + UnverifiedAuthorError, +} from '../../interface'; +import { getErrorMessage, getVerificationMessage } from '../errors'; +import { validateNodeName } from '../nodes/validations'; +import { EncryptedShare } from '../shares'; +import { + EncryptedBookmark, + EncryptedExternalInvitation, + EncryptedInvitation, + EncryptedInvitationWithNode, + EncryptedMember, + EncryptedPublicLink, + PublicLinkWithCreatorEmail, + SharesService, +} from './interface'; + +export const PUBLIC_LINK_GENERATED_PASSWORD_LENGTH = 12; + +// We do not support management of legacy public links anymore (that is no +// flag or bit 1). But we still need to support to read the legacy public +// link. +enum PublicLinkFlags { + Legacy = 0, + CustomPassword = 1, + GeneratedPasswordIncluded = 2, + GeneratedPasswordWithCustomPassword = 3, +} + +/** + * Provides crypto operations for sharing. + * + * The sharing crypto service is responsible for encrypting and decrypting + * shares, invitations, etc. + */ +export class SharingCryptoService { + private logger: Logger; + + constructor( + private telemetry: ProtonDriveTelemetry, + private driveCrypto: DriveCrypto, + private account: ProtonDriveAccount, + private sharesService: SharesService, + ) { + this.telemetry = telemetry; + this.logger = telemetry.getLogger('sharing-crypto'); + this.driveCrypto = driveCrypto; + this.account = account; + this.sharesService = sharesService; + } + + /** + * Generates a share key for a standard share used for sharing with other users. + * + * Standard share, in contrast to a root share, is encrypted with node key and + * can be managed by any admin. + */ + async generateShareKeys( + nodeKeys: { + key: PrivateKey; + passphraseSessionKey: SessionKey; + nameSessionKey: SessionKey; + }, + addressKey: PrivateKey, + ): Promise<{ + shareKey: { + encrypted: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + decrypted: { + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + }; + base64PpassphraseKeyPacket: string; + base64NameKeyPacket: string; + }> { + const shareKey = await this.driveCrypto.generateKey([nodeKeys.key, addressKey], addressKey); + + const { base64KeyPacket: base64PpassphraseKeyPacket } = await this.driveCrypto.encryptSessionKey( + nodeKeys.passphraseSessionKey, + shareKey.decrypted.key, + ); + const { base64KeyPacket: base64NameKeyPacket } = await this.driveCrypto.encryptSessionKey( + nodeKeys.nameSessionKey, + shareKey.decrypted.key, + ); + + return { + shareKey, + base64PpassphraseKeyPacket, + base64NameKeyPacket, + }; + } + + /** + * 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; + }> { + // All standard shares should be encrypted with node key. + // Using node key is essential so any admin can manage the share. + // Old shares are encrypted with address key only and thus available + // only to owners. Adding address keys (if available) is a fallback + // solution until all shares are migrated. + const decryptionKeys = [nodeKey]; + if (share.addressId) { + const address = await this.account.getOwnAddress(share.addressId); + decryptionKeys.push(...address.keys.map(({ key }) => key)); + } + const addressPublicKeys = await this.account.getPublicKeys(share.creatorEmail); + + const { key, passphraseSessionKey, verified, verificationErrors } = await this.driveCrypto.decryptKey( + share.encryptedCrypto.armoredKey, + share.encryptedCrypto.armoredPassphrase, + share.encryptedCrypto.armoredPassphraseSignature, + decryptionKeys, + addressPublicKeys, + ); + + const author: Result = + verified === VERIFICATION_STATUS.SIGNED_AND_VALID + ? resultOk(share.creatorEmail) + : resultError({ + claimedAuthor: share.creatorEmail, + error: getVerificationMessage(verified, verificationErrors), + }); + + 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. + */ + async encryptInvitation( + shareSessionKey: SessionKey, + inviterKey: PrivateKey, + inviteeEmail: string, + forceRefreshKeys?: boolean, + ): Promise<{ + base64KeyPacket: string; + base64KeyPacketSignature: string; + }> { + const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail, forceRefreshKeys); + 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 { + const inviteeAddress = await this.account.getOwnAddress(encryptedInvitation.inviteeEmail); + const inviteeKeys = inviteeAddress.keys.map((k) => k.key); + + const shareKey = await this.driveCrypto.decryptUnsignedKey( + encryptedInvitation.share.armoredKey, + encryptedInvitation.share.armoredPassphrase, + inviteeKeys, + ); + + let nodeName: Result; + try { + const result = await this.driveCrypto.decryptNodeName(encryptedInvitation.node.encryptedName, shareKey, []); + nodeName = resultOk(result.name); + } catch (error: unknown) { + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt item name: ${message}`; + nodeName = resultError(new Error(errorMessage, { cause: error })); + } + + return { + ...(await this.decryptInvitation(encryptedInvitation)), + node: { + uid: encryptedInvitation.node.uid, + name: nodeName, + type: encryptedInvitation.node.type, + mediaType: encryptedInvitation.node.mediaType, + }, + }; + } + + /** + * Verifies an invitation. + */ + async decryptInvitation(encryptedInvitation: EncryptedInvitation): Promise { + const addedByEmail = await this.verifyAddedByEmail(encryptedInvitation, async (publicKeys) => { + return this.driveCrypto.verifyInvitation( + encryptedInvitation.base64KeyPacket, + { base64: encryptedInvitation.base64KeyPacketSignature }, + publicKeys, + ); + }); + + return { + uid: encryptedInvitation.uid, + invitationTime: encryptedInvitation.invitationTime, + addedByEmail: addedByEmail, + inviteeEmail: encryptedInvitation.inviteeEmail, + role: encryptedInvitation.role, + }; + } + + /** + * Accepts an invitation by signing the session key by invitee. + */ + async acceptInvitation(encryptedInvitation: EncryptedInvitationWithNode): Promise<{ + base64SessionKeySignature: string; + }> { + const 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, + ); + return result; + } + + /** + * Encrypts an external invitation for sharing a node with another user. + * + * `inviteeEmail` is used to sign the invitation with `inviterKey`. + * + * External invitations are used to share nodes with users who are not + * registered with Proton Drive. The external invitation then requires + * the invitee to sign up to create key. Then it can be followed by + * regular invitation flow. + */ + async encryptExternalInvitation( + shareSessionKey: SessionKey, + inviterKey: PrivateKey, + inviteeEmail: string, + ): Promise<{ + base64ExternalInvitationSignature: string; + }> { + const result = await this.driveCrypto.encryptExternalInvitation(shareSessionKey, inviterKey, inviteeEmail); + return result; + } + + 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. + */ + 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, + invitationTime: encryptedInvitation.invitationTime, + addedByEmail: addedByEmail, + inviteeEmail: encryptedInvitation.inviteeEmail, + role: encryptedInvitation.role, + state: encryptedInvitation.state, + }; + } + + /** + * Verifies a member. + */ + async decryptMember(encryptedMember: EncryptedMember): Promise { + const addedByEmail = await this.verifyAddedByEmail(encryptedMember, async (publicKeys) => { + return this.driveCrypto.verifyInvitation( + encryptedMember.base64KeyPacket, + { base64: encryptedMember.base64KeyPacketSignature }, + publicKeys, + ); + }); + + return { + uid: encryptedMember.uid, + invitationTime: encryptedMember.invitationTime, + addedByEmail: addedByEmail, + inviteeEmail: encryptedMember.inviteeEmail, + role: encryptedMember.role, + }; + } + + 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, + password: string, + ): Promise<{ + crypto: { + base64SharePasswordSalt: string; + base64SharePassphraseKeyPacket: string; + armoredPassword: string; + }; + srp: SRPVerifier; + }> { + const address = await this.account.getOwnAddress(creatorEmail); + const addressKey = address.keys[address.primaryKeyIndex].key; + + const { base64SharePasswordSalt, base64SharePassphraseKeyPacket, armoredPassword, srp } = + await this.driveCrypto.encryptPublicLinkPasswordAndSessionKey(password, addressKey, shareSessionKey); + + return { + crypto: { + base64SharePasswordSalt, + base64SharePassphraseKeyPacket, + armoredPassword, + }, + srp, + }; + } + + async generatePublicLinkPassword(): Promise { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const values = crypto.getRandomValues(new Uint32Array(PUBLIC_LINK_GENERATED_PASSWORD_LENGTH)); + + let result = ''; + for (let i = 0; i < PUBLIC_LINK_GENERATED_PASSWORD_LENGTH; i++) { + result += charset[values[i] % charset.length]; + } + + return result; + } + + 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); + + return { + uid: encryptedPublicLink.uid, + creationTime: encryptedPublicLink.creationTime, + expirationTime: encryptedPublicLink.expirationTime, + role: encryptedPublicLink.role, + url: `${encryptedPublicLink.publicUrl}#${password}`, + customPassword, + creatorEmail: encryptedPublicLink.creatorEmail, + numberOfInitializedDownloads: encryptedPublicLink.numberOfInitializedDownloads, + }; + } + + private async decryptShareUrlPassword( + encryptedPublicLink: Pick, + addressKeys: PrivateKey[], + ): Promise<{ + password: string; + customPassword?: string; + }> { + const password = await this.driveCrypto.decryptShareUrlPassword( + encryptedPublicLink.armoredUrlPassword, + addressKeys, + ); + + switch (encryptedPublicLink.flags) { + // This is legacy that is not supported anymore. + // Availalbe only for reading. + case PublicLinkFlags.Legacy: + case PublicLinkFlags.CustomPassword: + return { + password, + }; + case PublicLinkFlags.GeneratedPasswordIncluded: + case PublicLinkFlags.GeneratedPasswordWithCustomPassword: + return splitGeneratedAndCustomPassword(password); + default: + throw new Error(`Unsupported public link with flags: ${encryptedPublicLink.flags}`); + } + } + + 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; + nodeName: Result; + }> { + // 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 { + 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`, { cause: originalError }); + return { + url: resultError(error), + customPassword: 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, password); + } catch (originalError: unknown) { + const error = + originalError instanceof Error + ? originalError + : new Error(c('Error').t`Unknown error`, { cause: originalError }); + return { + url, + customPassword, + nodeName: resultError(error), + }; + } + + const nodeName = await this.decryptBookmarkName(encryptedBookmark, shareKey); + + return { + url, + customPassword, + 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.recordMetric({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareUrlPassword', + error, + uid: encryptedBookmark.tokenId, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark password: ${message}`; + throw new DecryptionError(errorMessage, { cause: error }); + } + } + + private async decryptBookmarkKey(encryptedBookmark: EncryptedBookmark, password: string): Promise { + try { + // Use the password to decrypt the share key. + const { key: shareKey } = await this.driveCrypto.decryptKeyWithSrpPassword( + password, + encryptedBookmark.url.base64SharePasswordSalt, + encryptedBookmark.share.armoredKey, + encryptedBookmark.share.armoredPassphrase, + ); + + return shareKey; + } catch (error: unknown) { + this.telemetry.recordMetric({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'shareKey', + error, + uid: encryptedBookmark.tokenId, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark key: ${message}`; + throw new DecryptionError(errorMessage, { cause: error }); + } + } + + 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.recordMetric({ + eventName: 'decryptionError', + volumeType: MetricVolumeType.SharedPublic, + field: 'nodeName', + error, + uid: encryptedBookmark.tokenId, + }); + + const message = getErrorMessage(error); + const errorMessage = c('Error').t`Failed to decrypt bookmark name: ${message}`; + return resultError(new Error(errorMessage, { cause: error })); + } + } +} + +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 }; +} + +function generateConcanatedPassword(urlPassword: string, customPassword?: string): string { + 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 new file mode 100644 index 00000000..f43c1021 --- /dev/null +++ b/js/sdk/src/internal/sharing/events.test.ts @@ -0,0 +1,206 @@ +import { getMockLogger } from '../../tests/logger'; +import { DriveEvent, DriveEventType } from '../events'; +import { SharesManager } from '../shares/manager'; +import { SharingCache } from './cache'; +import { SharingEventHandler } from './events'; +import { NodesService } from './interface'; +import { SharingAccess } from './sharingAccess'; + +// FIXME: test tree_refresh and tree_remove + +describe('handleSharedByMeNodes', () => { + let cache: SharingCache; + let sharingEventHandler: SharingEventHandler; + let sharesManager: SharesManager; + let nodesService: NodesService; + + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error No need to implement all methods for mocking + cache = { + addSharedByMeNodeUid: jest.fn(), + removeSharedByMeNodeUid: jest.fn(), + setSharedWithMeNodeUids: jest.fn(), + getSharedByMeNodeUids: jest.fn().mockResolvedValue(['cachedNodeUid']), + hasSharedByMeNodeUidsLoaded: jest.fn().mockResolvedValue(true), + }; + 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(), + }; + sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService, {} as any); + }); + + 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(); + }); +}); + +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(), + }; + // @ts-expect-error No need to implement all methods for mocking + sharingAccess = { + iterateSharedNodesWithMe: jest.fn(), + }; + 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 () => { + const event: DriveEvent = { + type: DriveEventType.SharedWithMeUpdated, + eventId: 'event1', + treeEventScopeId: 'core', + }; + + const sharingEventHandler = new SharingEventHandler(getMockLogger(), cache, sharesManager, nodesService, {} as any); + 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, {} as any); + 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 new file mode 100644 index 00000000..ced3a8eb --- /dev/null +++ b/js/sdk/src/internal/sharing/events.ts @@ -0,0 +1,119 @@ +import { Logger } from '../../interface'; +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( + private logger: Logger, + private cache: SharingCache, + private shares: SharesService, + private nodesService: NodesService, + private management: SharingManagement, + ) {} + + /** + * 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 | InternalDriveEvent) { + if (isInternalDriveEvent(event)) { + await this.handleInternalDriveEvent(event); + return; + } + try { + await this.handleSharedWithMeNodeUidsLoaded(event); + await this.handleSharedByMeNodeUidsLoaded(event); + } catch (error: unknown) { + this.logger.error(`Skipping sharing cache update`, error); + } + } + + 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( + event.type, + ) + ) { + return; + } + + // 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; + } + + 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/internal/sharing/index.ts b/js/sdk/src/internal/sharing/index.ts new file mode 100644 index 00000000..5b65f492 --- /dev/null +++ b/js/sdk/src/internal/sharing/index.ts @@ -0,0 +1,60 @@ +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'; + +// 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. + * + * The sharing module is responsible for handling invitations, bookmarks, + * standard shares, listing shared nodes, etc. It includes API communication, + * encryption, decryption, caching, and event handling. + */ +export function initSharingModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveEntitiesCache: ProtonDriveEntitiesCache, + account: ProtonDriveAccount, + crypto: DriveCrypto, + sharesService: SharesService, + nodesService: NodesService, + shareTargetTypes: ShareTargetType[] = DEFAULT_SHARE_TARGET_TYPES, +) { + 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); + const sharingManagement = new SharingManagement( + telemetry.getLogger('sharing'), + api, + cache, + cryptoService, + account, + sharesService, + nodesService, + ); + const sharingEventHandler = new SharingEventHandler( + telemetry.getLogger('sharing-event-handler'), + cache, + sharesService, + nodesService, + sharingManagement, + ); + + return { + access: sharingAccess, + eventHandler: sharingEventHandler, + management: sharingManagement, + }; +} diff --git a/js/sdk/src/internal/sharing/interface.ts b/js/sdk/src/internal/sharing/interface.ts new file mode 100644 index 00000000..61be2bb1 --- /dev/null +++ b/js/sdk/src/internal/sharing/interface.ts @@ -0,0 +1,182 @@ +import { PrivateKey, SessionKey } from '../../crypto'; +import { MemberRole, MissingNode, NodeType, NonProtonInvitationState, PublicLink, ShareResult } from '../../interface'; +import { DecryptedNode } from '../nodes'; +import { EncryptedShare } from '../shares'; + +export enum SharingType { + SharedByMe = 'sharedByMe', + SharedWithMe = 'sharedWithMe', +} + +/** + * Internal interface for creating new invitation. + */ +export interface EncryptedInvitationRequest { + addedByEmail: string; + inviteeEmail: string; + base64KeyPacket: string; + base64KeyPacketSignature: string; + role: MemberRole; +} + +/** + * Internal interface of existing invitation on the API. + * + * This interface is used only for managing the invitations. For listing + * invitations with node metadata, see `EncryptedInvitationWithNode`. + */ +export interface EncryptedInvitation extends EncryptedInvitationRequest { + uid: string; + invitationTime: Date; +} + +/** + * Internal interface of existing invitation with the share and node metadata. + * + * Invitation with node is used for listing shared nodes with me, so it includes + * what is being shared as well. + */ +export interface EncryptedInvitationWithNode extends EncryptedInvitation { + share: { + armoredKey: string; + armoredPassphrase: string; + creatorEmail: string; + }; + node: { + uid: string; + type: NodeType; + mediaType?: string; + encryptedName: string; + }; +} + +/** + * Internal interface for creating new external invitation. + */ +export interface EncryptedExternalInvitationRequest { + inviterAddressId: string; + inviteeEmail: string; + role: MemberRole; + base64Signature: string; +} + +/** + * Internal interface of existing external invitation on the API. + */ +export interface EncryptedExternalInvitation extends Omit { + uid: string; + invitationTime: Date; + addedByEmail: string; + state: NonProtonInvitationState; +} + +/** + * Internal interface of existing member on the API. + */ +export interface EncryptedMember { + uid: string; + invitationTime: Date; + addedByEmail: string; + inviteeEmail: string; + role: MemberRole; + base64KeyPacket: string; + base64KeyPacketSignature: string; +} + +/** + * Internal interface of existing member with the share and node metadata. + */ +export interface EncryptedBookmark { + tokenId: string; + creationTime: Date; + share: { + armoredKey: string; + armoredPassphrase: string; + }; + url: { + encryptedUrlPassword?: string; + base64SharePasswordSalt: string; + }; + node: { + type: NodeType; + mediaType?: string; + encryptedName: string; + armoredKey: string; + armoredNodePassphrase: string; + file: { + base64ContentKeyPacket?: string; + }; + }; +} + +export interface EncryptedPublicLink { + uid: string; + creationTime: Date; + expirationTime?: Date; + role: MemberRole; + flags: number; + creatorEmail: string; + publicUrl: string; + numberOfInitializedDownloads: number; + armoredUrlPassword: string; + urlPasswordSalt: string; + base64SharePassphraseKeyPacket: string; + sharePassphraseSalt: string; +} + +export interface EncryptedPublicLinkCrypto { + base64SharePasswordSalt: string; + base64SharePassphraseKeyPacket: string; + armoredPassword: string; +} + +export interface ShareResultWithCreatorEmail extends ShareResult { + publicLink?: PublicLinkWithCreatorEmail; +} + +export interface PublicLinkWithCreatorEmail extends PublicLink { + creatorEmail: string; +} + +/** + * Interface describing the dependencies to the shares module. + */ +export interface SharesService { + getRootIDs(): Promise<{ volumeId: string }>; + loadEncryptedShare(shareId: string): Promise; + getMyFilesShareMemberEmailKey(): Promise<{ + email: string; + addressId: string; + addressKey: PrivateKey; + addressKeyId: string; + }>; + isOwnVolume(volumeId: string): Promise; +} + +/** + * Interface describing the dependencies to the nodes module. + */ +export interface NodesService { + getNode(nodeUid: string): Promise; + getNodeKeys(nodeUid: string): Promise<{ key: PrivateKey }>; + getNodePrivateAndSessionKeys(nodeUid: string): Promise<{ + key: PrivateKey; + passphraseSessionKey: SessionKey; + nameSessionKey: SessionKey; + }>; + getRootNodeEmailKey(nodeUid: string): Promise<{ + email: string; + addressId: string; + 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. + */ +export interface NodesEvents { + 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 new file mode 100644 index 00000000..05f9136c --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingAccess.test.ts @@ -0,0 +1,477 @@ +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 { NodesService, SharesService } from './interface'; +import { BATCH_LOADING_SIZE, SharingAccess } from './sharingAccess'; + +describe('SharingAccess', () => { + let apiService: SharingAPIService; + let cache: SharingCache; + let cryptoService: SharingCryptoService; + let sharesService: SharesService; + let nodesService: NodesService; + + let sharingAccess: SharingAccess; + + 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; + } + }; + + beforeEach(() => { + // @ts-expect-error No need to implement all methods for mocking + 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: '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 = { + getRootIDs: 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) { + for (const node of nodes) { + if (nodeUids.includes(node.nodeUid)) { + yield node; + } + } + }), + 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 when available', async () => { + cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(nodeUids); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodes()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedNodeUids).not.toHaveBeenCalled(); + expect(cache.setSharedByMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should iterate from API 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); + 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 when available', async () => { + cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(nodeUids); + + const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe()); + + expect(result).toEqual(nodes); + expect(apiService.iterateSharedWithMeNodeUids).not.toHaveBeenCalled(); + expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled(); + }); + + it('should iterate from API 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); + 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 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('https://example.com/file.pdf'), + customPassword: resultOk(undefined), + nodeName: resultOk('PublicDocument.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: 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: resultOk('Document.pdf'), + type: NodeType.File, + mediaType: 'image/jpeg', + }, + }), + ]); + }); + + it('should return degraded bookmark when custom password cannot be decrypted', async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultOk('https://example.com/file.pdf'), + customPassword: resultError('Password decryption failed'), + nodeName: resultOk('Document.pdf'), + }); + + const result = await Array.fromAsync(sharingAccess.iterateBookmarks()); + + expect(result).toEqual([ + resultError({ + uid: 'tokenId', + creationTime: new Date('2025-01-01'), + url: resultOk('https://example.com/file.pdf'), + customPassword: resultError('Password decryption failed'), + node: { + name: resultOk('Document.pdf'), + type: NodeType.File, + mediaType: 'image/jpeg', + }, + }), + ]); + }); + + it('should return degraded bookmark when node name cannot be decrypted', async () => { + cryptoService.decryptBookmark = jest.fn().mockResolvedValue({ + url: resultOk('https://example.com/file.pdf'), + customPassword: resultOk(undefined), + 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: resultOk('https://example.com/file.pdf'), + customPassword: resultOk(undefined), + node: { + 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: 'image/jpeg', + }, + }), + ]); + }); + }); + + 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'; + + 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 new file mode 100644 index 00000000..18704b9b --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingAccess.ts @@ -0,0 +1,209 @@ +import { c } from 'ttag'; + +import { ValidationError } from '../../errors'; +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 { 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. +export const BATCH_LOADING_SIZE = 30; + +/** + * Provides high-level actions for access shared nodes. + * + * The manager is responsible for listing shared by me, shared with me, + * invitations, bookmarks, etc., including API communication, encryption, + * decryption, and caching. + */ +export class SharingAccess { + constructor( + private apiService: SharingAPIService, + private cache: SharingCache, + private cryptoService: SharingCryptoService, + private sharesService: SharesService, + private nodesService: NodesService, + ) { + this.apiService = apiService; + this.cache = cache; + this.cryptoService = cryptoService; + this.sharesService = sharesService; + this.nodesService = nodesService; + } + + 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(); + yield* this.iterateSharedNodesFromCache(nodeUids, signal); + } catch { + const { volumeId } = await this.sharesService.getRootIDs(); + const nodeUidsIterator = this.apiService.iterateSharedNodeUids(volumeId, signal); + yield* this.iterateSharedNodesFromAPI( + nodeUidsIterator, + (nodeUids) => this.cache.setSharedByMeNodeUids(nodeUids), + signal, + ); + } + } + + /** + * @deprecated Use `iterateSharedWithMeNodeUids` instead. + */ + 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, + ); + } + } + + private async *iterateSharedNodesFromCache( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); + for (const nodeUid of nodeUids) { + yield* batchLoading.load(nodeUid); + } + yield* batchLoading.loadRest(); + } + + private async *iterateSharedNodesFromAPI( + nodeUidsIterator: AsyncGenerator, + setCache: (nodeUids: string[]) => Promise, + signal?: AbortSignal, + ): AsyncGenerator { + const loadedNodeUids: string[] = []; + const batchLoading = new BatchLoading({ + iterateItems: (nodeUids) => this.iterateNodesAndIgnoreMissingOnes(nodeUids, signal), + batchSize: BATCH_LOADING_SIZE, + }); + for await (const nodeUid of nodeUidsIterator) { + loadedNodeUids.push(nodeUid); + yield* batchLoading.load(nodeUid); + } + yield* batchLoading.loadRest(); + // Set cache only at the end. Once there is anything in the cache, + // it will be used instead of requesting the data from the API. + await setCache(loadedNodeUids); + } + + private async *iterateNodesAndIgnoreMissingOnes( + nodeUids: string[], + signal?: AbortSignal, + ): AsyncGenerator { + const nodeGenerator = this.nodesService.iterateNodes(nodeUids, signal); + for await (const node of nodeGenerator) { + if ('missingUid' in node) { + continue; + } + yield node; + } + } + + async removeSharedNodeWithMe(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + + const share = await this.sharesService.loadEncryptedShare(node.shareId); + const memberUid = share.membership?.memberUid; + if (!memberUid) { + throw new ValidationError(c('Error').t`You can leave only item that is shared with you`); + } + + await this.apiService.removeMember(memberUid); + if (await this.cache.hasSharedWithMeNodeUidsLoaded()) { + await this.cache.removeSharedWithMeNodeUid(nodeUid); + } + } + + 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); + yield invitation; + } + } + + async acceptInvitation(invitationUid: string): Promise { + const encryptedInvitation = await this.apiService.getInvitation(invitationUid); + const { base64SessionKeySignature } = await this.cryptoService.acceptInvitation(encryptedInvitation); + await this.apiService.acceptInvitation(invitationUid, base64SessionKeySignature); + if (await this.cache.hasSharedWithMeNodeUidsLoaded()) { + await this.cache.addSharedWithMeNodeUid(encryptedInvitation.node.uid); + } + } + + async rejectInvitation(invitationUid: string): Promise { + await this.apiService.rejectInvitation(invitationUid); + } + + async *iterateBookmarks(signal?: AbortSignal): AsyncGenerator { + for await (const bookmark of this.apiService.iterateBookmarks(signal)) { + const { url, customPassword, nodeName } = await this.cryptoService.decryptBookmark(bookmark); + + 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, + mediaType: bookmark.node.mediaType, + }, + }); + } else { + yield resultOk({ + uid: bookmark.tokenId, + creationTime: bookmark.creationTime, + url: url.value, + customPassword: customPassword.value, + node: { + name: nodeName.value, + type: bookmark.node.type, + mediaType: bookmark.node.mediaType, + }, + }); + } + } + } + + 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/sharing/sharingManagement.test.ts b/js/sdk/src/internal/sharing/sharingManagement.test.ts new file mode 100644 index 00000000..53583576 --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingManagement.test.ts @@ -0,0 +1,1219 @@ +import { ValidationError } from '../../errors'; +import { + Logger, + Member, + MemberRole, + NonProtonInvitation, + NonProtonInvitationState, + ProtonDriveAccount, + ProtonInvitation, + 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 { NodesService, SharesService } from './interface'; +import { SharingManagement } from './sharingManagement'; + +const DEFAULT_SHARE_ID = 'shareId'; + +describe('SharingManagement', () => { + let logger: Logger; + let apiService: SharingAPIService; + let cache: SharingCache; + let cryptoService: SharingCryptoService; + let accountService: ProtonDriveAccount; + let sharesService: SharesService; + let nodesService: NodesService; + + let sharingManagement: SharingManagement; + + beforeEach(() => { + logger = getMockLogger(); + + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createStandardShare: jest.fn().mockReturnValue({ shareId: 'newShareId', editorsCanShare: false }), + getShareInvitations: jest.fn().mockResolvedValue([]), + getShareExternalInvitations: jest.fn().mockResolvedValue([]), + getShareMembers: jest.fn().mockResolvedValue([]), + inviteProtonUser: jest.fn().mockImplementation((_, invitation) => ({ + ...invitation, + uid: 'created-invitation', + })), + updateInvitation: jest.fn(), + deleteInvitation: jest.fn(), + inviteExternalUser: jest.fn().mockImplementation((_, invitation) => ({ + ...invitation, + uid: 'created-external-invitation', + state: NonProtonInvitationState.Pending, + })), + updateExternalInvitation: jest.fn(), + deleteExternalInvitation: jest.fn(), + updateMember: jest.fn(), + removeMember: jest.fn(), + getPublicLink: jest.fn().mockResolvedValue(undefined), + removePublicLink: jest.fn(), + deleteShare: jest.fn(), + resendInvitationEmail: jest.fn(), + resendExternalInvitationEmail: jest.fn(), + createPublicLink: jest.fn().mockResolvedValue({ + uid: 'publicLinkUid', + publicUrl: 'publicLinkUrl', + }), + updatePublicLink: jest.fn(), + changeShareProperties: 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' } }, + }), + 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), + encryptInvitation: jest.fn().mockImplementation(() => {}), + encryptExternalInvitation: jest.fn().mockImplementation((invitation) => ({ + ...invitation, + base64ExternalInvitationSignature: 'external-signature', + })), + decryptPublicLink: jest.fn().mockImplementation((publicLink) => publicLink), + generatePublicLinkPassword: jest.fn().mockResolvedValue('generatedPassword'), + encryptPublicLink: jest.fn().mockImplementation(() => ({ + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', + })), + }; + // @ts-expect-error No need to implement all methods for mocking + accountService = { + hasProtonAccount: jest.fn().mockResolvedValue(true), + }; + // @ts-expect-error No need to implement all methods for mocking + sharesService = { + loadEncryptedShare: jest.fn().mockResolvedValue({ + id: DEFAULT_SHARE_ID, + addressId: 'addressId', + creatorEmail: 'address@example.com', + passphraseSessionKey: 'sharePassphraseSessionKey', + }), + getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }), + }; + // @ts-expect-error No need to implement all methods for mocking + nodesService = { + 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' }), + notifyNodeChanged: jest.fn(), + }; + + sharingManagement = new SharingManagement( + logger, + apiService, + cache, + 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'); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.getShareInvitations).not.toHaveBeenCalled(); + expect(apiService.getShareExternalInvitations).not.toHaveBeenCalled(); + expect(apiService.getShareMembers).not.toHaveBeenCalled(); + }); + + it('should return invitations', async () => { + const invitation = { uid: 'invitaiton', addedByEmail: 'email' }; + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); + + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [], + members: [], + publicLink: undefined, + }); + expect(cryptoService.decryptInvitation).toHaveBeenCalledWith(invitation); + }); + + it('should return external invitations', async () => { + const externalInvitation = { uid: 'external-invitation', addedByEmail: 'email' }; + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [externalInvitation], + members: [], + publicLink: undefined, + }); + expect(cryptoService.decryptExternalInvitation).toHaveBeenCalledWith( + externalInvitation, + 'sharePassphraseSessionKey', + ); + }); + + it('should return members', async () => { + const member = { uid: 'member', addedByEmail: 'email' }; + apiService.getShareMembers = jest.fn().mockResolvedValue([member]); + + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [], + members: [member], + publicLink: undefined, + }); + expect(cryptoService.decryptMember).toHaveBeenCalledWith(member); + }); + + it('should return public link', async () => { + const publicLink = { + uid: 'shared~publicLink', + }; + apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); + + const sharingInfo = await sharingManagement.getSharingInfo('volumeId~nodeUid'); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [], + members: [], + publicLink: publicLink, + }); + 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', () => { + 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.notifyNodeChanged = jest.fn(); + + 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, + editorsCanShare: false, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + 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', () => { + const nodeUid = 'volumeId~nodeUid'; + + let invitation: ProtonInvitation; + let externalInvitation: NonProtonInvitation; + let member: Member; + + beforeEach(async () => { + invitation = { + uid: 'invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'internal-email', + role: MemberRole.Viewer, + invitationTime: new Date(), + }; + externalInvitation = { + 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', + role: MemberRole.Viewer, + invitationTime: new Date(), + }; + + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); + + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + + apiService.getShareMembers = jest.fn().mockResolvedValue([member]); + }); + + 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'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [ + invitation, + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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', + }, + ], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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', + }, + ], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + 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 }], + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + 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 () => { + 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', () => { + 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'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [ + externalInvitation, + { + uid: 'created-external-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + state: 'pending', + }, + ], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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', + }, + ], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteExternalUser).toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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', + }, + ], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateExternalInvitation).toHaveBeenCalled(); + expect(apiService.inviteExternalUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + 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 () => { + 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', () => { + beforeEach(() => { + accountService.hasProtonAccount = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + }); + + it('should share node with proton and external email with default role', async () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { users: ['email', 'email2'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [ + invitation, + { + uid: 'created-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email', + role: 'viewer', + }, + ], + nonProtonInvitations: [ + externalInvitation, + { + uid: 'created-external-invitation', + addedByEmail: { ok: true, value: 'volume-email' }, + inviteeEmail: 'email2', + role: 'viewer', + state: 'pending', + }, + ], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + inviteeEmail: 'email', + }), + expect.anything(), + ); + expect(apiService.inviteExternalUser).toHaveBeenCalledWith( + 'shareId', + expect.objectContaining({ + inviteeEmail: 'email2', + }), + expect.anything(), + ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + }); + + 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', + }, + ], + publicLink: undefined, + }); + 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 () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Viewer }], + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateMember).not.toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + 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', + }, + ], + publicLink: undefined, + }); + 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 () => { + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + users: [{ email: 'member-email', role: MemberRole.Viewer }], + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.updateMember).not.toHaveBeenCalled(); + expect(apiService.updateInvitation).not.toHaveBeenCalled(); + expect(apiService.inviteProtonUser).not.toHaveBeenCalled(); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + }); + + describe('public link', () => { + it('should share node with public link', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + customPassword: undefined, + expiration: undefined, + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: 'publicLinkUid', + role: MemberRole.Viewer, + url: 'publicLinkUrl#generatedPassword', + creationTime: new Date(), + expirationTime: undefined, + customPassword: undefined, + creatorEmail: 'volume-email', + 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(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should share node with custom password and expiration', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + customPassword: 'customPassword', + expiration: new Date('2025-01-02'), + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: 'publicLinkUid', + role: MemberRole.Viewer, + url: 'publicLinkUrl#generatedPassword', + creationTime: new Date(), + expirationTime: new Date('2025-01-02'), + customPassword: 'customPassword', + creatorEmail: 'volume-email', + 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(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should update public link with custom password and expiration', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + const publicLink = { + uid: 'publicLinkUid', + url: 'publicLinkUrl#generatedpas', // Generated password must be 12 chararacters long. + creationTime: new Date('2025-01-01'), + role: MemberRole.Viewer, + customPassword: undefined, + expirationTime: undefined, + creatorEmail: 'publicLinkCreatorEmail', + }; + apiService.getPublicLink = jest.fn().mockResolvedValue(publicLink); + + const sharingInfo = await sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Editor, + customPassword: 'customPassword', + expiration: new Date('2025-01-02'), + }, + }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: { + uid: 'publicLinkUid', + role: MemberRole.Editor, + url: 'publicLinkUrl#generatedpas', + creationTime: new Date('2025-01-01'), + expirationTime: new Date('2025-01-02'), + customPassword: 'customPassword', + creatorEmail: 'publicLinkCreatorEmail', + }, + }); + expect(cryptoService.encryptPublicLink).toHaveBeenCalledWith( + 'publicLinkCreatorEmail', + 'sharePassphraseSessionKey', + 'generatedpascustomPassword', + ); + expect(apiService.updatePublicLink).toHaveBeenCalledWith( + 'publicLinkUid', + expect.objectContaining({ + role: MemberRole.Editor, + includesCustomPassword: true, + expirationTime: 1735776000, + crypto: 'publicLinkCrypto', + srp: 'publicLinkSrp', + }), + ); + expect(cache.addSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should not allow updating legacy public link', async () => { + apiService.getPublicLink = jest.fn().mockResolvedValue({ + uid: 'publicLinkUid', + url: 'publicLinkUrl#aaa', // Legacy public links doesn't have 12 chars. + }); + + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: true, + }), + ).rejects.toThrow('Legacy public link cannot be updated. Please re-create a new public link.'); + }); + + it('should not allow updating legacy public link without generated password', async () => { + apiService.getPublicLink = jest.fn().mockResolvedValue({ + uid: 'publicLinkUid', + url: 'publicLinkUrl', + }); + + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: true, + }), + ).rejects.toThrow('Legacy public link cannot be updated. Please re-create a new public link.'); + }); + + it('should not allow creating public link with expiration in the past', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01')); + + await expect( + sharingManagement.shareNode(nodeUid, { + publicLink: { + role: MemberRole.Viewer, + expiration: new Date('2024-01-01'), + }, + }), + ).rejects.toThrow('Expiration date cannot be in the past'); + expect(apiService.createStandardShare).not.toHaveBeenCalled(); + expect(apiService.createPublicLink).not.toHaveBeenCalled(); + }); + + 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(); + }); + }); + }); + + describe('unshareNode', () => { + const nodeUid = 'volumeId~nodeUid'; + + let invitation: ProtonInvitation; + let externalInvitation: NonProtonInvitation; + let member: Member; + let publicLink: PublicLink; + + beforeEach(async () => { + invitation = { + 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', + role: MemberRole.Viewer, + invitationTime: new Date(), + state: NonProtonInvitationState.Pending, + }; + member = { + uid: 'member', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'member-email', + role: MemberRole.Viewer, + invitationTime: new Date(), + }; + publicLink = { + uid: 'publicLink', + creationTime: new Date(), + role: MemberRole.Viewer, + url: 'url', + numberOfInitializedDownloads: 0, + }; + + 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'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should delete external invitation', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['external-email'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [], + members: [member], + publicLink, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should remove member', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['member-email'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [], + publicLink, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + 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 () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { users: ['non-existing-email'] }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should remove public link', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { publicLink: 'remove' }); + + expect(sharingInfo).toEqual({ + protonInvitations: [invitation], + nonProtonInvitations: [externalInvitation], + members: [member], + publicLink: undefined, + }); + expect(apiService.deleteShare).not.toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).not.toHaveBeenCalled(); + }); + + it('should remove share if all is removed', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.deleteShare).toHaveBeenCalled(); + expect(apiService.deleteInvitation).not.toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).not.toHaveBeenCalled(); + expect(apiService.removeMember).not.toHaveBeenCalled(); + expect(apiService.removePublicLink).not.toHaveBeenCalled(); + expect(nodesService.notifyNodeChanged).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); + }); + + it('should remove share if everything is manually removed', async () => { + const sharingInfo = await sharingManagement.unshareNode(nodeUid, { + users: ['internal-email', 'external-email', 'member-email'], + publicLink: 'remove', + }); + + expect(sharingInfo).toEqual(undefined); + expect(apiService.deleteShare).toHaveBeenCalled(); + expect(apiService.deleteInvitation).toHaveBeenCalled(); + expect(apiService.deleteExternalInvitation).toHaveBeenCalled(); + expect(apiService.removeMember).toHaveBeenCalled(); + expect(apiService.removePublicLink).toHaveBeenCalled(); + expect(cache.removeSharedByMeNodeUid).toHaveBeenCalledWith(nodeUid); + }); + }); + + describe('resendInvitationEmail', () => { + const nodeUid = 'volumeId~nodeUid'; + + const invitation: ProtonInvitation = { + uid: 'invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'internal-email', + role: MemberRole.Viewer, + invitationTime: new Date(), + }; + const externalInvitation: NonProtonInvitation = { + uid: 'external-invitation', + addedByEmail: resultOk('added-email'), + inviteeEmail: 'external-email', + role: MemberRole.Viewer, + invitationTime: new Date(), + state: NonProtonInvitationState.Pending, + }; + + beforeEach(() => { + apiService.getShareInvitations = jest.fn().mockResolvedValue([invitation]); + apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]); + apiService.getShareMembers = jest.fn().mockResolvedValue([]); + apiService.getPublicLink = jest.fn().mockResolvedValue(undefined); + }); + + it('should resend email for proton invitation', async () => { + await sharingManagement.resendInvitationEmail(nodeUid, invitation.uid); + + expect(apiService.resendInvitationEmail).toHaveBeenCalledWith(invitation.uid); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + + it('should resend email for external invitation', async () => { + await sharingManagement.resendInvitationEmail(nodeUid, externalInvitation.uid); + + expect(apiService.resendExternalInvitationEmail).toHaveBeenCalledWith(externalInvitation.uid); + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + }); + + it('should throw error when no sharing found for node', async () => { + nodesService.getNode = jest.fn().mockResolvedValue({ nodeUid, shareId: undefined }); + + await expect(sharingManagement.resendInvitationEmail(nodeUid, invitation.uid)).rejects.toThrow( + 'Node is not shared', + ); + + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + + it('should log when no invitation found', async () => { + await expect(sharingManagement.resendInvitationEmail(nodeUid, 'non-existent-uid')).rejects.toThrow( + 'Invitation not found', + ); + + expect(apiService.resendInvitationEmail).not.toHaveBeenCalled(); + expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled(); + }); + }); + + 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 new file mode 100644 index 00000000..c4f1fc57 --- /dev/null +++ b/js/sdk/src/internal/sharing/sharingManagement.ts @@ -0,0 +1,836 @@ +import { c } from 'ttag'; + +import { PrivateKey, SessionKey } from '../../crypto'; +import { ValidationError } from '../../errors'; +import { + Logger, + Member, + MemberRole, + NonProtonInvitation, + ProtonDriveAccount, + ProtonInvitation, + resultOk, + ShareNodeSettings, + SharePublicLinkSettingsObject, + ShareResult, + UnshareNodeSettings, +} from '../../interface'; +import { ErrorCode } from '../apiService'; +import { getErrorMessage } from '../errors'; +import { splitInvitationUid, splitNodeUid } from '../uids'; +import { SharingAPIService } from './apiService'; +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; + nodeName: string; +} + +interface Share { + volumeId: string; + shareId: string; + creatorEmail: string; + passphraseSessionKey: SessionKey; +} + +interface ContextShareAddress { + addressId: string; + addressKey: PrivateKey; + email: string; +} + +interface EmailOptions { + message?: string; + nodeName?: string; +} + +/** + * Provides high-level actions for managing sharing. + * + * The manager is responsible for sharing and unsharing nodes, and providing + * sharing details of nodes. + */ +export class SharingManagement { + constructor( + private logger: Logger, + private apiService: SharingAPIService, + private cache: SharingCache, + private cryptoService: SharingCryptoService, + private account: ProtonDriveAccount, + private sharesService: SharesService, + private nodesService: NodesService, + ) { + this.logger = logger; + this.apiService = apiService; + this.cache = cache; + this.cryptoService = cryptoService; + this.account = account; + this.sharesService = sharesService; + this.nodesService = nodesService; + } + + async getSharingInfo(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + + 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] = await Promise.all([ + Array.fromAsync(this.iterateShareInvitations(node.shareId)), + Array.fromAsync(this.iterateShareExternalInvitations(node.shareId, passphraseSessionKey)), + Array.fromAsync(this.iterateShareMembers(node.shareId)), + this.getPublicLink(node.shareId, volumeId), + ]); + + return { + protonInvitations, + nonProtonInvitations, + members, + publicLink, + editorsCanShare: encryptedShare.editorsCanShare, + }; + } + + 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, + sharePassphraseSessionKey: SessionKey, + ): AsyncGenerator { + const invitations = await this.apiService.getShareExternalInvitations(shareId); + for (const invitation of invitations) { + yield this.cryptoService.decryptExternalInvitation(invitation, sharePassphraseSessionKey); + } + } + + private async *iterateShareMembers(shareId: string): AsyncGenerator { + const members = await this.apiService.getShareMembers(shareId); + for (const member of members) { + yield this.cryptoService.decryptMember(member); + } + } + + 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); + } + + async shareNode(nodeUid: string, settings: ShareNodeSettings): Promise { + // Check what users are Proton users before creating share + // so if this fails, we don't create empty share. + const protonUsers = []; + const nonProtonUsers = []; + if (settings.users) { + for (const user of settings.users) { + const { email, role } = typeof user === 'string' ? { email: user, role: MemberRole.Viewer } : user; + const isProtonUser = await this.account.hasProtonAccount(email); + if (isProtonUser) { + protonUsers.push({ email, role }); + } else { + nonProtonUsers.push({ email, role }); + } + } + } + + // Check if expiration date is in the past before creating share + // so if this fails, we don't create empty share. + if ( + typeof settings.publicLink === 'object' && + settings.publicLink.expiration && + settings.publicLink.expiration < new Date() + ) { + throw new ValidationError(c('Error').t`Expiration date cannot be in the past`); + } + + let contextShareAddress: ContextShareAddress | undefined; + let currentSharing = await this.getInternalSharingInfo(nodeUid); + if (!currentSharing) { + const node = await this.nodesService.getNode(nodeUid); + 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, + editorsCanShare: result.editorsCanShare, + }; + 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); + } + + 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, + }; + + for (const user of protonUsers) { + const { email, role } = user; + + 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}`); + continue; + } + this.logger.info(`Invitation for ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateInvitation(existingInvitation.uid, role); + existingInvitation.role = role; + continue; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); + if (existingMember) { + if (existingMember.role === role) { + this.logger.info(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.logger.info(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateMember(existingMember.uid, role); + existingMember.role = role; + continue; + } + + this.logger.info(`Inviting user ${email} with role ${role} to node ${nodeUid}`); + const invitation = await this.inviteProtonUser( + contextShareAddress, + currentSharing.share, + email, + role, + emailOptions, + ); + currentSharing.protonInvitations.push(invitation); + } + + for (const user of nonProtonUsers) { + const { email, role } = user; + + 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}`, + ); + continue; + } + 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; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === email); + if (existingMember) { + if (existingMember.role === role) { + this.logger.info(`Member ${email} already exists with role ${role} to node ${nodeUid}`); + continue; + } + this.logger.info(`Member ${email} already exists, updating role to ${role} to node ${nodeUid}`); + await this.updateMember(existingMember.uid, role); + existingMember.role = role; + continue; + } + + this.logger.info(`Inviting external user ${email} with role ${role} to node ${nodeUid}`); + const invitation = await this.inviteExternalUser( + contextShareAddress, + currentSharing.share, + email, + role, + emailOptions, + ); + currentSharing.nonProtonInvitations.push(invitation); + } + + if (settings.publicLink) { + const options = settings.publicLink === true ? { role: MemberRole.Viewer } : settings.publicLink; + + if (currentSharing.publicLink) { + this.logger.info(`Updating public link with role ${options.role} to node ${nodeUid}`); + currentSharing.publicLink = await this.updateSharedLink( + currentSharing.share, + currentSharing.publicLink, + options, + ); + } else { + this.logger.info(`Sharing via public link with role ${options.role} to node ${nodeUid}`); + currentSharing.publicLink = await this.shareViaLink(contextShareAddress, currentSharing.share, options); + } + } + + return { + protonInvitations: currentSharing.protonInvitations, + nonProtonInvitations: currentSharing.nonProtonInvitations, + members: currentSharing.members, + publicLink: currentSharing.publicLink, + editorsCanShare: currentSharing.editorsCanShare, + }; + } + + async unshareNode(nodeUid: string, settings?: UnshareNodeSettings): Promise { + const currentSharing = await this.getInternalSharingInfo(nodeUid); + if (!currentSharing) { + return; + } + + if (!settings) { + this.logger.info(`Unsharing node ${nodeUid}`); + await this.deleteShareWithForce(currentSharing.share.shareId, nodeUid); + return; + } + + for (const userEmail of settings.users || []) { + 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, + ); + continue; + } + + 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, + ); + continue; + } + + const existingMember = currentSharing.members.find((member) => member.inviteeEmail === userEmail); + if (existingMember) { + this.logger.info(`Removing member ${userEmail} to node ${nodeUid}`); + await this.removeMember(existingMember.uid); + currentSharing.members = currentSharing.members.filter((member) => member.uid !== existingMember.uid); + continue; + } + + this.logger.info(`User ${userEmail} not found in sharing info for node ${nodeUid}`); + } + + if (settings.publicLink === 'remove') { + if (currentSharing.publicLink) { + this.logger.info(`Removing public link to node ${nodeUid}`); + await this.removeSharedLink(currentSharing.publicLink.uid); + } else { + this.logger.info(`Public link not found for node ${nodeUid}`); + } + currentSharing.publicLink = undefined; + } + + if ( + currentSharing.protonInvitations.length === 0 && + currentSharing.nonProtonInvitations.length === 0 && + currentSharing.members.length === 0 && + !currentSharing.publicLink + ) { + // Technically it is not needed to delete the share explicitly + // as it will be deleted when the last member is removed by the + // backend, but that might take a while and it is better to + // update local state immediately. + this.logger.info(`Deleting share ${currentSharing.share.shareId} for node ${nodeUid}`); + try { + await this.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 + // the share and it is not empty. + // If share is truly empty, backend will delete it eventually. + this.logger.warn( + `Failed to delete share ${currentSharing.share.shareId} for node ${nodeUid}: ${getErrorMessage(error)}`, + ); + } + return; + } + + return { + protonInvitations: currentSharing.protonInvitations, + nonProtonInvitations: currentSharing.nonProtonInvitations, + members: currentSharing.members, + publicLink: currentSharing.publicLink, + editorsCanShare: currentSharing.editorsCanShare, + }; + } + + private async getInternalSharingInfo(nodeUid: string): Promise { + const node = await this.nodesService.getNode(nodeUid); + if (!node.shareId) { + return; + } + const sharingInfo = await this.getSharingInfo(nodeUid); + if (!sharingInfo) { + return; + } + + const { volumeId } = splitNodeUid(nodeUid); + const { key: nodeKey } = await this.nodesService.getNodeKeys(nodeUid); + const encryptedShare = await this.sharesService.loadEncryptedShare(node.shareId); + const { passphraseSessionKey } = await this.cryptoService.decryptShare(encryptedShare, nodeKey); + + return { + ...sharingInfo, + share: { + volumeId, + shareId: node.shareId, + creatorEmail: encryptedShare.creatorEmail, + passphraseSessionKey: passphraseSessionKey, + }, + nodeName: node.name.ok ? node.name.value : node.name.error.name, + }; + } + + 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`); + } + + const { volumeId } = splitNodeUid(nodeUid); + const { email, addressId, addressKey } = await this.nodesService.getRootNodeEmailKey(nodeUid); + + const nodeKeys = await this.nodesService.getNodePrivateAndSessionKeys(nodeUid); + const keys = await this.cryptoService.generateShareKeys(nodeKeys, addressKey); + 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); + } + + const share = { + volumeId, + shareId, + creatorEmail: email, + passphraseSessionKey: keys.shareKey.decrypted.passphraseSessionKey, + }; + const contextShareAddress = { + email, + addressId, + addressKey, + }; + 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. + */ + 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); + } + } + + private async inviteProtonUser( + inviter: ContextShareAddress, + share: Share, + inviteeEmail: string, + role: MemberRole, + emailOptions: EmailOptions, + ): Promise { + 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, + ); + + return { + ...encryptedInvitation, + addedByEmail: resultOk(encryptedInvitation.addedByEmail), + }; + } + + private async updateInvitation(invitationUid: string, role: MemberRole): Promise { + await this.apiService.updateInvitation(invitationUid, { role }); + } + + async resendInvitationEmail(nodeUid: string, invitationUid: string): Promise { + const currentSharing = await this.getInternalSharingInfo(nodeUid); + + if (!currentSharing) { + throw new ValidationError(c('Error').t`Node is not shared`); + } + + const protonInvite = currentSharing.protonInvitations.find((invitation) => invitation.uid === invitationUid); + if (protonInvite) { + return await this.apiService.resendInvitationEmail(protonInvite.uid); + } + + const nonProtonInvite = currentSharing.nonProtonInvitations.find( + (invitation) => invitation.uid === invitationUid, + ); + if (nonProtonInvite) { + return await this.apiService.resendExternalInvitationEmail(nonProtonInvite.uid); + } + + throw new ValidationError(c('Error').t`Invitation not found`); + } + + private async deleteInvitation(invitationUid: string): Promise { + await this.apiService.deleteInvitation(invitationUid); + } + + private async inviteExternalUser( + inviter: ContextShareAddress, + share: Share, + inviteeEmail: string, + role: MemberRole, + emailOptions: EmailOptions, + ): Promise { + 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, + ); + + return { + uid: encryptedInvitation.uid, + invitationTime: encryptedInvitation.invitationTime, + addedByEmail: resultOk(inviter.email), + inviteeEmail, + role, + state: encryptedInvitation.state, + }; + } + + private async updateExternalInvitation(invitationUid: string, role: MemberRole): Promise { + await this.apiService.updateExternalInvitation(invitationUid, { role }); + } + + private async deleteExternalInvitation(invitationUid: string): Promise { + await this.apiService.deleteExternalInvitation(invitationUid); + } + + 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), + }; + } + + /** + * 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); + } + + private async updateMember(memberUid: string, role: MemberRole): Promise { + await this.apiService.updateMember(memberUid, { role }); + } + + private async shareViaLink( + inviter: ContextShareAddress, + 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; + + const { crypto, srp } = await this.cryptoService.encryptPublicLink( + inviter.email, + share.passphraseSessionKey, + password, + ); + const publicLink = await this.apiService.createPublicLink(share.shareId, { + creatorEmail: inviter.email, + role: options.role, + includesCustomPassword: !!options.customPassword, + expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined, + crypto, + srp, + }); + + return { + uid: publicLink.uid, + creationTime: new Date(), + role: options.role, + url: `${publicLink.publicUrl}#${generatedPassword}`, + customPassword: options.customPassword, + expirationTime: options.expiration, + numberOfInitializedDownloads: 0, + creatorEmail: inviter.email, + }; + } + + private async updateSharedLink( + share: Share, + 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) { + throw new ValidationError( + c('Error').t`Legacy public link cannot be updated. Please re-create a new public link.`, + ); + } + const password = options.customPassword ? `${generatedPassword}${options.customPassword}` : generatedPassword; + + const { crypto, srp } = await this.cryptoService.encryptPublicLink( + publicLink.creatorEmail, + share.passphraseSessionKey, + password, + ); + await this.apiService.updatePublicLink(publicLink.uid, { + role: options.role, + includesCustomPassword: !!options.customPassword, + expirationTime: options.expiration ? Math.floor(options.expiration.getTime() / 1000) : undefined, + crypto, + srp, + }); + + return { + ...publicLink, + role: options.role, + customPassword: options.customPassword, + expirationTime: options.expiration, + }; + } + + private async removeSharedLink(publicLinkUid: string): Promise { + await this.apiService.removePublicLink(publicLinkUid); + } +} diff --git a/js/sdk/src/internal/sharingPublic/apiService.ts b/js/sdk/src/internal/sharingPublic/apiService.ts new file mode 100644 index 00000000..3b6bc263 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/apiService.ts @@ -0,0 +1,50 @@ +import { DriveAPIService, drivePaths } from '../apiService'; + +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']; + +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. + * + * 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 apiService: DriveAPIService) { + this.apiService = apiService; + } + + 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, + }, + }, + ); + } + + async malwareScan(token: string, hashes: string[]) { + return this.apiService.post(`drive/urls/${token}/security`, { + Hashes: hashes, + }); + } +} diff --git a/js/sdk/src/internal/sharingPublic/cryptoReporter.ts b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts new file mode 100644 index 00000000..ed7a0941 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/cryptoReporter.ts @@ -0,0 +1,77 @@ +import { c } from 'ttag'; + +import { VERIFICATION_STATUS } from '../../crypto'; +import { + AnonymousUser, + Author, + Logger, + MetricsDecryptionErrorField, + MetricVerificationErrorField, + MetricVolumeType, + ProtonDriveTelemetry, + resultError, + resultOk, +} from '../../interface'; +import { getVerificationMessage, isNotApplicationError } from '../errors'; + +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, + ) { + if (isNotApplicationError(error)) { + return; + } + + 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 new file mode 100644 index 00000000..b243e2fc --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/index.ts @@ -0,0 +1,134 @@ +import { DriveCrypto, PrivateKey } from '../../crypto'; +import { + MemberRole, + ProtonDriveAccount, + ProtonDriveCryptoCache, + ProtonDriveEntitiesCache, + ProtonDriveTelemetry, +} from '../../interface'; +import { DriveAPIService } from '../apiService'; +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 { NodesSecurity } from './nodesSecurity'; +import { SharingPublicSharesManager } from './shares'; + +export { SharingPublicSessionManager } from './session/manager'; +export { getTokenAndPasswordFromUrl } from './session/url'; +export { UnauthDriveAPIService } from './unauthApiService'; + +/** + * 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, + driveEntitiesCache: ProtonDriveEntitiesCache, + driveCryptoCache: ProtonDriveCryptoCache, + driveCrypto: DriveCrypto, + account: ProtonDriveAccount, + url: string, + token: string, + publicShareKey: PrivateKey, + publicRootNodeUid: string, + publicRole: MemberRole, + isAnonymousContext: boolean, +) { + const shares = new SharingPublicSharesManager(account, publicShareKey, publicRootNodeUid); + const nodes = initSharingPublicNodesModule( + telemetry, + apiService, + driveEntitiesCache, + driveCryptoCache, + driveCrypto, + account, + shares, + url, + token, + publicShareKey, + publicRootNodeUid, + publicRole, + isAnonymousContext, + ); + + 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, + url: string, + token: string, + publicShareKey: PrivateKey, + publicRootNodeUid: string, + publicRole: MemberRole, + isAnonymousContext: boolean, +) { + const clientUid = undefined; // No client UID for public context yet. + const api = new SharingPublicNodesAPIService( + telemetry.getLogger('nodes-api'), + apiService, + 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 SharingPublicNodesCryptoService( + telemetry, + driveCrypto, + account, + sharesService, + cryptoReporter, + ); + const nodesAccess = new SharingPublicNodesAccess( + telemetry, + api, + cache, + cryptoCache, + cryptoService, + sharesService, + url, + token, + publicShareKey, + publicRootNodeUid, + isAnonymousContext, + ); + 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/nodes.ts b/js/sdk/src/internal/sharingPublic/nodes.ts new file mode 100644 index 00000000..cffdfc04 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/nodes.ts @@ -0,0 +1,280 @@ +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 { linkToEncryptedNode, NodeAPIService } from '../nodes/apiService'; +import { NodesCache } from '../nodes/cache'; +import { NodesCryptoCache } from '../nodes/cryptoCache'; +import { NodesCryptoService } from '../nodes/cryptoService'; +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 { validateNodeName } from '../nodes/validations'; +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, + 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. + * + * 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, + private token: string, + ) { + super(logger, apiService, clientUid); + this.publicRootNodeUid = publicRootNodeUid; + this.publicRole = publicRole; + this.token = token; + } + + protected linkToEncryptedNode( + volumeId: string, + link: PostLoadLinksMetadataResponse['Links'][0], + isOwnVolumeId: boolean, + ): 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, + // 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; + } + + 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 { + constructor( + telemetry: ProtonDriveTelemetry, + apiService: NodeAPIService, + cache: NodesCache, + cryptoCache: NodesCryptoCache, + cryptoService: NodesCryptoService, + sharesService: SharingPublicSharesManager, + private url: string, + 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; + } + + /** + * 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> { + // 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 { + 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; + } + + 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( + private sharingPublicApiService: SharingPublicNodesAPIService, + cryptoCache: NodesCryptoCache, + private sharingPublicCryptoService: SharingPublicNodesCryptoService, + nodesAccess: SharingPublicNodesAccess, + ) { + super(sharingPublicApiService, cryptoCache, sharingPublicCryptoService, nodesAccess); + } + + 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.deleteMyNodes(nodeUids, signal)) { + if (result.ok) { + await this.nodesAccess.notifyNodeDeleted(result.uid); + } + 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/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/internal/sharingPublic/session/apiService.ts b/js/sdk/src/internal/sharingPublic/session/apiService.ts new file mode 100644 index 00000000..4f46e0fc --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/apiService.ts @@ -0,0 +1,96 @@ +import { Logger } from '../../../interface'; +import { DriveAPIService, drivePaths, permissionsToMemberRole } from '../../apiService'; +import { makeNodeUid } from '../../uids'; +import { EncryptedShareCrypto, PublicLinkInfo, PublicLinkSession, 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 logger: Logger, + private apiService: DriveAPIService, + ) { + this.logger = logger; + 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, + 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, + }; + } + + /** + * 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<{ + session: PublicLinkSession; + encryptedShare: EncryptedShareCrypto; + rootUid: string; + }> { + const response = await this.apiService.post( + `drive/urls/${token}/auth`, + { + ClientProof: srp.clientProof, + ClientEphemeral: srp.clientEphemeral, + SRPSession: srp.srpSession, + }, + ); + + return { + session: { + serverProof: response.ServerProof, + sessionUid: response.UID, + sessionAccessToken: response.AccessToken, + }, + encryptedShare: { + base64UrlPasswordSalt: response.Share.SharePasswordSalt, + armoredKey: response.Share.ShareKey, + armoredPassphrase: response.Share.SharePassphrase, + publicPermissions: response.Share.PublicPermissions, + }, + rootUid: makeNodeUid(response.Share.VolumeID, response.Share.LinkID), + }; + } +} 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..90d854e3 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/httpClient.ts @@ -0,0 +1,48 @@ +import { + ProtonDriveHTTPClient, + ProtonDriveHTTPClientBlobRequest, + ProtonDriveHTTPClientJsonRequest, +} 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: ProtonDriveHTTPClientJsonRequest) { + 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: ProtonDriveHTTPClientBlobRequest) { + return this.httpClient.fetchBlob(this.getOptionsWithSessionHeaders(options)); + } + + 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) { + 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..9ab77303 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/index.ts @@ -0,0 +1,2 @@ +export { SharingPublicSessionManager } from './manager'; +export { SharingPublicLinkSession } from './session'; 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..7a87eed9 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/interface.ts @@ -0,0 +1,40 @@ +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 = { + version: number; + modulus: string; + serverEphemeral: string; + salt: string; + srpSession: string; +}; + +export type PublicLinkSrpAuth = { + clientProof: string; + clientEphemeral: string; + srpSession: string; +}; + +export type PublicLinkSession = { + serverProof: string; + sessionUid: string; + sessionAccessToken?: string; +}; + +export type EncryptedShareCrypto = { + base64UrlPasswordSalt: string; + armoredKey: string; + armoredPassphrase: string; + publicPermissions?: number; +}; 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..0f8620e3 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/manager.ts @@ -0,0 +1,122 @@ +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'; +import { EncryptedShareCrypto, PublicLinkInfo } from './interface'; +import { SharingPublicLinkSession } from './session'; + +/** + * 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(); + + private logger: Logger; + + constructor( + telemetry: ProtonDriveTelemetry, + private httpClient: ProtonDriveHTTPClient, + private driveCrypto: DriveCrypto, + private srpModule: SRPModule, + apiService: DriveAPIService, + ) { + this.logger = telemetry.getLogger('sharingPublicSession'); + this.httpClient = httpClient; + this.driveCrypto = driveCrypto; + this.srpModule = srpModule; + + this.api = new SharingPublicSessionAPIService(telemetry.getLogger('sharingPublicSession'), 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 token - The public link token. + */ + async getInfo(token: string): Promise<{ + isCustomPasswordProtected: boolean; + isLegacy: boolean; + vendorType: number; + directAccess?: { + nodeUid: string; + directRole: MemberRole; + publicRole: MemberRole; + }; + }> { + + const info = await this.api.initPublicLinkSession(token); + this.infosPerToken.set(token, info); + + return { + isCustomPasswordProtected: info.isCustomPasswordProtected, + isLegacy: info.isLegacy, + vendorType: info.vendorType, + directAccess: info.directAccess, + }; + } + + /** + * 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 token - The public link token. + * @param customPassword - The custom password for the public link, if it is + * custom password protected. + */ + async auth( + token: string, + urlPassword: string, + customPassword?: string, + ): Promise<{ + httpClient: SharingPublicSessionHttpClient; + shareKey: PrivateKey; + rootUid: string; + publicRole: MemberRole; + session: SharingPublicLinkSession; + }> { + 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); + const { encryptedShare, rootUid } = await session.auth(info.srp); + + const shareKey = await this.decryptShareKey(encryptedShare, password); + + return { + httpClient: new SharingPublicSessionHttpClient(this.httpClient, session), + shareKey, + rootUid, + publicRole: permissionsToMemberRole(this.logger, encryptedShare.publicPermissions), + session, + }; + } + + 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 new file mode 100644 index 00000000..b45a6947 --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/session/session.ts @@ -0,0 +1,83 @@ +import { SRPModule } from '../../../crypto'; +import { SharingPublicSessionAPIService } from './apiService'; +import { EncryptedShareCrypto, 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<{ encryptedShare: EncryptedShareCrypto; rootUid: string }> { + 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.session.serverProof !== expectedServerProof) { + throw new Error('Invalid server proof'); + } + + this.sessionUid = auth.session.sessionUid; + this.sessionAccessToken = auth.session.sessionAccessToken; + + return { + encryptedShare: auth.encryptedShare, + rootUid: auth.rootUid, + }; + } + + /** + * 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..06864318 --- /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/internal/sharingPublic/shares.ts b/js/sdk/src/internal/sharingPublic/shares.ts new file mode 100644 index 00000000..cf230cad --- /dev/null +++ b/js/sdk/src/internal/sharingPublic/shares.ts @@ -0,0 +1,49 @@ +import { PrivateKey } from '../../crypto'; +import { MetricVolumeType, ProtonDriveAccount } from '../../interface'; +import { splitNodeUid } from '../uids'; + +/** + * 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 { + constructor( + private account: ProtonDriveAccount, + private publicShareKey: PrivateKey, + private publicRootNodeUid: string, + ) { + this.account = account; + this.publicShareKey = publicShareKey; + this.publicRootNodeUid = publicRootNodeUid; + } + + async getRootIDs(): Promise<{ volumeId: string; rootNodeId: string; rootNodeUid: string }> { + const { volumeId, nodeId: rootNodeId } = splitNodeUid(this.publicRootNodeUid); + return { volumeId, rootNodeId, rootNodeUid: this.publicRootNodeUid }; + } + + async getSharePrivateKey(): Promise { + return this.publicShareKey; + } + + 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, + }; + } + + async getVolumeMetricContext(): Promise { + return MetricVolumeType.SharedPublic; + } +} 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/uids.ts b/js/sdk/src/internal/uids.ts new file mode 100644 index 00000000..3b9fe035 --- /dev/null +++ b/js/sdk/src/internal/uids.ts @@ -0,0 +1,83 @@ +export function makeDeviceUid(volumeId: string, deviceId: string) { + return `${volumeId}~${deviceId}`; +} + +export function splitDeviceUid(deviceUid: string) { + const parts = deviceUid.split('~'); + if (parts.length !== 2) { + throw new Error(`"${deviceUid}" is not valid device UID`); + } + const [volumeId, deviceId] = parts; + return { volumeId, deviceId }; +} + +export function makeNodeUid(volumeId: string, nodeId: string) { + return makeUid(volumeId, nodeId); +} + +export function splitNodeUid(nodeUid: string) { + const [volumeId, nodeId] = splitUid(nodeUid, 2, 'node'); + return { volumeId, nodeId }; +} + +export function makeNodeRevisionUid(volumeId: string, nodeId: string, revisionId: string) { + return makeUid(volumeId, nodeId, revisionId); +} + +export function splitNodeRevisionUid(nodeRevisionUid: string) { + const [volumeId, nodeId, revisionId] = splitUid(nodeRevisionUid, 3, 'revision'); + return { volumeId, nodeId, revisionId }; +} + +export function makeNodeUidFromRevisionUid(nodeRevisionUid: string) { + const { volumeId, nodeId } = splitNodeRevisionUid(nodeRevisionUid); + return makeNodeUid(volumeId, nodeId); +} + +export function makeNodeThumbnailUid(volumeId: string, nodeId: string, thumbnailId: string) { + return makeUid(volumeId, nodeId, thumbnailId); +} + +export function splitNodeThumbnailUid(nodeThumbnailUid: string) { + const [volumeId, nodeId, thumbnailId] = splitUid(nodeThumbnailUid, 3, 'thumbnail'); + return { volumeId, nodeId, thumbnailId }; +} + +export function makeInvitationUid(shareId: string, invitationId: string) { + return makeUid(shareId, invitationId); +} + +export function splitInvitationUid(invitationUid: string) { + const [shareId, invitationId] = splitUid(invitationUid, 2, 'invitation'); + return { shareId, invitationId }; +} + +export function makeMemberUid(shareId: string, memberId: string) { + return makeUid(shareId, memberId); +} + +export function splitMemberUid(memberUid: string) { + const [shareId, memberId] = splitUid(memberUid, 2, 'member'); + return { shareId, memberId }; +} + +export function makePublicLinkUid(shareId: string, publicLinkId: string) { + return makeUid(shareId, publicLinkId); +} + +export function splitPublicLinkUid(publicLinkUid: string) { + const [shareId, publicLinkId] = splitUid(publicLinkUid, 2, 'public link'); + return { shareId, publicLinkId }; +} + +function makeUid(...parts: string[]): string { + return parts.join('~'); +} + +function splitUid(uid: string, expectedParts: number, typeName: string): string[] { + const parts = uid.split('~'); + if (parts.length !== expectedParts) { + throw new Error(`"${uid}" is not a valid ${typeName} UID`); + } + return parts; +} diff --git a/js/sdk/src/internal/upload/apiService.ts b/js/sdk/src/internal/upload/apiService.ts new file mode 100644 index 00000000..17595df4 --- /dev/null +++ b/js/sdk/src/internal/upload/apiService.ts @@ -0,0 +1,468 @@ +import { c } from 'ttag'; + +import { AnonymousUser } from '../../interface'; +import { ThumbnailType } from '../../interface'; +import { APICodeError, DriveAPIService, drivePaths, isCodeOk } from '../apiService'; +import { makeNodeRevisionUid, makeNodeUid, splitNodeRevisionUid, splitNodeUid } from '../uids'; +import { UploadTokens } from './interface'; + +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< + 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 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< + 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< + 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']; + +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 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, + protected clientUid: string | undefined, + ) { + this.apiService = apiService; + this.clientUid = clientUid; + } + + 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 | AnonymousUser; + }, + ): Promise<{ + 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`, + { + ParentLinkID: parentNodeId, + Name: node.armoredEncryptedName, + Hash: node.hash, + MIMEType: node.mediaType, + ClientUID: this.clientUid || null, + IntendedUploadSize: intendedUploadSize, + 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; + }> { + const { volumeId, nodeId } = splitNodeUid(nodeUid); + const { revisionId: currentRevisionId } = splitNodeRevisionUid(revision.currentRevisionUid); + + 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; + }> { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + const result = await this.apiService.get( + `drive/v2/volumes/${volumeId}/links/${nodeId}/revisions/${revisionId}/verification`, + ); + + return { + verificationCode: Uint8Array.fromBase64(result.VerificationCode), + base64ContentKeyPacket: result.ContentKeyPacket, + }; + } + + async requestBlockUpload( + draftNodeRevisionUid: string, + addressId: string | AnonymousUser, + blocks: { + contentBlocks: { + index: number; + armoredSignature: string; + verificationToken: Uint8Array; + }[]; + thumbnails?: { + type: ThumbnailType; + }[]; + }, + ): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + const result = await this.apiService.post< + // TODO: Deprected fields but not properly marked in the types. + Omit< + PostRequestBlockUploadRequest, + 'ShareID' | 'Thumbnail' | 'ThumbnailHash' | 'ThumbnailSize' | 'BlockList' | 'ThumbnailList' + > & { + BlockList: Omit[]; + ThumbnailList: Omit[]; + }, + PostRequestBlockUploadResponse + >('drive/blocks', { + AddressID: addressId, + VolumeID: volumeId, + LinkID: nodeId, + RevisionID: revisionId, + BlockList: blocks.contentBlocks.map((block) => ({ + Index: block.index, + EncSignature: block.armoredSignature, + Verifier: { + Token: block.verificationToken.toBase64(), + }, + })), + ThumbnailList: (blocks.thumbnails || []).map((block) => ({ + Type: block.type, + })), + }); + + return { + blockTokens: result.UploadLinks.map((link) => ({ + index: link.Index, + bareUrl: link.BareURL, + token: link.Token, + })), + thumbnailTokens: (result.ThumbnailLinks || []).map((link) => ({ + // We can type as ThumbnailType because we are passing the type in the request. + type: link.ThumbnailType as ThumbnailType, + bareUrl: link.BareURL, + token: link.Token, + })), + }; + } + + async commitDraftRevision( + draftNodeRevisionUid: string, + options: { + armoredManifestSignature: string; + signatureEmail: string | AnonymousUser; + armoredExtendedAttributes: string; + checksumVerified?: boolean; + }, + ): 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, + ChecksumVerified: options.checksumVerified || false, + Photo: null, // Only used for photos in the Photo volume. + }); + } + + async deleteDraft(draftNodeUid: string): Promise { + const { volumeId, nodeId } = splitNodeUid(draftNodeUid); + + 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); + } + } + + async deleteDraftRevision(draftNodeRevisionUid: string): Promise { + const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(draftNodeRevisionUid); + 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 { + const formData = new FormData(); + formData.append('Block', new Blob([block]), 'blob'); + + 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 { + 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 + ); + } + + 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; + checksumVerified?: boolean; + block: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; + 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 ? content.block.armoredSignature : null, + ContentBlockVerificationToken: content.block + ? content.block.verificationToken.toBase64() + : null, + XAttr: metadata.armoredExtendedAttributes, + ChecksumVerified: content.checksumVerified || false, + Photo: null, // TODO + }; + + const formData = new FormData(); + 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) { + if (formData.get(`ThumbnailBlockType_${thumb.type}`)) { + throw new Error('Duplicate thumbnail types'); + } + formData.set( + `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; + checksumVerified?: boolean; + block: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; + 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 ? content.block.armoredSignature : null, + ContentBlockVerificationToken: content.block + ? content.block.verificationToken.toBase64() + : null, + XAttr: metadata.armoredExtendedAttributes, + ChecksumVerified: content.checksumVerified || false, + }; + + const formData = new FormData(); + 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) { + if (formData.get(`ThumbnailBlockType_${thumb.type}`)) { + throw new Error('Duplicate thumbnail types'); + } + formData.set( + `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 new file mode 100644 index 00000000..fc8f49b8 --- /dev/null +++ b/js/sdk/src/internal/upload/blockVerifier.ts @@ -0,0 +1,54 @@ +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; + + constructor( + private apiService: UploadAPIService, + private cryptoService: UploadCryptoService, + private nodeKey: PrivateKey, + private draftNodeRevisionUid: string, + ) { + this.apiService = apiService; + this.cryptoService = cryptoService; + this.draftNodeRevisionUid = draftNodeRevisionUid; + } + + async loadVerificationData() { + const result = await this.apiService.getVerificationData(this.draftNodeRevisionUid); + this.verificationCode = result.verificationCode; + this.contentKeyPacketSessionKey = await this.cryptoService.getContentKeyPacketSessionKey( + this.nodeKey, + result.base64ContentKeyPacket, + ); + } + + async verifyBlock(encryptedBlock: Uint8Array): Promise<{ + verificationToken: Uint8Array; + }> { + if (!this.verificationCode || !this.contentKeyPacketSessionKey) { + throw new Error('Verifying block before loading verification data'); + } + + return this.cryptoService.verifyBlock( + this.contentKeyPacketSessionKey, + this.verificationCode, + encryptedBlock, + ); + } +} diff --git a/js/sdk/src/internal/upload/chunkStreamReader.test.ts b/js/sdk/src/internal/upload/chunkStreamReader.test.ts new file mode 100644 index 00000000..874d843f --- /dev/null +++ b/js/sdk/src/internal/upload/chunkStreamReader.test.ts @@ -0,0 +1,89 @@ +import { ChunkStreamReader } from './chunkStreamReader'; + +describe('ChunkStreamReader', () => { + let stream: ReadableStream>; + + beforeEach(() => { + stream = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5, 6])); + controller.enqueue(new Uint8Array([7, 8, 9])); + controller.enqueue(new Uint8Array([10, 11, 12])); + controller.close(); + }, + }); + }); + + it('should yield chunks as enqueued if matching the size', async () => { + const reader = new ChunkStreamReader(stream, 3); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(new Uint8Array(chunk)); + } + + expect(chunks.length).toBe(4); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3])); + expect(chunks[1]).toEqual(new Uint8Array([4, 5, 6])); + expect(chunks[2]).toEqual(new Uint8Array([7, 8, 9])); + expect(chunks[3]).toEqual(new Uint8Array([10, 11, 12])); + }); + + it('should yield smaller chunks than enqueued chunks', async () => { + const reader = new ChunkStreamReader(stream, 2); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(new Uint8Array(chunk)); + } + + expect(chunks.length).toBe(6); + expect(chunks[0]).toEqual(new Uint8Array([1, 2])); + expect(chunks[1]).toEqual(new Uint8Array([3, 4])); + expect(chunks[2]).toEqual(new Uint8Array([5, 6])); + expect(chunks[3]).toEqual(new Uint8Array([7, 8])); + expect(chunks[4]).toEqual(new Uint8Array([9, 10])); + expect(chunks[5]).toEqual(new Uint8Array([11, 12])); + }); + + it('should yield bigger chunks than enqueued chunks', async () => { + const reader = new ChunkStreamReader(stream, 4); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(new Uint8Array(chunk)); + } + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(chunks[1]).toEqual(new Uint8Array([5, 6, 7, 8])); + expect(chunks[2]).toEqual(new Uint8Array([9, 10, 11, 12])); + }); + + it('should yield last incomplete chunk', async () => { + const reader = new ChunkStreamReader(stream, 5); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(new Uint8Array(chunk)); + } + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(chunks[1]).toEqual(new Uint8Array([6, 7, 8, 9, 10])); + expect(chunks[2]).toEqual(new Uint8Array([11, 12])); + }); + + it('should yield as one big chunk', async () => { + const reader = new ChunkStreamReader(stream, 100); + + const chunks: Uint8Array[] = []; + for await (const chunk of reader.iterateChunks()) { + chunks.push(new Uint8Array(chunk)); + } + + expect(chunks.length).toBe(1); + expect(chunks[0]).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])); + }); +}); diff --git a/js/sdk/src/internal/upload/chunkStreamReader.ts b/js/sdk/src/internal/upload/chunkStreamReader.ts new file mode 100644 index 00000000..c068e7ba --- /dev/null +++ b/js/sdk/src/internal/upload/chunkStreamReader.ts @@ -0,0 +1,49 @@ +/** + * This class is used to read a stream in chunks. + * + * WARNING: The chunks are reused to avoid allocating new memory for each chunk. + * Ensure that the previous chunk is fully read before reading the next chunk. + * If you need to keep previous chunks, copy them to a new array. + */ +export class ChunkStreamReader { + private reader: ReadableStreamDefaultReader>; + + private chunkSize: number; + + constructor(stream: ReadableStream>, chunkSize: number) { + this.reader = stream.getReader(); + this.chunkSize = chunkSize; + } + + async *iterateChunks(): AsyncGenerator> { + const buffer = new Uint8Array(this.chunkSize); + + let position = 0; + while (true) { + const { done, value } = await this.reader.read(); + if (done) { + break; + } + + let remainingValue = value; + while (remainingValue.length > 0) { + if (position + remainingValue.length < this.chunkSize) { + buffer.set(remainingValue, position); + position += remainingValue.length; + break; + } + + const remainingToFillBuffer = this.chunkSize - position; + buffer.set(remainingValue.slice(0, remainingToFillBuffer), position); + yield buffer; + + position = 0; + remainingValue = remainingValue.slice(remainingToFillBuffer); + } + } + + if (position > 0) { + yield buffer.slice(0, position); + } + } +} diff --git a/js/sdk/src/internal/upload/controller.ts b/js/sdk/src/internal/upload/controller.ts new file mode 100644 index 00000000..dbddf173 --- /dev/null +++ b/js/sdk/src/internal/upload/controller.ts @@ -0,0 +1,37 @@ +import { AbortError } from '../../errors'; +import { waitForCondition } from '../wait'; + +export class UploadController { + private paused = false; + public promise?: Promise<{ nodeRevisionUid: string; nodeUid: string }>; + + 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 { + this.paused = true; + } + + resume(): void { + this.paused = false; + } + + async completion(): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + if (!this.promise) { + throw new Error('UploadController.completion() called before upload started'); + } + return await this.promise; + } +} diff --git a/js/sdk/src/internal/upload/cryptoService.ts b/js/sdk/src/internal/upload/cryptoService.ts new file mode 100644 index 00000000..ed0139f9 --- /dev/null +++ b/js/sdk/src/internal/upload/cryptoService.ts @@ -0,0 +1,243 @@ +import { c } from 'ttag'; + +import { computeSHA256 } from '@protontech/crypto/subtle/hash.ts'; + +import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto'; +import { IntegrityError } from '../../errors'; +import { + AnonymousUser, + FeatureFlagProvider, + FeatureFlags, + Logger, + ProtonDriveTelemetry, + Thumbnail, +} from '../../interface'; +import { + EncryptedBlock, + EncryptedThumbnail, + NodeCrypto, + NodeCryptoSigningKeys, + NodeRevisionDraftKeys, + NodesService, +} 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( + parentUid: string, + 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) { + 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], signingKeys.nameAndPassphraseSigningKey, { + enableAead: useAeadFeatureFlag, + }), + this.driveCrypto.encryptNodeName(name, undefined, parentKeys.key, signingKeys.nameAndPassphraseSigningKey), + this.driveCrypto.generateLookupHash(name, parentKeys.hashKey), + ]); + + const contentKey = await this.driveCrypto.generateContentKey(nodeKeys.decrypted.key); + + return { + nodeKeys, + contentKey, + encryptedNode: { + encryptedName: armoredNodeName, + hash, + }, + 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, + }; + } + + async encryptThumbnail( + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + thumbnail: Thumbnail, + ): Promise { + const { encryptedData } = await this.driveCrypto.encryptThumbnailBlock( + thumbnail.thumbnail, + nodeRevisionDraftKeys.contentKeyPacketSessionKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, + ); + + const digestPromise = computeSHA256(encryptedData); + + return { + type: thumbnail.type, + encryptedData: encryptedData, + originalSize: thumbnail.thumbnail.length, + encryptedSize: encryptedData.length, + hashPromise: digestPromise, + }; + } + + async encryptBlock( + verifyBlock: ( + encryptedBlock: Uint8Array, + ) => Promise<{ verificationToken: Uint8Array }>, + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + block: Uint8Array, + index: number, + ): Promise { + const { encryptedData, armoredSignature } = await this.driveCrypto.encryptBlock( + block, + nodeRevisionDraftKeys.key, + nodeRevisionDraftKeys.contentKeyPacketSessionKey, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, + ); + const digestPromise = computeSHA256(encryptedData); + const { verificationToken } = await verifyBlock(encryptedData); + + return { + index, + encryptedData, + armoredSignature, + verificationToken, + originalSize: block.length, + encryptedSize: encryptedData.length, + hashPromise: digestPromise, + }; + } + + async commitFile( + nodeRevisionDraftKeys: NodeRevisionDraftKeys, + manifest: Uint8Array, + extendedAttributes: string, + ): Promise<{ + armoredManifestSignature: string; + signatureEmail: string | AnonymousUser; + armoredExtendedAttributes: string; + }> { + const { armoredManifestSignature } = await this.driveCrypto.signManifest( + manifest, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, + ); + + const { armoredExtendedAttributes } = await this.driveCrypto.encryptExtendedAttributes( + extendedAttributes, + nodeRevisionDraftKeys.key, + nodeRevisionDraftKeys.signingKeys.contentSigningKey, + ); + + return { + armoredManifestSignature, + signatureEmail: nodeRevisionDraftKeys.signingKeys.email, + armoredExtendedAttributes, + }; + } + + async getContentKeyPacketSessionKey(nodeKey: PrivateKey, base64ContentKeyPacket: string): Promise { + const { sessionKey } = await this.driveCrypto.decryptAndVerifySessionKey( + base64ContentKeyPacket, + undefined, + nodeKey, + [], + ); + return sessionKey; + } + + async verifyBlock( + contentKeyPacketSessionKey: SessionKey, + verificationCode: Uint8Array, + encryptedData: Uint8Array, + ): Promise<{ + verificationToken: Uint8Array; + }> { + // Attempt to decrypt data block, to try to detect bitflips / bad hardware + // + // We don't check the signature as it is an expensive operation, + // and we don't need to here as we always have the manifest signature + // + // Additionally, we use the key provided by the verification endpoint, to + // ensure the correct key was used to encrypt the data + try { + await this.driveCrypto.decryptBlock(encryptedData, contentKeyPacketSessionKey); + } catch (error) { + throw new IntegrityError(c('Error').t`Data integrity check of one part failed`, { + error, + }); + } + + // The verifier requires a 0-padded data packet, so we can + // access the array directly and fall back to 0. + 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 new file mode 100644 index 00000000..03710fc9 --- /dev/null +++ b/js/sdk/src/internal/upload/digests.ts @@ -0,0 +1,18 @@ +import { sha1 } from '@noble/hashes/legacy'; +import { bytesToHex } from '@noble/hashes/utils'; + +export class UploadDigests { + constructor(private digestSha1 = sha1.create()) { + this.digestSha1 = digestSha1; + } + + update(data: Uint8Array): void { + this.digestSha1.update(data); + } + + digests(): { sha1: string } { + return { + sha1: bytesToHex(this.digestSha1.digest()), + }; + } +} diff --git a/js/sdk/src/internal/upload/fileUploader.test.ts b/js/sdk/src/internal/upload/fileUploader.test.ts new file mode 100644 index 00000000..05d9f67b --- /dev/null +++ b/js/sdk/src/internal/upload/fileUploader.test.ts @@ -0,0 +1,210 @@ +import { Thumbnail, UploadMetadata } from '../../interface'; +import { UploadAPIService } from './apiService'; +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; + +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('FileUploader', () => { + 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: FileUploader; + + let startUploadSpy: jest.SpyInstance; + + 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', + nodeUid: 'nodeUid', + nodeKeys: { + signingKeys: { addressId: 'addressId' }, + }, + } as NodeRevisionDraft; + + metadata = {} as UploadMetadata; + + controller = new UploadController(); + onFinish = jest.fn(); + abortController = new AbortController(); + + uploader = new FileUploader( + telemetry, + apiService, + cryptoService, + uploadManager, + 'parentFolderUid', + 'name', + metadata, + onFinish, + () => Promise.resolve(false), + abortController.signal, + ); + + startUploadSpy = jest.spyOn(uploader as any, 'startUpload').mockReturnValue(Promise.resolve({ + nodeRevisionUid: 'revisionUid', + nodeUid: 'nodeUid' + })); + }); + + describe('uploadFromFile', () => { + // @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 set media type if not set', async () => { + 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.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.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.uploadFromFile(file, thumbnails, onProgress); + + await expect(uploader.uploadFromFile(file, thumbnails, onProgress)).rejects.toThrow('Upload already started'); + }); + }); + + describe('uploadFromStream', () => { + const stream = new ReadableStream(); + const thumbnails: Thumbnail[] = []; + const onProgress = jest.fn(); + + it('should start the upload process', async () => { + await uploader.uploadFromStream(stream, thumbnails, onProgress); + + expect(startUploadSpy).toHaveBeenCalledWith(stream, thumbnails, onProgress); + }); + + it('should throw an error if upload already started', async () => { + await uploader.uploadFromStream(stream, thumbnails, onProgress); + + await expect(uploader.uploadFromStream(stream, thumbnails, onProgress)).rejects.toThrow( + '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 new file mode 100644 index 00000000..cc80eb22 --- /dev/null +++ b/js/sdk/src/internal/upload/fileUploader.ts @@ -0,0 +1,304 @@ +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 { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader'; +import { StreamUploader } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; + +/** + * 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`, `FileRevisionUploader`, or `SmallFileUploader`. + */ +export abstract class Uploader { + protected controller: UploadController; + protected abortController: AbortController; + + constructor( + protected telemetry: UploadTelemetry, + protected apiService: UploadAPIService, + protected cryptoService: UploadCryptoService, + protected manager: UploadManager, + protected metadata: UploadMetadata, + protected onFinish: () => void, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, + protected signal?: AbortSignal, + ) { + this.telemetry = telemetry; + this.apiService = apiService; + this.cryptoService = cryptoService; + this.manager = manager; + this.metadata = metadata; + this.onFinish = onFinish; + this.shouldUseSmallFileUpload = shouldUseSmallFileUpload; + + this.signal = signal; + this.abortController = new AbortController(); + if (signal) { + signal.addEventListener('abort', () => { + this.abortController.abort(); + }); + } + + this.controller = new UploadController(this.abortController.signal); + } + + async uploadFromFile( + fileObject: File, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise { + this.assertNotStartedYet(); + this.assertUniqueThumbnailTypes(thumbnails); + + if (!this.metadata.mediaType) { + this.metadata.mediaType = fileObject.type; + } + if (!this.metadata.expectedSize) { + this.metadata.expectedSize = fileObject.size; + } + if (!this.metadata.modificationTime) { + this.metadata.modificationTime = new Date(fileObject.lastModified); + } + this.controller.promise = this.startUpload(fileObject.stream(), thumbnails, onProgress); + return this.controller; + } + + async uploadFromStream( + stream: ReadableStream, + 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`); + } + } + + 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( + stream: ReadableStream, + 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(); + + const onFinish = async (failure: boolean) => { + this.onFinish(); + if (failure) { + await this.deleteRevisionDraft(revisionDraft); + } + }; + + 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, + this.cryptoService, + this.manager, + blockVerifier, + revisionDraft, + this.metadata, + onFinish, + this.controller, + this.abortController, + ); + } + + protected abstract createRevisionDraft(): Promise<{ + revisionDraft: NodeRevisionDraft; + blockVerifier: BlockVerifier; + }>; + + protected abstract deleteRevisionDraft(revisionDraft: NodeRevisionDraft): Promise; +} + +/** + * 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, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, + signal?: AbortSignal, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, shouldUseSmallFileUpload, signal); + + this.parentFolderUid = parentFolderUid; + this.name = name; + } + + 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, + ); + await blockVerifier.loadVerificationData(); + } catch (error: unknown) { + this.onFinish(); + if (revisionDraft) { + await this.manager.deleteDraftNode(revisionDraft.nodeUid); + } + void this.telemetry.uploadInitFailed(this.parentFolderUid, error, this.metadata.expectedSize); + throw error; + } + + return { + revisionDraft, + blockVerifier, + }; + } + + 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); + } +} + +/** + * 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, + protected shouldUseSmallFileUpload: (expectedSize: number) => Promise, + signal?: AbortSignal, + ) { + super(telemetry, apiService, cryptoService, manager, metadata, onFinish, shouldUseSmallFileUpload, signal); + + this.nodeUid = nodeUid; + } + + 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, + ); + 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; + } + + return { + revisionDraft, + blockVerifier, + }; + } + + 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 new file mode 100644 index 00000000..d81d3877 --- /dev/null +++ b/js/sdk/src/internal/upload/index.test.ts @@ -0,0 +1,157 @@ +import { FeatureFlagProvider, ThumbnailType, UploadMetadata } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { FileRevisionUploader, FileUploader, Uploader } from './fileUploader'; +import { initUploadModule } from './index'; + +const RAW_SMALL_FILE_SIZE_LIMIT = (128 * 1024) / 1.1; // 128 KiB, must match index.ts + +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 = {}; + 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, + ); + + initSmallFileSpy = jest.spyOn(FileUploader.prototype as any, 'initSmallFileUploader').mockResolvedValue({ + nodeRevisionUid: 'revision-uid', + nodeUid: 'node-uid', + }); + 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(); + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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 new file mode 100644 index 00000000..3249641f --- /dev/null +++ b/js/sdk/src/internal/upload/index.ts @@ -0,0 +1,117 @@ +import { DriveCrypto } from '../../crypto'; +import type { FileUploader } from '../../interface'; +import { FeatureFlagProvider, FeatureFlags, ProtonDriveTelemetry, UploadMetadata } from '../../interface'; +import { DriveAPIService } from '../apiService'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { FileRevisionUploader, FileUploader as FileUploaderClass } from './fileUploader'; +import { NodesService, SharesService } from './interface'; +import { UploadManager } from './manager'; +import { UploadQueue } from './queue'; +import { UploadTelemetry } from './telemetry'; + +const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB + +/** + * Provides facade for the upload module. + * + * The upload module is responsible for handling file uploads, including + * metadata generation, content upload, API communication, encryption, + * and verifications. + */ +export function initUploadModule( + telemetry: ProtonDriveTelemetry, + apiService: DriveAPIService, + driveCrypto: DriveCrypto, + sharesService: SharesService, + nodesService: NodesService, + featureFlagProvider: FeatureFlagProvider, + clientUid?: string, + allowSmallFileUpload: boolean = true, +) { + const api = new UploadAPIService(apiService, clientUid); + const cryptoService = new UploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider); + + const uploadTelemetry = new UploadTelemetry(telemetry, sharesService); + const manager = new UploadManager(telemetry, api, cryptoService, nodesService, clientUid); + + const queue = new UploadQueue(); + + async function shouldUseSmallFileUpload(expectedSize: number): Promise { + const isEnabled = + allowSmallFileUpload && (await featureFlagProvider.isEnabled(FeatureFlags.DriveSmallFileUpload)); + if (!isEnabled) { + return false; + } + return expectedSize < SMALL_FILE_SIZE_LIMIT; + } + + /** + * 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 { + await queue.waitForCapacity(metadata.expectedSize, signal); + + const onFinish = () => { + queue.releaseCapacity(metadata.expectedSize); + }; + + return new FileUploaderClass( + uploadTelemetry, + api, + cryptoService, + manager, + parentFolderUid, + name, + metadata, + onFinish, + shouldUseSmallFileUpload, + 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 { + await queue.waitForCapacity(metadata.expectedSize, signal); + + const onFinish = () => { + queue.releaseCapacity(metadata.expectedSize); + }; + + return new FileRevisionUploader( + uploadTelemetry, + api, + cryptoService, + manager, + nodeUid, + metadata, + onFinish, + shouldUseSmallFileUpload, + signal, + ); + } + + return { + getFileUploader, + getFileRevisionUploader, + }; +} diff --git a/js/sdk/src/internal/upload/interface.ts b/js/sdk/src/internal/upload/interface.ts new file mode 100644 index 00000000..744178f9 --- /dev/null +++ b/js/sdk/src/internal/upload/interface.ts @@ -0,0 +1,146 @@ +import { PrivateKey, SessionKey } from '../../crypto'; +import { AnonymousUser, MetricVolumeType, Result, Revision, ThumbnailType } from '../../interface'; +import { DecryptedNode } from '../nodes'; + +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; + name: string; + encryptedName: string; + hash: string; + }; +}; + +export type NodeRevisionDraftKeys = { + key: PrivateKey; + contentKeyPacketSessionKey: SessionKey; + signingKeys: NodeCryptoSigningKeys; +}; + +export type NodeCrypto = { + nodeKeys: { + encrypted: { + armoredKey: string; + armoredPassphrase: string; + armoredPassphraseSignature: string; + }; + decrypted: { + passphrase: string; + key: PrivateKey; + passphraseSessionKey: SessionKey; + }; + }; + contentKey: { + encrypted: { + contentKeyPacket: Uint8Array; + base64ContentKeyPacket: string; + armoredContentKeyPacketSignature: string; + }; + decrypted: { + contentKeyPacketSessionKey: SessionKey; + }; + }; + encryptedNode: { + encryptedName: string; + hash: string; + }; + signingKeys: NodeCryptoSigningKeys; +}; + +export type NodeCryptoSigningKeys = { + email: string | AnonymousUser; + addressId: string | AnonymousUser; + nameAndPassphraseSigningKey: PrivateKey; + contentSigningKey: PrivateKey; +}; + +export type EncryptedBlockMetadata = { + encryptedSize: number; + originalSize: number; + hashPromise: Promise>; +}; + +export type EncryptedBlock = EncryptedBlockMetadata & { + index: number; + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; +}; + +export type EncryptedThumbnail = EncryptedBlockMetadata & { + type: ThumbnailType; + encryptedData: Uint8Array; +}; + +export type UploadTokens = { + blockTokens: { + index: number; + bareUrl: string; + token: string; + }[]; + thumbnailTokens: { + type: ThumbnailType; + bareUrl: string; + token: string; + }[]; +}; + +/** + * Interface describing the dependencies to the nodes module. + */ +export interface NodesService { + getNode(nodeUid: string): Promise; + getNodeKeys(nodeUid: string): Promise<{ + key: PrivateKey; + passphraseSessionKey: SessionKey; + contentKeyPacket?: Uint8Array; + contentKeyPacketSessionKey?: SessionKey; + hashKey?: Uint8Array; + }>; + getNodeSigningKeys( + uids: { nodeUid: string; parentNodeUid?: string } | { nodeUid?: string; parentNodeUid: string }, + ): Promise; + notifyChildCreated(nodeUid: string): Promise; + notifyNodeChanged(nodeUid: string): Promise; +} + +/** + * Interface describing the dependencies to the nodes module. + */ +export interface NodesEvents { + nodeCreated(node: DecryptedNode): Promise; + nodeUpdated(partialNode: { uid: string; activeRevision: Result }): Promise; +} + +export interface NodesServiceNode { + uid: string; + parentUid?: string; + 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. + */ +export interface SharesService { + getVolumeMetricContext(volumeId: string): Promise; +} diff --git a/js/sdk/src/internal/upload/manager.test.ts b/js/sdk/src/internal/upload/manager.test.ts new file mode 100644 index 00000000..a5ee40ed --- /dev/null +++ b/js/sdk/src/internal/upload/manager.test.ts @@ -0,0 +1,734 @@ +import { ValidationError } from '../../errors'; +import { ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface'; +import { getMockTelemetry } from '../../tests/telemetry'; +import { ErrorCode } from '../apiService'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { NodesService } from './interface'; +import { UploadManager } from './manager'; + +describe('UploadManager', () => { + let telemetry: ProtonDriveTelemetry; + let apiService: UploadAPIService; + let cryptoService: UploadCryptoService; + let nodesService: NodesService; + + let manager: UploadManager; + + const clientUid = 'clientUid'; + + beforeEach(() => { + telemetry = getMockTelemetry(); + // @ts-expect-error No need to implement all methods for mocking + apiService = { + createDraft: jest.fn().mockResolvedValue({ + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', + }), + deleteDraft: jest.fn(), + 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 = { + generateFileCrypto: jest.fn().mockResolvedValue({ + nodeKeys: { + decrypted: { key: 'newNode:key' }, + encrypted: { + armoredKey: 'newNode:armoredKey', + armoredPassphrase: 'newNode:armoredPassphrase', + armoredPassphraseSignature: 'newNode:armoredPassphraseSignature', + }, + }, + contentKey: { + decrypted: { contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey' }, + encrypted: { + base64ContentKeyPacket: 'newNode:base64ContentKeyPacket', + armoredContentKeyPacketSignature: 'newNode:armoredContentKeyPacketSignature', + }, + }, + encryptedNode: { + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', + }, + signingKeys: { + email: 'signatureEmail', + }, + }), + commitFile: jest.fn().mockResolvedValue({ + armoredManifestSignature: 'newNode:armoredManifestSignature', + 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) => ({ + uid: nodeUid, + parentUid: 'parentUid', + })), + getNodeKeys: jest.fn().mockResolvedValue({ + hashKey: 'parentNode:hashKey', + key: 'parentNode:nodekey', + }), + getNodeSigningKeys: jest.fn().mockResolvedValue({ + type: 'userAddress', + email: 'signatureEmail', + addressId: 'addressId', + }), + notifyChildCreated: jest.fn(), + notifyNodeChanged: jest.fn(), + }; + + manager = new UploadManager(telemetry, apiService, cryptoService, nodesService, clientUid); + }); + + 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'); + }); + + 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', + nodeKeys: { + key: 'newNode:key', + contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', + signingKeys: { + email: 'signatureEmail', + }, + }, + parentNodeKeys: { + hashKey: 'parentNode:hashKey', + }, + newNodeInfo: { + parentUid: 'parentUid', + name: 'name', + encryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', + }, + }); + expect(apiService.createDraft).toHaveBeenCalledWith('parentUid', { + armoredEncryptedName: 'newNode:encryptedName', + hash: 'newNode:hash', + mediaType: 'myMimeType', + intendedUploadSize: 100_000, + 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 () => { + 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 result = await manager.createDraftNode('volumeId~parentUid', 'name', {} as UploadMetadata); + + expect(result).toEqual({ + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', + nodeKeys: { + key: 'newNode:key', + contentKeyPacketSessionKey: 'newNode:ContentKeyPacketSessionKey', + signingKeys: { + email: 'signatureEmail', + }, + }, + parentNodeKeys: { + hashKey: 'parentNode:hashKey', + }, + newNodeInfo: { + parentUid: 'volumeId~parentUid', + name: 'name', + encryptedName: 'newNode:encryptedName', + 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.isUnfinishedUpload).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, 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.isUnfinishedUpload).toBe(true); + } + expect(apiService.deleteDraft).not.toHaveBeenCalled(); + }); + + 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', + ConflictDraftClientUID: clientUid, + }); + } + return { + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', + }; + }); + apiService.deleteDraft = jest.fn().mockImplementation(() => { + throw new Error('Failed to delete draft'); + }); + + 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(apiService.deleteDraft).toHaveBeenCalledTimes(1); + }); + }); + + 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); + }); + + 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', () => { + 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'); + }); + + 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', () => { + const nodeRevisionDraft = { + nodeUid: 'newNode:nodeUid', + nodeRevisionUid: 'newNode:nodeRevisionUid', + nodeKeys: { + key: { _idx: 32321 }, + contentKeyPacketSessionKey: 'newNode:contentKeyPacketSessionKey', + signatureAddress: { + email: 'signatureEmail', + addressId: 'addressId', + addressKey: 'addressKey', + } as any, + }, + }; + const manifest = new Uint8Array([1, 2, 3]); + const extendedAttributes = { + modificationTime: new Date(), + size: 123, + blockSizes: [100, 20, 3], + digests: { + sha1: 'sha1', + }, + }; + + it('should commit revision draft', async () => { + await manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes); + + expect(cryptoService.commitFile).toHaveBeenCalledWith( + nodeRevisionDraft.nodeKeys, + manifest, + expect.anything(), + ); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith( + nodeRevisionDraft.nodeRevisionUid, + expect.anything(), + ); + expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('newNode:nodeUid'); + expect(nodesService.notifyChildCreated).not.toHaveBeenCalled(); + }); + + 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, manifest, extendedAttributes); + + expect(cryptoService.commitFile).toHaveBeenCalledWith( + nodeRevisionDraft.nodeKeys, + manifest, + expect.anything(), + ); + expect(apiService.commitDraftRevision).toHaveBeenCalledWith( + nodeRevisionDraft.nodeRevisionUid, + expect.anything(), + ); + 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 new file mode 100644 index 00000000..fbbc7fdb --- /dev/null +++ b/js/sdk/src/internal/upload/manager.ts @@ -0,0 +1,435 @@ +import { c } from 'ttag'; + +import { PrivateKey, SessionKey } from '../../crypto'; +import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors'; +import { Logger, ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface'; +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 { NodeCrypto, NodeRevisionDraft, NodesService } from './interface'; + +/** + * UploadManager is responsible for creating and deleting draft nodes + * on the server. It handles the creation of draft nodes, including + * generating the necessary cryptographic keys and metadata. + */ +export class UploadManager { + protected logger: Logger; + + constructor( + telemetry: ProtonDriveTelemetry, + protected apiService: UploadAPIService, + protected cryptoService: UploadCryptoService, + protected nodesService: NodesService, + protected 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 { + const { parentHashKey, ...generatedNodeCrypto } = await this.generateNewFileCrypto(parentFolderUid, name); + + const { nodeUid, nodeRevisionUid } = await this.createDraftOnAPI( + parentFolderUid, + parentHashKey, + name, + metadata, + generatedNodeCrypto, + ); + + return { + nodeUid, + nodeRevisionUid, + nodeKeys: { + key: generatedNodeCrypto.nodeKeys.decrypted.key, + contentKeyPacketSessionKey: generatedNodeCrypto.contentKey.decrypted.contentKeyPacketSessionKey, + signingKeys: generatedNodeCrypto.signingKeys, + }, + parentNodeKeys: { + hashKey: parentHashKey, + }, + newNodeInfo: { + parentUid: parentFolderUid, + name, + encryptedName: generatedNodeCrypto.encryptedNode.encryptedName, + hash: generatedNodeCrypto.encryptedNode.hash, + }, + }; + } + + 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, + name: string, + metadata: UploadMetadata, + generatedNodeCrypto: NodeCrypto, + ): Promise<{ + nodeUid: string; + nodeRevisionUid: string; + }> { + try { + const result = await this.apiService.createDraft(parentFolderUid, { + armoredEncryptedName: generatedNodeCrypto.encryptedNode.encryptedName, + hash: generatedNodeCrypto.encryptedNode.hash, + mediaType: metadata.mediaType, + intendedUploadSize: reduceSizePrecision(metadata.expectedSize), + armoredNodeKey: generatedNodeCrypto.nodeKeys.encrypted.armoredKey, + armoredNodePassphrase: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphrase, + armoredNodePassphraseSignature: generatedNodeCrypto.nodeKeys.encrypted.armoredPassphraseSignature, + base64ContentKeyPacket: generatedNodeCrypto.contentKey.encrypted.base64ContentKeyPacket, + armoredContentKeyPacketSignature: + generatedNodeCrypto.contentKey.encrypted.armoredContentKeyPacketSignature, + signatureEmail: generatedNodeCrypto.signingKeys.email, + }); + return result; + } catch (error: unknown) { + return this.handleConflictError(parentFolderUid, metadata, error, async () => { + return this.createDraftOnAPI(parentFolderUid, parentHashKey, name, metadata, generatedNodeCrypto); + }); + } + } + + async uploadFile( + parentFolderUid: string, + nodeCrypto: NodeCrypto, + metadata: UploadMetadata, + commitPayload: { + armoredManifestSignature: string; + armoredExtendedAttributes: string; + checksumVerified?: boolean; + }, + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined, + 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, + checksumVerified: commitPayload.checksumVerified, + block: encryptedBlock + ? { + encryptedData: encryptedBlock.encryptedData, + armoredSignature: encryptedBlock.armoredSignature, + verificationToken: encryptedBlock.verificationToken, + } + : undefined, + 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, + ); + }); + } + } + + async uploadSmallRevision( + nodeUid: string, + nodeCrypto: Pick, + commitPayload: { + armoredManifestSignature: string; + armoredExtendedAttributes: string; + }, + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined, + 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( + `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(); + } + } + + 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; + } + + async deleteDraftNode(nodeUid: string): Promise { + try { + await this.apiService.deleteDraft(nodeUid); + } catch (error: unknown) { + // Only log the error but do not fail the operation as we are + // deleting draft only when somethign fails and original error + // will bubble up. + this.logger.error('Failed to delete draft node', error); + } + } + + async createDraftRevision(nodeUid: string, metadata: UploadMetadata): Promise { + const node = await this.nodesService.getNode(nodeUid); + const nodeKeys = await this.nodesService.getNodeKeys(nodeUid); + + if (!node.activeRevision?.ok || !nodeKeys.contentKeyPacketSessionKey) { + throw new ValidationError(c('Error').t`Creating revisions in non-files is not allowed`); + } + + const signingKeys = await this.cryptoService.getSigningKeysForExistingNode({ + nodeUid, + parentNodeUid: node.parentUid, + }); + + const { nodeRevisionUid } = await this.apiService.createDraftRevision(nodeUid, { + currentRevisionUid: node.activeRevision.value.uid, + intendedUploadSize: reduceSizePrecision(metadata.expectedSize), + }); + + return { + nodeUid, + nodeRevisionUid, + nodeKeys: { + key: nodeKeys.key, + contentKeyPacketSessionKey: nodeKeys.contentKeyPacketSessionKey, + signingKeys, + }, + }; + } + + async deleteDraftRevision(nodeRevisionUid: string): Promise { + try { + await this.apiService.deleteDraftRevision(nodeRevisionUid); + } catch (error: unknown) { + // Only log the error but do not fail the operation as we are + // deleting draft only when somethign fails and original error + // will bubble up. + this.logger.error('Failed to delete draft node revision', error); + } + } + + async commitDraft( + nodeRevisionDraft: NodeRevisionDraft, + manifest: Uint8Array, + extendedAttributes: { + modificationTime?: Date; + size: number; + blockSizes: number[]; + digests: { + sha1: string; + }; + }, + additionalExtendedAttributes?: object, + integrityInfo?: { checksumVerified: boolean }, + ): Promise { + const generatedExtendedAttributes = generateFileExtendedAttributes( + extendedAttributes, + additionalExtendedAttributes, + ); + const nodeCommitCrypto = await this.cryptoService.commitFile( + nodeRevisionDraft.nodeKeys, + manifest, + generatedExtendedAttributes, + ); + try { + 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 + // 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); + } + + 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) { + await this.nodesService.notifyChildCreated(nodeRevisionDraft.newNodeInfo.parentUid); + } else { + await this.nodesService.notifyNodeChanged(nodeRevisionDraft.nodeUid); + } + } +} 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 new file mode 100644 index 00000000..32143586 --- /dev/null +++ b/js/sdk/src/internal/upload/queue.ts @@ -0,0 +1,56 @@ +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. + * + * This is used to limit the number of concurrent uploads to avoid + * overloading the server, or get rate limited. + * + * Each file upload consumes memory and is limited by the number of + * concurrent block uploads for each file. + * + * This queue is straitforward and does not have any priority mechanism + * or other features, such as limiting total number of blocks being + * uploaded. That is something we want to add in the future to be + * more performant for many small file uploads. + */ +export class UploadQueue { + private totalFileUploads = 0; + + private totalExpectedSize = 0; + + 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(expectedSize: number) { + this.totalFileUploads--; + this.totalExpectedSize -= expectedSize; + } +} 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..4ef81ec1 --- /dev/null +++ b/js/sdk/src/internal/upload/smallFileUploader.test.ts @@ -0,0 +1,484 @@ +import { IntegrityError } from '../../errors'; +import { Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { mergeUint8Arrays } from '../utils'; +import { UploadAPIService } from './apiService'; +import { UploadCryptoService } from './cryptoService'; +import { NodeCrypto } from './interface'; +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); + +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, + hashPromise: Promise.resolve(new Uint8Array(32).fill(thumbnail.type)), + })), + 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, + 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 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); + }); + }); + + 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.upload(stream, thumbnails, undefined); + + 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), + blockHash: new Uint8Array(32).fill(ThumbnailType.Type1), + }, + { + type: ThumbnailType.Type2, + encryptedData: expect.any(Uint8Array), + blockHash: new Uint8Array(32).fill(ThumbnailType.Type2), + }, + ], + ); + + expect(cryptoService.encryptBlock).toHaveBeenCalledTimes(1); + expect(cryptoService.encryptThumbnail).toHaveBeenCalledTimes(2); + expect(cryptoService.commitFile).toHaveBeenCalledWith( + expect.anything(), + mergeUint8Arrays([ + new Uint8Array(32).fill(ThumbnailType.Type1), + new Uint8Array(32).fill(ThumbnailType.Type2), + 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.upload(stream, [], undefined); + + 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.upload(stream, thumbnails, undefined); + + 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.upload(stream, [], undefined); + + 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 promise = uploader.upload(stream, [], undefined); + + await expect(promise).rejects.toThrow(IntegrityError); + await expect(promise).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 promise = uploader.upload(stream, [], undefined); + + await expect(promise).rejects.toThrow(IntegrityError); + await expect(promise).rejects.toMatchObject({ + debug: expect.objectContaining({ + expectedSha1: 'a'.repeat(40), + }), + }); + }); + }); + + 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 result = await uploader.upload(stream, [], onProgress); + + 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', () => { + 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, + 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 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); + 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, + }), + [], + ); + }); + + 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 result = await uploader.upload(stream, [], undefined); + + 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 new file mode 100644 index 00000000..58d6f182 --- /dev/null +++ b/js/sdk/src/internal/upload/smallFileUploader.ts @@ -0,0 +1,367 @@ +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 { mergeUint8Arrays } from '../utils'; +import { verifyBlockWithContentKey } from './blockVerifier'; +import { UploadCryptoService } from './cryptoService'; +import { UploadDigests } from './digests'; +import { 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 { + protected logger: Logger; + protected abortController: AbortController; + + constructor( + protected telemetry: UploadTelemetry, + protected cryptoService: UploadCryptoService, + protected manager: UploadManager, + protected metadata: UploadMetadata, + protected onFinish: () => void, + protected signal: AbortSignal | undefined, + ) { + this.logger = telemetry.getLoggerForSmallUpload(); + this.abortController = new AbortController(); + } + + async upload( + 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; + checksumVerified?: boolean; + }; + encryptedBlock: + | { + encryptedData: Uint8Array; + armoredSignature: string; + verificationToken: Uint8Array; + } + | undefined; + 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 manifest = await this.getManifest(encryptedBlock, encryptedThumbnails); + const commitPayload = await this.encryptCommitPayload(nodeKeys, content.sha1, manifest); + + 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; + 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, + blockHash: await enc.hashPromise, + }); + } + return result; + } + + private async encryptContentBlock( + nodeKeys: NodeKeys, + content: 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; + 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(this.getTelemetryContextUid(), 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(this.getTelemetryContextUid(), false); + } + throw error; + } + } + + const blockHash = await encrypted.hashPromise; + return { + encryptedData: encrypted.encryptedData, + armoredSignature: encrypted.armoredSignature, + verificationToken: encrypted.verificationToken, + blockHash, + }; + } + + 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; + checksumVerified?: boolean; + }> { + this.logger.debug(`Preparing commit payload`); + + 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, + checksumVerified: !!(this.metadata.expectedSha1 && contentSha1 === this.metadata.expectedSha1), + }; + } +} + +/** + * Uploader for small new files using the single-request small file endpoint. + */ +export class SmallFileUploader extends SmallUploader { + constructor( + telemetry: UploadTelemetry, + cryptoService: UploadCryptoService, + manager: UploadManager, + metadata: UploadMetadata, + onFinish: () => void, + signal: AbortSignal | undefined, + private parentFolderUid: string, + private name: string, + ) { + super(telemetry, 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, + cryptoService: UploadCryptoService, + manager: UploadManager, + metadata: UploadMetadata, + onFinish: () => void, + signal: AbortSignal | undefined, + private nodeUid: string, + ) { + super(telemetry, 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.test.ts b/js/sdk/src/internal/upload/streamUploader.test.ts new file mode 100644 index 00000000..eccfc55b --- /dev/null +++ b/js/sdk/src/internal/upload/streamUploader.test.ts @@ -0,0 +1,679 @@ +import { IntegrityError } from '../../errors'; +import { Logger, Thumbnail, ThumbnailType, UploadMetadata } from '../../interface'; +import { getMockLogger } from '../../tests/logger'; +import { APIHTTPError, HTTPErrorCode } from '../apiService'; +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 { FILE_CHUNK_SIZE, StreamUploader } from './streamUploader'; +import { UploadTelemetry } from './telemetry'; + +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, + hashPromise: Promise.resolve('blockHash'), + }; +} + +function mockUploadBlock( + _: string, + __: string, + encryptedBlock: Uint8Array, + onProgress: (uploadedBytes: number) => void, +) { + onProgress(encryptedBlock.length); +} + +describe('StreamUploader', () => { + let logger: Logger; + 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(() => { + logger = getMockLogger(); + + // @ts-expect-error No need to implement all methods for mocking + telemetry = { + getLoggerForRevision: jest.fn().mockReturnValue(logger), + 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, + hashPromise: Promise.resolve('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: 'testVol~testNode~testRev', + nodeUid: 'testVol~testNode', + nodeKeys: { + signingKeys: { + 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, + controller, + abortController, + ); + }); + + describe('start', () => { + let thumbnails: Thumbnail[]; + let thumbnailSize: number; + + let onProgress: (uploadedBytes: number) => void; + let stream: ReadableStream; + + const verifySuccess = async () => { + const result = await uploader.start(stream, thumbnails, onProgress); + + expect(result).toEqual({ + nodeRevisionUid: 'testVol~testNode~testRev', + nodeUid: 'testVol~testNode', + }); + + 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(), + }, + }, + metadata.additionalMetadata, + { + checksumVerified: !!metadata.expectedSha1, + }, + ); + expect(telemetry.uploadFinished).toHaveBeenCalledTimes(1); + expect(telemetry.uploadFinished).toHaveBeenCalledWith('testVol~testNode~testRev', 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( + 'testVol~testNode~testRev', + 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); + let fileProgress = 0; + for (let i = 0; i < uploadedBytes.length; i++) { + fileProgress += uploadedBytes[i]; + expect(onProgress).toHaveBeenNthCalledWith(i + 1, fileProgress); + } + }; + + 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, + controller, + abortController, + ); + + 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, + controller, + abortController, + ); + + 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'); + }); + + // 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); + }); + + 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 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('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'; + + 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) { + 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.signingKeys.addressId, + { + contentBlocks: [ + { + index: 2, + 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]); + }); + + describe('verifyIntegrity', () => { + 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('testVol~testNode', 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('testVol~testNode', 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, + } as UploadMetadata, + onFinish, + controller, + abortController, + ); + + 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); + }); + + 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); + }); + }); + }); + + 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 new file mode 100644 index 00000000..e770f571 --- /dev/null +++ b/js/sdk/src/internal/upload/streamUploader.ts @@ -0,0 +1,707 @@ +import { c } from 'ttag'; + +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'; +import { makeNodeUidFromRevisionUid } from '../uids'; +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 { 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. + */ +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. + */ +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. + */ +export 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 { + protected maxUploadingBlocks = MAX_UPLOADING_BLOCKS; + + protected logger: Logger; + + protected digests: UploadDigests; + protected controller: UploadController; + + protected encryptedThumbnails = new Map(); + protected encryptedBlocks = new Map(); + protected encryptionFinished = false; + + protected ongoingUploads = new Map< + string, + { + index?: number; + uploadPromise: Promise; + } + >(); + 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, + protected cryptoService: UploadCryptoService, + protected uploadManager: UploadManager, + protected blockVerifier: BlockVerifier, + protected revisionDraft: NodeRevisionDraft, + protected metadata: UploadMetadata, + protected onFinish: (failure: boolean) => Promise, + protected uploadController: UploadController, + protected abortController: AbortController, + ) { + 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.digests = new UploadDigests(); + this.controller = uploadController; + this.abortController = abortController; + } + + async start( + stream: ReadableStream, + thumbnails: Thumbnail[], + onProgress?: (uploadedBytes: number) => void, + ): Promise<{ nodeRevisionUid: string; nodeUid: string }> { + 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; + if (!failure) { + onProgress?.(fileProgress); + } + }); + + 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 { + nodeRevisionUid: this.revisionDraft.nodeRevisionUid, + nodeUid: this.revisionDraft.nodeUid, + }; + } + + 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 (!this.isUploadAborted) { + await this.controller.waitWhilePaused(); + await this.waitForUploadCapacityAndBufferedBlocks(); + + if (this.isEncryptionFullyFinished || this.isUploadAborted) { + break; + } + + await this.requestAndInitiateUpload(onProgress); + + if (this.isEncryptionFullyFinished) { + break; + } + } + + // 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 + // 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)); + } + + protected async commitFile(thumbnails: Thumbnail[]) { + const digests = this.digests.digests(); + const integrityInfo = this.verifyIntegrity(thumbnails, digests); + + const extendedAttributes = { + modificationTime: this.metadata.modificationTime, + size: this.metadata.expectedSize, + blockSizes: this.uploadedBlockSizes, + digests, + }; + await this.uploadManager.commitDraft( + this.revisionDraft, + await this.getManifest(), + extendedAttributes, + this.metadata.additionalMetadata, + integrityInfo, + ); + } + + 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) { + if (this.isUploadAborted) { + break; + } + + 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.waitWhilePaused(); + await this.waitForBufferCapacity(); + + if (this.isUploadAborted) { + break; + } + + 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(makeNodeUidFromRevisionUid(this.revisionDraft.nodeRevisionUid), 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 ${index}`, error); + if (integrityError) { + void this.telemetry.logBlockVerificationError(makeNodeUidFromRevisionUid(this.revisionDraft.nodeRevisionUid), 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.signingKeys.addressId, + { + contentBlocks: Array.from( + this.encryptedBlocks.values().map((block) => ({ + index: block.index, + armoredSignature: block.armoredSignature, + verificationToken: block.verificationToken, + })), + ), + thumbnails: Array.from( + this.encryptedThumbnails.values().map((block) => ({ + type: block.type, + })), + ), + }, + ); + + // 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) { + 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; + }), + }); + } + + 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, { + index: blockToken.index, + uploadPromise: this.uploadBlock(blockToken, encryptedBlock, onProgress).finally(() => { + this.ongoingUploads.delete(uploadKey); + + // Help the garbage collector to clean up the memory. + encryptedBlock = undefined; + }), + }); + } + } + + 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 attempt = 0; + const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress); + + while (true) { + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedThumbnail.encryptedData, + progressCallback, + this.abortController.signal, + ); + this.uploadedThumbnails.push({ + type: encryptedThumbnail.type, + hashPromise: encryptedThumbnail.hashPromise, + encryptedSize: encryptedThumbnail.encryptedSize, + originalSize: encryptedThumbnail.originalSize, + }); + break; + } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError || this.isUploadAborted) { + throw error; + } + + rollbackProgress(); + + // 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 attempt = 0; + const { callback: progressCallback, rollback: rollbackProgress } = createProgressCallback(onProgress); + + while (true) { + if (this.isUploadAborted) { + throw this.error || new AbortError(); + } + + attempt++; + try { + logger.debug(`Uploading`); + await this.apiService.uploadBlock( + uploadToken.bareUrl, + uploadToken.token, + encryptedBlock.encryptedData, + progressCallback, + this.abortController.signal, + ); + this.uploadedBlocks.push({ + index: encryptedBlock.index, + hashPromise: encryptedBlock.hashPromise, + encryptedSize: encryptedBlock.encryptedSize, + originalSize: encryptedBlock.originalSize, + }); + break; + } catch (error: unknown) { + // Do not retry or report anything if the upload was aborted. + if (error instanceof AbortError || this.isUploadAborted) { + throw error; + } + + rollbackProgress(); + + 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 + ) { + logger.warn(`Token expired, fetching new token and retrying`); + const uploadTokens = await this.apiService.requestBlockUpload( + this.revisionDraft.nodeRevisionUid, + this.revisionDraft.nodeKeys.signingKeys.addressId, + { + contentBlocks: [ + { + index: encryptedBlock.index, + 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 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 { + await waitForCondition( + () => this.encryptedBlocks.size < MAX_BUFFERED_BLOCKS, + this.abortController.signal, + ); + } catch (error: unknown) { + if (error instanceof AbortError) { + return; + } + throw error; + } + } + } + + private async waitForUploadCapacityAndBufferedBlocks() { + while (this.ongoingUploads.size >= this.maxUploadingBlocks) { + await Promise.race(this.ongoingUploads.values().map(({ uploadPromise }) => uploadPromise)); + } + 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[], + digests: { sha1: string }, + ): { + checksumVerified: boolean; + } { + 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, + }); + } + 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, + }); + } + return { + checksumVerified: !!(this.metadata.expectedSha1 && digests.sha1 === this.metadata.expectedSha1), + }; + } + + /** + * 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); + } + + 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 async getManifest(): Promise> { + this.uploadedThumbnails.sort((a, b) => a.type - b.type); + this.uploadedBlocks.sort((a, b) => a.index - b.index); + const hashes = [ + ...(await Promise.all(this.uploadedThumbnails.map(({ hashPromise }) => hashPromise))), + ...(await Promise.all(this.uploadedBlocks.map(({ hashPromise }) => hashPromise))), + ]; + return mergeUint8Arrays(hashes); + } + + private async abortUpload(error: unknown) { + if (this.isUploadAborted) { + return; + } + this.error = error; + this.abortController.abort(error); + } + + private get isUploadAborted(): boolean { + return !!this.error || this.abortController.signal.aborted; + } +} diff --git a/js/sdk/src/internal/upload/telemetry.test.ts b/js/sdk/src/internal/upload/telemetry.test.ts new file mode 100644 index 00000000..d83e5ebe --- /dev/null +++ b/js/sdk/src/internal/upload/telemetry.test.ts @@ -0,0 +1,145 @@ +import { IntegrityError, RateLimitedError, ValidationError } from '../../errors'; +import { ProtonDriveTelemetry } from '../../interface'; +import { APIHTTPError } from '../apiService'; +import { SharesService } from './interface'; +import { UploadTelemetry } from './telemetry'; + +describe('UploadTelemetry', () => { + let mockTelemetry: jest.Mocked; + let sharesService: jest.Mocked; + let uploadTelemetry: UploadTelemetry; + + const parentNodeUid = 'volumeId~parentNodeId'; + const revisionUid = 'volumeId~nodeId~revisionId'; + + beforeEach(() => { + mockTelemetry = { + recordMetric: jest.fn(), + getLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }), + } as unknown as jest.Mocked; + + sharesService = { + getVolumeMetricContext: jest.fn().mockResolvedValue('own_volume'), + }; + + uploadTelemetry = new UploadTelemetry(mockTelemetry, sharesService); + }); + + it('should log failure during init (excludes uploaded size)', async () => { + const error = new Error('Failed'); + await uploadTelemetry.uploadInitFailed(parentNodeUid, error, 1000); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'upload', + volumeType: 'own_volume', + uploadedSize: 0, + approximateUploadedSize: 0, + expectedSize: 1000, + approximateExpectedSize: 4095, + error: 'unknown', + originalError: error, + }); + }); + + it('should log failure upload', async () => { + const error = new Error('Failed'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'upload', + volumeType: 'own_volume', + uploadedSize: 500, + approximateUploadedSize: 4095, + expectedSize: 1000, + approximateExpectedSize: 4095, + error: 'unknown', + originalError: error, + }); + }); + + it('should log successful upload (excludes error)', async () => { + await uploadTelemetry.uploadFinished(revisionUid, 1000); + + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith({ + eventName: 'upload', + volumeType: 'own_volume', + uploadedSize: 1000, + approximateUploadedSize: 4095, + expectedSize: 1000, + approximateExpectedSize: 4095, + }); + }); + + describe('detect error category', () => { + const verifyErrorCategory = (error: string) => { + expect(mockTelemetry.recordMetric).toHaveBeenCalledWith( + expect.objectContaining({ + error, + }), + ); + }; + + it('should detect "validation_error" for ValidationError', async () => { + const error = new ValidationError('out of quota'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('validation_error'); + }); + + it('should ignore AbortError', async () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + + expect(mockTelemetry.recordMetric).not.toHaveBeenCalled(); + }); + + it('should detect "rate_limited" error for RateLimitedError', async () => { + const error = new RateLimitedError('Rate limited'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('rate_limited'); + }); + + it('should detect "integrity_error" for IntegrityError', async () => { + const error = new IntegrityError('Integrity check failed'); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('integrity_error'); + }); + + it('should detect "4xx" error for APIHTTPError with 4xx status code', async () => { + const error = new APIHTTPError('Client error', 404); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('4xx'); + }); + + it('should detect "5xx" error for APIHTTPError with 5xx status code', async () => { + const error = new APIHTTPError('Server error', 500); + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('server_error'); + }); + + it('should detect "server_error" for TimeoutError', async () => { + const error = new Error('Timeout'); + error.name = 'TimeoutError'; + await uploadTelemetry.uploadFailed(revisionUid, error, 500, 1000); + verifyErrorCategory('server_error'); + }); + + it('should detect "network_error" for NetworkError', async () => { + const error = new Error('Network error'); + error.name = 'NetworkError'; + 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 new file mode 100644 index 00000000..819d5bd2 --- /dev/null +++ b/js/sdk/src/internal/upload/telemetry.ts @@ -0,0 +1,144 @@ +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'; + +export class UploadTelemetry { + readonly logger: Logger; + + constructor( + private telemetry: ProtonDriveTelemetry, + private sharesService: SharesService, + ) { + this.telemetry = telemetry; + this.logger = this.telemetry.getLogger('upload'); + this.sharesService = sharesService; + } + + getLoggerForSmallUpload() { + return new LoggerWithPrefix(this.logger, `small upload`); + } + + getLoggerForRevision(revisionUid: string) { + return new LoggerWithPrefix(this.logger, `revision ${revisionUid}`); + } + + 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, + }); + } + + async uploadInitFailed(parentFolderUid: string, error: unknown, expectedSize: number) { + const { volumeId } = splitNodeUid(parentFolderUid); + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + await this.sendTelemetry(volumeId, { + uploadedSize: 0, + expectedSize, + error: errorCategory, + originalError: error, + }); + } + + async uploadFailed(revisionUid: string, error: unknown, uploadedSize: number, expectedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + const errorCategory = getErrorCategory(error); + + // No error category means ignored error from telemetry. + // For example, aborted request. + if (!errorCategory) { + return; + } + + await this.sendTelemetry(volumeId, { + uploadedSize, + expectedSize, + error: errorCategory, + originalError: error, + }); + } + + async uploadFinished(revisionUid: string, uploadedSize: number) { + const { volumeId } = splitNodeRevisionUid(revisionUid); + await this.sendTelemetry(volumeId, { + uploadedSize, + expectedSize: uploadedSize, + }); + } + + private async sendTelemetry( + volumeId: string, + options: { + uploadedSize: number; + expectedSize: number; + error?: MetricsUploadErrorType; + originalError?: unknown; + }, + ) { + 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: 'upload', + volumeType, + approximateUploadedSize: reduceSizePrecision(options.uploadedSize), + approximateExpectedSize: reduceSizePrecision(options.expectedSize), + ...options, + }); + } +} + +function getErrorCategory(error: unknown): MetricsUploadErrorType | undefined { + if (error instanceof ValidationError) { + return 'validation_error'; + } + if (error instanceof RateLimitedError) { + return 'rate_limited'; + } + if (error instanceof IntegrityError) { + return 'integrity_error'; + } + if (error instanceof APIHTTPError) { + if (error.statusCode >= 400 && error.statusCode < 500) { + return '4xx'; + } + if (error.statusCode >= 500) { + return 'server_error'; + } + } + if (error instanceof Error) { + if (error.name === 'TimeoutError') { + return 'server_error'; + } + if (isNetworkError(error)) { + return 'network_error'; + } + if (error.name === 'AbortError') { + return undefined; + } + } + return 'unknown'; +} diff --git a/js/sdk/src/internal/utils.ts b/js/sdk/src/internal/utils.ts new file mode 100644 index 00000000..0c3959ce --- /dev/null +++ b/js/sdk/src/internal/utils.ts @@ -0,0 +1,9 @@ +export function mergeUint8Arrays(arrays: Uint8Array[]) { + const length = arrays.reduce((sum, arr) => sum + arr.length, 0); + const chunksAll = new Uint8Array(length); + arrays.reduce((position, arr) => { + chunksAll.set(arr, position); + return position + arr.length; + }, 0); + return chunksAll; +} diff --git a/js/sdk/src/internal/wait.test.ts b/js/sdk/src/internal/wait.test.ts new file mode 100644 index 00000000..c7ff2d3b --- /dev/null +++ b/js/sdk/src/internal/wait.test.ts @@ -0,0 +1,21 @@ +import { waitForCondition } from './wait'; + +describe('waitForCondition', () => { + it('should resolve immediately if condition is met', async () => { + const callback = jest.fn().mockReturnValue(true); + await waitForCondition(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should resolve after condition is met', async () => { + const callback = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + await waitForCondition(callback); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should reject if signal is aborted', async () => { + const signal = { aborted: true } as any as AbortSignal; + const callback = jest.fn().mockReturnValue(false); + await expect(waitForCondition(callback, signal)).rejects.toThrow('aborted'); + }); +}); diff --git a/js/sdk/src/internal/wait.ts b/js/sdk/src/internal/wait.ts new file mode 100644 index 00000000..e3b3a254 --- /dev/null +++ b/js/sdk/src/internal/wait.ts @@ -0,0 +1,26 @@ +import { AbortError } from '../errors'; + +const WAIT_TIME = 50; + +export function waitForCondition(callback: () => boolean, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + const waitForCondition = () => { + if (signal?.aborted) { + return reject(new AbortError()); + } + if (callback()) { + return resolve(); + } + setTimeout(waitForCondition, WAIT_TIME); + }; + waitForCondition(); + }); +} + +export async function waitSeconds(seconds: number) { + return wait(seconds * 1000); +} + +export async function wait(miliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, miliseconds)); +} diff --git a/js/sdk/src/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 new file mode 100644 index 00000000..0e7c5068 --- /dev/null +++ b/js/sdk/src/protonDriveClient.ts @@ -0,0 +1,1129 @@ +import { getConfig } from './config'; +import { DriveCrypto, SessionKey } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; +import { + BookmarkOrUid, + Device, + DeviceOrUid, + DeviceType, + DriveEvent, + FileDownloader, + FileUploader, + Logger, + MaybeBookmark, + MaybeMissingNode, + MaybeNode, + MemberRole, + NodeOrUid, + NodeResult, + NodeResultWithError, + NodeResultWithNewUid, + NodeType, + NonProtonInvitationOrUid, + ProtonDriveClientContructorParameters, + ProtonInvitation, + ProtonInvitationOrUid, + ProtonInvitationWithNode, + Revision, + RevisionOrUid, + SDKEvent, + ShareNodeSettings, + ShareResult, + ThumbnailResult, + ThumbnailType, + UnshareNodeSettings, + UploadMetadata, +} from './interface'; +import { DriveAPIService } from './internal/apiService'; +import { initDevicesModule } from './internal/devices'; +import { initDownloadModule } from './internal/download'; +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'; +import { initSharingModule } from './internal/sharing'; +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. + * + * The client provides high-level operations for managing nodes, sharing, + * and downloading/uploading files. It is the main entry point for using + * the ProtonDrive SDK. + */ +export class ProtonDriveClient { + private logger: Logger; + private sdkEvents: SDKEvents; + private events: DriveEventsService; + private shares: ReturnType; + private nodes: ReturnType; + private sharing: ReturnType; + private download: ReturnType; + private upload: ReturnType; + private devices: ReturnType; + private publicSessionManager: SharingPublicSessionManager; + + 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; + /** + * 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; + directAccess?: { + nodeUid: string; + directRole: MemberRole; + publicRole: MemberRole; + }; + }>; + /** + * Experimental feature to authenticate a public link and + * return the client for the public link to access it. + */ + authPublicLink: ( + url: string, + 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({ + httpClient, + entitiesCache, + cryptoCache, + account, + openPGPCryptoModule, + 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); + this.sdkEvents = new SDKEvents(telemetry); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); + 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, + fullConfig.clientUid, + ); + 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, + featureFlagProvider, + 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. + // 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), + ]; + this.events = new DriveEventsService( + telemetry, + apiService, + this.shares, + cacheEventListeners, + latestEventIdProvider, + ); + + this.publicSessionManager = new SharingPublicSessionManager( + telemetry, + httpClient, + cryptoModule, + srpModule, + apiService, + ); + + this.experimental = { + getNodeUrl: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); + return this.nodes.access.getNodeUrl(getUid(nodeUid)); + }, + getDocsKey: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting docs keys for ${getUid(nodeUid)}`); + const keys = await this.nodes.access.getNodeKeys(getUid(nodeUid)); + if (!keys.contentKeyPacketSessionKey) { + throw new Error('Node does not have a content key packet session key'); + } + return keys.contentKeyPacketSessionKey; + }, + getPublicLinkInfo: async (url: string) => { + 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); + this.logger.info(`Authenticating public link token ${token}`); + + const { httpClient, shareKey, rootUid, publicRole, session } = await this.publicSessionManager.auth( + token, + urlPassword, + customPassword, + ); + return new ProtonDrivePublicLinkClient({ + httpClient, + account, + openPGPCryptoModule, + srpModule, + config, + telemetry, + url, + token, + publicShareKey: shareKey, + publicRootNodeUid: rootUid, + isAnonymousContext, + publicRole, + session, + }); + }, + processCoreEvent: async (rawEvent: CoreApiEvent) => { + this.logger.debug(`Processing core event ${rawEvent.EventID}`); + return this.events.processCoreEvent(rawEvent); + }, + }; + } + + /** + * Subscribes to the general SDK events. + * + * This is not connected to the remote data updates. For that, use + * and see `subscribeToRemoteDataUpdates`. + * + * @param eventName - SDK event name. + * @param callback - Callback to be called when the event is emitted. + * @returns Callback to unsubscribe from the event. + */ + onMessage(eventName: SDKEvent, callback: () => void): () => void { + this.logger.debug(`Subscribing to event ${eventName}`); + return this.sdkEvents.addListener(eventName, callback); + } + + /** + * 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. + * + * 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. + * + * 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'); + return this.events.subscribeToTreeEvents(treeEventScopeId, callback); + } + + /** + * 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'); + 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.shares.loadEncryptedShare(shareId); + return makeNodeUid(share.volumeId, nodeId); + } + + /** + * @returns The root folder to My files section of the user. + */ + async getMyFilesRootFolder(): Promise { + this.logger.info('Getting my files root folder'); + return convertInternalNodePromise(this.nodes.access.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. + * + * The output is not sorted and the order of the children is not guaranteed. + * + * @param parentNodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the children of the given parent node. + * @deprecated Use `iterateFolderChildrenNodeUids` instead. + */ + async *iterateFolderChildren( + parentNodeUid: NodeOrUid, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating children of ${getUid(parentNodeUid)}`); + const iterator = this.nodes.access.iterateFolderChildren(getUid(parentNodeUid), filterOptions, signal); + yield* convertInternalNodeIterator(iterator); + } + + /** + * Iterates the trashed nodes. + * + * The list of trashed nodes is not cached and is fetched from the server + * on each call. The node data itself are served from cached if available. + * + * The output is not sorted and the order of the trashed nodes is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the trashed nodes. + * @deprecated Use `iterateTrashedNodeUids` instead. + */ + async *iterateTrashedNodes(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating trashed nodes'); + yield* convertInternalNodeIterator(this.nodes.access.iterateTrashedNodes(signal)); + } + + /** + * Iterates the nodes by their UIDs. + * + * The output is not sorted and the order of the nodes is not guaranteed. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the nodes. + */ + async *iterateNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} nodes`); + yield* convertInternalMissingNodeIterator(this.nodes.access.iterateNodes(getUids(nodeUids), signal)); + } + + /** + * Get the node by its UID. + * + * @param nodeUid - Node entity or its UID string. + * @returns The node entity. + */ + async getNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.nodes.access.getNode(getUid(nodeUid))); + } + + /** + * 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. + * + * @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 ValidationError} If another node with the same name already exists. + */ + async renameNode(nodeUid: NodeOrUid, newName: string): Promise { + this.logger.info(`Renaming node ${getUid(nodeUid)}`); + return convertInternalNodePromise(this.nodes.management.renameNode(getUid(nodeUid), newName)); + } + + /** + * Move the nodes to a new parent node. + * + * The operation is performed node by node and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to move, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * Only move withing the same section is supported at this moment. + * That means that the new parent node must be in the same section + * as the nodes being moved. E.g., moving from My files to Shared with + * me is not supported yet. + * + * @param nodeUids - List of node entities or their UIDs. + * @param newParentNodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the move operation + */ + async *moveNodes( + nodeUids: NodeOrUid[], + newParentNodeUid: NodeOrUid, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`); + 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. + * + * 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. + * + * @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 + */ + async *copyNodes( + nodesOrNodeUidsOrWithNames: (NodeOrUid | { uid: string; name: string })[], + newParentNodeUid: NodeOrUid, + signal?: AbortSignal, + ): AsyncGenerator { + 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); + } + + /** + * Trash the nodes. + * + * The operation is performed in batches and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to trash, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the trash operation + */ + async *trashNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Trashing ${nodeUids.length} nodes`); + yield* this.nodes.management.trashNodes(getUids(nodeUids), signal); + } + + /** + * Restore the nodes from the trash to their original place. + * + * The operation is performed in batches and the results are yielded + * as they are available. Order of the results is not guaranteed. + * + * If one of the nodes fails to restore, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the restore operation + */ + async *restoreNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Restoring ${nodeUids.length} nodes`); + yield* this.nodes.management.restoreNodes(getUids(nodeUids), signal); + } + + /** + * 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. + * + * If one of the nodes fails to delete, the operation continues with the + * rest of the nodes. Use `NodeResult` to check the status of the action. + * + * @param nodeUids - List of node entities or their UIDs. + * @param signal - Signal to abort the operation. + * @returns An async generator of the results of the delete operation + */ + async *deleteNodes(nodeUids: NodeOrUid[], signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Deleting ${nodeUids.length} nodes`); + yield* this.nodes.management.deleteTrashedNodes(getUids(nodeUids), signal); + } + + async emptyTrash(): Promise { + this.logger.info('Emptying trash'); + return this.nodes.management.emptyTrash(); + } + + /** + * Create a new folder. + * + * The folder is created in the given parent node. + * + * @param parentNodeUid - Node entity or its UID string of the parent folder. + * @param name - Name of the new folder. + * @param modificationTime - Optional modification time of the folder. + * @returns The created node entity. + * @throws {@link Error} If the parent node is not a folder. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + * @throws {@link Error} If another node with the same name already exists. + */ + async createFolder(parentNodeUid: NodeOrUid, name: string, modificationTime?: Date): Promise { + this.logger.info(`Creating folder in ${getUid(parentNodeUid)}`); + return convertInternalNodePromise( + this.nodes.management.createFolder(getUid(parentNodeUid), name, modificationTime), + ); + } + + /** + * Iterates the revisions of given node. + * + * The list of node revisions is not cached and is fetched and decrypted + * from the server on each call. + * + * The output is sorted by the revision date in descending order (newest + * first). + * + * @param nodeUid - Node entity or its UID string. + * @param signal - Signal to abort the operation. + * @returns An async generator of the node revisions. + */ + async *iterateRevisions(nodeUid: NodeOrUid, signal?: AbortSignal): AsyncGenerator { + this.logger.info(`Iterating revisions of ${getUid(nodeUid)}`); + yield* convertInternalRevisionIterator(this.nodes.revisions.iterateRevisions(getUid(nodeUid), signal)); + } + + /** + * Restore the node to the given revision. + * + * Warning: Restoring revisions might be accepted by the server but not + * applied. If the client re-loads list of revisions quickly after the + * restore, the change might not be visible. Update the UI optimistically to + * reflect the change. + * + * @param revisionUid - UID of the revision to restore. + */ + async restoreRevision(revisionUid: RevisionOrUid): Promise { + this.logger.info(`Restoring revision ${getUid(revisionUid)}`); + await this.nodes.revisions.restoreRevision(getUid(revisionUid)); + } + + /** + * Delete the revision. + * + * @param revisionUid - UID of the revision to delete. + */ + async deleteRevision(revisionUid: RevisionOrUid): Promise { + this.logger.info(`Deleting revision ${getUid(revisionUid)}`); + 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. + * + * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the shared nodes. + * @deprecated Use `iterateSharedNodeUids` instead. + */ + 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. + * + * The output is not sorted and the order of the shared nodes is not guaranteed. + * + * 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. + * @deprecated Use `iterateSharedWithMeNodeUids` instead. + */ + 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. + * + * @param nodeUid - Node entity or its UID string. + */ + async leaveSharedNode(nodeUid: NodeOrUid): Promise { + this.logger.info(`Leaving shared node with me ${getUid(nodeUid)}`); + await this.sharing.access.removeSharedNodeWithMe(getUid(nodeUid)); + } + + /** + * Iterates the invitations to shared nodes. + * + * The output is not sorted and the order of the invitations is not guaranteed. + * + * @param signal - Signal to abort the operation. + * @returns An async generator of the invitations. + */ + async *iterateInvitations(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating invitations'); + yield* this.sharing.access.iterateInvitations(signal); + } + + /** + * Accept the invitation to the shared node. + * + * @param invitationUid - Invitation entity or its UID string. + */ + 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 invitationUid - Invitation entity or its UID string. + */ + async rejectInvitation(invitationUid: ProtonInvitationOrUid): Promise { + this.logger.info(`Rejecting invitation ${getUid(invitationUid)}`); + await this.sharing.access.rejectInvitation(getUid(invitationUid)); + } + + /** + * 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); + } + + /** + * 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. + * + * @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. + * + * The sharing info contains the list of invitations, members, + * public link and permission for each. + * + * The sharing info is not cached and is fetched from the server + * on each call. + * + * @param nodeUid - Node entity or its UID string. + * @returns The sharing info of the node. Undefined if not shared. + */ + async getSharingInfo(nodeUid: NodeOrUid): Promise { + this.logger.info(`Getting sharing info for ${getUid(nodeUid)}`); + return this.sharing.management.getSharingInfo(getUid(nodeUid)); + } + + /** + * Share or update sharing of the node. + * + * If the node is already shared, the sharing settings are updated. + * If the member is already present but with different role, the role + * is updated. If the sharing settings is identical, the sharing info + * is returned without any change. + * + * @param nodeUid - Node entity or its UID string. + * @param settings - Settings for sharing the node. + * @returns The updated sharing info of the node. + */ + async shareNode(nodeUid: NodeOrUid, settings: ShareNodeSettings): Promise { + this.logger.info(`Sharing node ${getUid(nodeUid)}`); + return this.sharing.management.shareNode(getUid(nodeUid), settings); + } + + /** + * Unshare the node, completely or partially. + * + * @param nodeUid - Node entity or its UID string. + * @param settings - Settings for unsharing the node. If not provided, the node + * is unshared completely. + * @returns The updated sharing info of the node. Undefined if unshared completely. + */ + async unshareNode(nodeUid: NodeOrUid, settings?: UnshareNodeSettings): Promise { + if (!settings) { + this.logger.info(`Unsharing node ${getUid(nodeUid)}`); + } else { + this.logger.info(`Partially unsharing ${getUid(nodeUid)}`); + } + 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. + * + * @param nodeUid - Node entity or its UID string. + * @param invitationUid - Invitation entity or its UID string. + */ + async resendInvitation( + nodeUid: NodeOrUid, + invitationUid: ProtonInvitationOrUid | NonProtonInvitationOrUid, + ): Promise { + this.logger.info(`Resending invitation ${getUid(invitationUid)}`); + return this.sharing.management.resendInvitationEmail(getUid(nodeUid), getUid(invitationUid)); + } + + /** + * 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. + * + * 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.downloadToStream(stream, (downloadedBytes) => { ... }); + * + * signalController.abort(); // to cancel + * downloadController.pause(); // to pause + * downloadController.resume(); // to resume + * await downloadController.completion(); // to await completion + * ``` + */ + async getFileDownloader(nodeUid: NodeOrUid, signal?: AbortSignal): Promise { + this.logger.info(`Getting file downloader for ${getUid(nodeUid)}`); + return this.download.getFileDownloader(getUid(nodeUid), signal); + } + + /** + * Same as `getFileDownloader`, but for a specific revision of the file. + */ + async getFileRevisionDownloader(nodeRevisionUid: string, signal?: AbortSignal): Promise { + this.logger.info(`Getting file revision downloader for ${getUid(nodeRevisionUid)}`); + return this.download.getFileRevisionDownloader(nodeRevisionUid, signal); + } + + /** + * Iterates the thumbnails of the given nodes. + * + * The output is not sorted and the order of the nodes is not guaranteed. + * + * @param nodeUids - List of node entities or their UIDs. + * @param thumbnailType - Type of the thumbnail to download. + * @returns An async generator of the results of the restore operation + */ + async *iterateThumbnails( + nodeUids: NodeOrUid[], + thumbnailType?: ThumbnailType, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating ${nodeUids.length} thumbnails`); + yield* this.download.iterateThumbnails(getUids(nodeUids), thumbnailType, signal); + } + + /** + * Get the file uploader to upload a new file. For uploading a new + * revision, use `getFileRevisionUploader` instead. + * + * 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.uploadFromStream(stream, thumbnails, (uploadedBytes) => { ... }); + * + * signalController.abort(); // to cancel + * uploadController.pause(); // to pause + * uploadController.resume(); // to resume + * const { nodeUid, nodeRevisionUid } = await uploadController.completion(); // to await completion + * ``` + */ + async getFileUploader( + parentFolderUid: NodeOrUid, + name: string, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { + this.logger.info(`Getting file uploader for parent ${getUid(parentFolderUid)}`); + return this.upload.getFileUploader(getUid(parentFolderUid), name, metadata, signal); + } + + /** + * Same as `getFileUploader`, but for a uploading new revision of the file. + */ + async getFileRevisionUploader( + nodeUid: NodeOrUid, + metadata: UploadMetadata, + signal?: AbortSignal, + ): Promise { + this.logger.info(`Getting file revision uploader for ${getUid(nodeUid)}`); + return this.upload.getFileRevisionUploader(getUid(nodeUid), metadata, signal); + } + + /** + * 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. + * + * The output is not sorted and the order of the devices is not guaranteed. + * + * 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. + */ + async *iterateDevices(signal?: AbortSignal): AsyncGenerator { + this.logger.info('Iterating devices'); + 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. + * + * @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. + */ + async createDevice(name: string, deviceType: DeviceType): Promise { + this.logger.info(`Creating device of type ${deviceType}`); + return this.devices.createDevice(name, deviceType); + } + + /** + * Renames a device. + * + * @param deviceOrUid - Device entity or its UID string. + * @returns The updated device entity. + * @throws {@link ValidationError} If the name is empty, too long, or contains a slash. + */ + async renameDevice(deviceOrUid: DeviceOrUid, name: string): Promise { + this.logger.info(`Renaming device ${getUid(deviceOrUid)}`); + return this.devices.renameDevice(getUid(deviceOrUid), name); + } + + /** + * Deletes a device. + * + * @param deviceOrUid - Device entity or its UID string. + */ + async deleteDevice(deviceOrUid: DeviceOrUid): Promise { + this.logger.info(`Deleting device ${getUid(deviceOrUid)}`); + await this.devices.deleteDevice(getUid(deviceOrUid)); + } +} diff --git a/js/sdk/src/protonDrivePhotosClient.ts b/js/sdk/src/protonDrivePhotosClient.ts new file mode 100644 index 00000000..4fad1e0a --- /dev/null +++ b/js/sdk/src/protonDrivePhotosClient.ts @@ -0,0 +1,831 @@ +import { getConfig } from './config'; +import { DriveCrypto } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; +import { + DriveEvent, + FileDownloader, + FileUploader, + Logger, + MaybeMissingPhotoNode, + MaybePhotoNode, + NodeOrUid, + NodeResult, + NodeResultWithError, + NonProtonInvitationOrUid, + PhotoTag, + ProtonDriveClientContructorParameters, + ProtonInvitation, + ProtonInvitationOrUid, + ProtonInvitationWithNode, + SDKEvent, + ShareNodeSettings, + ShareResult, + ThumbnailResult, + ThumbnailType, + UnshareNodeSettings, + UploadMetadata, +} from './interface'; +import { DriveAPIService } from './internal/apiService'; +import { initDownloadModule } from './internal/download'; +import { + CoreApiEvent, + DriveEventsService, + DriveListener, + EventScheduler, + EventSubscription, + InternalDriveEvent, +} from './internal/events'; +import { + AlbumItem, + initPhotoSharesModule, + initPhotosModule, + initPhotosNodesModule, + 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 { 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. + * + * 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; + /** + * Iterates albums sorted by last activity time (most recent first). + * + * @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({ + httpClient, + entitiesCache, + cryptoCache, + account, + openPGPCryptoModule, + 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); + this.sdkEvents = new SDKEvents(telemetry); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); + const apiService = new DriveAPIService( + telemetry, + this.sdkEvents, + httpClient, + fullConfig.baseUrl, + fullConfig.language, + ); + const coreShares = initSharesModule(telemetry, apiService, entitiesCache, cryptoCache, account, cryptoModule); + this.photoShares = initPhotoSharesModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + account, + cryptoModule, + coreShares, + ); + this.nodes = initPhotosNodesModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + account, + cryptoModule, + this.photoShares, + fullConfig.clientUid, + ); + this.photos = initPhotosModule(telemetry, apiService, cryptoModule, this.photoShares, this.nodes.access); + this.sharing = initSharingModule( + telemetry, + apiService, + entitiesCache, + account, + cryptoModule, + this.photoShares, + this.nodes.access, + PHOTOS_SHARE_TARGET_TYPES, + ); + this.download = initDownloadModule( + telemetry, + apiService, + cryptoModule, + account, + this.photoShares, + this.nodes.access, + this.nodes.revisions, + ); + this.upload = initPhotoUploadModule( + telemetry, + apiService, + cryptoModule, + this.photoShares, + this.nodes.access, + featureFlagProvider, + fullConfig.clientUid, + ); + + // 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), + ]; + 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)); + }, + iterateAlbumUids: (signal?: AbortSignal) => { + 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); + }, + }; + } + + /** + * 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); + } + + /** + * 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'); + return this.events.subscribeToTreeEvents(treeEventScopeId, callback); + } + + /** + * 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'); + 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. + */ + 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. + * + * 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 { + 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'); + yield* convertInternalPhotoNodeIterator(this.nodes.access.iterateTrashedNodes(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* convertInternalMissingPhotoNodeIterator(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 convertInternalPhotoNodePromise(this.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 convertInternalPhotoNodePromise(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.deleteTrashedNodes(getUids(nodeUids), signal); + } + + /** + * Empty the trash for the photos volume. + */ + async emptyTrash(): Promise { + this.logger.info('Emptying photo volume trash'); + 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'); + yield* convertInternalPhotoNodeIterator(this.sharing.access.iterateSharedNodes(signal)); + } + + /** + * 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'); + + for await (const node of this.sharing.access.iterateSharedNodesWithMe(signal)) { + yield convertInternalPhotoNode(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); + } + + /** + * 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. + * + * 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)); + } + + /** + * 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); + } + + /** + * Get the file uploader to upload a new file. + * + * See `ProtonDriveClient.getFileUploader` for more information. + */ + async getFileUploader( + name: string, + metadata: UploadMetadata & { + captureTime?: Date; + mainPhotoNodeUid?: string; + tags?: PhotoTag[]; + }, + 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); + } + + /** + * 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. + * + * 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. + * @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 + .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); + } + + /** + * 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. 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. + * + * @param nodeUid - The UID of the album to delete. + * @param force - Whether to force the deletion. + */ + 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); + } + + /** + * 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* 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); + } + + /** + * 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. + * + * 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. + * @returns An async generator of the removed photo results. + */ + 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); + } + + /** + * 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. + * + * 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, + ); + } +} diff --git a/js/sdk/src/protonDrivePublicLinkClient.ts b/js/sdk/src/protonDrivePublicLinkClient.ts new file mode 100644 index 00000000..6c8b0bd1 --- /dev/null +++ b/js/sdk/src/protonDrivePublicLinkClient.ts @@ -0,0 +1,413 @@ +import { MemoryCache } from './cache'; +import { getConfig } from './config'; +import { DriveCrypto, OpenPGPCrypto, PrivateKey, SessionKey, SRPModule } from './crypto'; +import { NullFeatureFlagProvider } from './featureFlags'; +import { + CachedCryptoMaterial, + FeatureFlagProvider, + FileDownloader, + FileUploader, + Logger, + MaybeMissingNode, + MaybeNode, + MemberRole, + NodeOrUid, + NodeResult, + NodeType, + ProtonDriveAccount, + ProtonDriveConfig, + ProtonDriveHTTPClient, + ProtonDriveTelemetry, + SDKEvent, + ThumbnailResult, + ThumbnailType, + UploadMetadata, +} from './interface'; +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 { Telemetry } from './telemetry'; +import { + convertInternalMissingNodeIterator, + convertInternalNodeIterator, + convertInternalNodePromise, + getUid, + getUids, +} from './transformers'; + +/** + * 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; + private download: ReturnType; + private upload: ReturnType; + private session: SharingPublicLinkSession; + + 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; + /** + * 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. + */ + 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; + /** + * 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({ + httpClient, + account, + openPGPCryptoModule, + srpModule, + config, + telemetry, + featureFlagProvider, + url, + token, + publicShareKey, + publicRootNodeUid, + isAnonymousContext, + publicRole, + session, + }: { + httpClient: ProtonDriveHTTPClient; + account: ProtonDriveAccount; + openPGPCryptoModule: OpenPGPCrypto; + srpModule: SRPModule; + config?: ProtonDriveConfig; + telemetry?: ProtonDriveTelemetry; + featureFlagProvider?: FeatureFlagProvider; + url: string; + token: string; + publicShareKey: PrivateKey; + publicRootNodeUid: string; + isAnonymousContext: boolean; + publicRole: MemberRole; + session: SharingPublicLinkSession; + }) { + if (!telemetry) { + telemetry = new Telemetry(); + } + if (!featureFlagProvider) { + 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(); + const cryptoCache = new MemoryCache(); + + const fullConfig = getConfig(config); + this.sdkEvents = new SDKEvents(telemetry); + + const apiService = new UnauthDriveAPIService( + telemetry, + this.sdkEvents, + httpClient, + fullConfig.baseUrl, + fullConfig.language, + ); + const cryptoModule = new DriveCrypto(telemetry, openPGPCryptoModule, srpModule); + this.sharingPublic = initSharingPublicModule( + telemetry, + apiService, + entitiesCache, + cryptoCache, + cryptoModule, + account, + url, + token, + publicShareKey, + publicRootNodeUid, + publicRole, + isAnonymousContext, + ); + this.download = initDownloadModule( + telemetry, + apiService, + cryptoModule, + account, + 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, + apiService, + cryptoModule, + this.sharingPublic.shares, + this.sharingPublic.nodes.access, + featureFlagProvider, + fullConfig.clientUid, + // Public links do not support small file upload. + false, + ); + + this.experimental = { + getNodeUrl: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting node URL for ${getUid(nodeUid)}`); + return this.sharingPublic.nodes.access.getNodeUrl(getUid(nodeUid)); + }, + getDocsKey: async (nodeUid: NodeOrUid) => { + this.logger.debug(`Getting docs keys for ${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'); + } + 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); + }, + 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, + ), + ); + }, + getSessionInfo: (): { uid: string; accessToken: string | undefined } => { + this.logger.debug(`Getting session info`); + return this.session.session; + }, + }; + } + + /** + * 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. + */ + async getRootNode(): Promise { + this.logger.info(`Getting root node`); + const { rootNodeUid } = await this.sharingPublic.shares.getRootIDs(); + 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, + filterOptions?: { type?: NodeType }, + signal?: AbortSignal, + ): AsyncGenerator { + this.logger.info(`Iterating children of ${getUid(parentUid)}`); + yield* convertInternalNodeIterator( + 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))); + } + + /** + * 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 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.deleteMyNodes(getUids(nodeUids), signal); + } + + /** + * 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. + * + * 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); + } + + /** + * 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); + } + + /** + * 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); + } +} 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 new file mode 100644 index 00000000..6bdca714 --- /dev/null +++ b/js/sdk/src/telemetry.ts @@ -0,0 +1,376 @@ +import { Logger as LoggerInterface } from './interface'; + +export interface LogRecord { + time: Date; + level: LogLevel; + loggerName: string; + message: string; + error?: unknown; +} + +export enum LogLevel { + DEBUG = 'DEBUG', + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR', +} + +export interface LogFormatter { + format(log: LogRecord): string; +} + +export interface LogHandler { + log(log: LogRecord): void; +} + +export interface MetricRecord { + time: Date; + event: T; +} + +export type MetricEvent = { + eventName: string; +}; + +export interface MetricHandler { + onEvent(metric: MetricRecord): void; +} + +/** + * Telemetry class that logs messages and metrics. + * + * Example: + * + * ```typescript + * const memoryLogHandler = new MemoryLogHandler(); + * + * interface MetricEvents = { + * name: string, + * value: number, + * } + * class OwnMetricHandler implements MetricHandler { + * onEvent(metric: MetricRecord) { + * // Process metric event + * } + * } + * + * const telemetry = new Telemetry({ + * // Enable debug logging + * logFilter: new LogFilter({ level: LogLevel.DEBUG }), + * // Log to console and memory + * logHandlers: [new ConsoleLogHandler(), memoryLogHandler], + * // Log to console and own handler to process further + * metricHandlers: [new ConsoleMetricHandler(), ownMetricHandler], + * }); + * + * const logger = telemetry.getLogger('myLogger'); + * logger.debug('Debug message'); + * + * telemetry.recordMetric({ name: 'somethingHappened', value: 42 }); + * + * const logs = memoryLogHandler.getLogs(); + * // Process logs + * ``` + * + * @param logFilter - Log filter to filter logs based on log level, default INFO + * @param logHandlers - Log handlers to use for logging, see LogHandler implementations + * @param metricHandlers - Metric handlers to use for logging, see MetricHandler implementations + */ +export class Telemetry { + private logFilter: LogFilter; + private logHandlers: LogHandler[]; + private metricHandlers: MetricHandler[]; + + constructor(options?: { logFilter?: LogFilter; logHandlers?: LogHandler[]; metricHandlers?: MetricHandler[] }) { + this.logFilter = options?.logFilter || new LogFilter(); + this.logHandlers = options?.logHandlers || [new ConsoleLogHandler()]; + this.metricHandlers = options?.metricHandlers || [new ConsoleMetricHandler()]; + } + + getLogger(name: string): Logger { + return new Logger(name, this.logFilter, this.logHandlers); + } + + recordMetric(event: T): void { + const metric = { + time: new Date(), + event, + }; + this.metricHandlers.forEach((handler) => handler.onEvent(metric)); + } +} + +/** + * Logger class that logs messages with different levels. + * + * @param name - Name of the logger + * @param handlers - Log handlers to use for logging, see LogHandler implementations + */ +class Logger { + constructor( + private name: string, + private filter: LogFilter, + private handlers: LogHandler[], + ) { + this.name = name; + this.filter = filter; + this.handlers = handlers; + } + + debug(message: string) { + this.log({ + time: new Date(), + level: LogLevel.DEBUG, + loggerName: this.name, + message, + }); + } + + info(message: string) { + this.log({ + time: new Date(), + level: LogLevel.INFO, + loggerName: this.name, + message, + }); + } + + warn(message: string) { + this.log({ + time: new Date(), + level: LogLevel.WARNING, + loggerName: this.name, + message, + }); + } + + error(message: string, error?: unknown) { + this.log({ + time: new Date(), + level: LogLevel.ERROR, + loggerName: this.name, + message, + error, + }); + } + + private log(log: LogRecord) { + if (!this.filter.filter(log)) { + return; + } + this.handlers.forEach((handler) => handler.log(log)); + } +} + +/** + * Logger class that logs messages with a prefix. + * + * Example: + * + * ```typescript + * const logger = new Logger('myLogger', new LogFilter(), [new ConsoleLogHandler()]); + * const loggerWithPrefix = new LoggerWithPrefix(logger, 'prefix'); + * loggerWithPrefix.info('Info message'); + * ``` + */ +export class LoggerWithPrefix { + constructor( + private logger: LoggerInterface, + private prefix: string, + ) { + this.logger = logger; + this.prefix = prefix; + } + + info(message: string) { + this.logger.info(`${this.prefix}: ${message}`); + } + + debug(message: string) { + this.logger.debug(`${this.prefix}: ${message}`); + } + + warn(message: string) { + this.logger.warn(`${this.prefix}: ${message}`); + } + + error(message: string, error?: unknown) { + this.logger.error(`${this.prefix}: ${message}`, error); + } +} + +/** + * Filter logs based on log level. It can be configured by global level or + * per logger level. + * + * @param globalLevel - Global log level, default INFO + * @param loggerLevels - Log levels for specific loggers, default empty + */ +export class LogFilter { + private logLevelMap = { + DEBUG: 0, + INFO: 1, + WARNING: 2, + ERROR: 3, + }; + + private globalLevel: number; + private loggerLevels: { [loggerName: string]: number }; + + constructor(options?: { globalLevel?: LogLevel; loggerLevels?: { [loggerName: string]: LogLevel } }) { + this.globalLevel = this.logLevelMap[options?.globalLevel || LogLevel.INFO]; + this.loggerLevels = Object.fromEntries( + Object.entries(options?.loggerLevels || {}).map(([loggerName, level]) => [ + loggerName, + this.logLevelMap[level], + ]), + ); + } + + /** + * @returns False if the log should be ignored. + */ + filter(log: LogRecord) { + const logLevel = this.logLevelMap[log.level]; + if (logLevel < this.globalLevel) { + return false; + } + const loggerLevel = this.loggerLevels[log.loggerName] || 0; + if (logLevel < loggerLevel) { + return false; + } + return true; + } +} + +/** + * Log handler that logs to console. + * + * @param formatter - Formatter to use for log messages, default BasicLogFormatter + */ +export class ConsoleLogHandler implements LogHandler { + private logLevelMap = { + DEBUG: console.debug, // 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; + + constructor(formatter?: LogFormatter) { + this.formatter = formatter || new BasicLogFormatter(); + } + + log(log: LogRecord) { + const message = this.formatter.format(log); + this.logLevelMap[log.level](message); + } +} + +/** + * Log handler that stores logs in memory with option to retrieve later. + * + * Useful for keeping logs around and retrieve them on demand when an error + * occures. + * + * @param formatter - Formatter to use for log messages, default JSONLogFormatter + * @param maxLogs - Maximum number of logs to store, default 10000 + */ +export class MemoryLogHandler implements LogHandler { + private logs: string[] = []; + + private formatter: LogFormatter; + + constructor( + formatter?: LogFormatter, + private maxLogs = 10000, + ) { + this.formatter = formatter || new JSONLogFormatter(); + this.maxLogs = maxLogs; + } + + log(log: LogRecord) { + const message = this.formatter.format(log); + this.logs.push(message); + + if (this.logs.length > this.maxLogs) { + this.logs.shift(); + } + } + + getLogs() { + return this.logs; + } + + clear() { + this.logs = []; + } +} + +/** + * Formatter that formats logs as JSON. + * + * Useful for machine processing. + */ +export class JSONLogFormatter implements LogFormatter { + format(log: LogRecord) { + if (log.error instanceof Error) { + return JSON.stringify({ + ...log, + error: log.error.message, + stack: log.error.stack, + }); + } + return JSON.stringify(log); + } +} + +/** + * Formatter that formats logs as plain text. + * + * Useful for human reading. + */ +export class BasicLogFormatter implements LogFormatter { + format(log: LogRecord) { + let errorDetails = ''; + if (log.error) { + errorDetails = + log.error instanceof Error + ? `\nError: ${log.error.message}\nStack:\n${log.error.stack}` + : `\nError: ${log.error}`; + } + return `${log.time.toISOString()} ${log.level} [${log.loggerName}] ${log.message}${errorDetails}`; + } +} + +class ConsoleMetricHandler implements MetricHandler { + onEvent(metric: MetricRecord) { + // eslint-disable-next-line no-console + console.info( + `${metric.time.toISOString()} INFO [metric] ${metric.event.eventName} ${JSON.stringify({ ...metric.event, name: undefined })}`, + ); + } +} + +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; +} diff --git a/js/sdk/src/tests/logger.ts b/js/sdk/src/tests/logger.ts new file mode 100644 index 00000000..a3650fe4 --- /dev/null +++ b/js/sdk/src/tests/logger.ts @@ -0,0 +1,10 @@ +import { Logger } from '../interface'; + +export function getMockLogger(): Logger { + return { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +} diff --git a/js/sdk/src/tests/telemetry.ts b/js/sdk/src/tests/telemetry.ts new file mode 100644 index 00000000..6efc94fe --- /dev/null +++ b/js/sdk/src/tests/telemetry.ts @@ -0,0 +1,12 @@ +import { Logger, ProtonDriveTelemetry } from '../interface'; +import { getMockLogger } from './logger'; + +export function getMockTelemetry(): ProtonDriveTelemetry & { mockLogger: Logger } { + const mockLogger = getMockLogger(); + + return { + mockLogger, + getLogger: () => mockLogger, + recordMetric: jest.fn(), + }; +} diff --git a/js/sdk/src/transformers.ts b/js/sdk/src/transformers.ts new file mode 100644 index 00000000..c3c8eb87 --- /dev/null +++ b/js/sdk/src/transformers.ts @@ -0,0 +1,195 @@ +import { + DegradedNode as PublicDegradedNode, + DegradedPhotoNode as PublicDegradedPhotoNode, + MaybeMissingNode as PublicMaybeMissingNode, + MaybeMissingPhotoNode as PublicMaybeMissingPhotoNode, + MaybeNode as PublicMaybeNode, + MaybePhotoNode as PublicMaybePhotoNode, + MissingNode, + PhotoNode as PublicPhotoNode, + 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'; + +type InternalPartialNode = Pick< + InternalNode, + | 'uid' + | 'parentUid' + | 'name' + | 'keyAuthor' + | 'nameAuthor' + | 'directRole' + | 'membership' + | 'ownedBy' + | 'type' + | 'mediaType' + | 'isShared' + | 'isSharedPublicly' + | 'creationTime' + | 'modificationTime' + | 'trashTime' + | 'activeRevision' + | 'folder' + | 'totalStorageSize' + | 'errors' + | 'shareId' + | 'treeEventScopeId' +>; + +type NodeUid = string | { uid: string } | Result<{ uid: string }, { uid: string }>; + +export function getUid(nodeUid: NodeUid): string { + if (typeof nodeUid === 'string') { + return nodeUid; + } + // Directly passed NodeEntity or DegradedNode that has UID directly. + if ('uid' in nodeUid) { + return nodeUid.uid; + } + // MaybeNode that can be either NodeEntity or DegradedNode. + if (nodeUid.ok) { + return nodeUid.value.uid; + } + return nodeUid.error.uid; +} + +export function getUids(nodeUids: NodeUid[]): string[] { + return nodeUids.map(getUid); +} + +export async function* convertInternalNodeIterator( + nodeIterator: AsyncGenerator, +): AsyncGenerator { + for await (const node of nodeIterator) { + yield convertInternalNode(node); + } +} + +export async function* convertInternalMissingNodeIterator( + nodeIterator: AsyncGenerator, +): AsyncGenerator { + for await (const node of nodeIterator) { + if ('missingUid' in node) { + yield resultError(node); + } else { + yield convertInternalNode(node); + } + } +} + +export async function convertInternalNodePromise(nodePromise: Promise): Promise { + const node = await nodePromise; + return convertInternalNode(node); +} + +export function convertInternalNode(node: InternalPartialNode): PublicMaybeNode { + const baseNodeMetadata = { + uid: node.uid, + parentUid: node.parentUid, + keyAuthor: node.keyAuthor, + nameAuthor: node.nameAuthor, + directRole: node.directRole, + membership: node.membership, + ownedBy: node.ownedBy, + type: node.type, + mediaType: node.mediaType, + isShared: node.isShared, + isSharedPublicly: node.isSharedPublicly, + creationTime: node.creationTime, + modificationTime: node.modificationTime, + trashTime: node.trashTime, + totalStorageSize: node.totalStorageSize, + folder: node.folder, + deprecatedShareId: node.shareId, + treeEventScopeId: node.treeEventScopeId, + }; + + 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 { + 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, + album: photoNode.album, + } as PublicPhotoNode); + } + return resultError({ + ...node.error, + photo: photoNode.photo, + album: photoNode.album, + } as PublicDegradedPhotoNode); +} + +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, + state: revision.state, + creationTime: revision.creationTime, + contentAuthor: revision.contentAuthor, + storageSize: revision.storageSize, + claimedSize: revision.claimedSize, + claimedModificationTime: revision.claimedModificationTime, + claimedDigests: revision.claimedDigests, + claimedAdditionalMetadata: revision.claimedAdditionalMetadata, + }; +} diff --git a/js/sdk/src/version.ts b/js/sdk/src/version.ts new file mode 100644 index 00000000..c27c2caf --- /dev/null +++ b/js/sdk/src/version.ts @@ -0,0 +1,3 @@ +import { version } from '../package.json'; + +export const VERSION = version; diff --git a/js/sdk/tasks/linter.mjs b/js/sdk/tasks/linter.mjs new file mode 100644 index 00000000..6bffe55f --- /dev/null +++ b/js/sdk/tasks/linter.mjs @@ -0,0 +1,200 @@ +/* + * Usage node linter.mjs [arg] + * - arg can be a directory or a single file (default src) + */ +import { readFile } from 'fs/promises'; +import { sync } from 'glob'; +import path from 'path'; + +/** + * @typedef {string} FilePath + * @typedef {'format' | 'usage' | 'backticks' | 'plurals'} BrokenRuleType + * @typedef {{ file:FilePath, line:string, match:string, index:int, type: BrokenRuleType}} BrokenRule + * @typedef { Generator} BrokenRulesIterator + * @typedef { AsyncGenerator} AsyncBrokenRulesIterator + */ + +/** + * Test a rule inside the code and see if we find lines matching it + * @param {RegExp} rule rule to test inside the whole content + * @param {string} content text file content + * @param {BrokenRuleType} type type of rule you filter + * @return {{ errors: BrokenRulesIterator, match: bool}} + */ +function testRule(rule, content, type) { + const matches = content.match(rule); + + /** + * @param {FilePath}file to lint + * @yields {BrokenRule} + */ + function* errors(file) { + if (!matches?.length) { + return; + } + + for (const [index, line] of content.split('\n').entries()) { + const done = new Set(); + for (const match of matches) { + const hasNewLine = match.includes('\n'); + // If multiline matches + const [, string] = match.split('\n'); + const toMatch = hasNewLine ? string : match; + const id = `${line}:${index}${match}`; + if (!line.includes(toMatch) || done.has(id)) { + continue; + } + done.add(id); + yield { + file, + line, + match, + string, + index, + type, + }; + } + } + } + + return { match: matches?.length > 0, errors }; +} + +/** + * Iterate over all your source files and see if we can find broken translations + * @param {string} source source to iterate over (directory or a single file) + * @param {{isVerbose: bool}} + * @returns {AsyncBrokenRulesIterator} + */ +async function* errorIterator(source = 'src', options = { isVerbose: false }) { + const { ext } = path.parse(source); + const files = ext + ? [source] + : sync(path.join(source, '**', '*.{js,jsx,ts,tsx}'), { + ignore: [path.join(source, 'node_modules', '**'), path.join(source, 'dist', '**')], + }); + + for (const file of files) { + if (file.endsWith('.d.ts') || file.includes('tests')) { + continue; + } + if (file.includes('stories')) { + continue; + } + + if (options.isVerbose) { + console.log('[lint]', file); + } + const content = await readFile(file, 'utf-8'); + + const errorsFormat = testRule(/c\(\x27.+\x27\)\.(t|c)\(/g, content, 'format'); + if (errorsFormat.match) { + yield* errorsFormat.errors(file); + } + + const errorsUsage = testRule(/c\(\x27.+\x27\)\.(c\x60|\x60)/g, content, 'usage'); + if (errorsUsage.match) { + yield* errorsUsage.errors(file); + } + + const errorsPlurals = testRule( + /c\(\x27.+\x27\)\.ngettext\(msgid(\x60|\().+(\x60|\)),\s(\x27|\x22)/g, + content, + 'plurals' + ); + if (errorsPlurals.match) { + yield* errorsPlurals.errors(file); + } + + // https://regex101.com/r/cT9edH/1 + const errorsBackticks = testRule(/(?).t() or c().c() + but c().t\`\` +`; + } + if (error.type === 'usage') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: You should not use - c().c\`\` or c().\`\` + but c().t\`\` +`; + } + if (error.type === 'backticks') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: You should not use backticks for the context definition. It is a static string + best to use c(\x27\x27).t\`\` +`; + } + + if (error.type === 'plurals') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Plural form is - ngettext(msgid\`\`, \`\`, value) +`; + } + + if (error.type === 'newlines') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Unexpected newline inside the string. +`; + } + + if (error.type === 'numbers') { + return `🚨 [Error] ${error.file}:${error.index} + match: ${error.match} + line: ${error.line} + fix: Do not translate a string without anything else than numbers and/or spaces. +`; + } +} + +async function main() { + const [, , source] = process.argv; + const isVerbose = process.argv.includes('--verbose'); + let total = 0; + for await (const error of errorIterator(source, { isVerbose })) { + total++; + console.log(formatErrors(error)); + } + + total && console.log(`Found ${total} error(s)`); + + // If total => it means we have error, exit with code 1 + process.exit(+!!total); +} +main(); diff --git a/js/sdk/tsconfig.json b/js/sdk/tsconfig.json new file mode 100644 index 00000000..0caf4691 --- /dev/null +++ b/js/sdk/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": false, + "noImplicitAny": true, + // Many variables are unused during prototyping - uncomment later once more modules are implemented. + //"noUnusedLocals": true, + "strict": true, + "skipLibCheck": true, + "target": "esnext", + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true, + "types": ["@protontech/global-types", "mocha", "jest", "node"], + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "**/node_modules/*", + "**/coreTypes.ts", + "**/driveTypes.ts" + ], +} diff --git a/kt/build.gradle.kts b/kt/build.gradle.kts new file mode 100644 index 00000000..66ff4a00 --- /dev/null +++ b/kt/build.gradle.kts @@ -0,0 +1,120 @@ +import com.android.build.gradle.LibraryExtension +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import com.vanniktech.maven.publish.SonatypeHost +import java.util.Properties + +// 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 = providers.environmentVariable("VERSION").getOrElse("0.0.0-SNAPSHOT") + + 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..5f43bbaa --- /dev/null +++ b/kt/gradle.properties @@ -0,0 +1,28 @@ +# 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..b6f28a00 --- /dev/null +++ b/kt/libs.versions.toml @@ -0,0 +1,126 @@ +[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 +core = "36.3.2" +# Crypto +android-golib = "2.9.0-3" +# 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" +mockk = "1.13.9" + +[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" } + +# 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" } +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" } +mockk-jvm = { module = "io.mockk:mockk", version.ref = "mockk" } + +[bundles] +test-android = [ + "junit", + "coroutines-test", + "androidx-test-core-ktx", + "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" } +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..280509f5 --- /dev/null +++ b/kt/sdk/build.gradle.kts @@ -0,0 +1,173 @@ +import com.google.protobuf.gradle.proto + +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}") + } + } + defaultConfig { + consumerProguardFiles("proguard-rules.pro") + } + } + 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) + // 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(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") } + +tasks.named { name -> name == "javaDocReleaseGeneration" }.configureEach { + enabled = false +} diff --git a/kt/sdk/proguard-rules.pro b/kt/sdk/proguard-rules.pro new file mode 100644 index 00000000..77ef212e --- /dev/null +++ b/kt/sdk/proguard-rules.pro @@ -0,0 +1,10 @@ +-keep class com.google.protobuf.** { *; } +-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(...); +} diff --git a/kt/sdk/src/main/AndroidManifest.xml b/kt/sdk/src/main/AndroidManifest.xml new file mode 100644 index 00000000..036712d1 --- /dev/null +++ b/kt/sdk/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/kt/sdk/src/main/jni/Android.mk b/kt/sdk/src/main/jni/Android.mk new file mode 100644 index 00000000..9bb686bd --- /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 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 +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/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/global.c b/kt/sdk/src/main/jni/global.c new file mode 100644 index 00000000..a51f5059 --- /dev/null +++ b/kt/sdk/src/main/jni/global.c @@ -0,0 +1,202 @@ +#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 = 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; +} + +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, (long) 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); + } +} + +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, (long) bindings_handle + ); + return 0; + } else { + jclass cls = (*env)->GetObjectClass(env, obj); + jmethodID mid = (*env)->GetMethodID(env, cls, name, "(Ljava/nio/ByteBuffer;)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); + } +} + +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, (long) 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); + } +} + +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); + } +} + +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 new file mode 100644 index 00000000..db9dac1b --- /dev/null +++ b/kt/sdk/src/main/jni/global.h @@ -0,0 +1,40 @@ +#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 +); + +long pushDataToLongMethod( + intptr_t bindings_handle, + ByteArray value, + const char *name +); + +void pushDataAndLongToVoidMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle, + const char *name +); + +long pushDataAndLongToLongMethod( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle, + const char *name +); + +ByteArray callByteBufferMethod( + intptr_t bindings_handle, + const char *name +); + +#endif //PROTONDRIVE_GLOBAL_H diff --git a/kt/sdk/src/main/jni/job.c b/kt/sdk/src/main/jni/job.c new file mode 100644 index 00000000..b299556f --- /dev/null +++ b/kt/sdk/src/main/jni/job.c @@ -0,0 +1,69 @@ +#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; + } + + /* --- 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( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onCancel; +} 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..cb5dfc1d --- /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, + jclass clazz, + 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..7ac9baa9 --- /dev/null +++ b/kt/sdk/src/main/jni/proton_drive_sdk.c @@ -0,0 +1,202 @@ +#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, + 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); + + proton_drive_sdk_handle_request( + byteArray, + (intptr_t) ref, + onDriveSdkResponse + ); + + (*env)->ReleaseByteArrayElements(env, request, bufferElems, 0); +} + +void Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_handleResponse( + JNIEnv *env, + jclass clazz, + 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_sdk_handle_response( + (intptr_t) sdk_handle, + byteArray + ); + + (*env)->ReleaseByteArrayElements(env, response, bufferElems, 0); +} + +long onRead( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + return pushDataAndLongToLongMethod(bindings_handle, value, sdk_handle, "onRead"); +} + +long onWrite( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + 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 onYield(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onYield"); +} + +void onProgress(intptr_t bindings_handle, ByteArray value) { + pushDataToVoidMethod(bindings_handle, value, "onProgress"); +} + +long onSendHttpRequest( + intptr_t bindings_handle, + ByteArray value, + intptr_t sdk_handle +) { + return pushDataAndLongToLongMethod(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, + intptr_t sdk_handle +) { + pushDataAndLongToVoidMethod(bindings_handle, value, sdk_handle, "onAccountRequest"); +} + +void onRecordMetric( + intptr_t bindings_handle, + ByteArray value +) { + pushDataToVoidMethod(bindings_handle, value, "onRecordMetric"); +} + +long onFeatureEnabled( + intptr_t bindings_handle, + ByteArray value +) { + return pushDataToLongMethod(bindings_handle, value, "onFeatureEnabled"); +} + +void onSha1( + intptr_t bindings_handle, + ByteArray output +) { + pushDataToVoidMethod(bindings_handle, output, "onSha1"); +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getReadPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onRead; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getWritePointer( + JNIEnv *env, + jclass clazz +) { + 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_getYieldPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onYield; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getProgressPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onProgress; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getHttpClientRequestPointer( + JNIEnv *env, + jclass clazz +) { + 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, + jclass clazz +) { + return (jlong) (intptr_t) &onAccountRequest; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getRecordMetricPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onRecordMetric; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getFeatureEnabledPointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onFeatureEnabled; +} + +jlong Java_me_proton_drive_sdk_internal_ProtonDriveSdkNativeClient_getSha1Pointer( + JNIEnv *env, + jclass clazz +) { + return (jlong) (intptr_t) &onSha1; +} 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..dcd0c18c --- /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, + 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); + + proton_sdk_handle_request( + byteArray, + (intptr_t) ref, + 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, + jclass clazz +) { + return (jlong) (intptr_t) &onCallback; +} 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/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..e334b41e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Cancellable.kt @@ -0,0 +1,9 @@ +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..54d155ec --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CancellationTokenSource.kt @@ -0,0 +1,13 @@ +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/CommonDownloadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt new file mode 100644 index 00000000..722a7c05 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonDownloadController.kt @@ -0,0 +1,116 @@ +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, + internal val handle: Long, + private val bridge: JniDownloadController, + private val channel: Channel, + private val coroutineScopeConsumer: CoroutineScopeConsumer, + override val cancellationTokenSource: CancellationTokenSource, +) : SdkNode(downloader), DownloadController { + + 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 { + isPaused() + bridge.awaitCompletion(handle) + }.onSuccess { + log(INFO, "completed") + }.recoverCatching { error -> + if (error is CancellationException) { + log(INFO, "interrupted, will pause") + withContext(NonCancellable) { + pause() + } + throw error + } + if (isPaused()) { + log(INFO, "paused") + throw error + } + log(INFO, "aborted") + throw DownloadAbortedException(error) + }.getOrThrow() + } + + override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { + log(DEBUG, "tryResume") + coroutineScopeConsumer(coroutineScope) + if (!isPaused()) { + return false + } + log(INFO, "resume") + bridge.resume(handle).also { isPaused() } + return true + } + + override suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle).also { isPaused() } + } + + override suspend fun isPaused() = bridge.isPaused(handle).also { paused -> + log(DEBUG, "isPaused: $paused") + isPausedFlow.emit(paused) + } + + override suspend fun isDownloadCompleteWithVerificationIssue(): Boolean { + log(DEBUG, "isDownloadCompleteWithVerificationIssue") + return bridge.isDownloadCompleteWithVerificationIssue(handle) + } + + override fun close() { + log(DEBUG, "close") + channel.close() + bridge.free(handle) + super.close() + } + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + runCatching { + withTimeout(500.milliseconds) { awaitCompletion() } + }.recoverCatching { 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}") + } + } + } + + 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/CommonUploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt new file mode 100644 index 00000000..01647e14 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CommonUploadController.kt @@ -0,0 +1,114 @@ +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 +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, + 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) + + 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 { + isPaused() + bridge.awaitCompletion(handle) + }.onSuccess { + log(INFO, "completed") + }.recoverCatching { error -> + if (error is CancellationException) { + log(INFO, "interrupted, will pause") + withContext(NonCancellable) { + pause() + } + throw error + } + if (isPaused()) { + log(INFO, "paused") + throw error + } + log(INFO, "aborted") + throw UploadAbortedException(error) + }.getOrThrow() + } + + override suspend fun tryResume(coroutineScope: CoroutineScope): Boolean { + log(DEBUG, "tryResume") + coroutineScopeConsumer(coroutineScope) + if (!isPaused()) { + return false + } + log(INFO, "resume") + bridge.resume(handle).also { isPaused() } + return true + } + + override suspend fun pause() { + log(INFO, "pause") + bridge.pause(handle).also { isPaused() } + } + + 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() + runCatching { + withTimeout(500.milliseconds) { awaitCompletion() } + }.recoverCatching { 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}") + } + } + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "CommonUploadController(${handle.toLogId()}) $message") + } +} 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..858fdd1a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CorePublicAddressResolver.kt @@ -0,0 +1,23 @@ +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 { + val publicAddressInfo = publicAddressRepository.getPublicAddressInfo( + sessionUserId = userId, + email = emailAddress + ) + val publicAddressKeys = publicAddressInfo.address.keys + publicAddressInfo.unverified?.keys.orEmpty() + return publicAddressKeys.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..a5b9c0aa --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/CoreUserAddressResolver.kt @@ -0,0 +1,63 @@ +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..1bf02cfc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/DownloadController.kt @@ -0,0 +1,15 @@ +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 tryResume(coroutineScope: CoroutineScope): Boolean + 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 new file mode 100644 index 00000000..e1208d0d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Downloader.kt @@ -0,0 +1,12 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import java.nio.channels.WritableByteChannel + +interface Downloader : AutoCloseable, Cancellable { + + suspend fun downloadToStream( + coroutineScope: CoroutineScope, + channel: WritableByteChannel, + ): 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..fa6a12a0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileDownloader.kt @@ -0,0 +1,73 @@ +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.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.toLogId +import java.nio.channels.SeekableByteChannel +import java.nio.channels.WritableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class FileDownloader internal constructor( + client: SdkNode, + internal val handle: Long, + private val bridge: JniFileDownloader, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(client), Downloader { + + override suspend fun downloadToStream( + coroutineScope: CoroutineScope, + channel: WritableByteChannel, + ): DownloadController = cancellationTokenSource().let { source -> + log(INFO, "downloadToStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() + val handle = bridge.downloadToStream( + handle = handle, + cancellationTokenSourceHandle = source.handle, + onWrite = channel::write, + onSeek = if (channel is SeekableByteChannel) { + channel::seek + } else { + null + }, + onProgress = { progressUpdate -> + with(progressUpdate) { + bridge.internalLogger(DEBUG, "progress: ${progressUpdate.toPercentageString()}") + controllerReference.get()?.emitProgress(toEntity()) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonDownloadController( + downloader = this@FileDownloader, + handle = handle, + bridge = JniDownloadController(), + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + cancellationTokenSource = source, + ).also(controllerReference::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") + } +} + 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..efb9b1bc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/FileUploader.kt @@ -0,0 +1,70 @@ +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.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 +import java.nio.channels.ReadableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class FileUploader internal constructor( + client: SdkNode, + 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, + 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: ${progressUpdate.toPercentageString()}") + controllerReference.get()?.emitProgress(toEntity()) + } + }, + sha1Provider = sha1Provider, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonUploadController( + uploader = this@FileUploader, + handle = handle, + bridge = JniUploadController(), + cancellationTokenSource = source, + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + ).also(controllerReference::set) + } + + 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, "FileUploader(${handle.toLogId()}) $message") + } +} + 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..797e5bac --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/HttpSdkApi.kt @@ -0,0 +1,47 @@ +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.Streaming +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 = "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, + @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..f9de4bfe --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/LoggerProvider.kt @@ -0,0 +1,15 @@ +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..fe2184b3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/MetricCallback.kt @@ -0,0 +1,17 @@ +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/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/PhotosDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt new file mode 100644 index 00000000..5e02fcc9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosDownloader.kt @@ -0,0 +1,73 @@ +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.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.toLogId +import java.nio.channels.SeekableByteChannel +import java.nio.channels.WritableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class PhotosDownloader internal constructor( + client: SdkNode, + internal val handle: Long, + private val bridge: JniPhotosDownloader, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(client), Downloader { + + override suspend fun downloadToStream( + coroutineScope: CoroutineScope, + channel: WritableByteChannel, + ): DownloadController = cancellationTokenSource().let { source -> + log(INFO, "downloadToStream") + val coroutineScopeReference = AtomicReference(coroutineScope) + val controllerReference = AtomicReference() + val handle = bridge.downloadToStream( + handle = handle, + cancellationTokenSourceHandle = source.handle, + onWrite = channel::write, + onSeek = if (channel is SeekableByteChannel) { + channel::seek + } else { + null + }, + onProgress = { progressUpdate -> + with(progressUpdate) { + bridge.internalLogger(DEBUG, "progress: ${progressUpdate.toPercentageString()}") + controllerReference.get()?.emitProgress(toEntity()) + } + }, + coroutineScopeProvider = coroutineScopeReference::get, + ) + CommonDownloadController( + downloader = this@PhotosDownloader, + handle = handle, + bridge = JniDownloadController(), + channel = channel, + coroutineScopeConsumer = coroutineScopeReference::set, + cancellationTokenSource = source, + ).also(controllerReference::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") + } +} + 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..78f98cdf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PhotosUploader.kt @@ -0,0 +1,67 @@ +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.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 +import java.nio.channels.ReadableByteChannel +import java.util.concurrent.atomic.AtomicReference + +class PhotosUploader( + client: SdkNode, + 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, + 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: ${progressUpdate.toPercentageString()}") + 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) + + override suspend fun cancel() { + log(INFO, "cancel") + super.cancel() + } + + private fun log(level: LoggerProvider.Level, message: String) { + bridge.clientLogger(level, "PhotosUploader(${handle.toLogId()}) $message") + } +} + 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..6b660cfb --- /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/ProtonDriveClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt new file mode 100644 index 00000000..fdc88def --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveClient.kt @@ -0,0 +1,23 @@ +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 +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 + 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 + 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/ProtonDriveException.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt new file mode 100644 index 00000000..6bca7c14 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveException.kt @@ -0,0 +1,19 @@ +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 = */ message, + /* cause = */ cause, + /* enableSuppression = */ true, + /* writableStackTrace = */ false, +) + +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/ProtonDriveSdk.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt new file mode 100644 index 00000000..e614c176 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdk.kt @@ -0,0 +1,122 @@ +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 +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.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 + +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 = cancellationCoroutineScope { source -> + JniSession().run { + clientLogger(DEBUG, "ProtonDriveSdk sessionBegin") + Session(begin(source.handle, request), this, source) + } + } + + suspend fun sessionResume( + request: SessionResumeRequest, + ): Session = cancellationCoroutineScope { source -> + JniSession().run { + clientLogger(DEBUG, "ProtonDriveSdk sessionResume") + Session( + handle = resume(request), + bridge = this, + cancellationTokenSource = source + ) + } + } + + suspend fun protonDriveClientCreate( + coroutineScope: CoroutineScope, + userId: UserId, + apiProvider: ApiProvider, + request: ClientCreateRequest, + userAddressResolver: UserAddressResolver, + publicAddressResolver: PublicAddressResolver, + metricCallback: MetricCallback? = null, + featureEnabled: suspend (String) -> Boolean = { false }, + ): ProtonDriveClient = JniProtonDriveClient().run { + clientLogger(DEBUG, "ProtonDriveSdk protonDriveClientCreate(${userId.id.take(8)})") + InteropProtonDriveClient( + 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)})") + InteropProtonPhotosClient( + 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 + ) + } + + 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..041b2a8b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonDriveSdkException.kt @@ -0,0 +1,42 @@ +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 = buildString { + appendLine(super.toString()) + appendError(error, logMode = LogMode.Full) + } +} + +enum class LogMode { + Safe, Full +} + +fun ProtonDriveSdkException.errorToString(logMode: LogMode = LogMode.Safe): String = buildString { + error?.let { error -> + appendLine("SDK error: ${error.message}") + appendError(error, logMode) + } +} + +private fun StringBuilder.appendError(error: ProtonSdkError?, logMode: LogMode) { + error?.run { + appendLine("type: $type") + appendLine("domain: $domain") + appendLine("primaryCode: $primaryCode") + appendLine("secondaryCode: $secondaryCode") + 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, logMode) + } + } +} 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..850a504b --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonPhotosClient.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.flow.Flow +import me.proton.drive.sdk.entity.NodeUid +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 +} + 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..32e83b0c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkClient.kt @@ -0,0 +1,18 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.flow.Flow +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.ThumbnailType + +interface ProtonSdkClient : AutoCloseable { + fun enumerateThumbnails(nodeUids: List, type: ThumbnailType): Flow + 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 + suspend fun emptyTrash() +} 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..c5ee8962 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/ProtonSdkError.kt @@ -0,0 +1,119 @@ +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, + 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 { + fun toSafe(): S + + data class NodeNameConflict( + val conflictingNodeIsFileDraft: Boolean?, + val conflictingNodeUid: NodeUid?, + val conflictingRevisionUid: RevisionUid?, + ) : 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 + } + + data class NodeNotFound( + val nodeUid: NodeUid?, + ) : 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/PublicAddressResolver.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt new file mode 100644 index 00000000..147eb266 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/PublicAddressResolver.kt @@ -0,0 +1,6 @@ +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..65ad22d8 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/SdkNode.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk + +abstract class SdkNode(val parent: SdkNode?) : AutoCloseable { + + private var children: List = emptyList() + + init { + parent?.children += this + } + + override fun close() { + parent?.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..f1c94ffc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Session.kt @@ -0,0 +1,62 @@ +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( + internal val handle: Long, + private val bridge: JniSession, + override val cancellationTokenSource: CancellationTokenSource +) : SdkNode(null), AutoCloseable, Cancellable { + + suspend fun renew( + request: SessionRenewRequest, + ): Session { + log(DEBUG, "end") + return bridge.renew(handle, request).run { + Session(this, bridge, cancellationTokenSource) + } + } + + 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") + } +} + +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/TelemetryBridge.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt new file mode 100644 index 00000000..9ad68291 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/TelemetryBridge.kt @@ -0,0 +1,41 @@ +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/UploadController.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt new file mode 100644 index 00000000..95acf9ae --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UploadController.kt @@ -0,0 +1,16 @@ +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 tryResume(coroutineScope: CoroutineScope): Boolean + 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 new file mode 100644 index 00000000..c5d6e55c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/Uploader.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk + +import kotlinx.coroutines.CoroutineScope +import me.proton.drive.sdk.entity.ThumbnailType +import java.nio.channels.ReadableByteChannel + +interface Uploader : AutoCloseable, Cancellable { + + suspend fun uploadFromStream( + coroutineScope: CoroutineScope, + channel: ReadableByteChannel, + thumbnails: Map = emptyMap(), + sha1Provider: (() -> ByteArray)? = null, + ): UploadController +} 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..193b29ed --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/UserAddressResolver.kt @@ -0,0 +1,11 @@ +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..faf073d1 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/AnyConverter.kt @@ -0,0 +1,8 @@ +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/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/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/converter/LongConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt new file mode 100644 index 00000000..7151eaff --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/LongConverter.kt @@ -0,0 +1,10 @@ +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/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/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/StringConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt new file mode 100644 index 00000000..ef710a87 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/StringConverter.kt @@ -0,0 +1,10 @@ +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..09b182dc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/converter/UploadResultConverter.kt @@ -0,0 +1,11 @@ +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/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/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt new file mode 100644 index 00000000..6d414801 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Address.kt @@ -0,0 +1,22 @@ +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/Author.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt new file mode 100644 index 00000000..99cbb7dc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Author.kt @@ -0,0 +1,5 @@ +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/ClientCreateRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt new file mode 100644 index 00000000..614a3f0c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ClientCreateRequest.kt @@ -0,0 +1,14 @@ +package me.proton.drive.sdk.entity + +import me.proton.drive.sdk.LoggerProvider + +data class ClientCreateRequest( + val baseUrl: String, + val loggerProvider: LoggerProvider, + val entityCachePath: String? = null, + val secretCachePath: String? = null, + 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/entity/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/DriveError.kt new file mode 100644 index 00000000..a4c815ce --- /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? = null, + 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..3db4bc0c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileContentDigests.kt @@ -0,0 +1,6 @@ +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/entity/FileNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt new file mode 100644 index 00000000..dd94dc81 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileNode.kt @@ -0,0 +1,18 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class FileNode( + 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: 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 new file mode 100644 index 00000000..77d85bf3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevision.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class FileRevision( + val uid: RevisionUid, + val creationTime: Instant, + val sizeOnCloudStorage: Long, + val claimedSize: Long?, + val claimedDigests: FileContentDigests?, + 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 new file mode 100644 index 00000000..88a3721e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileRevisionUploaderRequest.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class FileRevisionUploaderRequest( + 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 new file mode 100644 index 00000000..326d5e8b --- /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: 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 new file mode 100644 index 00000000..b77631c6 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FileUploaderRequest.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class FileUploaderRequest( + val parentFolderUid: NodeUid, + val name: String, + val mediaType: String, + val fileSize: 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 new file mode 100644 index 00000000..9e809703 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/FolderNode.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class FolderNode( + 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, +) : Node 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 new file mode 100644 index 00000000..40737090 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/Node.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +sealed interface Node { + 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/NodeResultPair.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/NodeResultPair.kt new file mode 100644 index 00000000..bb42b965 --- /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: NodeUid + + 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/PhotoTag.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotoTag.kt new file mode 100644 index 00000000..bbfb79ed --- /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) { + Favorite(0), + Screenshot(1), + Video(2), + LivePhoto(3), + MotionPhoto(4), + Selfie(5), + Portrait(6), + Burst(7), + Panorama(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/PhotosTimelineItem.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt new file mode 100644 index 00000000..9babcf56 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosTimelineItem.kt @@ -0,0 +1,8 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +data class PhotosTimelineItem( + val nodeUid: NodeUid, + 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 new file mode 100644 index 00000000..9e6775f9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/PhotosUploaderRequest.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.entity + +import java.time.Instant + +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 overrideExistingDraftByOtherClient: Boolean, + val additionalMetadata: Map = emptyMap(), // optional +) 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..10c349fb --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientOptions.kt @@ -0,0 +1,12 @@ +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..d5bfca21 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ProtonClientTlsPolicy.kt @@ -0,0 +1,7 @@ +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/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/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt new file mode 100644 index 00000000..8ad9255a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionBeginRequest.kt @@ -0,0 +1,11 @@ +package me.proton.drive.sdk.entity + +import java.io.File + +data class SessionBeginRequest( + val username: String, + val password: String, + val appVersion: String, + val options: ProtonClientOptions, + val secretCachePath: String? = null, +) 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..157db8ac --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionRenewRequest.kt @@ -0,0 +1,10 @@ +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..52588c10 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/SessionResumeRequest.kt @@ -0,0 +1,15 @@ +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/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/entity/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt new file mode 100644 index 00000000..850d2bba --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/ThumbnailType.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +enum class ThumbnailType { + THUMBNAIL, + PREVIEW, +} 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 new file mode 100644 index 00000000..66005de2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/entity/UploadResult.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.entity + +data class UploadResult( + val nodeUid: NodeUid, + val revisionUid: RevisionUid, +) 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/Address.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt new file mode 100644 index 00000000..2d8e459c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Address.kt @@ -0,0 +1,28 @@ +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/AnyConverter.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt new file mode 100644 index 00000000..289d17a4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AnyConverter.kt @@ -0,0 +1,17 @@ +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/ApiRetrySucceededEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt new file mode 100644 index 00000000..1820437f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ApiRetrySucceededEventPayload.kt @@ -0,0 +1,9 @@ +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/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..1257ad9e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/AuthorResult.kt @@ -0,0 +1,22 @@ +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 = + 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/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..abdc8980 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/BlockVerificationErrorEventPayload.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +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/extension/ByteBuffer.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt new file mode 100644 index 00000000..4d28719a --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ByteBuffer.kt @@ -0,0 +1,9 @@ +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..109f0d07 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CancellableContinuation.kt @@ -0,0 +1,40 @@ +package me.proton.drive.sdk.extension + +import kotlinx.coroutines.CancellableContinuation +import me.proton.drive.sdk.converter.BooleanConverter +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.internal.ContinuationUnitOrErrorResponse +import me.proton.drive.sdk.internal.ContinuationValueOrErrorResponse +import me.proton.drive.sdk.internal.ResponseCallback + +fun CancellableContinuation.toUnitResponse(): ResponseCallback = + ContinuationUnitOrErrorResponse(this) + +val UnitResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toUnitResponse + +fun CancellableContinuation.toIntResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, IntConverter()) + +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()) + +val LongResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toLongResponse + +fun CancellableContinuation.toStringResponse(): ResponseCallback = + ContinuationValueOrErrorResponse(this, StringConverter()) + +val StringResponseCallback: (CancellableContinuation) -> ResponseCallback = + CancellableContinuation::toStringResponse 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/CoroutineScope.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt new file mode 100644 index 00000000..333bcdaf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/CoroutineScope.kt @@ -0,0 +1,38 @@ +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..3d720282 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DecryptionErrorEventPayload.kt @@ -0,0 +1,12 @@ +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 = takeIf { hasError() }?.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..d6ff02ae --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadError.kt @@ -0,0 +1,16 @@ +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_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/DownloadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt new file mode 100644 index 00000000..3507bd1c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DownloadEventPayload.kt @@ -0,0 +1,14 @@ +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(), + 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/DriveError.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt new file mode 100644 index 00000000..5b754d32 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/DriveError.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.ProtonDriveException +import me.proton.drive.sdk.entity.DriveError +import proton.drive.sdk.ProtonDriveSdk + +fun ProtonDriveSdk.DriveError.toEntity(): DriveError = DriveError( + message = takeIf { hasMessage() }?.message, + innerError = if (hasInnerError()) innerError.toEntity() else null, +) 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/extension/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt new file mode 100644 index 00000000..a9c74708 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/EncryptedField.kt @@ -0,0 +1,15 @@ +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..cdce69a2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Error.kt @@ -0,0 +1,58 @@ +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() + + "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() + + "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/FileContentDigests.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt new file mode 100644 index 00000000..edfe43fe --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileContentDigests.kt @@ -0,0 +1,11 @@ +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(), + sha1Verified = sha1Verified, +) + +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..6d9b53fc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileNode.kt @@ -0,0 +1,23 @@ +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 = 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 = activeRevision.toEntity(), + totalSizeOnCloudStorage = totalSizeOnCloudStorage, + errors = errorsList.map { it.toEntity() }, +) 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..be43f806 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileThumbnail.kt @@ -0,0 +1,21 @@ +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 = NodeUid(fileUid), + result = when (resultCase) { + ProtonDriveSdk.FileThumbnail.ResultCase.DATA -> Result.success(data.toByteArray()) + 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/FileUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt new file mode 100644 index 00000000..25808021 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FileUploaderRequest.kt @@ -0,0 +1,26 @@ +package me.proton.drive.sdk.extension + +import com.google.protobuf.kotlin.toByteString +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.value + this@toProtobuf.lastModificationTime?.toTimestamp()?.let { lastModificationTime = it } + 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/FolderNode.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt new file mode 100644 index 00000000..51546928 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/FolderNode.kt @@ -0,0 +1,20 @@ +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 = 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/GetFileRevisionUploaderRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt new file mode 100644 index 00000000..e26c18f0 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/GetFileRevisionUploaderRequest.kt @@ -0,0 +1,16 @@ +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.value + this.size = this@toProtobuf.size + this@toProtobuf.lastModificationTime?.toTimestamp()?.let { lastModificationTime = it } +} 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..1c8abeea --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/HttpStream.kt @@ -0,0 +1,72 @@ +package me.proton.drive.sdk.extension + +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.nio.ByteBuffer + + +internal suspend fun HttpStream.read( + request: HttpRequest +): RequestBody { + val buffer = Buffer() + if (request.hasSdkContentHandle()) { + val byteBuffer = ByteBuffer.allocateDirect(64 * 1024) + + while (true) { + byteBuffer.clear() + val bytesRead = read(request.sdkContentHandle, byteBuffer) + if (bytesRead <= 0) break + byteBuffer.position(bytesRead) + + // Flip so we can read bytes from ByteBuffer + byteBuffer.flip() + + // Write directly from ByteBuffer to okio Buffer + buffer.write(byteBuffer) + } + } + + return buffer.snapshot().toRequestBody() +} + + +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) + 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() + + // Write directly from ByteBuffer to okio + sink.write(buffer) + } + } + } +} 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..6d40fb09 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/MessageLite.kt @@ -0,0 +1,10 @@ +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/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/Node.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt new file mode 100644 index 00000000..8feb93cf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Node.kt @@ -0,0 +1,47 @@ +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.Node + +fun Node.getNameOrNull(): String? = name.getOrNull() + +fun Node.requireName(): String = + name.getOrElse { throw errors.toException("Node name unavailable") } + +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 { + this@toException.forEach { driveError -> + addSuppressed( + exception = ProtonDriveException( + message = driveError.message, + cause = driveError.innerError?.let { + ProtonDriveException( + message = it.message, + cause = it.innerError?.toException(), + ) + }, + ), + ) + } +} + +fun DriveError.toException(message: String): ProtonDriveSdkException = ProtonDriveSdkException( + message = "$message: ${this@toException.message}", + cause = innerError?.toException(), +) + +fun DriveError.toException(): ProtonDriveException = ProtonDriveException( + message = message, + cause = innerError?.toException(), +) 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..85e2d5be --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeNameConflictErrorData.kt @@ -0,0 +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 = takeIf { hasConflictingNodeIsFileDraft() }?.let { conflictingNodeIsFileDraft }, + conflictingNodeUid = takeIf { hasConflictingNodeUid() }?.let { NodeUid(conflictingNodeUid) }, + conflictingRevisionUid = takeIf { hasConflictingRevisionUid() }?.let { RevisionUid(conflictingRevisionUid) }, +) 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/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 new file mode 100644 index 00000000..cb928017 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/NodeResultListResponse.kt @@ -0,0 +1,15 @@ +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 = + resultsList.map { it.toEntity() } + +fun ProtonDriveSdk.NodeResultPair.toEntity(): NodeResultPair = + if (hasError()) { + NodeResultPair.Failure(nodeUid = NodeUid(nodeUid), error = error.toException()) + } else { + NodeResultPair.Success(nodeUid = NodeUid(nodeUid)) + } 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 + } + } + 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..2bc62fbb --- /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.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/PhotosTimelineList.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt new file mode 100644 index 00000000..4abfb663 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosTimelineList.kt @@ -0,0 +1,10 @@ +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 + +fun ProtonDriveSdk.PhotosTimelineItem.toEntity() = PhotosTimelineItem( + nodeUid = NodeUid(nodeUid), + 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 new file mode 100644 index 00000000..3ed5ede2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/PhotosUploaderRequest.kt @@ -0,0 +1,40 @@ +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.photosFileUploadMetadata + +internal fun PhotosUploaderRequest.toProtobuf( + clientHandle: Long, + cancellationTokenSourceHandle: Long, +) = drivePhotosClientGetPhotoUploaderRequest { + this.clientHandle = clientHandle + name = this@toProtobuf.name + mediaType = this@toProtobuf.mediaType + size = this@toProtobuf.fileSize + metadata = photosFileUploadMetadata { + this@toProtobuf.captureTime?.let { + captureTime = it.toTimestamp() + } + this@toProtobuf.lastModificationTime?.let { + lastModificationTime = it.toTimestamp() + } + additionalMetadata += this@toProtobuf.additionalMetadata.map { (name, data) -> + additionalMetadataProperty { + this.name = name + this.utf8JsonValue = data.toByteString() + } + } + this@toProtobuf.mainPhotoUid?.let { + mainPhotoUid = it + } + tags += this@toProtobuf.tags.map { photoTag -> + photoTag.toSdkPhotoTag() + } + } + overrideExistingDraftByOtherClient = this@toProtobuf.overrideExistingDraftByOtherClient + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + } 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..2e41d0e4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProgressUpdate.kt @@ -0,0 +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 = takeIf { hasBytesInTotal() }?.let { bytesInTotal } + ) +} + +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 { + (bytesCompleted.toDouble() / (BLOCK_SIZE)).roundToLong().let { blocks -> "indeterminate: $blocks" } +} 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..f5b2bae5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientOptions.kt @@ -0,0 +1,19 @@ +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..0712fb33 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonClientTlsPolicy.kt @@ -0,0 +1,15 @@ +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/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/ProtonSdk.IntegerOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt new file mode 100644 index 00000000..761f0602 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ProtonSdk.IntegerOrErrorResponse.kt @@ -0,0 +1,20 @@ +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/Revision.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt new file mode 100644 index 00000000..a1fc8b82 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Revision.kt @@ -0,0 +1,22 @@ +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 + +fun ProtonDriveSdk.FileRevision.toEntity() = FileRevision( + uid = RevisionUid(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(), +) 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/extension/SessionBeginRequest.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt new file mode 100644 index 00000000..3b464056 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionBeginRequest.kt @@ -0,0 +1,13 @@ +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() + this@toProtobuf.secretCachePath?.let { secretCachePath = it } + 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..0cec43fa --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionRenewRequest.kt @@ -0,0 +1,14 @@ +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..5e6ef146 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/SessionResumeRequest.kt @@ -0,0 +1,18 @@ +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/StringResult.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt new file mode 100644 index 00000000..aa7f702d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/StringResult.kt @@ -0,0 +1,15 @@ +package me.proton.drive.sdk.extension + +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(error.toEntity().toException("String result failure")) + + 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/Throwable.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt new file mode 100644 index 00000000..010a234e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/Throwable.kt @@ -0,0 +1,39 @@ +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 { + val exception = this@toProtonSdkError + type = exception.javaClass.name + this.message = exception.message?.let { + "$message, caused by ${exception.message}" + } ?: message + domain = exception.domain() + exception.primaryCode()?.let { primaryCode = it } + exception.secondaryCode()?.let { secondaryCode = it } + context = stackTraceToString() +} + +private fun Throwable.domain(): ProtonSdk.ErrorDomain = when (this) { + is NoCoroutineScopeException -> ProtonSdk.ErrorDomain.SuccessfulCancellation + is CancellationException -> ProtonSdk.ErrorDomain.SuccessfulCancellation + + 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 + } + + 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() 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, +) 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/extension/ThumbnailType.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt new file mode 100644 index 00000000..e2ce8883 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/ThumbnailType.kt @@ -0,0 +1,9 @@ +package me.proton.drive.sdk.extension + +import me.proton.drive.sdk.entity.ThumbnailType +import proton.drive.sdk.ProtonDriveSdk + +fun ThumbnailType.toProto() = when (this) { + ThumbnailType.THUMBNAIL -> ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_THUMBNAIL + ThumbnailType.PREVIEW -> ProtonDriveSdk.ThumbnailType.THUMBNAIL_TYPE_PREVIEW +} 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 +} 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..76bb0d37 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadError.kt @@ -0,0 +1,15 @@ +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_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/extension/UploadEventPayload.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt new file mode 100644 index 00000000..94d71e13 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadEventPayload.kt @@ -0,0 +1,14 @@ +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, + approximateExpectedSize = approximateExpectedSize, + uploadedSize = uploadedSize, + approximateUploadedSize = approximateUploadedSize, + error = takeIf { hasError() }?.error?.toEnum(), + originalError = takeIf { hasOriginalError() }?.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..01e37a4f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/UploadResult.kt @@ -0,0 +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(nodeUid), + revisionUid = 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..0d005581 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VerificationErrorEventPayload.kt @@ -0,0 +1,13 @@ +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 = takeIf { hasError() }?.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..72d15f33 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/extension/VolumeType.kt @@ -0,0 +1,13 @@ +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_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.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..cead75d7 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/AccountClientBridge.kt @@ -0,0 +1,57 @@ +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..eb48a050 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ApiProviderBridge.kt @@ -0,0 +1,141 @@ +package me.proton.drive.sdk.internal + +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 me.proton.drive.sdk.LoggerProvider.Level.DEBUG +import okhttp3.ResponseBody +import proton.sdk.ProtonSdk.HttpRequest +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, + 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 preparedRequest = request.prepare(httpStream) + val apiResult = RetryAfterDelay(isEnabled = preparedRequest.isRetryEnabled) { attempt -> + apiProvider.get(userId).invoke( + forceNoRetryOnConnectionErrors = true + ) { + execute(preparedRequest, attempt) + } + } + 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?.byteStream()?.let { inputStream -> + bindingsContentHandle = httpStream.write( + coroutineScope = coroutineScope, + channel = Channels.newChannel(inputStream), + ) + } + } + } + } + + 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()?.byteStream()?.let { inputStream -> + bindingsContentHandle = httpStream.write( + coroutineScope = coroutineScope, + channel = Channels.newChannel(inputStream), + ) + } + } + } + + 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( + request: PreparedRequest, + 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") + } + } + } + + @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 + } + } + + fun logger(message: String) = JniBase.globalSdkLogger(DEBUG, "network", message) +} + 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..50d57044 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/BaseContinuationResponse.kt @@ -0,0 +1,69 @@ +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.resume +import kotlin.coroutines.resumeWithException + +abstract class BaseContinuationResponse( + private val continuation: CancellableContinuation, +) : 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 { 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(), + 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/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/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/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/ContinuationUnitOrErrorResponse.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt new file mode 100644 index 00000000..164f92dc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationUnitOrErrorResponse.kt @@ -0,0 +1,21 @@ +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 +import java.nio.ByteBuffer +import kotlin.coroutines.Continuation + +class ContinuationUnitOrErrorResponse( + continuation: CancellableContinuation, +) : BaseContinuationResponse(continuation) { + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> error("No response was expected but: ${response.value.typeUrl}") + RESULT_NOT_SET -> Unit + 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 new file mode 100644 index 00000000..1102b4a2 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrErrorResponse.kt @@ -0,0 +1,30 @@ +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 +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import java.nio.ByteBuffer +import kotlin.coroutines.Continuation + +class ContinuationValueOrErrorResponse( + continuation: CancellableContinuation, + private val anyConverter: AnyConverter, +) : BaseContinuationResponse(continuation) { + + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> { + if (response.value.typeUrl != anyConverter.typeUrl) { + error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") + } + anyConverter.convert(response.value) + } + + 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 new file mode 100644 index 00000000..d80db288 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ContinuationValueOrNullResponse.kt @@ -0,0 +1,29 @@ +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 +import proton.sdk.ProtonSdk.Response.ResultCase.VALUE +import java.nio.ByteBuffer +import kotlin.coroutines.Continuation + +class ContinuationValueOrNullResponse( + continuation: CancellableContinuation, + private val anyConverter: AnyConverter, +) : BaseContinuationResponse(continuation) { + override fun invoke(data: ByteBuffer) = parse(data) { response -> + when (response.resultCase) { + VALUE -> { + if (response.value.typeUrl != anyConverter.typeUrl) { + error("Wrong converter for ${response.value.typeUrl} (${anyConverter.typeUrl})") + } + anyConverter.convert(response.value) + } + + RESULT_NOT_SET -> null + ERROR -> error(response.error) + null -> null + } + } +} 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/HttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt new file mode 100644 index 00000000..7eea4c5f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/HttpStream.kt @@ -0,0 +1,38 @@ +package me.proton.drive.sdk.internal + +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 + + +class HttpStream internal constructor( + private val bridge: JniHttpStream, +) : AutoCloseable { + + suspend fun read(sdkContentHandle: Long, buffer: ByteBuffer) = withContext(Dispatchers.IO) { + readOrThrow(sdkContentHandle, buffer) + } + + fun readBlocking(sdkContentHandle: Long, buffer: ByteBuffer) = runBlocking(Dispatchers.IO) { + 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 = + bridge.write(coroutineScope, channel) + + override fun close() { + bridge.release() + bridge.releaseAll() + } +} 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..8d5d2337 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/IgnoredIntegerOrErrorResponse.kt @@ -0,0 +1,7 @@ +package me.proton.drive.sdk.internal + +import java.nio.ByteBuffer + +class IgnoredIntegerOrErrorResponse : ClientResponseCallback { + override fun invoke(client: T, data: ByteBuffer) = Unit +} 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..deb9c610 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonDriveClient.kt @@ -0,0 +1,303 @@ +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.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 +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.driveClientGetNodeRequest +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 + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { 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, + lastModificationTime: Instant?, + ): FolderNode = cancellationCoroutineScope { source -> + log(INFO, "createFolder") + bridge.createFolder( + driveClientCreateFolderRequest { + this.parentFolderUid = parentFolderUid.value + folderName = name + lastModificationTime?.toTimestamp()?.let { this.lastModificationTime = it } + 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 fun enumerateFolderChildren( + folderUid: NodeUid, + ): Flow = channelFlow { + log(DEBUG, "enumerateFolderChildren") + cancellationCoroutineScope { source -> + bridge.enumerateFolderChildren( + coroutineScope = this@channelFlow, + request = driveClientEnumerateFolderChildrenRequest { + this.folderUid = folderUid.value + clientHandle = handle + cancellationTokenSourceHandle = source.handle + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { node -> + send(node.toEntity()) + } + ) + } + } + + override suspend fun getNode( + nodeUid: NodeUid, + ): Node? = 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 -> + 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 + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { 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) { + log(INFO, "downloader") + 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) { + log(INFO, "fileUploader") + 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) { + log(INFO, "fileRevisionUploader") + 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..03c3effd --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/InteropProtonPhotosClient.kt @@ -0,0 +1,210 @@ +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.Node +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 + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { fileThumbnail -> + send(fileThumbnail.toEntity()) + } + ) + } + } + + override fun enumerateTimeline(): Flow = channelFlow { + log(DEBUG, "enumerateTimeline") + 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, + ): Node? = 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 + yieldAction = ProtonDriveSdkNativeClient.getYieldPointer() + }, + yield = { 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) { + log(INFO, "downloader") + 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) { + log(INFO, "photosUploader") + 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") + } +} 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..0f712438 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBase.kt @@ -0,0 +1,27 @@ +package me.proton.drive.sdk.internal + +import me.proton.drive.sdk.LoggerProvider.Level +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 { + + 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" + + 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..ffd63b07 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonDriveSdk.kt @@ -0,0 +1,104 @@ +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 +import java.nio.ByteBuffer + +abstract class JniBaseProtonDriveSdk : JniBase() { + + private var released = false + private var clients = emptyList>() + private var permanentClients = emptyList>() + + fun dispatch( + name: String, + block: RequestKt.Dsl.() -> Unit, + ) { + check(released.not()) { "Cannot dispatch ${method(name)} after release" } + val nativeClient = ProtonDriveSdkNativeClient( + name = method(name), + response = { client, _ -> + client.release() + }, + logger = internalLogger, + ) + 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" } + // Create the callback here to capture the call stack trace + val responseCallback = callback(continuation) + val nativeClient = ProtonDriveSdkNativeClient( + name = method(name), + response = { client, buffer -> + client.release() + clients -= client + responseCallback(buffer) + }, + logger = internalLogger, + ) + clients += nativeClient + nativeClient.handleRequest(request(block)) + } + + suspend fun executeEnumerate( + name: String, + callback: (CancellableContinuation) -> ResponseCallback, + yield: 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 -> + client.release() + clients -= client + responseCallback(buffer) + }, + yieldHandler = YieldHandler.create(yield, parser) , + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + clients += nativeClient + 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" } + permanentClients += nativeClient + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + + fun releaseAll() { + internalLogger(VERBOSE, "Releasing all for ${javaClass.simpleName}") + released = true + 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 new file mode 100644 index 00000000..792d34ab --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniBaseProtonSdk.kt @@ -0,0 +1,82 @@ +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 + +abstract class JniBaseProtonSdk : JniBase() { + + private var clients = emptyList() + private var permanentClients = emptyList() + + fun dispatch( + name: String, + block: RequestKt.Dsl.() -> Unit, + ) { + val nativeClient = ProtonSdkNativeClient( + name = method(name), + response = { client, _ -> + client.release() + }, + ) + nativeClient.handleRequest(request(block)) + } + + suspend fun executeOnce( + name: String, + 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 -> + responseCallback.invoke(buffer) + client.release() + clients -= client + }, + logger = internalLogger, + ) + clients += nativeClient + nativeClient.handleRequest(request(block)) + } + + suspend fun executeOnce( + clientBuilder: (CancellableContinuation, ResponseCallback.() -> ClientResponseCallback) -> ProtonSdkNativeClient, + requestBuilder: (ProtonSdkNativeClient) -> Request, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = clientBuilder(continuation) { + { client, buffer -> + this(buffer) + client.release() + clients -= client + } + } + clients += nativeClient + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + + suspend fun executePersistent( + clientBuilder: (CancellableContinuation) -> ProtonSdkNativeClient, + requestBuilder: (ProtonSdkNativeClient) -> Request, + ): T = suspendCancellableCoroutine { continuation -> + val nativeClient = clientBuilder(continuation) + clients += nativeClient + nativeClient.handleRequest(requestBuilder(nativeClient)) + } + + fun releaseAll() { + 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/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/JniCancellationTokenSource.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt new file mode 100644 index 00000000..d2d1926d --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniCancellationTokenSource.kt @@ -0,0 +1,31 @@ +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..8194b8bc --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniDownloadController.kt @@ -0,0 +1,55 @@ +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.downloadControllerIsPausedRequest +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 + } + } + + suspend fun isPaused(handle: Long) = executeOnce("isPaused", BooleanResponseCallback) { + downloadControllerIsPaused = downloadControllerIsPausedRequest { + downloadControllerHandle = handle + } + } + + suspend fun isDownloadCompleteWithVerificationIssue(handle: Long): Boolean = + executeOnce("isDownloadCompleteWithVerificationIssue", BooleanResponseCallback) { + downloadControllerIsDownloadCompleteWithVerificationIssue = + downloadControllerIsDownloadCompleteWithVerificationIssueRequest { + 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/JniFileDownloader.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt new file mode 100644 index 00000000..faf8c01f --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileDownloader.kt @@ -0,0 +1,68 @@ +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 +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 JniFileDownloader internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun getFileDownloader( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + revisionUid: RevisionUid, + ): Long = executeOnce("create", LongResponseCallback) { + driveClientGetFileDownloader = driveClientGetFileDownloaderRequest { + this.revisionUid = revisionUid.value + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + } + } + + suspend fun downloadToStream( + handle: Long, + cancellationTokenSourceHandle: Long, + onWrite: suspend (ByteBuffer) -> Unit, + onSeek: ((Long, Int) -> Long)? = null, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + name = method("downloadToStream"), + response = continuation.toLongResponse().asClientResponseCallback(), + write = onWrite, + seek = onSeek, + progress = onProgress, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { client -> + request { + downloadToStream = downloadToStreamRequest { + this.downloaderHandle = handle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + writeAction = ProtonDriveSdkNativeClient.getWritePointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + cancelAction = JniJob.getCancelPointer() + if (onSeek != null) { + seekAction = ProtonDriveSdkNativeClient.getSeekPointer() + } + } + } + } + ) + + fun free(handle: Long) { + dispatch("free") { + fileDownloaderFree = fileDownloaderFreeRequest { fileDownloaderHandle = handle } + } + releaseAll() + } +} 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 new file mode 100644 index 00000000..8e9c6fa4 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniFileUploader.kt @@ -0,0 +1,90 @@ +package me.proton.drive.sdk.internal + +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 JniFileUploader internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun getFileUploader( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + request: FileUploaderRequest, + ): Long = executeOnce("getFile", LongResponseCallback) { + driveClientGetFileUploader = + request.toProtobuf(clientHandle, cancellationTokenSourceHandle) + } + + suspend fun getFileRevisionUploader( + 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, + sha1Provider: (() -> ByteArray)?, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + name = method("uploadFromStream"), + response = continuation.toLongResponse().asClientResponseCallback(), + read = onRead, + progress = onProgress, + sha1Provider = sha1Provider ?: { error("sha1Provider not configured for uploadFromStream") }, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { nativeClient -> + request { + uploadFromStream = uploadFromStreamRequest { + this.uploaderHandle = uploaderHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + 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) { + 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/JniHttpStream.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt new file mode 100644 index 00000000..8e3708b5 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniHttpStream.kt @@ -0,0 +1,65 @@ +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.nio.ByteBuffer +import java.nio.channels.ReadableByteChannel + +class JniHttpStream internal constructor( +) : JniBaseProtonSdk() { + + private var client: ProtonDriveSdkNativeClient<*>? = null + + internal var onBodyRead: (suspend () -> Unit)? = null + + fun write( + coroutineScope: CoroutineScope, + channel: ReadableByteChannel, + ): Long { + return ProtonDriveSdkNativeClient( + name = method("write"), + readHttpBody = { buffer -> + channel.read(buffer).also { numberOfByteRead -> + if (numberOfByteRead == -1) { + channel.close() + onBodyRead?.invoke() + } + } + }, + coroutineScopeProvider = { coroutineScope }, + logger = internalLogger + ).also { + client = it + }.asWeakReference() + } + + suspend fun read( + handle: Long, + buffer: ByteBuffer, + ): 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?.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 new file mode 100644 index 00000000..28fbe664 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniJob.kt @@ -0,0 +1,7 @@ +package me.proton.drive.sdk.internal + +object JniJob { + + @JvmStatic + external fun getCancelPointer(): Long +} 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..97539d78 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniLoggerProvider.kt @@ -0,0 +1,60 @@ +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( + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), + callback = ::onLog, + ) + }, + requestBuilder = { client -> + request { + loggerProviderCreate = loggerProviderCreate { + logAction = ProtonSdkNativeClient.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..600883f3 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniNativeLibrary.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.internal + +object JniNativeLibrary { + + @JvmStatic + external fun overrideName( + libraryName: ByteArray, + overridingLibraryName: ByteArray, + ) +} 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..8e24ff74 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosDownloader.kt @@ -0,0 +1,69 @@ +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 +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 getPhotoDownloader( + clientHandle: Long, + cancellationTokenSourceHandle: Long, + photoUid: NodeUid, + ): Long = executeOnce("create", LongResponseCallback) { + drivePhotosClientGetPhotoDownloader = drivePhotosClientGetPhotoDownloaderRequest { + this.photoUid = photoUid.value + this.clientHandle = clientHandle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + } + } + + suspend fun downloadToStream( + handle: Long, + cancellationTokenSourceHandle: Long, + onWrite: suspend (ByteBuffer) -> Unit, + onSeek: ((Long, Int) -> Long)? = null, + onProgress: suspend (ProtonDriveSdk.ProgressUpdate) -> Unit, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + name = method("downloadToStream"), + response = continuation.toLongResponse().asClientResponseCallback(), + write = onWrite, + seek = onSeek, + progress = onProgress, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { client -> + request { + drivePhotosClientDownloadToStream = drivePhotosClientDownloadToStreamRequest { + this.downloaderHandle = handle + this.cancellationTokenSourceHandle = cancellationTokenSourceHandle + writeAction = ProtonDriveSdkNativeClient.getWritePointer() + progressAction = ProtonDriveSdkNativeClient.getProgressPointer() + cancelAction = JniJob.getCancelPointer() + if (onSeek != null) { + seekAction = ProtonDriveSdkNativeClient.getSeekPointer() + } + } + } + } + ) + + fun free(handle: Long) { + dispatch("free") { + drivePhotosClientDownloaderFree = drivePhotosClientDownloaderFreeRequest { + fileDownloaderHandle = handle + } + } + releaseAll() + } +} 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..405f12bd --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniPhotosUploader.kt @@ -0,0 +1,95 @@ +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.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 getPhotoUploader( + 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, + sha1Provider: (() -> ByteArray)?, + coroutineScopeProvider: CoroutineScopeProvider, + ): Long = executePersistent( + clientBuilder = { continuation -> + ProtonDriveSdkNativeClient( + name = method("uploadFromStream"), + response = continuation.toLongResponse().asClientResponseCallback(), + read = onRead, + progress = onProgress, + sha1Provider = sha1Provider ?: { error("sha1Provider not configured for uploadFromStream") }, + logger = internalLogger, + coroutineScopeProvider = coroutineScopeProvider, + ) + }, + requestBuilder = { nativeClient -> + request { + drivePhotosClientUploadFromStream = drivePhotosClientUploadFromStreamRequest { + this.uploaderHandle = uploaderHandle + 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) { + 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() + } +} 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 new file mode 100644 index 00000000..8bf2dade --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonDriveClient.kt @@ -0,0 +1,192 @@ +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.NodeConverter +import me.proton.drive.sdk.converter.NodeResultListResponseConverter +import me.proton.drive.sdk.entity.ClientCreateRequest +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 +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 +import proton.sdk.telemetry + + +class JniProtonDriveClient internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun createFromSession(sessionHandle: Long) = + executeOnce("createFromSession", LongResponseCallback) { + driveClientCreateFromSession = driveClientCreateFromSessionRequest { + 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( + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), + httpClientRequest = onHttpClientRequest, + accountRequest = onAccountRequest, + logger = internalLogger, + recordMetric = onRecordMetric, + featureEnabled = onFeatureEnabled, + coroutineScopeProvider = { coroutineScope }, + ) + }, requestBuilder = { client -> + request { + driveClientCreate = driveClientCreateRequest { + baseUrl = request.baseUrl + httpClient = httpClient { + requestFunction = ProtonDriveSdkNativeClient.getHttpClientRequestPointer() + responseContentReadAction = httpResponseReadPointer + cancellationAction = JniJob.getCancelPointer() + } + accountRequestAction = ProtonDriveSdkNativeClient.getAccountRequestPointer() + request.entityCachePath?.let { entityCachePath = it } + request.secretCachePath?.let { secretCachePath = it } + 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 } + } + } + } + }) + + suspend fun getAvailableName( + request: ProtonDriveSdk.DriveClientGetAvailableNameRequest, + ): String = executeOnce("getAvailableName", StringResponseCallback) { + driveClientGetAvailableName = request + } + + suspend fun rename( + request: ProtonDriveSdk.DriveClientRenameRequest, + ): Unit = executeOnce("rename", UnitResponseCallback) { + driveClientRename = request + } + + suspend fun enumerateThumbnails( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DriveClientEnumerateThumbnailsRequest, + yield: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateThumbnails", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.FileThumbnail::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + driveClientEnumerateThumbnails = request + } + + suspend fun createFolder( + request: ProtonDriveSdk.DriveClientCreateFolderRequest, + ): ProtonDriveSdk.FolderNode = executeOnce("createFolder", NodeConverter().asCallback) { + driveClientCreateFolder = request + }.toFolder() + + suspend fun getMyFilesFolder( + request: ProtonDriveSdk.DriveClientGetMyFilesFolderRequest, + ): ProtonDriveSdk.FolderNode = executeOnce("getMyFilesFolder", NodeConverter().asCallback) { + driveClientGetMyFilesFolder = request + }.toFolder() + + suspend fun getNode( + request: ProtonDriveSdk.DriveClientGetNodeRequest, + ): ProtonDriveSdk.Node? = + executeOnce("getNode", NodeConverter().asNullableCallback) { + driveClientGetNode = request + } + + suspend fun enumerateFolderChildren( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DriveClientEnumerateFolderChildrenRequest, + yield: suspend (ProtonDriveSdk.Node) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateFolderChildren", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.Node::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + driveClientEnumerateFolderChildren = request + } + + suspend fun trashNodes( + request: ProtonDriveSdk.DriveClientTrashNodesRequest, + ): 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( + coroutineScope: ProducerScope, + request: ProtonDriveSdk.DriveClientEnumerateTrashRequest, + yield: suspend (ProtonDriveSdk.Node) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateTrash", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.Node::parseFrom, + coroutineScopeProvider = { coroutineScope } + ) { + driveClientEnumerateTrash = request + } + + suspend fun emptyTrash( + request: ProtonDriveSdk.DriveClientEmptyTrashRequest, + ): Unit = executeOnce("emptyTrash", UnitResponseCallback) { + driveClientEmptyTrash = 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/JniProtonPhotosClient.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt new file mode 100644 index 00000000..22654660 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniProtonPhotosClient.kt @@ -0,0 +1,165 @@ +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.NodeConverter +import me.proton.drive.sdk.converter.NodeResultListResponseConverter +import me.proton.drive.sdk.entity.ClientCreateRequest +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 +import me.proton.drive.sdk.extension.asNullableCallback +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 createFromSession(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( + name = method("create"), + response = continuation.toLongResponse().asClientResponseCallback(), + 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() + request.entityCachePath?.let { entityCachePath = it } + request.secretCachePath?.let { secretCachePath = it } + 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 } + } + } + } + }) + + suspend fun enumerateThumbnails( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DrivePhotosClientEnumerateThumbnailsRequest, + yield: suspend (ProtonDriveSdk.FileThumbnail) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateThumbnails", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.FileThumbnail::parseFrom, + coroutineScopeProvider = { coroutineScope }, + ) { + drivePhotosClientEnumerateThumbnails = request + } + + suspend fun enumerateTimeline( + coroutineScope: CoroutineScope, + request: ProtonDriveSdk.DrivePhotosClientEnumerateTimelineRequest, + 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, + ): ProtonDriveSdk.Node? = + executeOnce("getNode", NodeConverter().asNullableCallback) { + 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, + yield: suspend (ProtonDriveSdk.Node) -> Unit, + ): Unit = executeEnumerate( + name = "enumerateTrash", + callback = UnitResponseCallback, + yield = yield, + parser = ProtonDriveSdk.Node::parseFrom, + coroutineScopeProvider = { coroutineScope } + ) { + drivePhotosClientEnumerateTrash = request + } + + suspend fun emptyTrash( + request: ProtonDriveSdk.DrivePhotosClientEmptyTrashRequest, + ) = executeOnce("emptyTrash", UnitResponseCallback) { + drivePhotosClientEmptyTrash = request + } + + fun free(handle: Long) { + dispatch("free") { + drivePhotosClientFree = drivePhotosClientFreeRequest { + clientHandle = handle + } + } + releaseAll() + } +} 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..e1ca47ea --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniSession.kt @@ -0,0 +1,46 @@ +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..08abf1f9 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/JniUploadController.kt @@ -0,0 +1,57 @@ +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.asCallback +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 + +class JniUploadController internal constructor() : JniBaseProtonDriveSdk() { + + suspend fun awaitCompletion(handle: Long): UploadResult = + executeOnce("awaitCompletion", UploadResultConverter().asCallback) { + 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 + } + } + + suspend fun isPaused(handle: Long) = executeOnce("isPaused", BooleanResponseCallback) { + uploadControllerIsPaused = uploadControllerIsPausedRequest { + uploadControllerHandle = handle + } + } + + suspend fun dispose(handle: Long) = executeOnce("dispose", UnitResponseCallback) { + uploadControllerDispose = uploadControllerDisposeRequest { + 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/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/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/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/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 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..97cd4769 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonDriveSdkNativeClient.kt @@ -0,0 +1,435 @@ +package me.proton.drive.sdk.internal + +import com.google.protobuf.Any +import com.google.protobuf.Int32Value +import com.google.protobuf.Int64Value +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +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 +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 +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, + val response: ClientResponseCallback> = { _, _ -> error("response not configured for $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, + 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 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 }, +) { + 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() { + if (released.compareAndSet(false, true)) { + JniWeakReference.delete(clientWeakRef) + byteArrayPointers.releaseAll() + synchronized(weakReferenceLock) { + inactiveJobWeakReferences.forEach { ref -> + JniWeakReference.delete(ref) + } + inactiveJobWeakReferences.clear() + } + } else { + logger(VERBOSE, "Native client for $name already release") + } + } + + fun handleRequest( + request: ProtonDriveSdk.Request, + ) { + logger(VERBOSE, "handle request ${request.payloadCase.name} for $name") + handleRequest(clientWeakRef, request.toByteArray()) + } + + fun handleResponse( + sdkHandle: Long, + response: Response, + ) { + if (response.hasValue()) { + logger(VERBOSE, "handle response value: ${response.value.typeUrl} for $name") + } else { + if (response.resultCase == Response.ResultCase.ERROR) { + 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()) + } + + fun getByteArrayPointer(data: ByteArray): Long = byteArrayPointers.allocate(data) + + fun asWeakReference(): Long = clientWeakRef + + @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 onYield(data: ByteBuffer) = onCallback( + callback = "yield", + data = data, + parser = yieldHandler.parser, + block = yieldHandler.callback, + ) + + @Suppress("unused") // Called by JNI + 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): 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") } + }?.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 {} + }?.trackWeakReference() ?: 0 + + @Suppress("unused") // Called by JNI + 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") } + } + } + + @Suppress("unused") // Called by JNI + fun onSendHttpRequest( + data: ByteBuffer, + sdkHandle: Long, + ): Long = onRequest( + operation = "http-request", + data = data, + sdkHandle = sdkHandle, + parser = ProtonSdk.HttpRequest::parseFrom, + ) { httpRequest -> + logger( + VERBOSE, + "send http request for ${httpRequest.method} ${httpRequest.url} of size: ${data.capacity()}" + ) + val httpResponse = httpClientRequest(httpRequest) + logger( + VERBOSE, + "receive http response ${httpResponse.statusCode} for ${httpRequest.method} ${httpRequest.url}" + ) + response { value = httpResponse.asAny("proton.sdk.HttpResponse") } + }?.trackWeakReference() ?: 0 + + @Suppress("unused") // Called by JNI + fun onHttpResponseRead(buffer: ByteBuffer, sdkHandle: Long) { + 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") + response { value = Int32Value.of(bytesRead).asAny("google.protobuf.Int32Value") } + } + } + + @Suppress("unused") // Called by JNI + fun onAccountRequest( + data: ByteBuffer, + sdkHandle: Long, + ) { + onRequest( + operation = "request", + data = data, + sdkHandle = sdkHandle, + parser = ProtonDriveSdk.AccountRequest::parseFrom, + ) { accountRequest -> + logger(VERBOSE, "request for ${accountRequest.payloadCase.name} of size: ${data.capacity()}") + val response = accountRequest(accountRequest) + response { value = response } + } + } + + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onRecordMetric(data: ByteBuffer) = onCallback( + callback = "recordMetric", + data = data, + parser = ProtonSdk.MetricEvent::parseFrom, + block = recordMetric, + ) + + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onFeatureEnabled(data: ByteBuffer): Long = onFunction( + operation = "featureEnabled", + data = data, + parser = { buffer -> buffer.decodeToString() }, + ) { name -> + runCatching { + if (featureEnabled(name)) 1L else 0L + }.getOrElse { error -> + logger(WARN, "Cannot get feature flag $name") + logger(WARN, error.stackTraceToString()) + 0L + } + } + + @Suppress("TooGenericExceptionCaught", "unused") // Called by JNI + fun onSha1(output: ByteBuffer): Unit = onFunction(operation = "sha1Provider") { + runCatching { + val sha1 = sha1Provider() + if (output.capacity() < sha1.size) { + logger(WARN, "SHA1 output buffer too small: ${output.capacity()} < ${sha1.size}") + return@onFunction + } + output.put(sha1) + Unit + }.onFailure { error -> + logger(WARN, "Cannot get expected SHA1") + logger(WARN, error.stackTraceToString()) + } + } + + private fun onFunction( + operation: String, + block: suspend () -> R + ): R = runBlocking(Dispatchers.Unconfined) { + coroutineScope(operation).async { block() }.await() + } + + private fun onFunction( + operation: String, + data: ByteBuffer, + parser: (ByteBuffer) -> T, + block: suspend (T) -> R + ): 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 { + val response = block() + withContext(Dispatchers.Unconfined) { + responseOnce(sdkHandle, response) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + 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") + responseOnce(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") + private fun onRequest( + operation: String, + 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, responseOnce) { block(request) } + } catch (error: Throwable) { + responseOnce(sdkHandle, response { + this@response.error = error.toProtonSdkError( + "Error while parsing request for $operation" + ) + }) + null + } + + @Suppress("TooGenericExceptionCaught") + private fun onCallback( + callback: String, + data: ByteBuffer, + parser: (ByteBuffer) -> T, + block: suspend (T) -> Unit + ) { + try { + 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) { + throw error + } catch (error: Throwable) { + logger(WARN, "Error while $callback") + logger(WARN, error.stackTraceToString()) + } + }.invokeOnCompletion { error -> + if (error is CancellationException) { + 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()) + } + + } + + private fun coroutineScope(operation: String): CoroutineScope { + val scope = coroutineScopeProvider() + 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") + } + return scope + } + + 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 -> + JniWeakReference.delete(oldestRef) + } + } + } + } + } + + @Suppress("TooManyFunctions") + 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) + + @JvmStatic + external fun getReadPointer(): Long + + @JvmStatic + external fun getWritePointer(): Long + + @JvmStatic + external fun getSeekPointer(): Long + + @JvmStatic + external fun getYieldPointer(): Long + + @JvmStatic + external fun getProgressPointer(): Long + + @JvmStatic + external fun getHttpClientRequestPointer(): Long + + @JvmStatic + external fun getHttpResponseReadPointer(): Long + + @JvmStatic + external fun getAccountRequestPointer(): Long + + @JvmStatic + external fun getRecordMetricPointer(): Long + + @JvmStatic + external fun getFeatureEnabledPointer(): Long + + @JvmStatic + external fun getSha1Pointer(): 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 new file mode 100644 index 00000000..49011c7c --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/ProtonSdkNativeClient.kt @@ -0,0 +1,52 @@ +package me.proton.drive.sdk.internal + +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, + val response: ClientResponseCallback = { _, _ -> error("response not configured for $name") }, + 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() { + 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(clientWeakRef, request.toByteArray()) + } + + @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 + } +} 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..ba9762bf --- /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 (Int) -> ApiResult>, + ): ApiResult> { + var attempt = 0 + var remaining = MAX_DELAY_DURATION + var result: ApiResult> + do { + result = block(attempt) + if (!isEnabled) break + attempt++ + 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(attempt, 2.0) + .coerceAtMost(remaining) + else -> break + } + } + else -> break + } + remaining -= duration + delay(duration) + } while (remaining.isPositive() && attempt < 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)) +} 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) diff --git a/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt new file mode 100644 index 00000000..b0b10dbf --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/internal/YieldHandler.kt @@ -0,0 +1,24 @@ +package me.proton.drive.sdk.internal + +import java.nio.ByteBuffer + +interface YieldHandler { + val callback: suspend (T) -> Unit + val parser: (ByteBuffer) -> T + + companion object { + fun notConfigured(name: String) = object: YieldHandler { + override val callback: suspend (T) -> Unit + get() = error("YieldHandler not configured for $name") + override val parser: (ByteBuffer) -> T + get() = error("YieldHandler not configured for $name") + } + fun create( + callback: suspend (T) -> Unit, + parser: (ByteBuffer) -> T + ) = object : YieldHandler { + override val callback = callback + override val parser = parser + } + } +} 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..c8a65ea6 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/ApiRetrySucceededEvent.kt @@ -0,0 +1,6 @@ +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..d3590085 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/BlockVerificationErrorEvent.kt @@ -0,0 +1,6 @@ +package me.proton.drive.sdk.telemetry + +data class BlockVerificationErrorEvent( + val volumeType: VolumeType, + 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..0aceaeb6 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DecryptionErrorEvent.kt @@ -0,0 +1,9 @@ +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..d5d3e755 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadError.kt @@ -0,0 +1,13 @@ +package me.proton.drive.sdk.telemetry + +enum class DownloadError { + UNRECOGNIZED, + SERVER_ERROR, + NETWORK_ERROR, + 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/DownloadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt new file mode 100644 index 00000000..dc181c11 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/DownloadEvent.kt @@ -0,0 +1,11 @@ +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/EncryptedField.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt new file mode 100644 index 00000000..da161b31 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/EncryptedField.kt @@ -0,0 +1,12 @@ +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..cbe9614e --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadError.kt @@ -0,0 +1,12 @@ +package me.proton.drive.sdk.telemetry + +enum class UploadError { + UNRECOGNIZED, + SERVER_ERROR, + NETWORK_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/UploadEvent.kt b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt new file mode 100644 index 00000000..8ff7c086 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/UploadEvent.kt @@ -0,0 +1,11 @@ +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, + val originalError: String? = null, +) 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..30768c24 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VerificationErrorEvent.kt @@ -0,0 +1,10 @@ +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..26d1c621 --- /dev/null +++ b/kt/sdk/src/main/kotlin/me/proton/drive/sdk/telemetry/VolumeType.kt @@ -0,0 +1,10 @@ +package me.proton.drive.sdk.telemetry + +enum class VolumeType { + UNRECOGNIZED, + UNKNOWN, + OWN_VOLUME, + OWN_PHOTO_VOLUME, + SHARED, + SHARED_PUBLIC, +} diff --git a/kt/settings.gradle.kts b/kt/settings.gradle.kts new file mode 100644 index 00000000..477b39d0 --- /dev/null +++ b/kt/settings.gradle.kts @@ -0,0 +1,49 @@ +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") + 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/Package.swift b/swift/ProtonDriveSDK/Package.swift new file mode 100644 index 00000000..af8cf1da --- /dev/null +++ b/swift/ProtonDriveSDK/Package.swift @@ -0,0 +1,84 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ProtonDriveSDK", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v15), + .watchOS(.v8) + ], + products: [ + .library( + name: "ProtonDriveSDK", + targets: ["ProtonDriveSDK"] + ), + .library( + name: "ProtonDriveSDKTestingToolkit", + targets: ["ProtonDriveSDKTestingToolkit"] + ), + ], + 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: "37.0.1"), + ], + targets: [ + .binaryTarget( + name: "CProtonDriveSDK", + url: "https://github.com/ProtonDriveApps/sdk-swift/releases/download/{VERSION}/CProtonDriveSDK.xcframework.zip", + checksum: "{XCFRAMEWORK_CHECKSUM}" + ), + .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([ + // 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.osx-arm64.o", + "-llibbootstrapperdll.osx-x64.o", + ], .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])), + ] + ), + ] +) diff --git a/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift new file mode 100644 index 00000000..d3ad777c --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/CancellationTokenSource.swift @@ -0,0 +1,46 @@ +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) + } + 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 // fixes "variable 'strongSelf' was written to, but never read" warning + strongSelf = nil + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift new file mode 100644 index 00000000..9ea3be76 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/AccountClient.swift @@ -0,0 +1,121 @@ +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: 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) + 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 } + + Task { [driveClient] in + let accountClient = driveClient.accountClient + + let request = Proton_Drive_Sdk_AccountRequest(byteArray: byteArray) + + switch request.payload { + case .getAddress(let request): + 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): + 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): + 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): + 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 + } + 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: + let message = "cCompatibleAccountClientRequest.Proton_Drive_Sdk_AccountRequest.payload is null" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) + } + } +} + +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/Client/ProtonDriveClient/HttpClientProtocol.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/HttpClientProtocol.swift new file mode 100644 index 00000000..cae0be19 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/HttpClientProtocol.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftProtobuf + +/// Protocol to be implemented by object making http requests. +public protocol HttpClientProtocol: AnyObject, Sendable { + /// Drive api calls (takes `/drive/...` path) + func requestDriveApi( + method: String, + relativePath: String, + content: Data, + headers: [(String, [String])] + ) async -> Result + + /// 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])], + downloadStreamCreator: @Sendable @escaping (URLSession.AsyncBytes) -> AnyAsyncSequence + ) async -> Result +} + +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 { + fatalError("This method is not available on older OS versions.") + } + } + } + + 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/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift new file mode 100644 index 00000000..85a37098 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientRequestProcessor.swift @@ -0,0 +1,261 @@ +import Foundation + +enum HttpClientRequestProcessor { + 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 -1 + } + 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 httpRequestData = Proton_Sdk_HttpRequest(byteArray: byteArray) + + return BoxedCancellableTask.registered { + do { + try await HttpClientRequestProcessor.perform( + client: driveClient, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer, + provider: provider + ) + } catch { + SDKResponseHandler.sendErrorToSDK(error, callbackPointer: callbackPointer) + } + } + } + + static let cCompatibleHttpCancellationAction: CCallbackWithoutByteArray = { callbackHandle in + CallbackHandleRegistry.shared.cancel(callbackHandle) + } + + fileprivate static func perform( + client: ProtonSDKClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int, + provider: SDKClientProvider + ) async throws { + + 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: "/drive/" + relativeApiPath, + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer, + provider: provider + ) + case .storageUpload: + try await uploadToStorage( + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer, + provider: provider + ) + case .storageDownload: + try await downloadFromStorage( + client: client, + httpRequestData: httpRequestData, + callbackPointer: callbackPointer, + provider: provider + ) + case .UNRECOGNIZED(let int): + 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, + client: ProtonSDKClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int, + provider: SDKClientProvider + ) 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 + let 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 response = try await client.httpClient.requestDriveApi( + method: httpRequestData.method, + relativePath: driveRelativePath, + content: contentData, + headers: headers + ).get() + + // 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) + uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + bindingsHandle = CallbackHandleRegistry.shared.register(bytesOrStream, scope: .ownerManaged, owner: provider) + } 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) + } + + /// the storage upload calls are using stream to upload request body, but cache the whole response in memory + fileprivate static func uploadToStorage( + client: ProtonSDKClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int, + provider: SDKClientProvider + ) async throws { + let headers: [(String, [String])] = httpRequestData.headers.map { header in + (header.name, header.values) + } + + guard httpRequestData.hasSdkContentHandle else { + SDKResponseHandler.sendInteropErrorToSDK( + message: "Proton_Sdk_HttpRequest.sdk_content_handle is missing", + callbackPointer: callbackPointer + ) + return + } + + let (inputStream, outputStream, bufferLength) = try client.configuration.boundStreamsCreator() + let stream = try StreamForUpload( + inputStream: inputStream, + outputStream: outputStream, + bufferLength: bufferLength, + sdkContentHandle: httpRequestData.sdkContentHandle, + logger: client.logger + ) + + let response = try await client.httpClient.requestUploadToStorage( + method: httpRequestData.method, + url: httpRequestData.url, + content: stream, + headers: headers + ).get() + + let bindingsHandle: Int? + if let data = response.data, !data.isEmpty { + let uploadBuffer = BoxedRawBuffer(bufferSize: data.count, logger: client.logger) + uploadBuffer.copyBytes(from: data) + let bytesOrStream = BoxedStreamingData(uploadBuffer: uploadBuffer, logger: client.logger) + bindingsHandle = CallbackHandleRegistry.shared.register(bytesOrStream, scope: .ownerManaged, owner: provider) + } 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) + } + + /// the download upload calls are caching the whole request body in-memory, but stream the response data + fileprivate static func downloadFromStorage( + client: ProtonSDKClient, + httpRequestData: Proton_Sdk_HttpRequest, + callbackPointer: Int, + provider: SDKClientProvider + ) 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 + let 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 response = try await client.httpClient.requestDownloadFromStorage( + method: httpRequestData.method, + url: httpRequestData.url, + content: contentData, + headers: headers, + 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 { + $0.name = header.0 + $0.values = header.1 + } + } + $0.bindingsContentHandle = Int64(bindingsHandle) + $0.statusCode = Int32(response.statusCode) + } + SDKResponseHandler.send(callbackPointer: callbackPointer, message: httpResponse) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift new file mode 100644 index 00000000..9e07779a --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/HttpClientResponseProcessor.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftProtobuf + +enum HttpClientResponseProcessor { + + // 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 statePointer != 0 else { + let message = "cCompatibleHttpResponseRead.statePointer is null" + SDKResponseHandler.sendInteropErrorToSDK(message: message, callbackPointer: callbackPointer) + return + } + + Task { + 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 + + 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( + boxedRawBuffer: boxedRawBuffer, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + CallbackHandleRegistry.shared.remove(statePointer) + } + ) + } else if let boxedDownloadStream = boxedStreamingData.downloadStream { + await HttpClientResponseProcessor.passStream( + boxedDownloadStream: boxedDownloadStream, + buffer: buffer, + bufferSize: bufferSize, + callbackPointer: callbackPointer, + releaseBox: { + CallbackHandleRegistry.shared.remove(statePointer) + } + ) + } else { + CallbackHandleRegistry.shared.remove(statePointer) + SDKResponseHandler.sendInteropErrorToSDK(message: "Failed to pass valid BytesOrStream", + callbackPointer: callbackPointer) + } + } + } + + + fileprivate static func passStream( + boxedDownloadStream: BoxedDownloadStream, + buffer: sending UnsafeMutablePointer, + bufferSize: Int, + callbackPointer: Int, + releaseBox: () -> Void + ) 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) + } + } + + fileprivate static func passResponseBytes( + boxedRawBuffer: BoxedRawBuffer, + buffer: sending UnsafeMutablePointer, + bufferSize: Int, + callbackPointer: Int, + releaseBox: () -> Void + ) async { + let copiedBytesCount = 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/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift new file mode 100644 index 00000000..36f44134 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedCancellableTask.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Boxed task that can be cancelled via its memory address. +/// Retained via Unmanaged until completion or cancellation. +final class BoxedCancellableTask: RegistryCancellable, @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var onComplete: (() -> Void)? + + init(work: @escaping @Sendable () async -> Void) { + self.task = Task { [weak self] in + defer { + self?.complete() + } + 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?() + } + + fileprivate func setCompletionHandler(_ handler: @escaping () -> Void) { + lock.lock() + if task == nil { + // Task already completed/cancelled before the handler was set. + lock.unlock() + handler() + return + } + onComplete = handler + lock.unlock() + } + + 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?() + } + + /// 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/Networking/Model/BoxedDownloadStream.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift new file mode 100644 index 00000000..862651a9 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedDownloadStream.swift @@ -0,0 +1,34 @@ +import Foundation + +final class BoxedDownloadStream { + private let stream: AnyAsyncSequence + private var iterator: AnyAsyncIterator + + private let logger: Logger + + init(stream: AnyAsyncSequence, logger: Logger) { + self.stream = stream + self.iterator = stream.makeAsyncIterator() + self.logger = logger + } + + 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 { + logger.trace("BoxedDownloadStream.deinit", category: "memory management") + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift new file mode 100644 index 00000000..dbbb1007 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BoxedRawBuffer.swift @@ -0,0 +1,66 @@ +import Foundation + +final class BoxedRawBuffer { + 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) { + 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 { + 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/Client/ProtonDriveClient/Networking/Model/BytesOrStream.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BytesOrStream.swift new file mode 100644 index 00000000..382b7381 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/BytesOrStream.swift @@ -0,0 +1,24 @@ +import Foundation + +final class BoxedStreamingData { + 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: AnyAsyncSequence, 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/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift new file mode 100644 index 00000000..b56b82c1 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/Networking/Model/StreamForUpload.swift @@ -0,0 +1,190 @@ +import Foundation + +public final class StreamForUpload: NSObject, StreamDelegate, @unchecked Sendable { + + public let input: InputStream + let output: OutputStream + + public var onStreamError: (Error) -> Void = { _ in } + + let sdkContentHandle: Int64 + let logger: Logger + 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) + + 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 + self.logger = logger + self.input = inputStream + self.output = outputStream + 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() + } + + public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + guard aStream == output.outputStream else { return } + + if eventCode.contains(.hasSpaceAvailable) { + receivedHasSpaceAvailableEvent() + } + + if eventCode.contains(.errorOccurred) { + 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 { + 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 } + 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 = [] + } + } + 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...]) + } + } + case .failure(let error): + throw error + } + hasFinishedWriting() + } catch { + invokeStreamError(error) + } + } + + private func closeAndCleanUp() { + let shouldClose = stateQueue.sync { + let isAlreadyClosed = self.state == .isClosed + self.state = .isClosed + return !isAlreadyClosed + } + guard shouldClose else { return } + output.close() + // input is opened by URLSession (Apple Forum 76675); not the producer's to close. + } + + deinit { + closeAndCleanUp() + buffer.deallocate() + } +} + +extension OutputStream { + @objc open var outputStream: OutputStream { self } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift new file mode 100644 index 00000000..0bda1a15 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClient.swift @@ -0,0 +1,494 @@ +import Foundation +import SwiftProtobuf + +/// 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, ProtonSDKClient { + + private var clientHandle: ObjectHandle = 0 + nonisolated(unsafe) var sdkClientProvider: SDKClientProvider! + + private var uploadsManager: UploadsManager! + private var downloadsManager: DownloadsManager! + private var thumbnailsManager: DownloadThumbnailsManager! + + let logger: ProtonDriveSDK.Logger + let recordMetricEventCallback: RecordMetricEventCallback + let featureFlagProviderCallback: FeatureFlagProviderCallback + + let httpClient: HttpClientProtocol + let accountClient: AccountClientProtocol + let configuration: ProtonDriveClientConfiguration + + private enum OperationIdentifier: Hashable { + 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" + } + } + } + + private var activeOperations: [OperationIdentifier: CancellationTokenSource] = [:] + + public init( + configuration: ProtonDriveClientConfiguration, + httpClient: HttpClientProtocol, + accountClient: AccountClientProtocol, + logCallback: @escaping LogCallback, + 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 + self.configuration = configuration + + let clientCreateRequest = Proton_Drive_Sdk_DriveClientCreateRequest.with { + $0.baseURL = configuration.baseURL + + $0.accountRequestAction = Int64(ObjectHandle(callback: cCompatibleAccountClientRequest)) + + $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)) + } + + $0.featureEnabledFunction = Int64(ObjectHandle(callback: cCompatibleFeatureFlagProviderCallback)) + + if let entityCachePath = configuration.entityCachePath { + $0.entityCachePath = entityCachePath + } + 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 + 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 + // to prolong the client object existence + // 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: sdkClientProvider, scope: .indefinite, owner: nil, logger: logger + ) + assert(handle != 0) + self.clientHandle = ObjectHandle(handle) + logger.trace("client handle: \(clientHandle)", category: "ProtonDriveClient") + + self.uploadsManager = UploadsManager(clientHandle: clientHandle, logger: logger) + self.downloadsManager = DownloadsManager(clientHandle: clientHandle, logger: logger) + self.thumbnailsManager = DownloadThumbnailsManager(clientHandle: clientHandle, logger: logger) + } + + /// 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 -> VerificationIssue? { + let operation = try await downloadFileOperation( + revisionUid: revisionUid, + destinationUrl: destinationUrl, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + return try await operation.awaitDownloadWithResilience( + operationalResilience: configuration.downloadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + 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, + progressCallback: progressCallback + ) + } + + public func cancelDownload(cancellationToken: UUID) async throws { + 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, + name: String, + url: URL, + fileSize: Int64, + modificationDate: Date?, + mediaType: String, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, + expectedSHA1: Data? = nil, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + let operation = try await uploadFileOperation( + parentFolderUid: parentFolderUid, + name: name, + url: url, + fileSize: fileSize, + modificationDate: modificationDate, + mediaType: mediaType, + thumbnails: thumbnails, + overrideExistingDraft: overrideExistingDraft, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await startUpload( + operation: operation, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func uploadFileOperation( + parentFolderUid: SDKNodeUid, + name: String, + url: URL, + fileSize: Int64, + modificationDate: Date?, + mediaType: String, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, + expectedSHA1: Data? = nil, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + try await uploadsManager.uploadFileOperation( + parentFolderUid: parentFolderUid, + name: name, + fileURL: url, + fileSize: fileSize, + modificationDate: modificationDate, + mediaType: mediaType, + thumbnails: thumbnails, + overrideExistingDraft: overrideExistingDraft, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } + + public func startUpload( + 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 + ) + } + + /// 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, + fileSize: Int64, + modificationDate: Date?, + thumbnails: [ThumbnailData], + expectedSHA1: Data? = nil, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback, + onRetriableErrorReceived: @Sendable @escaping (Error) -> Void + ) async throws -> UploadedFileIdentifiers { + let operation = try await uploadNewRevisionOperation( + currentActiveRevisionUid: currentActiveRevisionUid, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await operation.awaitUploadWithResilience( + operationalResilience: configuration.uploadOperationalResilience, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func uploadNewRevisionOperation( + currentActiveRevisionUid: SDKRevisionUid, + fileURL: URL, + fileSize: Int64, + modificationDate: Date?, + thumbnails: [ThumbnailData], + expectedSHA1: Data? = nil, + cancellationToken: UUID, + progressCallback: @escaping ProgressCallback + ) async throws -> UploadOperation { + return try await uploadsManager.uploadNewRevisionOperation( + currentActiveRevisionUid: currentActiveRevisionUid, + fileURL: fileURL, + fileSize: fileSize, + modificationDate: modificationDate, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } + + public func cancelUpload(cancellationToken: UUID) async throws { + try await uploadsManager.cancelUpload(with: cancellationToken) + } + + 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) + return nil + } + return driveClient + } + + public func downloadThumbnails( + fileUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { + try await thumbnailsManager.downloadThumbnails( + fileUids: fileUids, + type: type, + cancellationToken: cancellationToken, + onThumbnailDownloaded: onThumbnailDownloaded + ) + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: sdkClientProvider) + 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 { + 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") + } + } + } +} + +// MARK: - Node action +extension ProtonDriveClient { + + public func createFolder( + parentFolderUid: SDKNodeUid, + folderName: String, + lastModificationTime: Date, + cancellationToken: UUID + ) async throws -> FolderNode { + let cancellationTokenSource = try await createCancellationTokenSource(.createFolder(cancellationToken), logger) + defer { + 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 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) + } + + 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 + 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 _: Void = try await SDKRequestHandler.send(renameRequest, logger: logger) + } + + 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_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: ProtonDriveSDKError? = result.hasError ? ProtonDriveSDKError(protoError: 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/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift new file mode 100644 index 00000000..3ac9a5dd --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonDriveClient/ProtonDriveClientConfiguration.swift @@ -0,0 +1,69 @@ +import Foundation + +public struct ProtonDriveClientConfiguration: Sendable { + #if os(iOS) + @usableFromInline static let defaultHttpTransportBufferSize = 64 * 1024 + #else + @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 httpApiCallsTimeout: Int32? + let httpStorageCallsTimeout: Int32? + + let downloadOperationalResilience: OperationalResilience + let uploadOperationalResilience: OperationalResilience + + let entityCachePath: String? + let secretCachePath: String? + let secretCacheEncryptionKey: Data? + + let boundStreamsCreator: @Sendable () throws -> (InputStream, OutputStream, Int) + let downloadStreamCreator: @Sendable (URLSession.AsyncBytes) -> AnyAsyncSequence + + public init( + 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, // 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 + self.httpTransferBufferSize = httpTransferBufferSize + self.httpApiCallsTimeout = httpApiCallsTimeout + self.httpStorageCallsTimeout = httpStorageCallsTimeout + self.downloadOperationalResilience = downloadOperationalResilience + self.uploadOperationalResilience = uploadOperationalResilience + self.boundStreamsCreator = boundStreamsCreator + self.downloadStreamCreator = downloadStreamCreator + self.entityCachePath = entityCachePath + self.secretCachePath = secretCachePath + self.secretCacheEncryptionKey = secretCacheEncryptionKey + } +} diff --git a/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift new file mode 100644 index 00000000..8eccf1b6 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/ProtonPhotosClient/ProtonPhotosClient.swift @@ -0,0 +1,297 @@ +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! + + let accountClient: AccountClientProtocol + let configuration: ProtonDriveClientConfiguration + let httpClient: HttpClientProtocol + let logger: ProtonDriveSDK.Logger + let recordMetricEventCallback: RecordMetricEventCallback + let featureFlagProviderCallback: FeatureFlagProviderCallback + + public init( + configuration: ProtonDriveClientConfiguration, + httpClient: HttpClientProtocol, + accountClient: AccountClientProtocol, + 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.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 + // to prolong the client object existence + // 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: sdkClientProvider, + scope: .indefinite, + owner: nil, + logger: logger + ) + + assert(handle != 0) + self.clientHandle = ObjectHandle(handle) + 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) + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: sdkClientProvider) + 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 enumerateTimeline(in folderUid: SDKNodeUid) async throws -> [PhotoTimelineItem] { + let cancellationTokenSource = try await CancellationTokenSource(logger: logger) + defer { + cancellationTokenSource.free() + } + + 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 _: Void = try await SDKRequestHandler.send( + request, + state: WeakReference(value: accumulator), + scope: .ownerManaged, + owner: accumulator, + logger: logger + ) + + return accumulator.items + } +} + +// MARK: - Download +extension ProtonPhotosClient { + public func downloadThumbnails( + photoUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { + try await thumbnailsManager.downloadPhotoThumbnails( + photoUids: photoUids, + type: type, + cancellationToken: cancellationToken, + onThumbnailDownloaded: onThumbnailDownloaded + ) + } + + /// 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 + ) + } +} + +// MARK: - Upload +extension ProtonPhotosClient { + public func uploadPhoto( + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + captureTime: Date, + mainPhotoUid: SDKNodeUid?, + mediaType: String, + thumbnails: [ThumbnailData], + tags: [Int], + additionalMetadata: [AdditionalMetadata], + expectedSHA1: Data? = nil, + 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, + mainPhotoUid: mainPhotoUid, + mediaType: mediaType, + thumbnails: thumbnails, + tags: tags, + additionalMetadata: additionalMetadata, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + + return try await startUpload( + operation: operation, + onRetriableErrorReceived: onRetriableErrorReceived + ) + } + + public func startUpload( + 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 + ) + } + + public func uploadOperation( + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date, + captureTime: Date, + mainPhotoUid: SDKNodeUid?, + mediaType: String, + thumbnails: [ThumbnailData], + tags: [Int], + additionalMetadata: [AdditionalMetadata], + expectedSHA1: Data? = nil, + 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, + mainPhotoUid: mainPhotoUid, + mediaType: mediaType, + thumbnails: thumbnails, + tags: mappedTags, + additionalMetadata: additionalMetadata, + expectedSHA1: expectedSHA1, + cancellationToken: cancellationToken, + progressCallback: progressCallback + ) + } + + public func cancelUpload(with token: UUID) async throws { + try await uploadManager.cancelUpload(with: token) + } +} 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/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..942bfb22 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Client/SDKClientProvider.swift @@ -0,0 +1,27 @@ +import Foundation + +final class SDKClientProvider: @unchecked Sendable { + 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/Downloads/DownloadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift new file mode 100644 index 00000000..81f967aa --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadOperation.swift @@ -0,0 +1,269 @@ +import Foundation + +public typealias VerificationIssue = ProtonDriveSDKDataIntegrityError + +public enum DownloadOperationResult: Sendable { + case succeeded + case completedWithVerificationError(VerificationIssue) + case pausedOnError(Error) + case failed(Error) +} + +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 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, + downloadControllerHandle: ObjectHandle, + progressCallbackWrapper: ProgressCallbackWrapper, + 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 = .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 + self.onOperationDispose = onOperationDispose + } + + // 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 -> 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): + 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 { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + + try await SDKRequestHandler.send(awaitDownloadCompletionRequest, logger: logger) as Void + return .succeeded + } 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) + } + } + + 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 { + // 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) + } + } + + public func pause() async throws { + await pauseState.setRequested(true) + let pauseRequest = Proton_Drive_Sdk_DownloadControllerPauseRequest.with { + $0.downloadControllerHandle = downloadControllerHandleForProtos + } + try await SDKRequestHandler.send(pauseRequest, logger: logger) as Void + } + + public func resume() async throws { + await pauseState.setRequested(false) + 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) + } + + 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() + } + + deinit { + Self.freeSDKObjects(downloadControllerHandle, fileDownloaderHandle, logger, nodeType, onOperationDispose) + } + + private static func freeSDKObjects( + _ downloadControllerHandle: ObjectHandle, + _ fileDownloaderHandle: ObjectHandle, + _ logger: Logger?, + _ nodeType: NodeType, + _ onOperationDispose: @Sendable @escaping () async -> Void + ) { + Task { + await onOperationDispose() + await freeDownloadController(Int64(downloadControllerHandle), logger) + switch nodeType { + case .file: + await freeFileDownloader(Int64(fileDownloaderHandle), logger) + case .photo: + await freePhotoDownloader(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 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 { + $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") + } + } + +} + +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/DownloadThumbnailsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift new file mode 100644 index 00000000..367242fb --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadThumbnailsManager.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Handles file download operations for ProtonDrive +actor DownloadThumbnailsManager { + + 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 + func downloadThumbnails( + fileUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { + 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_DriveClientEnumerateThumbnailsRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.fileUids = fileUids.map(\.sdkCompatibleIdentifier) + $0.type = type.sdkType + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + $0.yieldAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) + } + + let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) + + let _: Void = try await SDKRequestHandler.send( + thumbnailsRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, + logger: logger + ) + } + + func downloadPhotoThumbnails( + photoUids: [SDKNodeUid], + type: ThumbnailData.ThumbnailType, + cancellationToken: UUID, + onThumbnailDownloaded: @escaping ThumbnailCallback + ) async throws { + 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_DrivePhotosClientEnumerateThumbnailsRequest.with { + $0.clientHandle = Int64(clientHandle) + $0.photoUids = photoUids.map(\.sdkCompatibleIdentifier) + $0.type = type.sdkType + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + $0.yieldAction = Int64(ObjectHandle(callback: cThumbnailEnumerationCallback)) + } + + let callbackState = ThumbnailEnumerationCallbackWrapper(callback: onThumbnailDownloaded) + + let _: Void = try await SDKRequestHandler.send( + thumbnailsRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, + logger: logger + ) + } + + func cancelDownload(with cancellationToken: UUID) async throws { + guard let downloadCancellationToken = activeDownloads[cancellationToken] else { + throw ProtonDriveSDKError(interopError: .noCancellationTokenForIdentifier(operation: "thumbnails download")) + } + + try await downloadCancellationToken.cancel() + downloadCancellationToken.free() + + 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/FileOperations/Downloads/DownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift new file mode 100644 index 00000000..d8ed02c5 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/DownloadsManager.swift @@ -0,0 +1,176 @@ +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: cProgressCallbackForDownload)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, + logger: logger + ) + + let operation = DownloadOperation( + fileDownloaderHandle: downloaderHandle, + downloadControllerHandle: downloadControllerHandle, + progressCallbackWrapper: 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) + } + ) + + 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), + scope: .ownerManaged, + owner: callbackState, + 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 + 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 + } + + /// 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/Downloads/PhotoDownloadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift new file mode 100644 index 00000000..d0492797 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/PhotoDownloadsManager.swift @@ -0,0 +1,103 @@ +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: cProgressCallbackForDownload)) + $0.cancellationTokenSourceHandle = Int64(cancellationTokenSource.handle) + } + + let callbackState = ProgressCallbackWrapper(callback: progressCallback) + let downloadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + downloaderRequest, + state: WeakReference(value: callbackState), + scope: .ownerManaged, + owner: callbackState, + logger: logger + ) + + return DownloadOperation( + fileDownloaderHandle: downloaderHandle, + downloadControllerHandle: downloadControllerHandle, + progressCallbackWrapper: callbackState, + logger: logger, + nodeType: .photo, + 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/FileOperations/Downloads/cThumbnailEnumerationCallback.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift new file mode 100644 index 00000000..5f1da986 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Downloads/cThumbnailEnumerationCallback.swift @@ -0,0 +1,29 @@ +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/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/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/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/FileOperations/Uploads/PhotoUploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift new file mode 100644 index 00000000..81cd6ad5 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/PhotoUploadsManager.swift @@ -0,0 +1,183 @@ +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, + captureTime: Date, + mainPhotoUid: SDKNodeUid?, + mediaType: String, + thumbnails: [ThumbnailData], + tags: [Proton_Drive_Sdk_PhotoTag], + additionalMetadata: [AdditionalMetadata], + 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, + modificationDate: modificationDate, + mediaType: mediaType, + captureTime: captureTime, + mainPhotoUid: mainPhotoUid, + tags: tags, + additionalMetadata: additionalMetadata, + cancellationHandle: cancellationHandle + ) + + let uploadOperation = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationToken: cancellationToken, + cancellationHandle: cancellationHandle, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 + ) + return uploadOperation + } + + private func uploadFromFile( + fileUploaderHandle: ObjectHandle, + fileURL: URL, + progressCallback: @escaping ProgressCallback, + cancellationToken: UUID, + cancellationHandle: ObjectHandle, + thumbnails: [ThumbnailData], + expectedSHA1: Data? = nil + ) 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: 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 + $0.dataPointer = Int64(handle) + $0.dataLength = Int64(count) + } + } + } + + let uploadOperationState = UploadOperationState(callback: progressCallback, expectedSHA1: expectedSHA1) + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + uploaderRequest, + state: WeakReference(value: uploadOperationState), + scope: .ownerManaged, + owner: uploadOperationState, + logger: logger + ) + + return UploadOperation( + fileUploaderHandle: fileUploaderHandle, + uploadControllerHandle: uploadControllerHandle, + uploadOperationState: uploadOperationState, + 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, + captureTime: Date, + mainPhotoUid: SDKNodeUid?, + tags: [Proton_Drive_Sdk_PhotoTag], + additionalMetadata: [AdditionalMetadata], + 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_PhotosFileUploadMetadata.with { metadata in + metadata.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) + metadata.additionalMetadata = additionalMetadata.map { $0.toSDK } + metadata.captureTime = Google_Protobuf_Timestamp(date: captureTime) + if let mainPhotoUid { + metadata.mainPhotoUid = mainPhotoUid.sdkCompatibleIdentifier + } + metadata.tags = tags + } + $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/Uploads/UploadOperation.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift new file mode 100644 index 00000000..3ad34889 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadOperation.swift @@ -0,0 +1,283 @@ +import Darwin +import Foundation +import CProtonDriveSDK + +public enum UploadOperationResult: Sendable { + case succeeded(UploadedFileIdentifiers) + case pausedOnError(Error) + case pausedByClient(Error) + case failed(Error) +} + +public final class UploadOperation: Sendable { + private let fileUploaderHandle: ObjectHandle + private let uploadControllerHandle: ObjectHandle + 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 + + private var uploadControllerHandleForProto: Int64 { Int64(uploadControllerHandle) } + + init(fileUploaderHandle: ObjectHandle, + uploadControllerHandle: ObjectHandle, + 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.uploadOperationState = uploadOperationState + self.logger = logger + self.nodeType = nodeType + self.expectedSHA1 = expectedSHA1 + self.onOperationCancel = onOperationCancel + 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(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) { + 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(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) + guard let result = UploadedFileIdentifiers(interopUploadResult: uploadResult) else { + throw ProtonDriveSDKError( + interopError: .wrongResult(message: "Wrong uid format in Proton_Drive_Sdk_UploadResult: \(uploadResult)") + ) + } + if cleanUpTemporaryState { + try? await self.cleanUpTemporaryState() + } + return .succeeded(result) + } catch { + do { + let isPaused = try await isPaused() + if isPaused { + // 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() + } + return .failed(error) + } + } catch let isPausedError { + logger?.info("Checking isPaused status failed with: \(isPausedError.localizedDescription)", + category: "UploadOperation") + 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, nodeType, onOperationDispose) + } + + private static func freeSDKObjects( + _ uploadControllerHandle: ObjectHandle, + _ fileUploaderHandle: ObjectHandle, + _ logger: Logger?, + _ nodeType: NodeType, + _ onOperationDispose: @Sendable @escaping () async -> Void + ) { + Task { + await onOperationDispose() + await freeUploadController(Int64(uploadControllerHandle), logger: logger) + switch nodeType { + case .file: + await freeFileUploader(Int64(fileUploaderHandle), logger) + case .photo: + await freePhotoUploader(Int64(fileUploaderHandle), logger) + } + } + } + + /// 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") + } + } + + /// 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 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_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") + } + } +} + +final class UploadOperationState: Sendable { + let callback: ProgressCallback + let expectedSHA1: Data? + + init(callback: @escaping ProgressCallback, expectedSHA1: Data?) { + self.callback = callback + self.expectedSHA1 = expectedSHA1 + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } +} + +let cExpectedSha1CallbackForUpload: CCallback = { statePointer, byteArray in + typealias BoxType = BoxedCompletionBlock> + guard let stateRawPointer = UnsafeRawPointer(bitPattern: statePointer) else { + assertionFailure("cExpectedSha1CallbackForUpload.statePointer is nil") + return + } + + let stateTypedPointer = Unmanaged.fromOpaque(stateRawPointer) + 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) + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift new file mode 100644 index 00000000..e9fb9394 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/FileOperations/Uploads/UploadsManager.swift @@ -0,0 +1,234 @@ +import Foundation +import SwiftProtobuf + +/// Handles file upload operations for ProtonDrive +actor UploadsManager { + + 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 uploadFileOperation( + parentFolderUid: SDKNodeUid, + name: String, + fileURL: URL, + fileSize: Int64, + modificationDate: Date?, + mediaType: String, + thumbnails: [ThumbnailData], + overrideExistingDraft: Bool, + expectedSHA1: Data?, + 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 buildFileUploader( + parentFolderUid: parentFolderUid.sdkCompatibleIdentifier, + name: name, + mediaType: mediaType, + fileSize: fileSize, + modificationDate: modificationDate, + overrideExistingDraft: overrideExistingDraft, + cancellationHandle: cancellationHandle, + logger: logger + ) + + let uploadOperation = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationToken: cancellationToken, + cancellationHandle: cancellationHandle, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 + ) + return uploadOperation + } + + func uploadNewRevisionOperation( + currentActiveRevisionUid: SDKRevisionUid, + fileURL: URL, + fileSize: Int64, + modificationDate: Date?, + thumbnails: [ThumbnailData], + expectedSHA1: Data?, + 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 getRevisionUploader( + currentActiveRevisionUid: currentActiveRevisionUid, + fileSize: fileSize, + modificationDate: modificationDate, + cancellationHandle: cancellationHandle + ) + + let uploadController = try await uploadFromFile( + fileUploaderHandle: uploaderHandle, + fileURL: fileURL, + progressCallback: progressCallback, + cancellationToken: cancellationToken, + cancellationHandle: cancellationHandle, + thumbnails: thumbnails, + expectedSHA1: expectedSHA1 + ) + 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() + + 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 UploadsManager { + private func buildFileUploader( + parentFolderUid: String, + name: String, + mediaType: String, + fileSize: Int64, + modificationDate: Date?, + overrideExistingDraft: Bool, + cancellationHandle: ObjectHandle?, + 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 + if let modificationDate { $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 + } + + private func getRevisionUploader( + currentActiveRevisionUid: SDKRevisionUid, + fileSize: Int64, + 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 + if let modificationDate { $0.lastModificationTime = Google_Protobuf_Timestamp(date: modificationDate) } + + if let cancellationHandle = cancellationHandle { + $0.cancellationTokenSourceHandle = Int64(cancellationHandle) + } + } + + let uploaderHandle: ObjectHandle = try await SDKRequestHandler.send(uploaderRequest, logger: logger) + assert(uploaderHandle != 0) + return uploaderHandle + } + + private func uploadFromFile( + fileUploaderHandle: ObjectHandle, + fileURL: URL, + progressCallback: @escaping ProgressCallback, + cancellationToken: UUID, + cancellationHandle: ObjectHandle, + thumbnails: [ThumbnailData], + expectedSHA1: Data? + ) 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: 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 + $0.dataPointer = Int64(handle) + $0.dataLength = Int64(count) + } + } + } + + let uploadOperationState = UploadOperationState(callback: progressCallback, expectedSHA1: expectedSHA1) + let uploadControllerHandle: ObjectHandle = try await SDKRequestHandler.send( + uploaderRequest, + state: WeakReference(value: uploadOperationState), + scope: .ownerManaged, + owner: uploadOperationState, + logger: logger + ) + + return UploadOperation( + fileUploaderHandle: fileUploaderHandle, + uploadControllerHandle: uploadControllerHandle, + uploadOperationState: uploadOperationState, + logger: logger, + nodeType: .file, + expectedSHA1: expectedSHA1, + 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) + } + ) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift new file mode 100644 index 00000000..c660a66a --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/BoxedContinuation.swift @@ -0,0 +1,45 @@ +protocol Resumable: AnyObject { + associatedtype ReturnType + typealias Continuation = CheckedContinuation + + func resume(returning value: sending ReturnType) + func resume(throwing error: Error) +} + +extension Resumable where ReturnType == Void { + func resume() { + self.resume(returning: ()) + } +} + +// Boxed completion +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 + self.state = state + } + + func resume(returning value: ResultType) { + guard let completionBlock else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + completionBlock(.success(value)) + self.completionBlock = nil + } + + func resume(throwing error: any Error) { + guard let completionBlock else { + assertionFailure("Attempt at calling continuation twice, programmer's error, must fix") + return + } + completionBlock(.failure(error)) + self.completionBlock = nil + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift new file mode 100644 index 00000000..89142472 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/CallbackHandleRegistry.swift @@ -0,0 +1,133 @@ +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 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 { + 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() + 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, ownerIdentity: owner.map(ObjectIdentifier.init)) + 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. + /// + /// 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.ownerIdentity == identity }.map { $0.key } + for key in keysToRemove { + entries.removeValue(forKey: key) + } + lock.unlock() + } + +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift new file mode 100644 index 00000000..c59dd358 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/FeatureFlags.swift @@ -0,0 +1,29 @@ +import Foundation + +public typealias FeatureFlagProviderCallback = @Sendable (String, (Bool) -> Void) -> 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 + } + + 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() + return 0 + } + + // Convert ByteArray to String + 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/InternalTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift new file mode 100644 index 00000000..6f5e19a3 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InternalTypes.swift @@ -0,0 +1,115 @@ +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: T) { + let callbackAddress: UnsafeRawPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + self = ObjectHandle(bitPattern: callbackAddress) + } +} + +extension ObjectHandle { + init(rawPointer: UnsafeRawPointer) { + self.init(UInt(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. +/// `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. + +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 + +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() + } + } +} + +// 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 { + 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 { + assertionFailure("The protobuf message could not be created") + self.init() + } + } +} + +extension Proton_Sdk_ProtonClientTlsPolicy { + init(tlsPolicy: TlsPolicy) { + switch tlsPolicy { + case .strict: + self = .strict + + case .noCertificatePinning: + self = .noCertificatePinning + + case .noCertificateValidation: + self = .noCertificateValidation + } + } +} + +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..9c0d7602 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/InteropRequest.swift @@ -0,0 +1,33 @@ +protocol InteropRequest { + associatedtype CallResultType: Sendable + associatedtype StateType +} + +extension InteropRequest { + typealias BoxedStateType = BoxedCompletionBlock +} + +extension Proton_Drive_Sdk_DriveClientCreateRequest: InteropRequest { + typealias CallResultType = ObjectHandle + typealias StateType = SDKClientProvider +} + +extension Proton_Drive_Sdk_DrivePhotosClientCreateRequest: InteropRequest { + typealias CallResultType = ObjectHandle + typealias StateType = SDKClientProvider +} + +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..8354dff9 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/Message+Packaging.swift @@ -0,0 +1,315 @@ +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_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_StreamReadRequest: + Proton_Sdk_Request.with { + $0.payload = .streamRead(request) + } + + case let request as Proton_Sdk_LoggerProviderCreate: + Proton_Sdk_Request.with { + $0.payload = .loggerProviderCreate(request) + } + + // MARK: - Drive Client + + case let request as Proton_Drive_Sdk_DriveClientCreateRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientCreate(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_DriveClientFreeRequest: + 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 { + $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_DriveClientRenameRequest: + Proton_Drive_Sdk_Request.with { + $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_DriveClientEnumerateThumbnailsRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .driveClientEnumerateThumbnails(request) + } + + // MARK: - Uploads + + case let request as Proton_Drive_Sdk_UploadFromFileRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadFromFile(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_UploadControllerIsPausedRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .uploadControllerIsPaused(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_UploadControllerPauseRequest: + Proton_Drive_Sdk_Request.with { + $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_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) + } + + // MARK: - Downloads + + case let request as Proton_Drive_Sdk_DownloadToFileRequest: + Proton_Drive_Sdk_Request.with { + $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) + } + + case let request as Proton_Drive_Sdk_DownloadControllerIsPausedRequest: + Proton_Drive_Sdk_Request.with { + $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) + } + + case let request as Proton_Drive_Sdk_DownloadControllerPauseRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .downloadControllerPause(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) + } + + // MARK: - Photo Client + + 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_DrivePhotosClientEnumerateThumbnailsRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientEnumerateThumbnails(request) + } + + case let request as Proton_Drive_Sdk_DrivePhotosClientEnumerateTimelineRequest: + Proton_Drive_Sdk_Request.with { + $0.payload = .drivePhotosClientEnumerateTimeline(request) + } + + // MARK: - Photo Downloads + + 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) + } + + // 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)")) + } + } + + 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 + } + 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 type: \(self)") + throw ProtonDriveSDKError(interopError: .wrongProto(message: "Unknown response type: \(self)")) + } + } + + private func serialisedByteArray() throws -> ByteArray { + ByteArray(data: try serializedData()) + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift new file mode 100644 index 00000000..4afb5f96 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/ProgressCallbackWrapper.swift @@ -0,0 +1,53 @@ +import Foundation +import CProtonDriveSDK + +final class ProgressCallbackWrapper: Sendable { + let callback: ProgressCallback + + init(callback: @escaping ProgressCallback) { + self.callback = callback + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } +} + +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( + 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) +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift new file mode 100644 index 00000000..48bc1e41 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/PublicTypes.swift @@ -0,0 +1,406 @@ +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 + } +} + +/// 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 + } +} + +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: Result + public let creationTime: Double + public let trashTime: Double? + public let nameAuthor: Author + 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)) + } + self.uid = uid + self.parentUid = sdkFolderNode.hasParentUid ? .init(sdkCompatibleIdentifier: sdkFolderNode.parentUid) : nil + self.name = StringResultParser().parse(sdkFolderNode.name) + self.creationTime = sdkFolderNode.creationTime.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) } + } +} + +// FIXME: Preserve distinction between verified and claimed email addresses to match original interface. +public struct Author: Sendable { + 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 { + 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" + } + } +} + +public struct FileNode: Sendable { + public let uid: String + public let parentUid: String + public let name: Result + 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 + self.parentUid = sdkFileNode.parentUid + 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) } + } +} + +public struct FileRevision: Sendable { + 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 + self.creationTime = sdkFileRevision.creationTime.timeIntervalSince1970 + self.sizeOnCloudStorage = sdkFileRevision.sizeOnCloudStorage + 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")) + } + } +} + +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) + else { return nil } + self.nodeUid = nodeUid + self.revisionUid = revisionUid + } +} + +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 + self.captureTime = item.captureTime.timeIntervalSince1970 + } +} + +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 +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 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) + } + + public var isCompleted: Bool { fractionCompleted == 1.0 } + + public init(bytesCompleted: Int64?, bytesTotal: Int64?) { + self.bytesCompleted = bytesCompleted + self.bytesTotal = bytesTotal + } +} + +/// Callback for thumbnail updates +public typealias ThumbnailCallback = @Sendable (Result) -> Void + +/// Thumbnail with file id +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 + } + self.fileUid = fileUid + 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 + } + } + + #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 +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift new file mode 100644 index 00000000..cb87cf04 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKRequestHandler.swift @@ -0,0 +1,226 @@ +import Foundation +import CProtonDriveSDK +import SwiftProtobuf + +/// Sends requests to SDK and handles responses +enum SDKRequestHandler { + + // MARK: - Simple requests (without state) + + /// 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) + } + + /// 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?, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, + completionBlock: @escaping (Result) -> Void + ) { + send(request, state: (), logger: logger, scope: scope, owner: owner, completionBlock: completionBlock) + } + + // MARK: - Requests with additional state + + /// Async/await API for request with state for types with the generics documented via InteropRequest protocol. + static func sendInteropRequest( + _ request: T, + state: T.StateType, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, + logger: Logger? + ) async throws -> T.CallResultType { + 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, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, + logger: Logger? + ) async throws -> U { + try await withCheckedThrowingContinuation { continuation in + send(request, state: state, logger: logger, scope: scope, owner: owner) { (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?, + scope: CallbackScope = .operation, + owner: AnyObject? = nil, + 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 requestArray = ByteArray(data: envelopedRequestData) + defer { + logger?.trace("deferred deallocate of requestData", category: "SDKRequestHandler") + requestArray.deallocate() + } + + 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) + let pointer = Unmanaged.passRetained(boxedState) + 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") + proton_drive_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) + } else { + logger?.trace(" -> proton_sdk_handle_request", category: "SDKRequestHandler") + proton_sdk_handle_request(requestArray, bindingsHandle, sdkResponseCallbackWithState) + } + } catch { + completionBlock(.failure(error)) + } + } +} + +/// C-compatible callback function for SDK responses. +let sdkResponseCallbackWithState: CCallback = { statePointer, responseArray in + 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 + } + + 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? any Resumable else { + 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 { + 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 + switch box { + case let int64Box as any Resumable: + int64Box.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(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: "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: "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: "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_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) + + 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) + + case .value: // unknown value type + throw ProtonDriveSDKError(interopError: .wrongSDKResponse(message: "Unknown SDK call response value type")) + + case .error(let error): + throw ProtonDriveSDKError(protoError: 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..4b337d63 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/SDKResponseHandler.swift @@ -0,0 +1,126 @@ +import Foundation +import CProtonDriveSDK +import SwiftProtobuf + +enum SDKResponseHandler { + static func send(callbackPointer: Int, message: Message) { + do { + let byteArray = try message.serializedIntoResponse() + 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)") + } + } + + /// 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) + } + + /// 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, assert: Bool = true) { + if assert { + 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: 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 { + + 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 + let secondaryCode: Int? = nil + let context: String? = nil + let innerError: Proton_Sdk_Error? = nil + let additionalData: Codable? = nil + + switch nsError { + + 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 + } + } + } +} diff --git a/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift new file mode 100644 index 00000000..fe1aca96 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/Plumbing/StreamCallbackWrapper.swift @@ -0,0 +1,163 @@ +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 + } + + deinit { + CallbackHandleRegistry.shared.removeAll(ownedBy: self) + } + + 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) + + return BoxedCancellableTask.registered { + 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) + } + } + } +} + +/// 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 + + return BoxedCancellableTask.registered { + 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) + } + } + } +} + +/// 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 = { callbackHandle in + CallbackHandleRegistry.shared.cancel(callbackHandle) +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift new file mode 100644 index 00000000..f0aaee50 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/AdditionalErrorData.swift @@ -0,0 +1,188 @@ +import Foundation +import SwiftProtobuf + +struct AdditionalErrorDataFactory { + func make(data: Google_Protobuf_Any) -> AdditionalErrorData? { + return NodeNameConflictErrorData(data: data) + ?? MissingContentBlockErrorData(data: data) + ?? ContentSizeMismatchErrorData(data: data) + ?? ThumbnailCountMismatchErrorData(data: data) + ?? ChecksumMismatchErrorData(data: data) + ?? NodeNotFoundErrorData(data: data) + } +} + +public protocol AdditionalErrorData: Sendable { + func toProtobufAny() -> Google_Protobuf_Any? + func errorDescription() -> String +} + +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 + } + } + + 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 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 + + 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/ProtonDriveSDKDriveError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift new file mode 100644 index 00000000..488c3458 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKDriveError.swift @@ -0,0 +1,21 @@ +import Foundation + +public final class ProtonDriveSDKDriveError: Error, LocalizedError { + 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 + } + + public var errorDescription: String? { + var desc: [String] = [message, innerError?.localizedDescription].compactMap { $0 } + return desc.joined(separator: ", ") + } +} diff --git a/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift new file mode 100644 index 00000000..03baca07 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/ProtonDriveSDKError/ProtonDriveSDKError.swift @@ -0,0 +1,432 @@ +import Foundation +import SwiftProtobuf + +// MARK: - Swift Types (hiding protobuf implementation) + +public struct ProtonDriveSDKError: LocalizedError, Sendable { + + public enum Domain: Sendable, Equatable { + // SDK domains + case undefined + case successfulCancellation + case api + case network + case transport + case serialization + case cryptography + case dataIntegrity + case businessLogic + + // 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 + 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 + case .businessLogic: + self = .businessLogic + } + } + } + + public enum InteropErrorTypes: Sendable { + case noCancellationTokenForIdentifier(operation: String) + case wrongProto(message: String) + case wrongSDKResponse(message: String) + case wrongResult(message: String) + case incorrectIDFormat(id: String) + case containsUnknownPhotoTags(tags: [Int]) + + 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" + case .containsUnknownPhotoTags: return "ContainsUnknownPhotoTags" + } + } + + 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 + case .incorrectIDFormat(let id): return "ID \(id) is not in the correct format" + case .containsUnknownPhotoTags(let tags): return "Contains unknown photo tags \(tags)" + } + } + } + + // 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 } + public let additionalErrorData: AdditionalErrorData? + + 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 + } + if let data = additionalErrorData?.toProtobufAny() { + $0.additionalData = data + } + } + } + + 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 + if protoError.hasAdditionalData { + self.additionalErrorData = AdditionalErrorDataFactory().make(data: protoError.additionalData) + } else { + self.additionalErrorData = 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 + self.additionalErrorData = 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 + let manifestSignatureVerificationErrorPrimaryCode = 5 + let contentUploadIntegrityErrorPrimaryCode = 6 + 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 manifestSignatureVerificationErrorPrimaryCode: + return .manifestSignatureVerification(message: message, context: context) + case contentUploadIntegrityErrorPrimaryCode: + return .contentUploadIntegrity(message: message, context: context, additionalData: additionalErrorData?.errorDescription()) + 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?) + case manifestSignatureVerification(message: String, context: String?) + case contentUploadIntegrity(message: String, context: String?, additionalData: String?) + + public enum NodeMetadataPart: Int, Sendable { + case key = 0 + case passphrase = 1 + case name = 2 + case extendedAttributes = 3 + case contentKey = 4 + case hashKey = 5 + 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 + +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? { message } +} diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift new file mode 100644 index 00000000..bd057173 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Logger.swift @@ -0,0 +1,87 @@ +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)]" + 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 = { 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 + } + + 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() + return + } + + let logEvent = LogEvent(sdkLogEvent: Proton_Sdk_LogEvent(byteArray: byteArray)) + 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: 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: 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: String) { + self.log(level: .error, message, category: category) + } + + func info(_ message: String, category: String) { + self.log(level: .info, message, category: category) + } + + 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) + ) + } +} + +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/TelemetryAndLogging/LoggerTypes.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift new file mode 100644 index 00000000..ce4a4588 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/LoggerTypes.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct LogEvent: Sendable { + public let level: LogLevel + public let message: String + public let category: String + 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: String, + timestamp: Date = .now, + 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: sdkLogEvent.categoryName, + thread: Thread.current.number, + // this is not implemented on SDK side + file: "", + function: "", + 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 + } +} diff --git a/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift new file mode 100644 index 00000000..3265bd38 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/Telemetry.swift @@ -0,0 +1,31 @@ +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 + } + + 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() + 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..cd76d2d1 --- /dev/null +++ b/swift/ProtonDriveSDK/Sources/TelemetryAndLogging/TelemetryTypes.swift @@ -0,0 +1,266 @@ +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 volumeType: VolumeType + public let retryHelped: Bool + + init(sdkEventPayload: Proton_Drive_Sdk_BlockVerificationErrorEventPayload) { + self.volumeType = .init(sdkVolumeType: sdkEventPayload.volumeType) + 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 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 + self.approximateDownloadedSize = sdkDownloadEventPayload.approximateDownloadedSize + 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 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 + self.approximateUploadedSize = sdkUploadEventPayload.approximateUploadedSize + 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 unrecognized = -1 + case unknown = 0 + case ownVolume = 1 + case ownPhotoVolume = 2 + case shared = 3 + case sharedPublic = 4 + + init(sdkVolumeType: Proton_Drive_Sdk_VolumeType) { + switch sdkVolumeType { + case .unknown: + self = .unknown + case .ownVolume: + self = .ownVolume + case .ownPhotoVolume: + self = .ownPhotoVolume + case .shared: + self = .shared + case .sharedPublic: + self = .sharedPublic + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized VolumeType from the SDK \(value)") + self = .unrecognized + } + } +} + +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 + case validationError = 7 + + 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 .validationError: + self = .validationError + 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 + case validationError = 6 + + 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 .validationError: + self = .validationError + case .httpClientSideError: + self = .httpClientSideError + case .unknown: + self = .unknown + case .UNRECOGNIZED(let value): + assertionFailure("Received unrecognized UploadError from the SDK \(value)") + self = .unknown + } + } +}