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