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
+126
-35
No files matched your search
@@ -3794,6 +3794,7 @@ impl ProtonDriveClient {
|
||||
creation_time: now_epoch_seconds(),
|
||||
expiration_time,
|
||||
has_custom_password,
|
||||
custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3856,6 +3857,7 @@ impl ProtonDriveClient {
|
||||
role,
|
||||
expiration_time,
|
||||
has_custom_password,
|
||||
custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned),
|
||||
..link
|
||||
})
|
||||
}
|
||||
@@ -3936,7 +3938,7 @@ impl ProtonDriveClient {
|
||||
let response: ShareUrlsResponse = self.http.get(&path).await?;
|
||||
match response.share_urls.into_iter().next() {
|
||||
Some(dto) => {
|
||||
let url = self.recover_public_link_url(&dto).await;
|
||||
let (url, custom_password) = self.recover_public_link_secret(&dto).await;
|
||||
Some(PublicLink {
|
||||
share_id,
|
||||
public_link_id: dto.share_url_id,
|
||||
@@ -3945,6 +3947,7 @@ impl ProtonDriveClient {
|
||||
creation_time: dto.create_time,
|
||||
expiration_time: dto.expiration_time,
|
||||
has_custom_password: dto.flags == 3,
|
||||
custom_password,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -3956,24 +3959,32 @@ impl ProtonDriveClient {
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Rebuild the full public URL (`{public_url}#{generated}`) for an existing
|
||||
/// link by decrypting its stored `Password` with the owner's address key. The
|
||||
/// stored secret is the generated password with any custom password appended;
|
||||
/// only the leading generated portion belongs in the URL fragment (the
|
||||
/// recipient supplies the custom password separately). Returns `None` if the
|
||||
/// link carries no password or it can't be decrypted, so the caller still gets
|
||||
/// the link's metadata.
|
||||
async fn recover_public_link_url(&self, dto: &ShareUrlDto) -> Option<String> {
|
||||
if dto.public_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let enc = dto.password.as_deref()?;
|
||||
let (_, email, _) = self.membership_address().await.ok()?;
|
||||
let (address_keys, _) = self.own_address_keys(&email).await.ok()?;
|
||||
let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?;
|
||||
let full = String::from_utf8_lossy(&bytes);
|
||||
let generated: String = full.chars().take(PUBLIC_LINK_PASSWORD_LEN).collect();
|
||||
Some(format!("{}#{generated}", dto.public_url))
|
||||
/// Rebuild the full public URL (`{public_url}#{generated}`) and the custom
|
||||
/// password of an existing link by decrypting its stored `Password` with the
|
||||
/// owner's address key. The stored secret is the generated password with any
|
||||
/// custom password appended; only the leading generated portion belongs in
|
||||
/// the URL fragment (the recipient supplies the custom password separately).
|
||||
/// Both are `None` if the link carries no password or it can't be
|
||||
/// decrypted, so the caller still gets the link's metadata.
|
||||
async fn recover_public_link_secret(
|
||||
&self,
|
||||
dto: &ShareUrlDto,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let Some(enc) = dto.password.as_deref() else {
|
||||
return (None, None);
|
||||
};
|
||||
let full = async {
|
||||
let (_, email, _) = self.membership_address().await.ok()?;
|
||||
let (address_keys, _) = self.own_address_keys(&email).await.ok()?;
|
||||
let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
};
|
||||
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
|
||||
@@ -4221,7 +4232,7 @@ impl ProtonDriveClient {
|
||||
// fragment is the generated part; the share key is locked with the
|
||||
// whole string.
|
||||
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!(
|
||||
"https://drive.proton.me/urls/{}#{url_password}",
|
||||
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`).
|
||||
fn split_bookmark_password(password: &str) -> (&str, Option<&str>) {
|
||||
fn split_link_password(password: &str) -> (&str, Option<&str>) {
|
||||
let split = password
|
||||
.char_indices()
|
||||
.nth(PUBLIC_LINK_PASSWORD_LEN)
|
||||
@@ -8984,7 +8995,7 @@ mod tests {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
use crate::dtos::{
|
||||
@@ -9003,23 +9014,20 @@ mod tests {
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
|
||||
#[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!(
|
||||
split_bookmark_password("AbCdEf123456"),
|
||||
("AbCdEf123456", None)
|
||||
);
|
||||
assert_eq!(
|
||||
split_bookmark_password("AbCdEf123456s3cr3t!"),
|
||||
split_link_password("AbCdEf123456s3cr3t!"),
|
||||
("AbCdEf123456", Some("s3cr3t!"))
|
||||
);
|
||||
// A custom password may hold any character; the split counts chars,
|
||||
// not bytes, and never cuts one in half.
|
||||
assert_eq!(
|
||||
split_bookmark_password("AbCdEf123456pässwört"),
|
||||
split_link_password("AbCdEf123456pässwört"),
|
||||
("AbCdEf123456", Some("pässwört"))
|
||||
);
|
||||
// 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`.
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`).
|
||||
//! Reading shared-with-me nodes and leaving them lives on the client already.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use proton_sdk::crypto::VerificationStatus;
|
||||
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,7 +151,9 @@ pub struct ShareMember {
|
||||
}
|
||||
|
||||
/// A public share link on a node.
|
||||
#[derive(Debug, Clone)]
|
||||
///
|
||||
/// `Debug` leaves out the custom password.
|
||||
#[derive(Clone)]
|
||||
pub struct PublicLink {
|
||||
/// The share this link belongs to.
|
||||
pub share_id: ShareId,
|
||||
@@ -166,6 +170,31 @@ pub struct PublicLink {
|
||||
pub expiration_time: Option<i64>,
|
||||
/// Whether the link is additionally protected by a custom password.
|
||||
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).
|
||||
@@ -250,7 +279,9 @@ pub struct IncomingInvitation {
|
||||
|
||||
/// A public link the user has saved to their account ("bookmark"). Opening it
|
||||
/// 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 {
|
||||
/// The bookmark's token — the handle for
|
||||
/// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark).
|
||||
@@ -270,10 +301,51 @@ pub struct Bookmark {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() {
|
||||
// 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"
|
||||
);
|
||||
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!(
|
||||
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"
|
||||
);
|
||||
assert_eq!(fetched.expiration_time, None, "the expiry is cleared");
|
||||
assert_eq!(fetched.custom_password, None);
|
||||
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
|
||||
.await
|
||||
.expect("the link opens with the URL alone again");
|
||||
|
||||
Reference in new issue
Block a user