diff --git a/README.md b/README.md index 6a46151..21882af 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,11 @@ Moving a file or folder within the same volume is an offline cryptographic opera | | Incoming Invites | ✅ | List, accept, or reject invitations shared *with* the current user. | | **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. | | | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). | +| | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. | +| | Photo Tags | ✅ | Add/remove classification tags (`update_photos`); `Favorite` on photos in our own timeline. | +| | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`). | +| | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported. | +| | Album Writes | ❌ | Creating albums and adding/removing photos is not ported. | | | Photos Volume Create| ❌ | Volume creation is not yet ported. | | **Caching** | Pluggable Cache | ✅ | Pluggable entity cache (`with_entity_cache`); keys/secrets remain strictly in memory. | diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index aa414d4..dc04f4e 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**: `e0ad37e30ab16dd78899d1f4475fd6760b4c5c43` -- **Date**: 2026-07-24 +- **Pinned**: `941aafa55fb179c8ee347c9d0b4660a3fc893f09` +- **Date**: 2026-07-31 > **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. @@ -31,3 +31,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). | | 2026-07-24 | `1b6ff835` → `e0ad37e3` | `d2ffde20`, `724b6771` | both features were already ported from the TypeScript SDK, so this closed the remaining parity deltas. ported: **shared-by-me on the photos volume** (`SharingOperations.EnumerateSharedNodeUidsAsync` now takes a volume resolver — `page_shared_by_me(volume_id)` + `ProtonPhotosClient::enumerate_shared_node_uids`, empty when the account has no photos volume) and `SharedByMeLinkDto.ContextShareID` (kept `Option` — recent upstream addition, older backends omit it); **per-node move outcomes** (`move_nodes` now returns `Vec<(NodeUid, Result<()>)>` in input order, mirroring the breaking `MoveNodesAsync` result map; per-link envelope codes via the new `aggregate_outcomes`, which `check_aggregate` now sits on). cosmetic, not ported: `AnchorID` retyped `string` → `LinkId` (ours is `String`). diverged deliberately: we keep the batch endpoint `PUT volumes/{vid}/links/move-multiple` — upstream still loops per node behind a `// FIXME: … use the batch move endpoint`; and the drive-side listing keeps the `_by_me` suffix (upstream renamed both to `EnumerateSharedNodeUids`) because `proton-drive-linux` already consumes that name. | | 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. | +| 2026-07-31 | `e0ad37e3` → `941aafa5` | `42526703`, `f2fa871b`, `814ae781`, `c528a376` | ported: **node-name validation** (`validate_node_name`: empty or >255 chars rejected before create/rename; counted in `char`s — C# counts UTF-16 units, and neither is the server's own accounting); **album read ops** (`AlbumDto` + `enumerate_album_node_uids` / `enumerate_album`, `GET photos/volumes/{vid}/albums` and `…/albums/{lid}/children?Sort=Captured&Desc=1`, both paged on `AnchorID`; `LinkDetailsDto::folder_properties` reads an album's hash key out of the `Album` block, which also makes `parent_hash_key` work for album children); **photo + album node metadata** (`Node::photo` / `Node::album` — capture time, content hash, main/related photos, tags, album inclusions; photo count, cover photo, last activity — added as optional `Node` fields rather than `NodeKind` variants so persisted node blobs keep deserializing, mirroring how `membership` landed); **photo tag updates** (`update_photos`, `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` via the new `ApiHttpClient::delete_with_body`, `POST …/favorite` for photos on our own photos volume, one outcome per input update like `move_nodes`); **shared-with-me photos/albums** (`ShareTargetType::is_photos_item` over `v2/sharedwithme` + the dedicated `GET photos/albums/shared-with-me`, whose rows carry their own `VolumeID`). deferred: `03b1cb7f` **favoriting shared photos** — needs `PhotosCrypto` + `PhotoTransferPayloadBuilder` (name/passphrase session keys rewrapped to the timeline root, related-photo fan-out, batches of 20); our `rewrap_message_to` / `recover_message_session_key` make it feasible, but until it lands `update_photos` fails that one update explicitly instead of silently no-oping. not ported: `4c1dd783` (`IsSharedPublicly` → `IsSharedByUrl` rename — `proton-drive-linux` consumes `is_shared_publicly`), `1477ea45` (C# user-facing error strings; our error text is our own), the second half of `42526703` (server-supplied names validated into a per-node `InvalidNameError` — we have no node error list to put it in). no-op: `82d605b7` (C# `FifoFlexibleSemaphore` cancel race — tokio's `Semaphore` is cancel-safe and drops queued waiters), `8bdd9420` (throw on resume-after-dispose; Rust ownership makes it unreachable), `ca11470f` + `c947fce7` (CExports/interop only). Album *writes* (create, add/remove photos) remain unported — upstream has no C# public API for them either. Live validation pending: every photos endpoint here needs a photos-volume account. | diff --git a/crates/proton-drive-rs/src/cache.rs b/crates/proton-drive-rs/src/cache.rs index b73bc54..1f16ecf 100644 --- a/crates/proton-drive-rs/src/cache.rs +++ b/crates/proton-drive-rs/src/cache.rs @@ -166,6 +166,8 @@ mod tests { is_shared_publicly: false, signature_email: None, membership: None, + photo: None, + album: None, verification: crate::node::NodeVerification::default(), } } diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 7d4faf6..adb0f88 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -44,35 +44,41 @@ use crate::crypto::{ }; use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ - AcceptInvitationRequest, AggregateLinksResponse, 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, PhotosAttributesDto, RenameLinkRequest, RevisionConflict, - RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, - RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest, - ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, - ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse, + 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, RevisionResponse, 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::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; -use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; +use crate::node::{ + AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, + ThumbnailType, +}; +use crate::photos::{ + AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, +}; use crate::revision::{ MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking, }; @@ -178,6 +184,29 @@ enum NodeDetail { /// `NodeOperations.GetAvailableNameAsync` `batchSize`). const NAME_AVAILABILITY_BATCH: usize = 10; +/// Longest node name the API accepts (C# `NodeOperations.MaxNodeNameLength`). +const MAX_NODE_NAME_LENGTH: usize = 255; + +/// Reject a caller-supplied node name before it is encrypted and sent. +/// +/// C# `NodeOperations.ValidateNodeName` (itself mirroring the JS SDK's +/// `validateNodeName`), applied on create, rename and rename-during-move. The +/// length is counted in `char`s: C# counts UTF-16 code units, which differs only +/// for astral-plane characters, and neither matches the server's own byte +/// accounting — this is a client-side guard against obviously bad input, not the +/// authority. +fn validate_node_name(name: &str) -> Result<()> { + if name.is_empty() { + return Err(ProtonError::invalid_operation("Name must not be empty")); + } + if name.chars().count() > MAX_NODE_NAME_LENGTH { + return Err(ProtonError::invalid_operation(format!( + "Name must be {MAX_NODE_NAME_LENGTH} characters long at most" + ))); + } + Ok(()) +} + /// High-level Proton Drive client. /// /// Holds an authenticated session plus an [`AccountClient`]. Because Proton's @@ -1207,7 +1236,7 @@ impl ProtonDriveClient { let link = detail.link; let file = detail .file - .or(detail.photo) + .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { @@ -1299,7 +1328,7 @@ impl ProtonDriveClient { let link = detail.link; let file = detail .file - .or(detail.photo) + .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { @@ -1926,7 +1955,7 @@ impl ProtonDriveClient { let link = detail.link; let file = detail .file - .or(detail.photo) + .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { @@ -2413,6 +2442,7 @@ impl ProtonDriveClient { last_modification_time: Option, ) -> Result { let mut timer = self.telemetry.start("create_folder"); + validate_node_name(name)?; let volume_id = parent_uid.volume_id.clone(); // Resolve the parent folder key + hash key and the membership address. @@ -2544,6 +2574,7 @@ impl ProtonDriveClient { new_media_type: Option<&str>, ) -> Result<()> { let mut timer = self.telemetry.start("rename_node"); + validate_node_name(new_name)?; let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; @@ -4959,13 +4990,18 @@ impl ProtonDriveClient { for_photos, ) .await?; - let folder = details + let detail = details .links .into_iter() .next() - .and_then(|d| d.folder) .ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?; - Ok(parent_key.decrypt_armored_message(&folder.hash_key)?) + // An album carries its hash key in the `Album` block instead of `Folder` + // (C# builds a synthetic `FolderDto` from `AlbumDto`), and album children + // are hashed under it exactly as a folder's are. + let folder = detail + .folder_properties() + .ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?; + Ok(parent_key.decrypt_armored_message(folder.hash_key)?) } /// Fetch the block verification code, validating that the returned content @@ -5525,6 +5561,266 @@ impl ProtonDriveClient { .collect()) } + /// Page the albums on the account's photos volume. + /// + /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`: + /// `GET photos/volumes/{vid}/albums`, paged on `AnchorID`. Empty when the + /// account has no photos volume (C# yields nothing rather than erroring). + pub(crate) async fn enumerate_album_node_uids(&self) -> Result> { + let mut timer = self.telemetry.start("enumerate_album_node_uids"); + if !self.ensure_photos().await? { + timer.success(); + return Ok(Vec::new()); + } + let volume_id = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root") + .volume_id; + + let mut uids = Vec::new(); + let mut anchor: Option = None; + loop { + let path = match &anchor { + Some(anchor_id) => { + format!("photos/volumes/{volume_id}/albums?AnchorID={anchor_id}") + } + None => format!("photos/volumes/{volume_id}/albums"), + }; + let page: AlbumListResponse = self.http.get(&path).await?; + + for album in &page.albums { + uids.push(NodeUid::new(volume_id.clone(), album.id.clone())); + } + + anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + timer.success(); + Ok(uids) + } + + /// Page one album's photos, newest capture first. + /// + /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumAsync`: + /// `GET photos/volumes/{vid}/albums/{lid}/children?Sort=Captured&Desc=1`, + /// paged on `AnchorID`. The album's own volume is used, so an album shared + /// with us (on the sharer's photos volume) enumerates the same way. + pub(crate) async fn enumerate_album(&self, album_uid: &NodeUid) -> Result> { + let mut timer = self.telemetry.start("enumerate_album"); + let mut items = Vec::new(); + let mut anchor: Option = None; + loop { + let base = format!( + "photos/volumes/{}/albums/{}/children?Sort=Captured&Desc=1", + album_uid.volume_id, album_uid.link_id + ); + let path = match &anchor { + Some(anchor_id) => format!("{base}&AnchorID={anchor_id}"), + None => base, + }; + let page: AlbumItemListResponse = self.http.get(&path).await?; + + for photo in &page.photos { + items.push(AlbumItem { + uid: NodeUid::new(album_uid.volume_id.clone(), photo.id.clone()), + capture_time: photo.capture_time, + }); + } + + anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + timer.success(); + Ok(items) + } + + /// Page the albums other users share with us. + /// + /// Mirrors C# `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`: + /// `GET photos/albums/shared-with-me`, paged on `AnchorID`. Unlike + /// `v2/sharedwithme` each row carries its own `VolumeID` — a shared album + /// lives on the sharer's photos volume — and the listing is account-wide, so + /// it works even when *we* have no photos volume. + pub(crate) async fn enumerate_shared_with_me_album_uids(&self) -> Result> { + let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids"); + let mut uids = Vec::new(); + let mut anchor: Option = None; + loop { + let path = match &anchor { + Some(anchor_id) => format!("photos/albums/shared-with-me?AnchorID={anchor_id}"), + None => "photos/albums/shared-with-me".to_string(), + }; + let page: SharedAlbumsResponse = self.http.get(&path).await?; + + for album in &page.albums { + uids.push(NodeUid::new(album.volume_id.clone(), album.link_id.clone())); + } + + anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + timer.success(); + Ok(uids) + } + + /// The photos and albums shared *with* us, from `v2/sharedwithme`. + /// + /// The same listing [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids) + /// pages, kept to the target types the Photos client owns (C# + /// `ProtonPhotosClient.ShareTargetTypes` = Photo + Album). Exposed through + /// [`ProtonPhotosClient`](crate::ProtonPhotosClient). + pub(crate) async fn enumerate_photos_shared_with_me_node_uids(&self) -> Result> { + let mut timer = self + .telemetry + .start("enumerate_photos_shared_with_me_node_uids"); + let mut uids = Vec::new(); + let mut anchor: Option = None; + loop { + let path = match &anchor { + Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"), + None => "v2/sharedwithme".to_string(), + }; + let page: SharedWithMeResponse = self.http.get(&path).await?; + + for link in &page.links { + if ShareTargetType::from_raw(link.share_target_type) + .is_some_and(ShareTargetType::is_photos_item) + { + uids.push(NodeUid::new(link.volume_id.clone(), link.link_id.clone())); + } + } + + anchor = page.anchor_id.filter(|id| !id.is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + timer.success(); + Ok(uids) + } + + /// Apply tag additions/removals to photos. + /// + /// Mirrors C# `PhotoOperations.UpdatePhotosAsync`: one outcome per input + /// update, in input order, so a photo that fails does not stop the others — + /// the outer `Err` is reserved for what makes the whole call impossible. + /// `Favorite` is special-cased exactly as upstream: it is *set* through + /// `POST photos/volumes/{vid}/links/{lid}/favorite` and only for photos on + /// our own photos volume, while every other tag (and every removal, + /// `Favorite` included) goes through the tags endpoints. + /// + /// Favoriting a photo on someone else's volume needs the photo — and its + /// related photos — re-encrypted for our timeline root and sent as the + /// favorite request body (upstream `03b1cb7f`, `PhotoTransferPayloadBuilder`). + /// That is not ported, so such an update fails with an explicit error rather + /// than silently doing nothing. + pub(crate) async fn update_photos( + &self, + updates: &[PhotoTagsUpdate], + ) -> Result)>> { + let mut timer = self.telemetry.start("update_photos"); + timer.attr("photo_count", updates.len()); + if updates.is_empty() { + timer.success(); + return Ok(Vec::new()); + } + + // Resolved once: the volume a bodyless favorite is valid on. `None` when + // the account has no photos volume, which makes every favorite fail below. + let own_photos_volume = if self.ensure_photos().await? { + self.cache + .lock() + .await + .photos_root + .clone() + .map(|root| root.volume_id) + } else { + None + }; + + let mut outcomes = Vec::with_capacity(updates.len()); + for update in updates { + let outcome = self + .apply_tag_update(update, own_photos_volume.as_ref()) + .await; + outcomes.push((update.node_uid.clone(), outcome)); + } + timer.success(); + Ok(outcomes) + } + + /// One photo's tag update (C# `PhotoOperations.ApplyTagUpdateAsync`). + async fn apply_tag_update( + &self, + update: &PhotoTagsUpdate, + own_photos_volume: Option<&VolumeId>, + ) -> Result<()> { + let volume_id = &update.node_uid.volume_id; + let link_id = &update.node_uid.link_id; + + if update.tags_to_add.contains(&PhotoTag::Favorite) { + match own_photos_volume { + Some(own) if own == volume_id => { + let path = format!("photos/volumes/{volume_id}/links/{link_id}/favorite"); + // An empty object, not `()`: serde renders the unit type as + // `null`, which the API rejects. + let _: proton_sdk::api::ApiResponse = + self.http.post(&path, &serde_json::json!({})).await?; + } + _ => { + return Err(ProtonError::invalid_operation( + "favoriting a photo that is not on this account's photos volume is not supported yet", + )); + } + } + } + + // `Favorite` has its own endpoint and is never sent as a plain tag. + let tags_to_add: Vec = update + .tags_to_add + .iter() + .filter(|&&tag| tag != PhotoTag::Favorite) + .map(|&tag| tag as i32) + .collect(); + if !tags_to_add.is_empty() { + let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags"); + let _: proton_sdk::api::ApiResponse = self + .http + .post(&path, &PhotoTagsRequest { tags: tags_to_add }) + .await?; + } + + if !update.tags_to_remove.is_empty() { + let tags_to_remove: Vec = update + .tags_to_remove + .iter() + .map(|&tag| tag as i32) + .collect(); + let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags"); + let _: proton_sdk::api::ApiResponse = self + .http + .delete_with_body( + &path, + &PhotoTagsRequest { + tags: tags_to_remove, + }, + ) + .await?; + } + + Ok(()) + } + /// Download and decrypt a photo's active revision into `output` (photos /// routing). C# `PhotosFileDownloader`: the node is resolved via the photos /// endpoint; blocks are fetched from their absolute storage URLs exactly as @@ -5545,7 +5841,7 @@ impl ProtonDriveClient { let link = detail.link; let file = detail .file - .or(detail.photo) + .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { @@ -5847,6 +6143,11 @@ impl ProtonDriveClient { .clone() .map(|pid| NodeUid::new(volume_id.clone(), pid)); + // Album- and photo-only metadata, layered onto the folder/file node the + // way C# layers `AlbumNode : FolderNode` / `PhotoNode : FileNode`. + let mut album = None; + let mut photo = None; + let kind = match link.parsed_type() { LinkType::Folder | LinkType::Album => { let node_key = node_key.as_ref().ok_or_else(|| { @@ -5858,6 +6159,15 @@ impl ProtonDriveClient { .await .folder_keys .put(uid.clone(), node_key.clone()); + album = details.album.as_ref().map(|album| AlbumProperties { + photo_count: album.photo_count, + cover_photo_uid: album + .cover_link_id + .clone() + .map(|link_id| NodeUid::new(volume_id.clone(), link_id)), + last_activity_time: (album.last_activity_time != 0) + .then_some(album.last_activity_time), + }); NodeKind::Folder } LinkType::File => { @@ -5927,6 +6237,31 @@ impl ProtonDriveClient { } } } + photo = details.photo.as_ref().map(|photo| PhotoProperties { + capture_time: photo.capture_time, + content_hash: photo.content_hash.clone(), + main_photo_uid: photo + .main_photo_link_id + .clone() + .map(|link_id| NodeUid::new(volume_id.clone(), link_id)), + related_photo_uids: photo + .related_photo_link_ids + .iter() + .map(|link_id| NodeUid::new(volume_id.clone(), link_id.clone())) + .collect(), + // An unknown tag discriminant is dropped: the server may add + // tags this SDK predates, and that must not fail the node. + tags: photo + .tags + .iter() + .filter_map(|&t| PhotoTag::from_raw(t)) + .collect(), + album_uids: photo + .album_inclusions + .iter() + .map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone())) + .collect(), + }); NodeKind::File { media_type: file.media_type.clone(), total_size_on_storage: file.total_size_on_storage, @@ -5967,6 +6302,8 @@ impl ProtonDriveClient { // Present only when the node is shared *with* us; it is what says // whether we may write to it. membership: details.membership.as_ref().map(share_membership_from_dto), + photo, + album, verification, }; @@ -6700,11 +7037,13 @@ fn drive_items(page: &SharedWithMeResponse) -> Vec { #[cfg(test)] mod tests { use super::{ - CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes, - alternate_names, assemble_manifest, check_aggregate, 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, + CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, + MAX_NODE_NAME_LENGTH, aggregate_outcomes, alternate_names, assemble_manifest, + check_aggregate, 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, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto, @@ -7138,6 +7477,22 @@ mod tests { assert!(to_drive_event(&vid, &event(99, None)).is_err()); } + #[test] + fn node_names_are_validated_before_they_are_sent() { + // C# `NodeOperations.ValidateNodeName`: empty and over-long names are + // rejected client-side rather than round-tripping to a server error. + assert!(validate_node_name("report.txt").is_ok()); + assert!(validate_node_name("").is_err()); + + let longest = "a".repeat(MAX_NODE_NAME_LENGTH); + assert!(validate_node_name(&longest).is_ok()); + assert!(validate_node_name(&format!("{longest}a")).is_err()); + + // Counted in characters, not bytes: a 255-emoji name is 1020 bytes and + // still legal. + assert!(validate_node_name(&"🙂".repeat(MAX_NODE_NAME_LENGTH)).is_ok()); + } + #[test] fn alternate_names_keeps_extension() { let got: Vec = alternate_names("report.txt").take(3).collect(); diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index f853dcd..03a963c 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -142,10 +142,15 @@ pub struct LinkDetailsDto { #[serde(rename = "File")] pub file: Option, /// Photos-volume `/links` returns file properties under `Photo` (a superset - /// of `File`) rather than `File`. Deserialized as [`FileDto`]; the extra - /// photo fields are ignored. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`. + /// of `File`) rather than `File`. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`. #[serde(rename = "Photo")] - pub photo: Option, + pub photo: Option, + /// Album properties, for a link of type [`LinkType::Album`]. An album + /// decrypts exactly like a folder — the block simply carries the hash key + /// under the same name plus the album-only metadata (C# `AlbumDto`, layered + /// onto a `FolderNode` by `DtoToMetadataConverter.ConvertAlbumMetadataAsync`). + #[serde(rename = "Album")] + pub album: Option, /// Present when the node is shared (with members and/or via a public link). #[serde(rename = "Sharing", default)] pub sharing: Option, @@ -180,8 +185,153 @@ impl LinkDetailsDto { /// File properties for a file/photo node, preferring `File` and falling back /// to the photos-volume `Photo` block. pub fn file_properties(&self) -> Option<&FileDto> { - self.file.as_ref().or(self.photo.as_ref()) + self.file + .as_ref() + .or_else(|| self.photo.as_ref().map(|photo| &photo.file)) } + + /// Folder properties for a folder/album node. An album's hash key and + /// extended attributes live in the `Album` block, which is otherwise a + /// superset of `Folder` — C# builds a synthetic `FolderDto` from it. + pub fn folder_properties(&self) -> Option> { + if let Some(folder) = self.folder.as_ref() { + return Some(FolderProperties { + hash_key: &folder.hash_key, + extended_attributes: folder.extended_attributes.as_deref(), + }); + } + self.album.as_ref().map(|album| FolderProperties { + hash_key: &album.hash_key, + extended_attributes: album.extended_attributes.as_deref(), + }) + } +} + +/// The folder-shaped part of a `Folder` or `Album` block (C# +/// `ConvertAlbumMetadataAsync`'s synthetic `FolderDto`). +pub struct FolderProperties<'a> { + pub hash_key: &'a str, + pub extended_attributes: Option<&'a str>, +} + +/// Photo properties on a photos-volume link (C# `PhotoDto : FileDto`). +#[derive(Debug, Deserialize)] +pub struct PhotoDto { + /// The file properties a photo shares with any other file. + #[serde(flatten)] + pub file: FileDto, + /// Capture time in seconds since the Unix epoch. + #[serde(rename = "CaptureTime", default)] + pub capture_time: i64, + /// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash + /// key — the second half of duplicate detection. + #[serde(rename = "ContentHash", default)] + pub content_hash: Option, + /// Set when this photo is a *related* photo (live-photo video, burst + /// sibling): the link id of the main photo it belongs to. + #[serde(rename = "MainPhotoLinkID", default)] + pub main_photo_link_id: Option, + /// The related photos of this (main) photo. + #[serde(rename = "RelatedPhotosLinkIDs", default)] + pub related_photo_link_ids: Vec, + /// Classification tags as their `PhotoTag` discriminants; unknown values are + /// dropped when the node is built. + #[serde(rename = "Tags", default)] + pub tags: Vec, + /// The albums this photo has been added to. + #[serde(rename = "Albums", default)] + pub album_inclusions: Vec, +} + +/// One album a photo belongs to (C# `PhotoAlbumInclusionDto`). +#[derive(Debug, Deserialize)] +pub struct PhotoAlbumInclusionDto { + #[serde(rename = "AlbumLinkID")] + pub album_link_id: LinkId, + #[serde(rename = "AddedTime", default)] + pub creation_time: i64, +} + +/// Album properties on a photos-volume link (C# `AlbumDto`). +#[derive(Debug, Deserialize)] +pub struct AlbumDto { + /// The album's child-name hash key — same field a folder carries. + #[serde(rename = "NodeHashKey")] + pub hash_key: String, + #[serde(rename = "XAttr")] + pub extended_attributes: Option, + #[serde(rename = "PhotoCount", default)] + pub photo_count: i32, + #[serde(rename = "CoverLinkID", default)] + pub cover_link_id: Option, + /// Epoch seconds of the last change to the album's contents. + #[serde(rename = "LastActivityTime", default)] + pub last_activity_time: i64, +} + +/// `GET photos/volumes/{vid}/albums` — the albums on a photos volume, paged on +/// `AnchorID` (C# `AlbumListResponse`). +#[derive(Debug, Deserialize)] +pub struct AlbumListResponse { + #[serde(rename = "Albums", default)] + pub albums: Vec, + #[serde(rename = "More", default)] + pub more: bool, + #[serde(rename = "AnchorID", default)] + pub anchor_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumListItemDto { + #[serde(rename = "LinkID")] + pub id: LinkId, +} + +/// `GET photos/volumes/{vid}/albums/{lid}/children` — an album's photos, newest +/// capture first, paged on `AnchorID` (C# `AlbumItemListResponse`). +#[derive(Debug, Deserialize)] +pub struct AlbumItemListResponse { + #[serde(rename = "Photos", default)] + pub photos: Vec, + #[serde(rename = "More", default)] + pub more: bool, + #[serde(rename = "AnchorID", default)] + pub anchor_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumItemDto { + #[serde(rename = "LinkID")] + pub id: LinkId, + #[serde(rename = "CaptureTime", default)] + pub capture_time: i64, +} + +/// `GET photos/albums/shared-with-me` — albums other users share with us. Unlike +/// `v2/sharedwithme` each row carries its own volume (C# `SharedAlbumsResponse`). +#[derive(Debug, Deserialize)] +pub struct SharedAlbumsResponse { + #[serde(rename = "Albums", default)] + pub albums: Vec, + #[serde(rename = "More", default)] + pub more: bool, + #[serde(rename = "AnchorID", default)] + pub anchor_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SharedAlbumDto { + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + #[serde(rename = "LinkID")] + pub link_id: LinkId, +} + +/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`). +#[derive(Debug, Serialize)] +pub struct PhotoTagsRequest { + #[serde(rename = "Tags")] + pub tags: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -1136,6 +1286,12 @@ impl ShareTargetType { pub fn is_drive_item(self) -> bool { matches!(self, Self::Folder | Self::File | Self::ProtonVendor) } + + /// Whether the Photos client owns this kind (C# + /// `ProtonPhotosClient.ShareTargetTypes`). + pub fn is_photos_item(self) -> bool { + matches!(self, Self::Album | Self::Photo) + } } /// `GET devices` — the account's registered devices. C# `DeviceListResponse`. @@ -1275,6 +1431,119 @@ mod tests { assert_eq!(response.context_share_id, ShareId::new("context-share-1")); } + #[test] + fn album_link_details_expose_folder_properties() { + // An album's hash key arrives in the `Album` block, not `Folder`; the + // rest of the client reads it through `folder_properties` (C# builds a + // synthetic `FolderDto` from `AlbumDto`). + let raw = r#"{ + "Link": { + "LinkID": "album-1", "Type": 3, "ParentLinkID": "root", "State": 1, + "CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k", + "NodePassphrase": "p" + }, + "Album": { + "NodeHashKey": "hash-key", "XAttr": "xattr", "PhotoCount": 7, + "CoverLinkID": "photo-9", "LastActivityTime": 1700000000 + } + }"#; + let details: LinkDetailsDto = serde_json::from_str(raw).unwrap(); + let album = details.album.as_ref().expect("album block"); + assert_eq!(album.photo_count, 7); + assert_eq!(album.cover_link_id.as_ref().unwrap().as_str(), "photo-9"); + assert_eq!(album.last_activity_time, 1_700_000_000); + + let folder = details + .folder_properties() + .expect("album reads as a folder"); + assert_eq!(folder.hash_key, "hash-key"); + assert_eq!(folder.extended_attributes, Some("xattr")); + assert!(matches!(details.link.parsed_type(), LinkType::Album)); + } + + #[test] + fn photo_link_details_carry_file_and_photo_properties() { + // The photos volume returns file properties under `Photo` — a superset + // of `File` — so both halves have to come out of the one block. + let raw = r#"{ + "Link": { + "LinkID": "photo-1", "Type": 2, "ParentLinkID": "root", "State": 1, + "CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k", + "NodePassphrase": "p" + }, + "Photo": { + "MediaType": "image/jpeg", "TotalEncryptedSize": 42, + "ContentKeyPacket": "packet", + "CaptureTime": 1700000000, "ContentHash": "abcd", + "MainPhotoLinkID": "photo-main", + "RelatedPhotosLinkIDs": ["photo-2"], + "Tags": [0, 2, 99], + "Albums": [{ "AlbumLinkID": "album-1", "AddedTime": 1700000001 }] + } + }"#; + let details: LinkDetailsDto = serde_json::from_str(raw).unwrap(); + let file = details.file_properties().expect("file properties"); + assert_eq!(file.media_type, "image/jpeg"); + assert_eq!(file.total_size_on_storage, 42); + + let photo = details.photo.as_ref().expect("photo block"); + assert_eq!(photo.capture_time, 1_700_000_000); + assert_eq!(photo.content_hash.as_deref(), Some("abcd")); + assert_eq!( + photo.main_photo_link_id.as_ref().unwrap().as_str(), + "photo-main" + ); + assert_eq!(photo.related_photo_link_ids.len(), 1); + // Unknown discriminants survive the wire; they are dropped where the + // node is built, not here. + assert_eq!(photo.tags, [0, 2, 99]); + assert_eq!(photo.album_inclusions[0].album_link_id.as_str(), "album-1"); + } + + #[test] + fn album_listings_page_on_anchor() { + let albums: AlbumListResponse = serde_json::from_str( + r#"{"Albums":[{"LinkID":"album-1"}],"More":true,"AnchorID":"album-1"}"#, + ) + .unwrap(); + assert_eq!(albums.albums[0].id.as_str(), "album-1"); + assert!(albums.more); + assert_eq!(albums.anchor_id.unwrap().as_str(), "album-1"); + + let items: AlbumItemListResponse = serde_json::from_str( + r#"{"Photos":[{"LinkID":"photo-1","CaptureTime":1700000000}],"More":false}"#, + ) + .unwrap(); + assert_eq!(items.photos[0].capture_time, 1_700_000_000); + assert!(!items.more); + assert!(items.anchor_id.is_none()); + + // Shared albums live on the sharer's volume, so each row carries one. + let shared: SharedAlbumsResponse = serde_json::from_str( + r#"{"Albums":[{"VolumeID":"vol-9","LinkID":"album-3"}],"More":false}"#, + ) + .unwrap(); + assert_eq!(shared.albums[0].volume_id.as_str(), "vol-9"); + assert_eq!(shared.albums[0].link_id.as_str(), "album-3"); + } + + #[test] + fn only_photos_share_targets_are_photos_items() { + // C# `ProtonPhotosClient.ShareTargetTypes` — the complement of the Drive + // client's set, minus the root share, which is neither's. + for (raw, expected) in [ + (0, false), + (1, false), + (2, false), + (3, true), + (4, true), + (5, false), + ] { + let kind = ShareTargetType::from_raw(raw).expect("known target type"); + assert_eq!(kind.is_photos_item(), expected, "target type {raw}"); + } + } + #[test] fn only_drive_share_targets_are_drive_items() { // C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 5904399..acc7b1c 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -54,8 +54,13 @@ pub use event_manager::{ EventManagerConfig, MemoryCursorStore, }; pub use events::{DriveEvent, DriveEventScopeId}; -pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; -pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient}; +pub use node::{ + AlbumProperties, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, ThumbnailType, +}; +pub use photos::{ + AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, + ProtonPhotosClient, +}; pub use proton_sdk::account::KeySalt; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo}; diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index 55ee6e9..e73c9d2 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -4,6 +4,7 @@ use proton_sdk::crypto::VerificationStatus; use proton_sdk::ids::NodeUid; use serde::{Deserialize, Serialize}; +use crate::photos::PhotoTag; use crate::sharing::ShareMembership; /// A decrypted Drive node (folder or file). @@ -37,6 +38,16 @@ pub struct Node { /// one written before this field existed must keep deserializing. #[serde(default)] pub membership: Option, + /// Photo-only metadata, present on a photos-volume file node. C# layers this + /// as `PhotoNode : FileNode`; here it is an optional block so a `Node` + /// persisted before photos existed still deserializes. + #[serde(default)] + pub photo: Option, + /// Album-only metadata, present on an album node. An album is otherwise a + /// folder (C# `AlbumNode : FolderNode`), so [`Node::kind`] stays + /// [`NodeKind::Folder`]. + #[serde(default)] + pub album: Option, /// Per-field signature-verification results gathered while decrypting the /// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`): /// the node is always returned; the caller inspects this to decide trust. @@ -44,6 +55,39 @@ pub struct Node { pub verification: NodeVerification, } +/// Photo-only metadata on a photos-volume file node. Mirrors the fields C# +/// `PhotoNode` adds to `FileNode`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoProperties { + /// Capture time in seconds since the Unix epoch. + pub capture_time: i64, + /// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash + /// key — what duplicate detection matches on. `None` on older photos. + pub content_hash: Option, + /// Set when this is a *related* photo (live-photo video, burst sibling): + /// the main photo it belongs to. + pub main_photo_uid: Option, + /// This (main) photo's related photos. + pub related_photo_uids: Vec, + /// Classification tags. Values the server sends that this SDK does not know + /// are dropped rather than failing the node. + pub tags: Vec, + /// The albums this photo has been added to. + pub album_uids: Vec, +} + +/// Album-only metadata on an album node. Mirrors the fields C# `AlbumNode` adds +/// to `FolderNode`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumProperties { + pub photo_count: i32, + /// The photo shown as the album's cover, when it has one. + pub cover_photo_uid: Option, + /// Epoch seconds of the last change to the album's contents; `None` when the + /// server sends none. + pub last_activity_time: Option, +} + /// Outcome of verifying the signatures encountered while decrypting a node. /// /// Each field carries the [`VerificationStatus`] of one signed artifact. The @@ -310,6 +354,8 @@ mod tests { membership_id: "member-1".into(), permissions: 6, }), + photo: None, + album: None, verification: NodeVerification::default(), }; diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index 121c5f4..aa3a8d5 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -46,6 +46,43 @@ pub enum PhotoTag { Raw = 9, } +impl PhotoTag { + /// Map a wire tag discriminant, or `None` for one this SDK does not know + /// (the server may add tags; an unknown one is dropped, not an error). + pub fn from_raw(value: i32) -> Option { + match value { + 0 => Some(Self::Favorite), + 1 => Some(Self::Screenshot), + 2 => Some(Self::Video), + 3 => Some(Self::LivePhoto), + 4 => Some(Self::MotionPhoto), + 5 => Some(Self::Selfie), + 6 => Some(Self::Portrait), + 7 => Some(Self::Burst), + 8 => Some(Self::Panorama), + 9 => Some(Self::Raw), + _ => None, + } + } +} + +/// One photo's tag changes, applied by +/// [`ProtonPhotosClient::update_photos`]. Mirrors C# `PhotoTagsUpdate`. +#[derive(Debug, Clone)] +pub struct PhotoTagsUpdate { + pub node_uid: NodeUid, + pub tags_to_add: Vec, + pub tags_to_remove: Vec, +} + +/// One entry of an album listing: a photo and its capture time (epoch seconds). +/// C# `AlbumItem(NodeUid Uid, DateTime CaptureTime)`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AlbumItem { + pub uid: NodeUid, + pub capture_time: i64, +} + /// Caller-supplied metadata for a photo upload. Mirrors C# /// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults /// to the upload time when unset; `main_photo_uid` links a related photo @@ -220,6 +257,53 @@ impl ProtonPhotosClient { pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result> { self.drive.find_photo_duplicates(name, contents).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 + /// [`enumerate_nodes`](Self::enumerate_nodes) — an album node is a folder + /// carrying [`Node::album`](crate::Node::album). + pub async fn enumerate_album_node_uids(&self) -> Result> { + self.drive.enumerate_album_node_uids().await + } + + /// The photos in `album_uid`, newest capture first. + /// C# `ProtonPhotosClient.EnumerateAlbumAsync`. + pub async fn enumerate_album(&self, album_uid: &NodeUid) -> Result> { + self.drive.enumerate_album(album_uid).await + } + + /// The photos and albums other users share with us, as [`NodeUid`]s — the + /// photos counterpart of `ProtonDriveClient::enumerate_shared_with_me_node_uids` + /// (C# `ProtonPhotosClient.EnumerateSharedWithMeNodeUidsAsync`, which filters + /// `v2/sharedwithme` to the Photo/Album target types). + pub async fn enumerate_shared_with_me_node_uids(&self) -> Result> { + self.drive.enumerate_photos_shared_with_me_node_uids().await + } + + /// The albums shared with us, as [`NodeUid`]s, from the dedicated + /// `photos/albums/shared-with-me` listing (C# + /// `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`). Each row carries + /// its own volume — these live on the sharer's photos volume. + pub async fn enumerate_shared_with_me_album_uids(&self) -> Result> { + self.drive.enumerate_shared_with_me_album_uids().await + } + + /// Add and/or remove classification tags on photos. + /// + /// C# `ProtonPhotosClient.UpdatePhotosAsync`: one outcome per input update, + /// in input order — a photo that fails does not stop the others. + /// [`PhotoTag::Favorite`] is not a plain tag: it is set through the dedicated + /// `favorite` endpoint, and only for photos that already live on our own + /// photos volume. Favoriting a *shared* photo needs the photo re-encrypted + /// for our timeline root, which is not ported yet and fails that update. + /// Removing `Favorite` goes through the ordinary tag-removal endpoint. + pub async fn update_photos( + &self, + updates: &[PhotoTagsUpdate], + ) -> Result)>> { + self.drive.update_photos(updates).await + } } #[cfg(test)] diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs index 9e5c932..09079c1 100644 --- a/crates/proton-drive-rs/src/public_link.rs +++ b/crates/proton-drive-rs/src/public_link.rs @@ -556,6 +556,10 @@ impl ProtonDrivePublicLinkClient { // A public-link visitor has no account membership — access comes // from the link's own share password, not from a share member row. membership: None, + // A public link points at a Drive folder or file; the photos volume + // is not reachable this way, so there is no photo/album metadata. + photo: None, + album: None, verification: Default::default(), }) } @@ -567,7 +571,10 @@ impl ProtonDrivePublicLinkClient { /// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point /// into either. fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> { - details.file.as_ref().or(details.photo.as_ref()) + details + .file + .as_ref() + .or_else(|| details.photo.as_ref().map(|photo| &photo.file)) } fn decode_base64(value: &str, what: &str) -> Result> { diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 5d82833..4e3ec55 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -320,6 +320,19 @@ impl ApiHttpClient { self.send::<(), T>(Method::DELETE, path, None).await } + /// `DELETE {path}` with a JSON body, returning a typed success body. + /// + /// A few Drive endpoints take their operand in the body of a `DELETE` + /// (C# `HttpApiCallBuilder.DeleteAsync(route, payload, ...)`), e.g. removing + /// photo tags. + pub async fn delete_with_body( + &self, + path: &str, + body: &B, + ) -> Result { + self.send::(Method::DELETE, path, Some(body)).await + } + async fn send( &self, method: Method,