From 1068ddf5d044d874a0068186ed4fa24ed58750c9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 28 Sep 2026 01:06:59 +0200 Subject: [PATCH] feat(drive): read a photo's location from its extended attributes Other Proton clients write a `Location` object with `Latitude` and `Longitude` into a photo's encrypted extended attributes. The decoder dropped it. It is now decoded and exposed as `PhotoProperties::location`, a `PhotoLocation` in WGS 84 decimal degrees. Coordinates that are missing, not finite, or off the globe give no location rather than a wrong one. The new field defaults on deserialization, so nodes cached before this change still load. `PhotoProperties` gains a public field, which breaks code that builds it with a struct literal. --- crates/proton-drive-rs/src/client.rs | 9 ++++-- crates/proton-drive-rs/src/crypto.rs | 17 ++++++++++ crates/proton-drive-rs/src/dtos.rs | 13 ++++++++ crates/proton-drive-rs/src/lib.rs | 4 +-- crates/proton-drive-rs/src/node.rs | 48 ++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index bd67397..97c5d4e 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -79,8 +79,8 @@ use crate::dtos::{ }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, - Thumbnail, ThumbnailType, + AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, + RevisionState, Thumbnail, ThumbnailType, }; use crate::photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, @@ -7476,6 +7476,7 @@ impl ProtonDriveClient { let mut claimed_size = None; let mut claimed_modification_time = None; let mut content_sha1 = None; + let mut location = None; if let Some(node_key) = node_key.as_ref() && let Some(rev) = file.active_revision.as_ref() && let Some(xattr) = rev.extended_attributes.as_deref() @@ -7490,6 +7491,9 @@ impl ProtonDriveClient { { Ok((attrs, status)) => { verification.extended_attributes = Some(status); + location = attrs + .location + .and_then(|l| PhotoLocation::from_claimed(l.latitude, l.longitude)); if let Some(common) = attrs.common { claimed_size = common.size; claimed_modification_time = common.modification_time; @@ -7525,6 +7529,7 @@ impl ProtonDriveClient { .iter() .map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone())) .collect(), + location, }); NodeKind::File { media_type: file.media_type.clone(), diff --git a/crates/proton-drive-rs/src/crypto.rs b/crates/proton-drive-rs/src/crypto.rs index 5cd1edc..35a987e 100644 --- a/crates/proton-drive-rs/src/crypto.rs +++ b/crates/proton-drive-rs/src/crypto.rs @@ -315,5 +315,22 @@ mod tests { let decoded = decrypt_xattr(&node.key, &armored); assert!(decoded.common.is_none()); + assert!(decoded.location.is_none()); + } + + #[test] + fn extended_attributes_carry_a_photo_location() { + let node = generate_node_key().expect("generate node key"); + let xattr = r#"{"Common":{"Size":1},"Location":{"Latitude":52.52,"Longitude":-13.405},"Camera":{"Device":"Pixel"}}"#; + let armored = node + .key + .encrypt_and_sign(&node.key, xattr.as_bytes(), false, true) + .expect("encrypt xattr"); + + let location = decrypt_xattr(&node.key, &armored) + .location + .expect("location present"); + assert_eq!(location.latitude, Some(52.52)); + assert_eq!(location.longitude, Some(-13.405)); } } diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index a12a7c2..c85bd61 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -706,6 +706,19 @@ pub struct ActiveRevisionDto { pub struct DecryptedExtendedAttributes { #[serde(rename = "Common", default)] pub common: Option, + /// Where a photo was taken, as the uploading client read it from the file's + /// EXIF. Only photos carry it, and not every client writes it. + #[serde(rename = "Location", default)] + pub location: Option, +} + +/// `Location` in the decrypted `XAttr` payload: WGS 84 decimal degrees. +#[derive(Debug, Default, Deserialize)] +pub struct DecryptedLocationExtendedAttributes { + #[serde(rename = "Latitude", default)] + pub latitude: Option, + #[serde(rename = "Longitude", default)] + pub longitude: Option, } #[derive(Debug, Default, Deserialize)] diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 19d62c7..f255c78 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -56,8 +56,8 @@ pub use event_manager::{ }; pub use events::{DriveEvent, DriveEventScopeId}; pub use node::{ - AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, - Thumbnail, ThumbnailType, + AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, + RevisionState, Thumbnail, ThumbnailType, }; pub use photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index 97ffe59..b7e17eb 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -100,6 +100,33 @@ pub struct PhotoProperties { pub tags: Vec, /// The albums this photo has been added to. pub album_uids: Vec, + /// Where the photo was taken, from the active revision's extended + /// attributes. `None` when the uploader wrote no location, the attributes + /// failed to decrypt, or the coordinates are not a valid position. + #[serde(default)] + pub location: Option, +} + +/// A photo's position in WGS 84 decimal degrees, as the uploading client +/// claimed it (`XAttr` `Location`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PhotoLocation { + pub latitude: f64, + pub longitude: f64, +} + +impl PhotoLocation { + /// A position from claimed coordinates: both present, finite, and on the + /// globe. Anything else is no position at all rather than a wrong one. + pub fn from_claimed(latitude: Option, longitude: Option) -> Option { + let (latitude, longitude) = (latitude?, longitude?); + ((-90.0..=90.0).contains(&latitude) && (-180.0..=180.0).contains(&longitude)).then_some( + Self { + latitude, + longitude, + }, + ) + } } /// Album-only metadata on an album node. Mirrors the fields C# `AlbumNode` adds @@ -411,4 +438,25 @@ mod tests { assert!(back.membership.is_none()); assert_eq!(back.name, "My Documents"); } + + #[test] + fn a_claimed_location_off_the_globe_is_no_location() { + assert_eq!( + PhotoLocation::from_claimed(Some(52.52), Some(13.405)), + Some(PhotoLocation { + latitude: 52.52, + longitude: 13.405 + }) + ); + assert_eq!(PhotoLocation::from_claimed(Some(52.52), None), None); + assert_eq!(PhotoLocation::from_claimed(Some(91.0), Some(0.0)), None); + assert_eq!(PhotoLocation::from_claimed(Some(0.0), Some(f64::NAN)), None); + } + + #[test] + fn photo_properties_cached_before_locations_still_parse() { + let legacy = r#"{"capture_time":1,"content_hash":null,"main_photo_uid":null,"related_photo_uids":[],"tags":[],"album_uids":[]}"#; + let back: PhotoProperties = serde_json::from_str(legacy).unwrap(); + assert!(back.location.is_none()); + } }