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.
This commit is contained in:
nvrl committed 2026-09-28 01:06:59 +02:00
1 parent 917bad2ef4
commit 1068ddf5d0
5 files changed
+87 -4

No files matched your search

+7 -2
View File
@@ -79,8 +79,8 @@ use crate::dtos::{
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{ use crate::node::{
AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties,
Thumbnail, ThumbnailType, RevisionState, Thumbnail, ThumbnailType,
}; };
use crate::photos::{ use crate::photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
@@ -7476,6 +7476,7 @@ impl ProtonDriveClient {
let mut claimed_size = None; let mut claimed_size = None;
let mut claimed_modification_time = None; let mut claimed_modification_time = None;
let mut content_sha1 = None; let mut content_sha1 = None;
let mut location = None;
if let Some(node_key) = node_key.as_ref() if let Some(node_key) = node_key.as_ref()
&& let Some(rev) = file.active_revision.as_ref() && let Some(rev) = file.active_revision.as_ref()
&& let Some(xattr) = rev.extended_attributes.as_deref() && let Some(xattr) = rev.extended_attributes.as_deref()
@@ -7490,6 +7491,9 @@ impl ProtonDriveClient {
{ {
Ok((attrs, status)) => { Ok((attrs, status)) => {
verification.extended_attributes = Some(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 { if let Some(common) = attrs.common {
claimed_size = common.size; claimed_size = common.size;
claimed_modification_time = common.modification_time; claimed_modification_time = common.modification_time;
@@ -7525,6 +7529,7 @@ impl ProtonDriveClient {
.iter() .iter()
.map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone())) .map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone()))
.collect(), .collect(),
location,
}); });
NodeKind::File { NodeKind::File {
media_type: file.media_type.clone(), media_type: file.media_type.clone(),
+17
View File
@@ -315,5 +315,22 @@ mod tests {
let decoded = decrypt_xattr(&node.key, &armored); let decoded = decrypt_xattr(&node.key, &armored);
assert!(decoded.common.is_none()); 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));
} }
} }
+13
View File
@@ -706,6 +706,19 @@ pub struct ActiveRevisionDto {
pub struct DecryptedExtendedAttributes { pub struct DecryptedExtendedAttributes {
#[serde(rename = "Common", default)] #[serde(rename = "Common", default)]
pub common: Option<DecryptedCommonExtendedAttributes>, pub common: Option<DecryptedCommonExtendedAttributes>,
/// 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<DecryptedLocationExtendedAttributes>,
}
/// `Location` in the decrypted `XAttr` payload: WGS 84 decimal degrees.
#[derive(Debug, Default, Deserialize)]
pub struct DecryptedLocationExtendedAttributes {
#[serde(rename = "Latitude", default)]
pub latitude: Option<f64>,
#[serde(rename = "Longitude", default)]
pub longitude: Option<f64>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
+2 -2
View File
@@ -56,8 +56,8 @@ pub use event_manager::{
}; };
pub use events::{DriveEvent, DriveEventScopeId}; pub use events::{DriveEvent, DriveEventScopeId};
pub use node::{ pub use node::{
AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoProperties, RevisionState, AlbumProperties, FileThumbnail, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties,
Thumbnail, ThumbnailType, RevisionState, Thumbnail, ThumbnailType,
}; };
pub use photos::{ pub use photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
+48
View File
@@ -100,6 +100,33 @@ pub struct PhotoProperties {
pub tags: Vec<PhotoTag>, pub tags: Vec<PhotoTag>,
/// The albums this photo has been added to. /// The albums this photo has been added to.
pub album_uids: Vec<NodeUid>, pub album_uids: Vec<NodeUid>,
/// 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<PhotoLocation>,
}
/// 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<f64>, longitude: Option<f64>) -> Option<Self> {
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 /// 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!(back.membership.is_none());
assert_eq!(back.name, "My Documents"); 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());
}
} }