From aa17b526929162c003a94f499ff24fdcdad2fc11 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 27 Aug 2026 15:58:06 +0200 Subject: [PATCH] feat(photos): save photos to the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports upstream C# e0e198e2 (SavePhotosToTimelineAsync, itself the JS saveToTimeline). Photos already on our photos volume are moved into the timeline root via PUT photos/volumes/{vid}/links/transfer-multiple, batched so no request carries more than ten links and a photo is never split from its related photos; photos on another volume — from an album shared with us — are copied via POST volumes/{vid}/links/{lid}/copy, one request each. That closes the cross-volume copy hole (TS copyPhoto). The re-encryption is what add_photos_to_album already did, so it moves into a shared photo_target_payload: name re-encrypted to the target key and signed by the photos membership address, name/content hashes under the target hash key, node passphrase rewrapped so the original NodePassphraseSignature survives. A photo the server reports an incomplete group for is re-queued once with the related photos it named; only its second answer becomes an outcome, so outcomes arrive in completion order, not input order. Also adds ProtonPhotosClient::enumerate_events (c8d03244's only non-interop hunk) and bumps the upstream pin to c8d03244. Upstream 29e58378 (upload telemetry) and 0537d906 (NodeProvider refactor) are deliberately not ported — see UPSTREAM_SYNC.md. --- README.md | 3 +- UPSTREAM_SYNC.md | 5 +- crates/proton-drive-rs/src/client.rs | 602 ++++++++++++++++++++++++++- crates/proton-drive-rs/src/dtos.rs | 116 +++++- crates/proton-drive-rs/src/photos.rs | 31 +- 5 files changed, 728 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index da042cd..6b204c7 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,8 @@ neither upstream SDK, original to `proton-sdk-rs` · ❌ not implemented. | | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`) and by us (`enumerate_shared_node_uids`). | | | Duplicate Detection | ✅ | `find_duplicates` matches candidates on name/content HMAC before re-uploading. | | | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported (upstream `03b1cb7f`). | -| | Album Writes | ❌ | Creating albums and adding/removing photos — no C# public API upstream either. | +| | Save to Timeline | ✅ | `save_photos_to_timeline` moves photos already on our photos volume into the timeline root and copies cross-volume ones (from albums shared with us). | +| | Album Writes | ⚠️ | `create_album` and `add_photos_to_album` are ported from the TypeScript SDK (C# has no public album write API); album rename, cover photo and delete are not. | | | Photos Volume Create| ❌ | Volume creation is not yet ported. | | **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`, or `with_entity_repository` to chain one onto a `with_key_salts` client); keys/secrets remain strictly in memory. Retained deliberately — upstream C# removed its entity cache. | | **Validation** | Node Names | ✅ | Empty or >255-char names are rejected client-side before create/rename (counted in `char`s; C# counts UTF-16 units). | diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index fb8c13a..0c035a4 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `df6b6bfde620c12868cd5a444d027f8d01974b75` -- **Date**: 2026-08-10 +- **Pinned**: `c8d03244938a6b4d107c755df8904d7d971ed1c2` +- **Date**: 2026-08-27 > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > exist in its history. Triage by commit subject/date when a pin goes stale. @@ -36,3 +36,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 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. | +| 2026-08-27 | `df6b6bfd` → `c8d03244` | `e0e198e2`, `c8d03244` | ported: **save photos to the timeline** (`ProtonPhotosClient::save_photos_to_timeline` — C# `PhotoOperations.SavePhotosToTimelineAsync`, itself the JS `saveToTimeline`). Photos already on our photos volume are *moved* into the timeline root through `PUT photos/volumes/{vid}/links/transfer-multiple`, batched so no request carries more than ten links (main photo + related photos counted together, never split across requests); photos on another volume — from a shared-with-me album — are *copied* through `POST volumes/{vid}/links/{lid}/copy`, one request each, with their related photos nested in `Photos`. This closes the **cross-volume copy** hole logged in the 2026-08-18 row (TS `copyPhoto`), so photos from albums shared with us can now be kept. The re-encryption is what `add_photos_to_album` already did — name re-encrypted to the target key and signed by the photos membership address, name/content hashes recomputed under the target hash key, node passphrase **rewrapped** via `rewrap_message_to` so the original `NodePassphraseSignature` survives — so it was lifted into a shared `photo_target_payload` (C# `PhotosCrypto.EncryptPhotoForTarget` + `PhotoTransferPayloadBuilder`) that both paths build on. Retry semantics mirror upstream exactly: a photo the server reports an incomplete group for is re-queued **once** with the related photos it named, and only its second answer becomes an outcome; a photo already parented to the timeline root fails its own outcome. Also ported: `ProtonPhotosClient::enumerate_events` (`c8d03244`'s only non-interop hunk — a passthrough to the Drive client's, which we already had). diverged deliberately: upstream returns `IAsyncEnumerable`; ours collects `Vec<(NodeUid, Result<()>)>` like `move_nodes` and the other batch ops — and, because of the retry queue, outcome order follows completion, not input, which the API docs state. not ported: `29e58378` **per-file upload performance metrics** (`UploadPerformanceEvent`, size/route/block-count classification, active-vs-paused attempt timing on `UploadController`) — it is telemetry plumbing for C#'s interop metric callback; our `proton-sdk::telemetry` has its own `TelemetryEvent`/`OpTimer` shape and we have no pause/resume upload controller, and nothing about the upload itself changes. structural, not ported: `0537d906` (C# merges its batch loaders and `DtoToMetadataConverter` into one `NodeProvider`; the only behavior-adjacent bit is dropping the cache's claim/acquisition dance, which our single-flight already covers). Live validation pending: the whole path needs a photos-volume account, and the cross-volume half needs a second account sharing an album with it. | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index f436063..0f0fc01 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -49,7 +49,8 @@ use crate::dtos::{ AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, - CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest, + CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, + CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, @@ -70,8 +71,8 @@ use crate::dtos::{ SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, - UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, - VolumeTrashResponse, + TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, + VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -5971,12 +5972,6 @@ impl ProtonDriveClient { } /// 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, @@ -5985,6 +5980,36 @@ impl ProtonDriveClient { email: &str, signing_key: &PrivateKey, ) -> Result { + let payload = self + .photo_target_payload(uid, album_key, album_hash_key, email, signing_key) + .await?; + Ok(AddPhotoToAlbumEntry { + link_id: payload.uid.link_id, + name_hash: payload.name_hash, + name: payload.name, + name_signature_email: payload.name_signature_email, + passphrase: payload.passphrase, + content_hash: payload.content_hash, + }) + } + + /// Re-encrypt one photo's name, passphrase and hashes for a target node — + /// an album or the timeline root (C# `PhotosCrypto.EncryptPhotoForTarget` + /// via `PhotoTransferPayloadBuilder`, TS `preparePhotoPayload`). + /// + /// 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 + /// payload carries no signature of its own — both upstreams sign one only + /// for photos whose key author is anonymous). + async fn photo_target_payload( + &self, + uid: &NodeUid, + target_key: &PrivateKey, + target_hash_key: &[u8], + email: &str, + signing_key: &PrivateKey, + ) -> Result { let node = self .get_photos_node(uid) .await? @@ -5996,7 +6021,7 @@ impl ProtonDriveClient { }; 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" + "photo {uid} has no content digest; it cannot be re-encrypted for another node" )) })?; @@ -6012,18 +6037,24 @@ impl ProtonDriveClient { 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)?; + let passphrase = parent_key.rewrap_message_to(&link.passphrase, target_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)?, + Ok(PhotoTargetPayload { + uid: uid.clone(), + parent_uid: link + .parent_id + .clone() + .map(|parent_id| NodeUid::new(uid.volume_id.clone(), parent_id)), + original_name_hash: link.name_hash.clone(), + name_hash: hex::encode(hmac_sha256(target_hash_key, node.name.as_bytes())), + name: target_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, + target_hash_key, sha1.to_ascii_lowercase().as_bytes(), )), + related: Vec::new(), }) } @@ -6095,6 +6126,360 @@ impl ProtonDriveClient { Ok(()) } + /// Copy photos into this account's own timeline, keeping them even after + /// the originals stop being shared. One outcome per input photo. + /// + /// Mirrors C# `PhotoOperations.SavePhotosToTimelineAsync` (itself the JS + /// `saveToTimeline`): a photo already on our photos volume is *moved* into + /// the timeline root via `PUT photos/volumes/{vid}/links/transfer-multiple`, + /// batched so no request carries more than ten links; a photo on another + /// volume — one from a shared-with-me album — is *copied* through + /// `POST volumes/{vid}/links/{lid}/copy`, one request each. Both re-encrypt + /// the photo's name and passphrase for the timeline root, related photos + /// included. + /// + /// When the server answers that the photo's group is incomplete it names + /// the related photos it wants; that photo is re-queued **once** with them + /// and only its second answer becomes an outcome. A photo already parented + /// to the timeline root fails its own outcome, as upstream reports it. + /// Outcome order therefore follows completion, not input. + pub(crate) async fn save_photos_to_timeline( + &self, + photo_uids: &[NodeUid], + ) -> Result)>> { + /// Photos whose payloads are built before a round of requests goes out + /// (C# `SaveToTimelineBatchSize`). + const SAVE_BATCH: usize = 20; + /// Links — main photos plus related ones — per `transfer-multiple` + /// request (C# `MaxLinksPerTransferBatch`). + const MAX_LINKS_PER_TRANSFER: usize = 10; + + let mut timer = self.telemetry.start("save_photos_to_timeline"); + timer.attr("photo_count", photo_uids.len()); + if photo_uids.is_empty() { + timer.success(); + return Ok(Vec::new()); + } + + // The timeline root is the target for every photo, so a missing photos + // volume fails the whole call rather than each photo in turn. + if !self.ensure_photos().await? { + return Err(ProtonError::invalid_operation( + "this account has no photos volume to save photos to", + )); + } + let target_uid = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root"); + let target_key = self.folder_node_key(&target_uid).await?; + let target_hash_key = self + .parent_hash_key_ctx(&target_uid, &target_key, true) + .await?; + let (_address_id, email, signing_key) = self.photos_membership_address().await?; + let target = TimelineTarget { + uid: target_uid, + key: target_key, + hash_key: target_hash_key, + email, + signing_key, + }; + + let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len()); + // Each entry is a photo plus the related photos the server asked for on + // a previous attempt; `retried` is what keeps that a single retry. + let mut queue: VecDeque<(NodeUid, Vec)> = photo_uids + .iter() + .cloned() + .map(|uid| (uid, Vec::new())) + .collect(); + let mut retried: HashSet = HashSet::new(); + + while !queue.is_empty() { + let mut same_volume: Vec = Vec::new(); + let mut cross_volume: Vec = Vec::new(); + for _ in 0..SAVE_BATCH { + let Some((uid, extra_related)) = queue.pop_front() else { + break; + }; + match self + .timeline_photo_payload(&uid, &extra_related, &target) + .await + { + Ok(payload) if payload.uid.volume_id == target.uid.volume_id => { + same_volume.push(payload) + } + Ok(payload) => cross_volume.push(payload), + Err(error) => outcomes.push((uid, Err(error))), + } + } + + // A photo and its related photos must not be split across requests, + // so the batch closes before the one that would overflow it. + let mut batch: Vec = Vec::new(); + let mut links = 0usize; + for payload in same_volume { + let count = 1 + payload.related.len(); + if !batch.is_empty() && links + count > MAX_LINKS_PER_TRANSFER { + self.transfer_photos_to_timeline( + &target, + &batch, + &mut queue, + &mut retried, + &mut outcomes, + ) + .await?; + batch.clear(); + links = 0; + } + links += count; + batch.push(payload); + } + if !batch.is_empty() { + self.transfer_photos_to_timeline( + &target, + &batch, + &mut queue, + &mut retried, + &mut outcomes, + ) + .await?; + } + + for payload in cross_volume { + self.copy_photo_to_timeline( + &target, + &payload, + &mut queue, + &mut retried, + &mut outcomes, + ) + .await; + } + } + + timer.success(); + Ok(outcomes) + } + + /// One photo's timeline payload: the photo itself plus its related photos, + /// re-encrypted for the timeline root (C# + /// `PhotoTransferPayloadBuilder.BuildPayloadsAsync`). + /// + /// `extra_related` are the ones the server demanded on a previous attempt, + /// merged with what the photo itself declares. A related photo that cannot + /// be built is skipped rather than failing its main photo, as upstream does. + async fn timeline_photo_payload( + &self, + uid: &NodeUid, + extra_related: &[NodeUid], + target: &TimelineTarget, + ) -> Result { + let mut payload = self + .photo_target_payload( + uid, + &target.key, + &target.hash_key, + &target.email, + &target.signing_key, + ) + .await?; + if payload.parent_uid.as_ref() == Some(&target.uid) { + return Err(ProtonError::invalid_operation(format!( + "photo {uid} is already in the timeline" + ))); + } + + let node = self + .get_photos_node(uid) + .await? + .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; + let mut related: Vec = node + .photo + .as_ref() + .map(|photo| photo.related_photo_uids.clone()) + .unwrap_or_default(); + for extra in extra_related { + if !related.contains(extra) { + related.push(extra.clone()); + } + } + + for related_uid in related { + if let Ok(related_payload) = self + .photo_target_payload( + &related_uid, + &target.key, + &target.hash_key, + &target.email, + &target.signing_key, + ) + .await + { + payload.related.push(related_payload); + } + } + Ok(payload) + } + + /// Post one `transfer-multiple` batch and record an outcome for each of its + /// main photos (C# `TransferSameVolumeBatchAsync` + `RecordTransferOutcomes`). + async fn transfer_photos_to_timeline( + &self, + target: &TimelineTarget, + payloads: &[PhotoTargetPayload], + queue: &mut VecDeque<(NodeUid, Vec)>, + retried: &mut HashSet, + outcomes: &mut Vec<(NodeUid, Result<()>)>, + ) -> Result<()> { + /// `ResponseCode::Success` as it arrives inside a per-link sub-response. + const SUCCESS_CODE: i32 = 1000; + + let mut links: Vec = Vec::new(); + let mut sent: Vec<&PhotoTargetPayload> = Vec::new(); + for payload in payloads { + // The endpoint matches each link on the hash it currently has, so a + // photo whose link carries none cannot be moved. + let items: Option> = std::iter::once(payload) + .chain(payload.related.iter()) + .map(transfer_link_item) + .collect(); + match items { + Some(items) => { + links.extend(items); + sent.push(payload); + } + None => outcomes.push(( + payload.uid.clone(), + Err(ProtonError::invalid_operation(format!( + "photo {} has no name hash on its current parent; it cannot be moved", + payload.uid + ))), + )), + } + } + if links.is_empty() { + return Ok(()); + } + + let path = format!( + "photos/volumes/{}/links/transfer-multiple", + target.uid.volume_id + ); + let response: TransferPhotosResponse = self + .http + .put( + &path, + &TransferPhotosRequest { + parent_link_id: target.uid.link_id.clone(), + links, + name_signature_email: target.email.clone(), + signature_email: None, + }, + ) + .await?; + + // Only the links the server reports on failed; a main photo without an + // entry succeeded, and a related photo's own failure is not reported + // (the incomplete-group case is keyed on the main link), exactly as C# + // and JS map it. + let mut failures: HashMap, ProtonError)> = HashMap::new(); + for outcome in response.responses { + let body = &outcome.response; + if body.code == SUCCESS_CODE && body.error.is_none() { + continue; + } + let missing: Vec = body + .details + .as_ref() + .map(|details| details.missing.as_slice()) + .unwrap_or_default() + .iter() + .map(|link_id| NodeUid::new(target.uid.volume_id.clone(), link_id.clone())) + .collect(); + let error = if missing.is_empty() { + ProtonError::invalid_operation( + body.error + .clone() + .unwrap_or_else(|| format!("transfer failed with code {}", body.code)), + ) + } else { + missing_related_photos_error(&missing) + }; + failures.insert(outcome.link_id.to_string(), (missing, error)); + } + + for payload in sent { + let failure = failures.remove(&payload.uid.link_id.to_string()); + record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes); + } + Ok(()) + } + + /// Copy one photo that lives on another volume into the timeline (C# + /// `CopyCrossVolumePhotoAsync`, JS `copyPhoto`). + async fn copy_photo_to_timeline( + &self, + target: &TimelineTarget, + payload: &PhotoTargetPayload, + queue: &mut VecDeque<(NodeUid, Vec)>, + retried: &mut HashSet, + outcomes: &mut Vec<(NodeUid, Result<()>)>, + ) { + let path = format!( + "volumes/{}/links/{}/copy", + payload.uid.volume_id, payload.uid.link_id + ); + let request = CopyPhotoRequest { + target_volume_id: target.uid.volume_id.clone(), + target_parent_link_id: target.uid.link_id.clone(), + name_hash: payload.name_hash.clone(), + name: payload.name.clone(), + name_signature_email: payload.name_signature_email.clone(), + passphrase: payload.passphrase.clone(), + passphrase_signature: None, + signature_email: None, + photos: CopyPhotoContent { + content_hash: payload.content_hash.clone(), + related_photos: payload + .related + .iter() + .map(|related| CopyPhotoRelatedItem { + link_id: related.uid.link_id.clone(), + name_hash: related.name_hash.clone(), + name: related.name.clone(), + passphrase: related.passphrase.clone(), + content_hash: related.content_hash.clone(), + }) + .collect(), + }, + }; + + let failure = match self + .http + .post::<_, CopyPhotoResponse>(&path, &request) + .await + { + Ok(_) => None, + // The incomplete-group answer arrives as an ordinary API error whose + // `Details.Missing` names the related photos on the *source* volume. + Err(ProtonError::Api(error)) => { + let missing = missing_related_links(&error, &payload.uid.volume_id); + let error = if missing.is_empty() { + ProtonError::Api(error) + } else { + missing_related_photos_error(&missing) + }; + Some((missing, error)) + } + Err(error) => Some((Vec::new(), error)), + }; + record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes); + } + /// Page the albums on the account's photos volume. /// /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`: @@ -7589,6 +7974,101 @@ fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership } } +/// One `transfer-multiple` link item, or `None` when the photo's link carries +/// no name hash for the parent it is leaving — the endpoint needs it to match. +fn transfer_link_item(payload: &PhotoTargetPayload) -> Option { + Some(TransferPhotoLinkItem { + link_id: payload.uid.link_id.clone(), + name_hash: payload.name_hash.clone(), + original_name_hash: payload.original_name_hash.clone()?, + name: payload.name.clone(), + passphrase: payload.passphrase.clone(), + content_hash: payload.content_hash.clone(), + passphrase_signature: None, + }) +} + +/// The related photos an error envelope's `Details.Missing` names, on the volume +/// the failing photo lives on (C# `MissingRelatedPhotosException`). +fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec { + error + .details + .as_ref() + .and_then(|details| details.get("Missing")) + .and_then(|missing| missing.as_array()) + .map(|missing| { + missing + .iter() + .filter_map(|link_id| link_id.as_str()) + .map(|link_id| NodeUid::new(volume_id.clone(), LinkId::from(link_id.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +fn missing_related_photos_error(missing: &[NodeUid]) -> ProtonError { + let missing: Vec = missing.iter().map(|uid| uid.to_string()).collect(); + ProtonError::invalid_operation(format!( + "server wants related photos saved with it: {}", + missing.join(", ") + )) +} + +/// Turn one photo's answer into an outcome, or re-queue it once when the server +/// asked for its related photos (C# `PhotoOperations.RecordOutcome`). `failure` +/// carries the related photos the server named, when it named any. +fn record_timeline_outcome( + uid: NodeUid, + failure: Option<(Vec, ProtonError)>, + queue: &mut VecDeque<(NodeUid, Vec)>, + retried: &mut HashSet, + outcomes: &mut Vec<(NodeUid, Result<()>)>, +) { + match failure { + None => outcomes.push((uid, Ok(()))), + // `insert` is false once this photo has already been re-queued, which is + // what stops a server that keeps asking from looping forever. + Some((missing, _error)) if !missing.is_empty() && retried.insert(uid.clone()) => { + queue.push_back((uid, missing)); + } + Some((_, error)) => outcomes.push((uid, Err(error))), + } +} + +/// The timeline root and the keys photos are re-encrypted for on their way into +/// it (C# `PhotoOperations.TimelineTarget`). +struct TimelineTarget { + uid: NodeUid, + key: PrivateKey, + hash_key: Vec, + /// The photos share membership address: what signs the re-encrypted names. + email: String, + signing_key: PrivateKey, +} + +/// One photo re-encrypted for a target node, plus its related photos (C# +/// `TransferEncryptedPhotoPayload`). Feeds both the album `add-multiple` entries +/// and the timeline transfer/copy requests, which all want the same ciphertext +/// and hashes in slightly different wire shapes. +#[derive(Debug, Clone)] +struct PhotoTargetPayload { + uid: NodeUid, + /// Where the photo lives now — the timeline path rejects a photo already + /// parented to the target. + parent_uid: Option, + /// The photo's name hash under its *current* parent's hash key, straight + /// from the link. Only the transfer endpoint needs it. + original_name_hash: Option, + name_hash: String, + name: String, + name_signature_email: String, + passphrase: String, + content_hash: String, + /// Live-photo video and burst siblings, re-encrypted the same way. The + /// server refuses a photo whose group is incomplete. + related: Vec, +} + /// The Drive-owned items of one `v2/sharedwithme` page, in the order the API /// returned them. /// @@ -7617,9 +8097,10 @@ mod tests { CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, MAX_NODE_NAME_LENGTH, NodeAction, aggregate_outcomes, alternate_names, assemble_manifest, context_share_path, drive_items, epoch_to_iso8601, is_expired_upload_token, - is_listable_revision_state, is_upload_timeout, join_node_path, path_segments, - run_context_share_mutation, share_membership_from_dto, small_upload_applicable, - take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, + is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, + path_segments, record_timeline_outcome, run_context_share_mutation, + share_membership_from_dto, small_upload_applicable, take_block_target, + take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto, @@ -8271,4 +8752,87 @@ mod tests { ))); assert!(!is_upload_timeout(&api_error(ResponseCode::Success, 504))); } + + #[test] + fn a_photo_the_server_wants_related_photos_for_is_requeued_once() { + let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo")); + let related = NodeUid::new(VolumeId::new("vol"), LinkId::new("related")); + let mut queue = std::collections::VecDeque::new(); + let mut retried = std::collections::HashSet::new(); + let mut outcomes = Vec::new(); + + let failure = || { + Some(( + vec![related.clone()], + ProtonError::invalid_operation("missing"), + )) + }; + + // First answer: re-queued with the related photo, no outcome yet. + record_timeline_outcome( + uid.clone(), + failure(), + &mut queue, + &mut retried, + &mut outcomes, + ); + assert_eq!( + queue.pop_front(), + Some((uid.clone(), vec![related.clone()])) + ); + assert!(outcomes.is_empty()); + + // Second: the retry is spent, so it becomes a failed outcome instead of + // going round again. + record_timeline_outcome( + uid.clone(), + failure(), + &mut queue, + &mut retried, + &mut outcomes, + ); + assert!(queue.is_empty()); + assert_eq!(outcomes.len(), 1); + assert!(outcomes[0].1.is_err()); + } + + #[test] + fn a_photo_that_succeeds_records_one_successful_outcome() { + let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo")); + let mut queue = std::collections::VecDeque::new(); + let mut retried = std::collections::HashSet::new(); + let mut outcomes = Vec::new(); + + record_timeline_outcome(uid.clone(), None, &mut queue, &mut retried, &mut outcomes); + + assert!(queue.is_empty()); + assert_eq!(outcomes.len(), 1); + assert!(outcomes[0].1.is_ok()); + } + + #[test] + fn missing_related_links_reads_the_error_details() { + let volume_id = VolumeId::new("vol"); + let error = ProtonApiError { + code: ResponseCode::InvalidRequirements, + http_status: 422, + message: "missing related photos".into(), + details: Some(serde_json::json!({ "Missing": ["a", "b"] })), + }; + + assert_eq!( + missing_related_links(&error, &volume_id), + vec![ + NodeUid::new(volume_id.clone(), LinkId::new("a")), + NodeUid::new(volume_id.clone(), LinkId::new("b")), + ] + ); + + // An error that names nothing is an ordinary failure, not a retry. + let plain = ProtonApiError { + details: None, + ..error + }; + assert!(missing_related_links(&plain, &volume_id).is_empty()); + } } diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index edd6728..c0a43b2 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -413,34 +413,138 @@ pub struct AddPhotoToAlbumEntry { #[derive(Debug, Deserialize)] pub struct AddPhotosToAlbumResponse { #[serde(rename = "Responses", default)] - pub responses: Vec, + pub responses: Vec, } #[derive(Debug, Deserialize)] -pub struct AddPhotoToAlbumOutcome { +pub struct PhotoLinkOutcome { #[serde(rename = "LinkID")] pub link_id: LinkId, #[serde(rename = "Response")] - pub response: AddPhotoToAlbumOutcomeBody, + pub response: PhotoLinkOutcomeBody, } #[derive(Debug, Deserialize)] -pub struct AddPhotoToAlbumOutcomeBody { +pub struct PhotoLinkOutcomeBody { #[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, + pub details: Option, } #[derive(Debug, Deserialize)] -pub struct AddPhotoToAlbumOutcomeDetails { +pub struct PhotoLinkOutcomeDetails { #[serde(rename = "Missing", default)] pub missing: Vec, } +/// `PUT photos/volumes/{vid}/links/transfer-multiple` — move photos that +/// already live on our own photos volume into another node on it (C# +/// `PhotosApiClient.TransferPhotosAsync`, JS `transferPhotos`). Used to save +/// photos into the timeline root; a main photo's related photos travel in the +/// same `Links` list. +#[derive(Debug, Serialize)] +pub struct TransferPhotosRequest { + #[serde(rename = "ParentLinkID")] + pub parent_link_id: LinkId, + #[serde(rename = "Links")] + pub links: Vec, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: String, + /// Sent explicitly as null: the API wants it only for an anonymous node, + /// which this path never moves (C# sends the same explicit null). + #[serde(rename = "SignatureEmail")] + pub signature_email: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TransferPhotoLinkItem { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + /// Name hash under the *target's* hash key. + #[serde(rename = "Hash")] + pub name_hash: String, + /// The photo's current name hash, under the hash key of the parent it is + /// moving out of — the server matches the link on it. + #[serde(rename = "OriginalHash")] + pub original_name_hash: String, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "ContentHash")] + pub content_hash: String, + /// As `SignatureEmail` above: anonymous nodes only. + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TransferPhotosResponse { + #[serde(rename = "Responses", default)] + pub responses: Vec, +} + +/// `POST volumes/{vid}/links/{lid}/copy` — copy a photo that lives on *another* +/// volume (one shared with us) into our own (C# `PhotosApiClient.CopyPhotoAsync`, +/// JS `copyPhoto`). Unlike the transfer endpoint this takes one photo per +/// request, with its related photos nested in `Photos`. +#[derive(Debug, Serialize)] +pub struct CopyPhotoRequest { + #[serde(rename = "TargetVolumeID")] + pub target_volume_id: VolumeId, + #[serde(rename = "TargetParentLinkID")] + pub target_parent_link_id: LinkId, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NameSignatureEmail")] + pub name_signature_email: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde( + rename = "NodePassphraseSignature", + skip_serializing_if = "Option::is_none" + )] + pub passphrase_signature: Option, + #[serde(rename = "SignatureEmail", skip_serializing_if = "Option::is_none")] + pub signature_email: Option, + #[serde(rename = "Photos")] + pub photos: CopyPhotoContent, +} + +#[derive(Debug, Serialize)] +pub struct CopyPhotoContent { + #[serde(rename = "ContentHash")] + pub content_hash: String, + #[serde(rename = "RelatedPhotos")] + pub related_photos: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CopyPhotoRelatedItem { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "ContentHash")] + pub content_hash: String, +} + +#[derive(Debug, Deserialize)] +pub struct CopyPhotoResponse { + #[serde(rename = "LinkID")] + pub link_id: LinkId, +} + /// `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 ce9cb38..f929799 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -15,10 +15,11 @@ use std::io::{Cursor, Read}; use serde::{Deserialize, Serialize}; use proton_sdk::error::Result; -use proton_sdk::ids::NodeUid; +use proton_sdk::ids::{DriveEventId, NodeUid}; use proton_sdk::session::ProtonApiSession; use crate::client::ProtonDriveClient; +use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{FileThumbnail, Node, Thumbnail, ThumbnailType}; /// One photos-timeline entry: a photo node and its capture time (epoch @@ -361,6 +362,34 @@ impl ProtonPhotosClient { self.drive.update_photos(updates).await } + /// Copy photos into this account's own timeline, keeping them even after + /// the originals stop being shared. One outcome per input photo. + /// + /// C# `ProtonPhotosClient.SavePhotosToTimelineAsync`: a photo already on our + /// photos volume is moved into the timeline root, one from another volume + /// (a shared-with-me album) is copied. Related photos travel with their main + /// photo. A photo already in the timeline fails its own outcome, and a photo + /// whose group the server reports as incomplete is retried once with the + /// related photos it named — so outcomes arrive in completion order, not + /// input order. + pub async fn save_photos_to_timeline( + &self, + photo_uids: &[NodeUid], + ) -> Result)>> { + self.drive.save_photos_to_timeline(photo_uids).await + } + + /// Drive events on a photos scope, from `cursor` onwards + /// (C# `ProtonPhotosClient.EnumerateEventsAsync`, which delegates to the + /// Drive client exactly as this does). + pub async fn enumerate_events( + &self, + scope: &DriveEventScopeId, + cursor: Option<&DriveEventId>, + ) -> Result> { + self.drive.enumerate_events(scope, cursor).await + } + /// [`update_photos`](Self::update_photos) as a stream: each photo's outcome /// arrives as that photo's update finishes, mirroring the C# /// `IAsyncEnumerable`. The `Err` item is a failure that