Skip to main content

libsignal_service/websocket/
profile.rs

1use libsignal_protocol::Aci;
2use reqwest::Method;
3use serde::{Deserialize, Serialize};
4use zkgroup::profiles::{ProfileKeyCommitment, ProfileKeyVersion};
5
6use crate::{
7    content::ServiceError,
8    push_service::AvatarWrite,
9    utils::{serde_base64, serde_optional_base64},
10    websocket::{self, account::DeviceCapabilities, SignalWebSocket},
11};
12
13/// A donation badge returned by the server on profile fetch.
14///
15/// Mirrors the JSON shape of Signal-Android's `SignalServiceProfile.Badge`.
16/// Display metadata is render-ready (name, description, sprites6 image URLs).
17#[derive(Clone, Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct Badge {
20    /// Server catalog id (e.g. "BOOSTING").
21    #[serde(default)]
22    pub id: String,
23    /// Badge category string.
24    #[serde(default)]
25    pub category: String,
26    /// Render-ready display name.
27    #[serde(default)]
28    pub name: String,
29    /// Render-ready description.
30    #[serde(default)]
31    pub description: String,
32    /// Sprite image URLs (density-tagged).
33    #[serde(default)]
34    pub sprites6: Vec<String>,
35    /// Expiration epoch millis. Java sends this as BigDecimal.
36    #[serde(default)]
37    pub expiration: Option<f64>,
38    /// Whether the badge is displayed on the profile.
39    #[serde(default)]
40    pub visible: bool,
41    /// Duration badge is valid for, in seconds.
42    #[serde(default)]
43    pub duration: i64,
44}
45
46#[derive(Clone, Debug, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct SignalServiceProfile {
49    #[serde(default, with = "serde_optional_base64")]
50    pub identity_key: Option<Vec<u8>>,
51    #[serde(default, with = "serde_optional_base64")]
52    pub name: Option<Vec<u8>>,
53    #[serde(default, with = "serde_optional_base64")]
54    pub about: Option<Vec<u8>>,
55    #[serde(default, with = "serde_optional_base64")]
56    pub about_emoji: Option<Vec<u8>>,
57
58    // TODO: not sure whether this is via optional_base64
59    // #[serde(default, with = "serde_optional_base64")]
60    // pub payment_address: Option<Vec<u8>>,
61    pub avatar: Option<String>,
62    pub unidentified_access: Option<String>,
63
64    #[serde(default)]
65    pub unrestricted_unidentified_access: bool,
66
67    pub capabilities: DeviceCapabilities,
68
69    /// Donation badges the server reports for this profile.
70    #[serde(default)]
71    pub badges: Vec<Badge>,
72}
73
74#[derive(Debug, Serialize)]
75#[serde(rename_all = "camelCase")]
76struct SignalServiceProfileWrite<'s> {
77    /// Hex-encoded
78    version: &'s str,
79    #[serde(with = "serde_base64")]
80    name: &'s [u8],
81    #[serde(with = "serde_base64")]
82    about: &'s [u8],
83    #[serde(with = "serde_base64")]
84    about_emoji: &'s [u8],
85    avatar: bool,
86    same_avatar: bool,
87    #[serde(with = "serde_base64")]
88    commitment: &'s [u8],
89}
90
91impl SignalWebSocket<websocket::Identified> {
92    pub async fn retrieve_profile_by_id(
93        &mut self,
94        address: Aci,
95        profile_key: Option<zkgroup::profiles::ProfileKey>,
96    ) -> Result<SignalServiceProfile, ServiceError> {
97        let path = if let Some(key) = profile_key {
98            let version =
99                bincode::serialize(&key.get_profile_key_version(address))?;
100            let version = std::str::from_utf8(&version)
101                .expect("hex encoded profile key version");
102            format!("/v1/profile/{}/{}", address.service_id_string(), version)
103        } else {
104            format!("/v1/profile/{}", address.service_id_string())
105        };
106        // TODO: set locale to en_US
107        self.http_request(Method::GET, path)?
108            .send()
109            .await?
110            .service_error_for_status()
111            .await?
112            .json()
113            .await
114    }
115
116    /// Writes a profile and returns the avatar URL, if one was provided.
117    ///
118    /// The name, about and emoji fields are encrypted with an [`ProfileCipher`][struct@crate::profile_cipher::ProfileCipher].
119    /// See [`AccountManager`][struct@crate::AccountManager] for a convenience method.
120    ///
121    /// Java equivalent: `writeProfile`
122    pub async fn write_profile<'s, C, S>(
123        &mut self,
124        version: &ProfileKeyVersion,
125        name: &[u8],
126        about: &[u8],
127        emoji: &[u8],
128        commitment: &ProfileKeyCommitment,
129        avatar: AvatarWrite<&mut C>,
130    ) -> Result<Option<String>, ServiceError>
131    where
132        C: std::io::Read + Send + 's,
133        S: AsRef<str>,
134    {
135        // Bincode is transparent and will return a hex-encoded string.
136        let version = bincode::serialize(version)?;
137        let version = std::str::from_utf8(&version)
138            .expect("profile_key_version is hex encoded string");
139        let commitment = bincode::serialize(commitment)?;
140
141        let command = SignalServiceProfileWrite {
142            version,
143            name,
144            about,
145            about_emoji: emoji,
146            avatar: !matches!(avatar, AvatarWrite::NoAvatar),
147            same_avatar: matches!(avatar, AvatarWrite::RetainAvatar),
148            commitment: &commitment,
149        };
150
151        // XXX this should  be a struct; cfr ProfileAvatarUploadAttributes
152        let upload_url: Result<String, _> = self
153            .http_request(Method::PUT, "/v1/profile")?
154            .send_json(&command)
155            .await?
156            .service_error_for_status()
157            .await?
158            .json()
159            .await;
160
161        match (upload_url, avatar) {
162            (_url, AvatarWrite::NewAvatar(_avatar)) => {
163                // FIXME
164                unreachable!("Uploading avatar unimplemented");
165            },
166            // FIXME cleanup when #54883 is stable and MSRV:
167            // or-patterns syntax is experimental
168            // see issue #54883 <https://github.com/rust-lang/rust/issues/54883> for more information
169            (Err(_), AvatarWrite::RetainAvatar)
170            | (Err(_), AvatarWrite::NoAvatar) => {
171                // OWS sends an empty string when there's no attachment
172                Ok(None)
173            },
174            (Ok(_resp), AvatarWrite::RetainAvatar)
175            | (Ok(_resp), AvatarWrite::NoAvatar) => {
176                tracing::warn!(
177                    "No avatar supplied but got avatar upload URL. Ignoring"
178                );
179                Ok(None)
180            },
181        }
182    }
183}
184
185impl SignalWebSocket<websocket::Unidentified> {
186    pub async fn retrieve_profile_avatar(
187        &mut self,
188        path: &str,
189    ) -> Result<impl futures::io::AsyncRead + Send + Unpin, ServiceError> {
190        self.unidentified_push_service.get_from_cdn(0, path).await
191    }
192
193    pub async fn retrieve_groups_v2_profile_avatar(
194        &mut self,
195        path: &str,
196    ) -> Result<impl futures::io::AsyncRead + Send + Unpin, ServiceError> {
197        self.unidentified_push_service.get_from_cdn(0, path).await
198    }
199}