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