mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
validate_node_name, albums, photo tags
This commit is contained in:
1 parent
db66211a4d
commit
3616f15cfc
10 files changed
+830
-43
No files matched your search
@@ -166,6 +166,8 @@ mod tests {
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: crate::node::NodeVerification::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user