validate_node_name, albums, photo tags

This commit is contained in:
nvrl committed 2026-07-31 19:32:59 +02:00
1 parent db66211a4d
commit 3616f15cfc
10 files changed
+830 -43

No files matched your search

+5
View File
@@ -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. |
+3 -2
View File
@@ -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. |
+2
View File
@@ -166,6 +166,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: crate::node::NodeVerification::default(),
}
}
+389 -34
View File
@@ -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<i64>,
) -> Result<NodeUid> {
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<Vec<NodeUid>> {
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<LinkId> = 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<Vec<AlbumItem>> {
let mut timer = self.telemetry.start("enumerate_album");
let mut items = Vec::new();
let mut anchor: Option<LinkId> = 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<Vec<NodeUid>> {
let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids");
let mut uids = Vec::new();
let mut anchor: Option<LinkId> = 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<Vec<NodeUid>> {
let mut timer = self
.telemetry
.start("enumerate_photos_shared_with_me_node_uids");
let mut uids = Vec::new();
let mut anchor: Option<String> = 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<Vec<(NodeUid, 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<i32> = 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<i32> = 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<SharedWithMeItem> {
#[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<String> = alternate_names("report.txt").take(3).collect();
+273 -4
View File
@@ -142,10 +142,15 @@ pub struct LinkDetailsDto {
#[serde(rename = "File")]
pub file: Option<FileDto>,
/// 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<FileDto>,
pub photo: Option<PhotoDto>,
/// 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<AlbumDto>,
/// Present when the node is shared (with members and/or via a public link).
#[serde(rename = "Sharing", default)]
pub sharing: Option<LinkSharingDto>,
@@ -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<FolderProperties<'_>> {
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<String>,
/// 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<LinkId>,
/// The related photos of this (main) photo.
#[serde(rename = "RelatedPhotosLinkIDs", default)]
pub related_photo_link_ids: Vec<LinkId>,
/// Classification tags as their `PhotoTag` discriminants; unknown values are
/// dropped when the node is built.
#[serde(rename = "Tags", default)]
pub tags: Vec<i32>,
/// The albums this photo has been added to.
#[serde(rename = "Albums", default)]
pub album_inclusions: Vec<PhotoAlbumInclusionDto>,
}
/// 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<String>,
#[serde(rename = "PhotoCount", default)]
pub photo_count: i32,
#[serde(rename = "CoverLinkID", default)]
pub cover_link_id: Option<LinkId>,
/// 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<AlbumListItemDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[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<AlbumItemDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[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<SharedAlbumDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[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<i32>,
}
#[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
+7 -2
View File
@@ -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};
+46
View File
@@ -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<ShareMembership>,
/// 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<PhotoProperties>,
/// 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<AlbumProperties>,
/// 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<String>,
/// Set when this is a *related* photo (live-photo video, burst sibling):
/// the main photo it belongs to.
pub main_photo_uid: Option<NodeUid>,
/// This (main) photo's related photos.
pub related_photo_uids: Vec<NodeUid>,
/// Classification tags. Values the server sends that this SDK does not know
/// are dropped rather than failing the node.
pub tags: Vec<PhotoTag>,
/// The albums this photo has been added to.
pub album_uids: Vec<NodeUid>,
}
/// 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<NodeUid>,
/// Epoch seconds of the last change to the album's contents; `None` when the
/// server sends none.
pub last_activity_time: Option<i64>,
}
/// 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(),
};
+84
View File
@@ -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<Self> {
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<PhotoTag>,
pub tags_to_remove: Vec<PhotoTag>,
}
/// 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<Vec<NodeUid>> {
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<Vec<NodeUid>> {
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<Vec<AlbumItem>> {
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<Vec<NodeUid>> {
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<Vec<NodeUid>> {
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<Vec<(NodeUid, Result<()>)>> {
self.drive.update_photos(updates).await
}
}
#[cfg(test)]
+8 -1
View File
@@ -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<Vec<u8>> {
+13
View File
@@ -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<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T> {
self.send::<B, T>(Method::DELETE, path, Some(body)).await
}
async fn send<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,