From 61117fd7dcec611ae90d9536853aac620df03c99 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 18 Aug 2026 01:30:45 +0200 Subject: [PATCH] feature(photos) implemented dups, import from takeout --- Cargo.lock | 4 +- Cargo.toml | 2 +- UPSTREAM_SYNC.md | 1 + crates/proton-drive-rs/Cargo.toml | 2 +- crates/proton-drive-rs/src/client.rs | 531 +++++++++++++++++++++++++-- crates/proton-drive-rs/src/dtos.rs | 114 ++++++ crates/proton-drive-rs/src/photos.rs | 88 +++++ 7 files changed, 712 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 235fbc1..5bcf943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2097,7 +2097,7 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.6.0" +version = "0.6.1" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c7afc2a..91ee03d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/proton-sdk", "crates/proton-drive-rs"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-sdk-rs" diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index 03f54db..fb8c13a 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -35,3 +35,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. | | 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask>>` into `IAsyncEnumerable`, and `UpdatePhotosAsync` into `IAsyncEnumerable`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). | | 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. | +| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. | diff --git a/crates/proton-drive-rs/Cargo.toml b/crates/proton-drive-rs/Cargo.toml index 5758b37..8ef795c 100644 --- a/crates/proton-drive-rs/Cargo.toml +++ b/crates/proton-drive-rs/Cargo.toml @@ -14,7 +14,7 @@ readme = "../../README.md" documentation = "https://docs.rs/proton-drive-rs" [dependencies] -proton-sdk = { path = "../proton-sdk", version = "0.6.0" } +proton-sdk = { path = "../proton-sdk", version = "0.6.1" } tokio.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 7c4e8ea..f436063 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -44,32 +44,34 @@ use crate::crypto::{ }; use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ - AcceptInvitationRequest, AggregateLinksResponse, AlbumItemListResponse, AlbumListResponse, - BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, - BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, - BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, - CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, - DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, - DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, - ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, - ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, - FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, - FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse, - InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, - InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest, - InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, - LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, - MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, - NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, - PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest, - RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, - RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, - ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto, - ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse, - SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, - ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, - TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, - VolumeEventListResponse, VolumeTrashResponse, + AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, + AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest, + AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest, + BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, + BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, + CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest, + CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, + DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, + DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, + ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, + FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, + FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, + FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse, + InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse, + InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse, + LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, + LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, + MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, + NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RenameLinkRequest, + RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, + RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, + ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, + ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, + SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, + SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, + ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, + UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, + VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -5616,6 +5618,483 @@ impl ProtonDriveClient { .collect()) } + /// Batch form of [`find_photo_duplicates`](Self::find_photo_duplicates): + /// one `POST volumes/{vid}/photos/duplicates` per [`DUPLICATE_BATCH`] input + /// pairs instead of one per photo. + /// + /// Each input is `(name, sha1_hex)` — the *plaintext* SHA-1 of the photo's + /// contents, lowercase hex, which the caller usually already computed while + /// reading the file. The result is one match list per input, in input order. + /// + /// Not an upstream port: the TS/C# SDKs only expose the one-photo call, but + /// the endpoint already takes a `NameHashes` array, and a Google Takeout + /// import asks this question tens of thousands of times. + pub(crate) async fn find_photo_duplicates_many( + &self, + items: &[(String, String)], + ) -> Result>> { + const ACTIVE_LINK_STATE: i32 = 1; + /// Name hashes per duplicates request. + const DUPLICATE_BATCH: usize = 150; + + let mut out = vec![Vec::new(); items.len()]; + if items.is_empty() { + return Ok(out); + } + if !self.ensure_photos().await? { + return Err(ProtonError::invalid_operation( + "account has no photos volume", + )); + } + let photos_root = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root"); + let root_key = self.folder_node_key(&photos_root).await?; + let hash_key = self + .parent_hash_key_ctx(&photos_root, &root_key, true) + .await?; + + // Two inputs can share a name (the same photo under two Takeout album + // folders), so a name hash maps to *every* input position that produced + // it, and each position is matched on its own content hash. + let mut positions: HashMap> = HashMap::new(); + let mut name_hashes: Vec = Vec::new(); + let mut content_hashes: Vec = Vec::with_capacity(items.len()); + for (index, (name, sha1_hex)) in items.iter().enumerate() { + let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes())); + positions + .entry(name_hash.clone()) + .or_insert_with(|| { + name_hashes.push(name_hash.clone()); + Vec::new() + }) + .push(index); + content_hashes.push(hex::encode(hmac_sha256( + &hash_key, + sha1_hex.to_ascii_lowercase().as_bytes(), + ))); + } + + for chunk in name_hashes.chunks(DUPLICATE_BATCH) { + let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id); + let response: FindPhotoDuplicatesResponse = self + .http + .post( + &path, + &FindPhotoDuplicatesRequest { + name_hashes: chunk.to_vec(), + }, + ) + .await?; + + for duplicate in response.duplicate_hashes { + let Some(link_id) = duplicate.link_id else { + continue; + }; + if duplicate.link_state != Some(ACTIVE_LINK_STATE) + || duplicate.name_hash.is_empty() + || duplicate.content_hash.is_empty() + { + continue; + } + // The server echoes the name hash it matched; find the inputs + // that asked for it and keep the ones whose content agrees too. + let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) else { + continue; + }; + for &index in indices { + if duplicate + .content_hash + .eq_ignore_ascii_case(&content_hashes[index]) + { + out[index] + .push(NodeUid::new(photos_root.volume_id.clone(), link_id.clone())); + } + } + } + } + + Ok(out) + } + + /// Which of `names` any *active* photo on the volume already carries. + /// + /// The cheap half of [`find_photo_duplicates_many`](Self::find_photo_duplicates_many): + /// it matches on the name hash alone, so it needs no content digest and + /// therefore no reading of the candidate files. A `true` is "maybe a + /// duplicate, hash it and ask again"; a `false` is conclusive. + pub(crate) async fn photo_name_collisions(&self, names: &[String]) -> Result> { + const ACTIVE_LINK_STATE: i32 = 1; + const DUPLICATE_BATCH: usize = 150; + + let mut out = vec![false; names.len()]; + if names.is_empty() { + return Ok(out); + } + if !self.ensure_photos().await? { + return Err(ProtonError::invalid_operation( + "account has no photos volume", + )); + } + let photos_root = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root"); + let root_key = self.folder_node_key(&photos_root).await?; + let hash_key = self + .parent_hash_key_ctx(&photos_root, &root_key, true) + .await?; + + let mut positions: HashMap> = HashMap::new(); + let mut name_hashes: Vec = Vec::new(); + for (index, name) in names.iter().enumerate() { + let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes())); + positions + .entry(name_hash.clone()) + .or_insert_with(|| { + name_hashes.push(name_hash.clone()); + Vec::new() + }) + .push(index); + } + + for chunk in name_hashes.chunks(DUPLICATE_BATCH) { + let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id); + let response: FindPhotoDuplicatesResponse = self + .http + .post( + &path, + &FindPhotoDuplicatesRequest { + name_hashes: chunk.to_vec(), + }, + ) + .await?; + for duplicate in response.duplicate_hashes { + if duplicate.link_id.is_none() || duplicate.link_state != Some(ACTIVE_LINK_STATE) { + continue; + } + if let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) { + for &index in indices { + out[index] = true; + } + } + } + } + + Ok(out) + } + + /// Create an album named `name` on the account's photos volume. + /// + /// Ported from the TypeScript SDK (`AlbumsManager.createAlbum` + + /// `AlbumsCryptoService.createAlbum`); C# exposes no album write API. An + /// album is a folder node parented to the photos root, so the crypto is the + /// folder create: a generated node key locked to a fresh passphrase that is + /// encrypted to the photos root key and signed by the photos share's + /// membership address, plus the album's own child-name hash key. + pub(crate) async fn create_album(&self, name: &str) -> Result { + let mut timer = self.telemetry.start("create_album"); + validate_node_name(name)?; + if !self.ensure_photos().await? { + return Err(ProtonError::invalid_operation( + "account has no photos volume", + )); + } + let photos_root = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root"); + + let root_key = self.folder_node_key(&photos_root).await?; + let root_hash_key = self + .parent_hash_key_ctx(&photos_root, &root_key, true) + .await?; + let (_address_id, email, signing_key) = self.photos_membership_address().await?; + + let node = generate_node_key()?; + let node_hash_key = generate_node_hash_key(&node.key)?; + + let request = AlbumCreationRequest { + locked: false, + link: AlbumCreationLink { + name: root_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?, + name_hash: hex::encode(hmac_sha256(&root_hash_key, name.as_bytes())), + key: node.locked_armored, + passphrase: root_key.encrypt(&node.passphrase)?, + passphrase_signature: signing_key.sign_detached(&node.passphrase)?, + signature_email: email, + node_hash_key, + extended_attributes: None, + }, + }; + + let path = format!("photos/volumes/{}/albums", photos_root.volume_id); + let created: AlbumCreationResponse = self.http.post(&path, &request).await?; + + timer.success(); + Ok(NodeUid::new( + photos_root.volume_id, + created.album.link.link_id, + )) + } + + /// Add photos that already live on the album's volume to `album_uid`, + /// returning one outcome per input photo in input order. + /// + /// Ported from the TypeScript SDK (`AddToAlbumProcess.processSameVolumeQueue` + /// with `PhotoTransferPayloadBuilder`). Each photo's name and node passphrase + /// are re-encrypted to the album key and its name/content hashes recomputed + /// under the album's hash key; the photo itself is not re-uploaded and stays + /// in the timeline. A main photo's related photos (live-photo video, burst + /// siblings) travel with it, because the server refuses the group otherwise. + /// + /// Photos on a *different* volume (an album shared with us) need the TS + /// `copyPhoto` path, which is not ported — those fail their own outcome + /// rather than the whole call. + pub(crate) async fn add_photos_to_album( + &self, + album_uid: &NodeUid, + photo_uids: &[NodeUid], + ) -> Result)>> { + /// Photos per `add-multiple` request, related photos included (TS + /// `ADD_PHOTOS_BATCH_SIZE`). + const ADD_BATCH: usize = 10; + + let mut timer = self.telemetry.start("add_photos_to_album"); + let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len()); + if photo_uids.is_empty() { + timer.success(); + return Ok(outcomes); + } + + let album_key = self.folder_node_key(album_uid).await?; + let album_hash_key = self + .parent_hash_key_ctx(album_uid, &album_key, true) + .await?; + let (_address_id, email, signing_key) = self.photos_membership_address().await?; + + let mut wanted: Vec<&NodeUid> = Vec::new(); + for uid in photo_uids { + if uid.volume_id != album_uid.volume_id { + outcomes.push(( + uid.clone(), + Err(ProtonError::invalid_operation(format!( + "photo {uid} is on another volume than album {album_uid}; \ + copying photos across volumes is not implemented" + ))), + )); + continue; + } + wanted.push(uid); + } + + // One `add-multiple` batch at a time: build each photo's payload, fan + // the related photos in beside it, then post and map the per-link + // outcomes back onto the *main* photos the caller asked about. + let mut batch: Vec = Vec::new(); + let mut entries: Vec = Vec::new(); + let mut queue: std::collections::VecDeque = wanted.into_iter().cloned().collect(); + + while let Some(uid) = queue.pop_front() { + let payload = match self + .album_photo_entries(&uid, &album_key, &album_hash_key, &email, &signing_key) + .await + { + Ok(payload) => payload, + Err(error) => { + outcomes.push((uid, Err(error))); + continue; + } + }; + + // A photo plus its related photos must not be split across requests. + if !entries.is_empty() && entries.len() + payload.len() > ADD_BATCH { + self.post_album_additions(album_uid, &entries, &batch, &mut outcomes) + .await?; + entries.clear(); + batch.clear(); + } + entries.extend(payload); + batch.push(uid); + } + if !entries.is_empty() { + self.post_album_additions(album_uid, &entries, &batch, &mut outcomes) + .await?; + } + + timer.success(); + Ok(outcomes) + } + + /// Build the `AlbumData` entries for one photo: the photo itself first, then + /// each of its related photos (TS `preparePhotoPayload`). + async fn album_photo_entries( + &self, + uid: &NodeUid, + album_key: &PrivateKey, + album_hash_key: &[u8], + email: &str, + signing_key: &PrivateKey, + ) -> Result> { + let node = self + .get_photos_node(uid) + .await? + .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; + + let related: Vec = node + .photo + .as_ref() + .map(|photo| photo.related_photo_uids.clone()) + .unwrap_or_default(); + + let mut entries = vec![ + self.album_photo_entry(uid, album_key, album_hash_key, email, signing_key) + .await?, + ]; + for related_uid in related { + entries.push( + self.album_photo_entry(&related_uid, album_key, album_hash_key, email, signing_key) + .await?, + ); + } + Ok(entries) + } + + /// Re-encrypt one photo's name and passphrase to the album key. + /// + /// The passphrase is *rewrapped*, not re-encrypted from plaintext: only its + /// key packet changes recipient, so the locked node key still unlocks and + /// the original `NodePassphraseSignature` stays valid (which is why the + /// entry carries no signature of its own — TS sends one only for photos + /// whose key author is anonymous). + async fn album_photo_entry( + &self, + uid: &NodeUid, + album_key: &PrivateKey, + album_hash_key: &[u8], + email: &str, + signing_key: &PrivateKey, + ) -> Result { + let node = self + .get_photos_node(uid) + .await? + .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; + let NodeKind::File { content_sha1, .. } = &node.kind else { + return Err(ProtonError::invalid_operation(format!( + "{uid} is not a photo file" + ))); + }; + let sha1 = content_sha1.clone().ok_or_else(|| { + ProtonError::invalid_operation(format!( + "photo {uid} has no content digest; it cannot be added to an album" + )) + })?; + + let details = self + .get_link_details_ctx(&uid.volume_id, std::slice::from_ref(&uid.link_id), true) + .await?; + let link = &details + .links + .first() + .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))? + .link; + + let parent_key = self + .resolve_parent_key_ctx(&uid.volume_id, link, true) + .await?; + let passphrase = parent_key.rewrap_message_to(&link.passphrase, album_key)?; + + Ok(AddPhotoToAlbumEntry { + link_id: uid.link_id.clone(), + name_hash: hex::encode(hmac_sha256(album_hash_key, node.name.as_bytes())), + name: album_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?, + name_signature_email: email.to_string(), + passphrase, + content_hash: hex::encode(hmac_sha256( + album_hash_key, + sha1.to_ascii_lowercase().as_bytes(), + )), + }) + } + + /// Post one `add-multiple` batch and record an outcome for every photo in + /// `batch` (the main photos; a related photo's failure fails its main one). + async fn post_album_additions( + &self, + album_uid: &NodeUid, + entries: &[AddPhotoToAlbumEntry], + batch: &[NodeUid], + outcomes: &mut Vec<(NodeUid, Result<()>)>, + ) -> Result<()> { + /// `ResponseCode::Success` as it arrives inside a per-link sub-response. + const SUCCESS_CODE: i32 = 1000; + + let path = format!( + "photos/volumes/{}/albums/{}/add-multiple", + album_uid.volume_id, album_uid.link_id + ); + let response: AddPhotosToAlbumResponse = self + .http + .post( + &path, + &AddPhotosToAlbumRequest { + album_data: entries.to_vec(), + }, + ) + .await?; + + // Only the links the server reports on failed; everything else in the + // batch succeeded (TS builds the same error map and yields ok for the + // rest). + let mut failures: HashMap = HashMap::new(); + for outcome in response.responses { + let body = &outcome.response; + // `Code == 1000` and no `Error` is the only success shape; a missing + // code (0) counts as a failure, as it does in TS. + if body.code == SUCCESS_CODE && body.error.is_none() { + continue; + } + let missing = body + .details + .as_ref() + .map(|details| details.missing.as_slice()) + .unwrap_or_default(); + let message = if !missing.is_empty() { + let missing: Vec = missing.iter().map(|id| id.to_string()).collect(); + format!( + "server wants related photos added with it: {}", + missing.join(", ") + ) + } else { + body.error + .clone() + .unwrap_or_else(|| format!("add to album failed with code {}", body.code)) + }; + failures.insert(outcome.link_id.to_string(), message); + } + + for uid in batch { + match failures.get(&uid.link_id.to_string()) { + Some(message) => outcomes.push(( + uid.clone(), + Err(ProtonError::invalid_operation(message.clone())), + )), + None => outcomes.push((uid.clone(), Ok(()))), + } + } + Ok(()) + } + /// Page the albums on the account's photos volume. /// /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`: diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 03a963c..edd6728 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -327,6 +327,120 @@ pub struct SharedAlbumDto { pub link_id: LinkId, } +/// `POST photos/volumes/{vid}/albums` — create an album. Ported from the +/// TypeScript SDK (`PhotosAPIService.createAlbum`); C# has no public album +/// write API. The album is a folder node on the photos volume, so its `Link` +/// block carries exactly what a folder create does. +#[derive(Debug, Serialize)] +pub struct AlbumCreationRequest { + /// TS sends `Locked: false` — a locked album is one that cannot take new + /// photos, which a freshly created album never is. + #[serde(rename = "Locked")] + pub locked: bool, + #[serde(rename = "Link")] + pub link: AlbumCreationLink, +} + +#[derive(Debug, Serialize)] +pub struct AlbumCreationLink { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: String, + #[serde(rename = "SignatureEmail")] + pub signature_email: String, + #[serde(rename = "NodeHashKey")] + pub node_hash_key: String, + #[serde(rename = "XAttr")] + pub extended_attributes: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumCreationResponse { + #[serde(rename = "Album")] + pub album: AlbumCreationAlbum, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumCreationAlbum { + #[serde(rename = "Link")] + pub link: AlbumCreationLinkResponse, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumCreationLinkResponse { + #[serde(rename = "LinkID")] + pub link_id: LinkId, +} + +/// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that +/// already live on the album's own volume. Ported from the TypeScript SDK +/// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's +/// name and passphrase to the album key; a main photo's related photos are +/// flattened into the same list. +#[derive(Debug, Serialize)] +pub struct AddPhotosToAlbumRequest { + #[serde(rename = "AlbumData")] + pub album_data: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AddPhotoToAlbumEntry { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + /// Name hash under the *album's* hash key. + #[serde(rename = "Hash")] + pub name_hash: String, + /// The photo's name re-encrypted to the album key. + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: String, + /// The photo's node passphrase re-encrypted to the album key. + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + /// HMAC of the plaintext SHA-1 under the album's hash key. + #[serde(rename = "ContentHash")] + pub content_hash: String, +} + +#[derive(Debug, Deserialize)] +pub struct AddPhotosToAlbumResponse { + #[serde(rename = "Responses", default)] + pub responses: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AddPhotoToAlbumOutcome { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "Response")] + pub response: AddPhotoToAlbumOutcomeBody, +} + +#[derive(Debug, Deserialize)] +pub struct AddPhotoToAlbumOutcomeBody { + #[serde(rename = "Code", default)] + pub code: i32, + #[serde(rename = "Error", default)] + pub error: Option, + /// Carries `Missing` — related photos the server demands alongside this one. + #[serde(rename = "Details", default)] + pub details: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddPhotoToAlbumOutcomeDetails { + #[serde(rename = "Missing", default)] + pub missing: Vec, +} + /// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`). #[derive(Debug, Serialize)] pub struct PhotoTagsRequest { diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index f527f2d..ce9cb38 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -258,6 +258,62 @@ impl ProtonPhotosClient { self.drive.find_photo_duplicates(name, contents).await } + /// Batch [`find_duplicates`](Self::find_duplicates) over many photos the + /// caller has already hashed: each input is `(name, sha1_hex)` — the + /// lowercase-hex plaintext SHA-1 of that photo's contents — and the result + /// holds one match list per input, in input order. + /// + /// Prefer this when checking a whole library (a Google Takeout import): it + /// asks the duplicates endpoint about 150 names per request rather than one, + /// and never needs the photo bytes in memory. + pub async fn find_duplicates_many( + &self, + items: &[(String, String)], + ) -> Result>> { + self.drive.find_photo_duplicates_many(items).await + } + + /// Which of `names` an active photo on the volume already carries — the + /// name-hash-only prefilter in front of + /// [`find_duplicates_many`](Self::find_duplicates_many). + /// + /// A `true` means "there is a same-named photo, so hash this file and check + /// its content too"; a `false` is conclusive and saves reading the file at + /// all. Importing a photo library is mostly `false`s. + pub async fn name_collisions(&self, names: &[String]) -> Result> { + self.drive.photo_name_collisions(names).await + } + + /// Create an album named `name` on the account's photos volume, returning + /// its [`NodeUid`]. + /// + /// Ported from the TypeScript SDK (`AlbumsManager.createAlbum`) — C# has no + /// public album write API. Errors when the account has no photos volume; + /// the name is *not* deduplicated, so two albums may share a name unless the + /// caller checks [`enumerate_album_node_uids`](Self::enumerate_album_node_uids) + /// first. + pub async fn create_album(&self, name: &str) -> Result { + self.drive.create_album(name).await + } + + /// Add photos already on the album's volume to `album_uid`, one outcome per + /// input photo in input order (a photo that fails does not stop the others). + /// + /// Ported from the TypeScript SDK (`AlbumsManager.addPhotos`). The photos + /// stay in the timeline — an album membership re-encrypts a photo's name and + /// passphrase to the album key, it does not copy content. Related photos + /// (live-photo video, burst siblings) are sent alongside their main photo + /// because the server rejects the group otherwise. Photos on another volume + /// (from an album shared with us) need the unported `copyPhoto` path and + /// fail their own outcome. + pub async fn add_photos_to_album( + &self, + album_uid: &NodeUid, + photo_uids: &[NodeUid], + ) -> Result)>> { + self.drive.add_photos_to_album(album_uid, photo_uids).await + } + /// The albums on the account's photos volume, as [`NodeUid`]s. /// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the /// account has no photos volume. Materialize with @@ -358,6 +414,38 @@ mod tests { assert_eq!(parsed.photos[1].content_hash.as_deref(), Some("ff")); } + #[test] + fn album_creation_response_deserializes_server_shape() { + let raw = r#"{ "Album": { "Link": { "LinkID": "album-1" } } }"#; + let parsed: crate::dtos::AlbumCreationResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.album.link.link_id.to_string(), "album-1"); + } + + #[test] + fn add_photos_response_reports_missing_related_photos() { + // The per-link sub-responses of `albums/{lid}/add-multiple`: one plain + // success, one failure naming the related photos the server wants sent + // alongside the main one. + let raw = r#"{ + "Responses": [ + { "LinkID": "photo-1", "Response": { "Code": 1000 } }, + { "LinkID": "photo-2", "Response": { + "Code": 2000, "Error": "missing", "Details": { "Missing": ["photo-3"] } + } } + ] + }"#; + let parsed: crate::dtos::AddPhotosToAlbumResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.responses.len(), 2); + assert_eq!(parsed.responses[0].response.code, 1000); + assert!(parsed.responses[0].response.error.is_none()); + let failed = &parsed.responses[1].response; + assert_eq!(failed.error.as_deref(), Some("missing")); + assert_eq!( + failed.details.as_ref().unwrap().missing[0].to_string(), + "photo-3" + ); + } + #[test] fn duplicate_response_deserializes_server_shape() { let raw = r#"{