mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
917bad2ef4
commit
1068ddf5d0
5 files changed
+87
-4
No files matched your search
@@ -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(),
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user