mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feat(sharing): expose a public link's custom password
get_public_link recovers the custom password along with the URL fragment, so a caller can keep it while update_public_link changes the role or the expiry. Debug for PublicLink and Bookmark redacts the URL and the custom password.
This commit is contained in:
1 parent
781301a77b
commit
2c5d62e9cf
4 files changed
+123
-32
No files matched your search
+7
-2
@@ -25,6 +25,9 @@ This file starts after 0.9.2; earlier changes are in the git history.
|
|||||||
expiry of the link on a node. The URL does not change. `None` removes a custom
|
expiry of the link on a node. The URL does not change. `None` removes a custom
|
||||||
password or an expiry. A legacy link cannot be updated. Ported from the
|
password or an expiry. A legacy link cannot be updated. Ported from the
|
||||||
TypeScript SDK (`updateSharedLink`).
|
TypeScript SDK (`updateSharedLink`).
|
||||||
|
- **A public link's custom password.** `PublicLink::custom_password` holds the
|
||||||
|
custom password of a link, recovered from the stored link password, so a
|
||||||
|
caller can keep it while `update_public_link` changes the role or the expiry.
|
||||||
- **Walking a public link.** `ProtonDrivePublicLinkClient::enumerate_subtree`
|
- **Walking a public link.** `ProtonDrivePublicLinkClient::enumerate_subtree`
|
||||||
and `enumerate_subtree_reporting` return every node below a shared folder,
|
and `enumerate_subtree_reporting` return every node below a shared folder,
|
||||||
breadth first, listing several folders of a level at once.
|
breadth first, listing several folders of a level at once.
|
||||||
@@ -50,6 +53,8 @@ This file starts after 0.9.2; earlier changes are in the git history.
|
|||||||
is retried under the client's retry policy. A write still is not: it may have
|
is retried under the client's retry policy. A write still is not: it may have
|
||||||
been applied. Read-only POSTs in `proton-drive-rs` (link details, thumbnails,
|
been applied. Read-only POSTs in `proton-drive-rs` (link details, thumbnails,
|
||||||
photo duplicates, `checkAvailableHashes`) now use `post_query`.
|
photo duplicates, `checkAvailableHashes`) now use `post_query`.
|
||||||
|
- `Debug` for `PublicLink` and `Bookmark` no longer prints the URL or the
|
||||||
|
custom password; it shows `<redacted>` instead.
|
||||||
- `remove_photos_from_album` refuses a photo on another volume per photo, in
|
- `remove_photos_from_album` refuses a photo on another volume per photo, in
|
||||||
the input order, without sending it.
|
the input order, without sending it.
|
||||||
|
|
||||||
@@ -60,6 +65,6 @@ This file starts after 0.9.2; earlier changes are in the git history.
|
|||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
- `Bookmark` has a new public field, `custom_password`. Code that builds a
|
- `Bookmark` and `PublicLink` have a new public field, `custom_password`. Code
|
||||||
`Bookmark` with a struct literal must set it.
|
that builds either with a struct literal must set it.
|
||||||
- `ResponseCode` has a new variant. An exhaustive `match` on it needs an arm.
|
- `ResponseCode` has a new variant. An exhaustive `match` on it needs an arm.
|
||||||
@@ -3794,6 +3794,7 @@ impl ProtonDriveClient {
|
|||||||
creation_time: now_epoch_seconds(),
|
creation_time: now_epoch_seconds(),
|
||||||
expiration_time,
|
expiration_time,
|
||||||
has_custom_password,
|
has_custom_password,
|
||||||
|
custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3856,6 +3857,7 @@ impl ProtonDriveClient {
|
|||||||
role,
|
role,
|
||||||
expiration_time,
|
expiration_time,
|
||||||
has_custom_password,
|
has_custom_password,
|
||||||
|
custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned),
|
||||||
..link
|
..link
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3936,7 +3938,7 @@ impl ProtonDriveClient {
|
|||||||
let response: ShareUrlsResponse = self.http.get(&path).await?;
|
let response: ShareUrlsResponse = self.http.get(&path).await?;
|
||||||
match response.share_urls.into_iter().next() {
|
match response.share_urls.into_iter().next() {
|
||||||
Some(dto) => {
|
Some(dto) => {
|
||||||
let url = self.recover_public_link_url(&dto).await;
|
let (url, custom_password) = self.recover_public_link_secret(&dto).await;
|
||||||
Some(PublicLink {
|
Some(PublicLink {
|
||||||
share_id,
|
share_id,
|
||||||
public_link_id: dto.share_url_id,
|
public_link_id: dto.share_url_id,
|
||||||
@@ -3945,6 +3947,7 @@ impl ProtonDriveClient {
|
|||||||
creation_time: dto.create_time,
|
creation_time: dto.create_time,
|
||||||
expiration_time: dto.expiration_time,
|
expiration_time: dto.expiration_time,
|
||||||
has_custom_password: dto.flags == 3,
|
has_custom_password: dto.flags == 3,
|
||||||
|
custom_password,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -3956,24 +3959,32 @@ impl ProtonDriveClient {
|
|||||||
Ok(link)
|
Ok(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the full public URL (`{public_url}#{generated}`) for an existing
|
/// Rebuild the full public URL (`{public_url}#{generated}`) and the custom
|
||||||
/// link by decrypting its stored `Password` with the owner's address key. The
|
/// password of an existing link by decrypting its stored `Password` with the
|
||||||
/// stored secret is the generated password with any custom password appended;
|
/// owner's address key. The stored secret is the generated password with any
|
||||||
/// only the leading generated portion belongs in the URL fragment (the
|
/// custom password appended; only the leading generated portion belongs in
|
||||||
/// recipient supplies the custom password separately). Returns `None` if the
|
/// the URL fragment (the recipient supplies the custom password separately).
|
||||||
/// link carries no password or it can't be decrypted, so the caller still gets
|
/// Both are `None` if the link carries no password or it can't be
|
||||||
/// the link's metadata.
|
/// decrypted, so the caller still gets the link's metadata.
|
||||||
async fn recover_public_link_url(&self, dto: &ShareUrlDto) -> Option<String> {
|
async fn recover_public_link_secret(
|
||||||
if dto.public_url.is_empty() {
|
&self,
|
||||||
return None;
|
dto: &ShareUrlDto,
|
||||||
}
|
) -> (Option<String>, Option<String>) {
|
||||||
let enc = dto.password.as_deref()?;
|
let Some(enc) = dto.password.as_deref() else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let full = async {
|
||||||
let (_, email, _) = self.membership_address().await.ok()?;
|
let (_, email, _) = self.membership_address().await.ok()?;
|
||||||
let (address_keys, _) = self.own_address_keys(&email).await.ok()?;
|
let (address_keys, _) = self.own_address_keys(&email).await.ok()?;
|
||||||
let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?;
|
let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?;
|
||||||
let full = String::from_utf8_lossy(&bytes);
|
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||||
let generated: String = full.chars().take(PUBLIC_LINK_PASSWORD_LEN).collect();
|
};
|
||||||
Some(format!("{}#{generated}", dto.public_url))
|
let Some(full) = full.await else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let (generated, custom) = split_link_password(&full);
|
||||||
|
let url = (!dto.public_url.is_empty()).then(|| format!("{}#{generated}", dto.public_url));
|
||||||
|
(url, custom.map(str::to_owned))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a public link, revoking access via the URL
|
/// Remove a public link, revoking access via the URL
|
||||||
@@ -4221,7 +4232,7 @@ impl ProtonDriveClient {
|
|||||||
// fragment is the generated part; the share key is locked with the
|
// fragment is the generated part; the share key is locked with the
|
||||||
// whole string.
|
// whole string.
|
||||||
let password = String::from_utf8_lossy(&bytes).into_owned();
|
let password = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
let (url_password, custom_password) = split_bookmark_password(&password);
|
let (url_password, custom_password) = split_link_password(&password);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://drive.proton.me/urls/{}#{url_password}",
|
"https://drive.proton.me/urls/{}#{url_password}",
|
||||||
dto.token.token
|
dto.token.token
|
||||||
@@ -8078,9 +8089,9 @@ fn public_link_permissions(role: MemberRole) -> Result<i32> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a bookmark's stored secret into the generated URL password and the
|
/// Split a public link's stored secret into the generated URL password and the
|
||||||
/// custom password appended to it, if any (TS `splitGeneratedAndCustomPassword`).
|
/// custom password appended to it, if any (TS `splitGeneratedAndCustomPassword`).
|
||||||
fn split_bookmark_password(password: &str) -> (&str, Option<&str>) {
|
fn split_link_password(password: &str) -> (&str, Option<&str>) {
|
||||||
let split = password
|
let split = password
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.nth(PUBLIC_LINK_PASSWORD_LEN)
|
.nth(PUBLIC_LINK_PASSWORD_LEN)
|
||||||
@@ -8984,7 +8995,7 @@ mod tests {
|
|||||||
is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links,
|
is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links,
|
||||||
path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys,
|
path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys,
|
||||||
run_context_share_mutation, share_membership_from_dto, small_upload_applicable,
|
run_context_share_mutation, share_membership_from_dto, small_upload_applicable,
|
||||||
small_upload_should_fall_back, split_bookmark_password, take_block_target,
|
small_upload_should_fall_back, split_link_password, take_block_target,
|
||||||
take_thumbnail_target, to_drive_event, validate_node_name,
|
take_thumbnail_target, to_drive_event, validate_node_name,
|
||||||
};
|
};
|
||||||
use crate::dtos::{
|
use crate::dtos::{
|
||||||
@@ -9003,23 +9014,20 @@ mod tests {
|
|||||||
use tokio::sync::{Mutex, Notify, RwLock};
|
use tokio::sync::{Mutex, Notify, RwLock};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bookmark_password_splits_into_the_generated_part_and_the_custom_one() {
|
fn a_link_password_splits_into_the_generated_part_and_the_custom_one() {
|
||||||
|
assert_eq!(split_link_password("AbCdEf123456"), ("AbCdEf123456", None));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
split_bookmark_password("AbCdEf123456"),
|
split_link_password("AbCdEf123456s3cr3t!"),
|
||||||
("AbCdEf123456", None)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
split_bookmark_password("AbCdEf123456s3cr3t!"),
|
|
||||||
("AbCdEf123456", Some("s3cr3t!"))
|
("AbCdEf123456", Some("s3cr3t!"))
|
||||||
);
|
);
|
||||||
// A custom password may hold any character; the split counts chars,
|
// A custom password may hold any character; the split counts chars,
|
||||||
// not bytes, and never cuts one in half.
|
// not bytes, and never cuts one in half.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
split_bookmark_password("AbCdEf123456pässwört"),
|
split_link_password("AbCdEf123456pässwört"),
|
||||||
("AbCdEf123456", Some("pässwört"))
|
("AbCdEf123456", Some("pässwört"))
|
||||||
);
|
);
|
||||||
// A legacy password shorter than a generated one is all URL password.
|
// A legacy password shorter than a generated one is all URL password.
|
||||||
assert_eq!(split_bookmark_password("short"), ("short", None));
|
assert_eq!(split_link_password("short"), ("short", None));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A batch envelope with one successful link and one that failed with `code`.
|
/// A batch envelope with one successful link and one that failed with `code`.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`).
|
//! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`).
|
||||||
//! Reading shared-with-me nodes and leaving them lives on the client already.
|
//! Reading shared-with-me nodes and leaving them lives on the client already.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use proton_sdk::crypto::VerificationStatus;
|
use proton_sdk::crypto::VerificationStatus;
|
||||||
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -149,7 +151,9 @@ pub struct ShareMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A public share link on a node.
|
/// A public share link on a node.
|
||||||
#[derive(Debug, Clone)]
|
///
|
||||||
|
/// `Debug` leaves out the custom password.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct PublicLink {
|
pub struct PublicLink {
|
||||||
/// The share this link belongs to.
|
/// The share this link belongs to.
|
||||||
pub share_id: ShareId,
|
pub share_id: ShareId,
|
||||||
@@ -166,6 +170,31 @@ pub struct PublicLink {
|
|||||||
pub expiration_time: Option<i64>,
|
pub expiration_time: Option<i64>,
|
||||||
/// Whether the link is additionally protected by a custom password.
|
/// Whether the link is additionally protected by a custom password.
|
||||||
pub has_custom_password: bool,
|
pub has_custom_password: bool,
|
||||||
|
/// The custom password itself, when the link has one and the stored link
|
||||||
|
/// password could be decrypted. Pass it back to
|
||||||
|
/// [`update_public_link`](crate::ProtonDriveClient::update_public_link) to
|
||||||
|
/// keep it while changing the role or the expiry.
|
||||||
|
pub custom_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for PublicLink {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("PublicLink")
|
||||||
|
.field("share_id", &self.share_id)
|
||||||
|
.field("public_link_id", &self.public_link_id)
|
||||||
|
.field("url", &redacted(&self.url))
|
||||||
|
.field("role", &self.role)
|
||||||
|
.field("creation_time", &self.creation_time)
|
||||||
|
.field("expiration_time", &self.expiration_time)
|
||||||
|
.field("has_custom_password", &self.has_custom_password)
|
||||||
|
.field("custom_password", &redacted(&self.custom_password))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `Debug` shows for a secret: whether there is one, never its value.
|
||||||
|
fn redacted(secret: &Option<String>) -> Option<&'static str> {
|
||||||
|
secret.as_ref().map(|_| "<redacted>")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pending invitation to a Proton user (not yet accepted).
|
/// A pending invitation to a Proton user (not yet accepted).
|
||||||
@@ -250,7 +279,9 @@ pub struct IncomingInvitation {
|
|||||||
|
|
||||||
/// A public link the user has saved to their account ("bookmark"). Opening it
|
/// A public link the user has saved to their account ("bookmark"). Opening it
|
||||||
/// visits the public URL; there is no local copy.
|
/// visits the public URL; there is no local copy.
|
||||||
#[derive(Debug, Clone)]
|
///
|
||||||
|
/// `Debug` leaves out the URL's secret fragment and the custom password.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Bookmark {
|
pub struct Bookmark {
|
||||||
/// The bookmark's token — the handle for
|
/// The bookmark's token — the handle for
|
||||||
/// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark).
|
/// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark).
|
||||||
@@ -270,10 +301,51 @@ pub struct Bookmark {
|
|||||||
pub creation_time: i64,
|
pub creation_time: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Bookmark {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Bookmark")
|
||||||
|
.field("token", &self.token)
|
||||||
|
.field("url", &"<redacted>")
|
||||||
|
.field("custom_password", &redacted(&self.custom_password))
|
||||||
|
.field("node_name", &self.node_name)
|
||||||
|
.field("is_folder", &self.is_folder)
|
||||||
|
.field("creation_time", &self.creation_time)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_output_leaves_out_link_secrets() {
|
||||||
|
let link = PublicLink {
|
||||||
|
share_id: "s1".into(),
|
||||||
|
public_link_id: "l1".into(),
|
||||||
|
url: Some("https://drive.proton.me/urls/T0K3N#AbCdEf123456".into()),
|
||||||
|
role: MemberRole::Viewer,
|
||||||
|
creation_time: 1,
|
||||||
|
expiration_time: None,
|
||||||
|
has_custom_password: true,
|
||||||
|
custom_password: Some("s3cr3t".into()),
|
||||||
|
};
|
||||||
|
let bookmark = Bookmark {
|
||||||
|
token: "T0K3N".into(),
|
||||||
|
url: "https://drive.proton.me/urls/T0K3N#AbCdEf123456".into(),
|
||||||
|
custom_password: Some("s3cr3t".into()),
|
||||||
|
node_name: Some("Holiday".into()),
|
||||||
|
is_folder: true,
|
||||||
|
creation_time: 1,
|
||||||
|
};
|
||||||
|
for shown in [format!("{link:?}"), format!("{bookmark:?}")] {
|
||||||
|
assert!(!shown.contains("AbCdEf123456"), "{shown}");
|
||||||
|
assert!(!shown.contains("s3cr3t"), "{shown}");
|
||||||
|
assert!(shown.contains("<redacted>"), "{shown}");
|
||||||
|
}
|
||||||
|
assert!(format!("{bookmark:?}").contains("Holiday"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() {
|
fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() {
|
||||||
// The three masks Proton actually uses, plus the absent case.
|
// The three masks Proton actually uses, plus the absent case.
|
||||||
|
|||||||
@@ -846,6 +846,11 @@ async fn update_public_link_changes_role_password_and_expiry() {
|
|||||||
"the expiry must be stored"
|
"the expiry must be stored"
|
||||||
);
|
);
|
||||||
assert_eq!(fetched.url.as_deref(), Some(url.as_str()));
|
assert_eq!(fetched.url.as_deref(), Some(url.as_str()));
|
||||||
|
assert_eq!(
|
||||||
|
fetched.custom_password.as_deref(),
|
||||||
|
Some(custom),
|
||||||
|
"the custom password is recovered from the stored link password"
|
||||||
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
|
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
|
||||||
@@ -873,6 +878,7 @@ async fn update_public_link_changes_role_password_and_expiry() {
|
|||||||
"the custom password is removed"
|
"the custom password is removed"
|
||||||
);
|
);
|
||||||
assert_eq!(fetched.expiration_time, None, "the expiry is cleared");
|
assert_eq!(fetched.expiration_time, None, "the expiry is cleared");
|
||||||
|
assert_eq!(fetched.custom_password, None);
|
||||||
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
|
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
|
||||||
.await
|
.await
|
||||||
.expect("the link opens with the URL alone again");
|
.expect("the link opens with the URL alone again");
|
||||||
|
|||||||
Reference in new issue
Block a user