Skip to main content

bitwarden_send/
access.rs

1use bitwarden_api_api::{apis::ApiClient, models};
2use bitwarden_core::ApiError;
3use bitwarden_error::bitwarden_error;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7#[cfg(feature = "wasm")]
8use tsify::Tsify;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::*;
11
12use crate::{SendParseError, SendType, send_client::SendClient};
13
14// ===== Public output types (returned to callers) =====
15
16/// View of a send's accessible content, returned after a successful send access call.
17/// Name, text, and file fields are encrypted and must be decrypted client-side using the
18/// key derived from the URL fragment.
19#[derive(Debug, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
22pub struct SendAccessResponse {
23    /// The send access ID
24    pub id: Option<String>,
25    /// The send type.
26    #[serde(rename = "type")]
27    pub type_: Option<SendType>,
28    /// Encrypted send name
29    pub name: Option<String>,
30    /// Text content (if type is Text)
31    pub text: Option<SendAccessTextResponse>,
32    /// File metadata (if type is File)
33    pub file: Option<SendAccessFileResponse>,
34    /// When the send expires.
35    pub expiration_date: Option<DateTime<Utc>>,
36    /// The creator's identifier (email), if not hidden
37    pub creator_identifier: Option<String>,
38}
39
40/// Encrypted text content of a text send.
41#[derive(Debug, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
44pub struct SendAccessTextResponse {
45    /// Encrypted text content
46    pub text: Option<String>,
47    /// Whether to hide the text by default
48    pub hidden: bool,
49}
50
51/// Encrypted file metadata of a file send.
52#[derive(Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
55pub struct SendAccessFileResponse {
56    /// The file ID
57    pub id: Option<String>,
58    /// Encrypted file name
59    pub file_name: Option<String>,
60    /// File size in bytes as a string
61    pub size: Option<String>,
62    /// Human-readable size (e.g. "4.2 KB")
63    pub size_name: Option<String>,
64}
65
66/// File download URL data returned from a send file access call.
67#[derive(Debug, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
70pub struct SendFileDownloadData {
71    /// The file ID
72    pub id: Option<String>,
73    /// The pre-signed download URL
74    pub url: Option<String>,
75}
76
77// ===== Error types =====
78
79/// Error returned when accessing a send fails.
80#[bitwarden_error(flat)]
81#[derive(Debug, Error)]
82pub enum AccessSendError {
83    /// An API or network error occurred.
84    #[error(transparent)]
85    Api(#[from] ApiError),
86    /// The response body could not be parsed into a [`SendAccessResponse`] — either a
87    /// required field was missing, the send type was an unrecognized value, or a date
88    /// field was malformed.
89    #[error(transparent)]
90    Parse(#[from] SendParseError),
91}
92
93/// Error returned when getting send file download data fails.
94#[bitwarden_error(flat)]
95#[derive(Debug, Error)]
96pub enum GetFileDownloadDataError {
97    /// An API or network error occurred.
98    #[error(transparent)]
99    Api(#[from] ApiError),
100}
101
102// ===== HTTP request functions =====
103
104async fn access_send_v1(
105    api_client: &ApiClient,
106    send_id: &str,
107    password: Option<String>,
108) -> Result<SendAccessResponse, AccessSendError> {
109    let resp = api_client
110        .sends_api()
111        .access(send_id, Some(models::SendAccessRequestModel { password }))
112        .await
113        .map_err(ApiError::from)?;
114    Ok(resp.try_into()?)
115}
116
117async fn access_send(
118    api_client: &ApiClient,
119    access_token: &str,
120) -> Result<SendAccessResponse, AccessSendError> {
121    let resp = api_client
122        .sends_api()
123        .access_using_auth(access_token)
124        .await
125        .map_err(ApiError::from)?;
126    Ok(resp.try_into()?)
127}
128
129async fn get_file_download_data_v1(
130    api_client: &ApiClient,
131    send_id: &str,
132    file_id: &str,
133    password: Option<String>,
134) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
135    let resp = api_client
136        .sends_api()
137        .get_send_file_download_data(
138            send_id,
139            file_id,
140            Some(models::SendAccessRequestModel { password }),
141        )
142        .await
143        .map_err(ApiError::from)?;
144    Ok(resp.into())
145}
146
147async fn get_file_download_data(
148    api_client: &ApiClient,
149    file_id: &str,
150    access_token: &str,
151) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
152    let resp = api_client
153        .sends_api()
154        .get_send_file_download_data_using_auth(file_id, access_token)
155        .await
156        .map_err(ApiError::from)?;
157    Ok(resp.into())
158}
159
160// ===== Conversions from API response models =====
161
162impl TryFrom<models::SendAccessResponseModel> for SendAccessResponse {
163    type Error = SendParseError;
164
165    fn try_from(r: models::SendAccessResponseModel) -> Result<Self, Self::Error> {
166        Ok(SendAccessResponse {
167            id: r.id,
168            type_: r.r#type.map(SendType::try_from).transpose()?,
169            name: r.name,
170            text: r.text.map(|t| SendAccessTextResponse {
171                text: t.text,
172                hidden: t.hidden.unwrap_or(false),
173            }),
174            file: r.file.map(|f| SendAccessFileResponse {
175                id: f.id,
176                file_name: f.file_name,
177                size: f.size,
178                size_name: f.size_name,
179            }),
180            expiration_date: r.expiration_date.map(|s| s.parse()).transpose()?,
181            creator_identifier: r.creator_identifier,
182        })
183    }
184}
185
186impl From<models::SendFileDownloadDataResponseModel> for SendFileDownloadData {
187    fn from(r: models::SendFileDownloadDataResponseModel) -> Self {
188        SendFileDownloadData {
189            id: r.id,
190            url: r.url,
191        }
192    }
193}
194
195// ===== SendClient methods =====
196
197#[cfg_attr(feature = "wasm", wasm_bindgen)]
198impl SendClient {
199    /// Accesses a send using the V1 (legacy) API endpoint.
200    /// The `password` is the SHA256 hash of the user-entered password, if the send is
201    /// password-protected. The returned [SendAccessResponse] contains encrypted fields that must
202    /// be decrypted client-side using the key derived from the URL fragment.
203    pub async fn access_send_v1(
204        &self,
205        send_id: String,
206        password: Option<String>,
207    ) -> Result<SendAccessResponse, AccessSendError> {
208        let config = self.client.internal.get_api_configurations();
209        access_send_v1(&config.api_client, &send_id, password).await
210    }
211
212    /// Accesses a send using the V2 API endpoint, authenticated with a send access token.
213    /// The returned [SendAccessResponse] contains encrypted fields that must be decrypted
214    /// client-side using the key derived from the URL fragment.
215    pub async fn access_send(
216        &self,
217        access_token: String,
218    ) -> Result<SendAccessResponse, AccessSendError> {
219        let config = self.client.internal.get_api_configurations();
220        access_send(&config.api_client, &access_token).await
221    }
222
223    /// Gets file download data for a file send using the V1 (legacy) API endpoint.
224    /// The `password` is the SHA256 hash of the user-entered password, if the send is
225    /// password-protected.
226    pub async fn get_file_download_data_v1(
227        &self,
228        send_id: String,
229        file_id: String,
230        password: Option<String>,
231    ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
232        let config = self.client.internal.get_api_configurations();
233        get_file_download_data_v1(&config.api_client, &send_id, &file_id, password).await
234    }
235
236    /// Gets file download data for a file send using the V2 API endpoint, authenticated
237    /// with a send access token.
238    pub async fn get_file_download_data(
239        &self,
240        access_token: String,
241        file_id: String,
242    ) -> Result<SendFileDownloadData, GetFileDownloadDataError> {
243        let config = self.client.internal.get_api_configurations();
244        get_file_download_data(&config.api_client, &file_id, &access_token).await
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use bitwarden_api_api::{
251        apis::ApiClient,
252        models::{
253            SendAccessResponseModel, SendFileDownloadDataResponseModel, SendFileModel,
254            SendTextModel, SendType,
255        },
256    };
257
258    use super::*;
259
260    const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
261    const FILE_ID: &str = "file-id-abc";
262    const ACCESS_TOKEN: &str = "send-access-token";
263
264    // ===== access_send_v1 =====
265
266    #[tokio::test]
267    async fn test_access_send_v1_text() {
268        let api_client = ApiClient::new_mocked(|mock| {
269            mock.sends_api
270                .expect_access()
271                .returning(|id, request| {
272                    assert_eq!(id, SEND_ID);
273                    let request = request.expect("request body should be present");
274                    assert_eq!(request.password, Some("hashed-password".to_string()));
275                    Ok(SendAccessResponseModel {
276                        object: Some("send-access".to_string()),
277                        id: Some(SEND_ID.to_string()),
278                        r#type: Some(SendType::Text),
279                        auth_type: None,
280                        name: Some("encrypted-name".to_string()),
281                        file: None,
282                        text: Some(Box::new(SendTextModel {
283                            text: Some("encrypted-text".to_string()),
284                            hidden: Some(true),
285                        })),
286                        expiration_date: Some("2025-01-10T00:00:00Z".to_string()),
287                        creator_identifier: Some("[email protected]".to_string()),
288                    })
289                })
290                .once();
291        });
292
293        let result = access_send_v1(&api_client, SEND_ID, Some("hashed-password".to_string()))
294            .await
295            .unwrap();
296
297        assert_eq!(result.id, Some(SEND_ID.to_string()));
298        assert_eq!(result.type_, Some(crate::SendType::Text));
299        assert_eq!(result.name, Some("encrypted-name".to_string()));
300        let text = result.text.expect("text variant should be populated");
301        assert_eq!(text.text, Some("encrypted-text".to_string()));
302        assert!(text.hidden);
303        assert!(result.file.is_none());
304        assert_eq!(
305            result.expiration_date,
306            Some("2025-01-10T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
307        );
308        assert_eq!(
309            result.creator_identifier,
310            Some("[email protected]".to_string())
311        );
312    }
313
314    #[tokio::test]
315    async fn test_access_send_v1_http_error() {
316        let api_client = ApiClient::new_mocked(|mock| {
317            mock.sends_api
318                .expect_access()
319                .returning(|_id, _request| {
320                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
321                        "Simulated error",
322                    )))
323                })
324                .once();
325        });
326
327        let result = access_send_v1(&api_client, SEND_ID, None).await;
328
329        assert!(matches!(result.unwrap_err(), AccessSendError::Api(_)));
330    }
331
332    // ===== access_send =====
333
334    #[tokio::test]
335    async fn test_access_send_file() {
336        let api_client = ApiClient::new_mocked(|mock| {
337            mock.sends_api
338                .expect_access_using_auth()
339                .returning(|token| {
340                    assert_eq!(token, ACCESS_TOKEN);
341                    Ok(SendAccessResponseModel {
342                        object: Some("send-access".to_string()),
343                        id: Some(SEND_ID.to_string()),
344                        r#type: Some(SendType::File),
345                        auth_type: None,
346                        name: Some("encrypted-name".to_string()),
347                        file: Some(Box::new(SendFileModel {
348                            id: Some(FILE_ID.to_string()),
349                            file_name: Some("encrypted-file-name".to_string()),
350                            size: Some("4200".to_string()),
351                            size_name: Some("4.2 KB".to_string()),
352                        })),
353                        text: None,
354                        expiration_date: None,
355                        creator_identifier: None,
356                    })
357                })
358                .once();
359        });
360
361        let result = access_send(&api_client, ACCESS_TOKEN).await.unwrap();
362
363        assert_eq!(result.id, Some(SEND_ID.to_string()));
364        assert_eq!(result.type_, Some(crate::SendType::File));
365        assert_eq!(result.name, Some("encrypted-name".to_string()));
366        assert!(result.text.is_none());
367        let file = result.file.expect("file variant should be populated");
368        assert_eq!(file.id, Some(FILE_ID.to_string()));
369        assert_eq!(file.file_name, Some("encrypted-file-name".to_string()));
370        assert_eq!(file.size, Some("4200".to_string()));
371        assert_eq!(file.size_name, Some("4.2 KB".to_string()));
372        assert_eq!(result.expiration_date, None);
373        assert_eq!(result.creator_identifier, None);
374    }
375
376    #[tokio::test]
377    async fn test_access_send_http_error() {
378        let api_client = ApiClient::new_mocked(|mock| {
379            mock.sends_api
380                .expect_access_using_auth()
381                .returning(|_token| {
382                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
383                        "Simulated error",
384                    )))
385                })
386                .once();
387        });
388
389        let result = access_send(&api_client, ACCESS_TOKEN).await;
390
391        assert!(matches!(result.unwrap_err(), AccessSendError::Api(_)));
392    }
393
394    // ===== get_file_download_data_v1 =====
395
396    #[tokio::test]
397    async fn test_get_file_download_data_v1() {
398        let api_client = ApiClient::new_mocked(|mock| {
399            mock.sends_api
400                .expect_get_send_file_download_data()
401                .returning(|send_id, file_id, request| {
402                    assert_eq!(send_id, SEND_ID);
403                    assert_eq!(file_id, FILE_ID);
404                    let request = request.expect("request body should be present");
405                    assert_eq!(request.password, Some("hashed-password".to_string()));
406                    Ok(SendFileDownloadDataResponseModel {
407                        object: Some("send-fileDownload".to_string()),
408                        id: Some(FILE_ID.to_string()),
409                        url: Some("https://example.com/download".to_string()),
410                    })
411                })
412                .once();
413        });
414
415        let result = get_file_download_data_v1(
416            &api_client,
417            SEND_ID,
418            FILE_ID,
419            Some("hashed-password".to_string()),
420        )
421        .await
422        .unwrap();
423
424        assert_eq!(result.id, Some(FILE_ID.to_string()));
425        assert_eq!(result.url, Some("https://example.com/download".to_string()));
426    }
427
428    #[tokio::test]
429    async fn test_get_file_download_data_v1_http_error() {
430        let api_client = ApiClient::new_mocked(|mock| {
431            mock.sends_api
432                .expect_get_send_file_download_data()
433                .returning(|_send_id, _file_id, _request| {
434                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
435                        "Simulated error",
436                    )))
437                })
438                .once();
439        });
440
441        let result = get_file_download_data_v1(&api_client, SEND_ID, FILE_ID, None).await;
442
443        assert!(matches!(
444            result.unwrap_err(),
445            GetFileDownloadDataError::Api(_)
446        ));
447    }
448
449    // ===== get_file_download_data =====
450
451    #[tokio::test]
452    async fn test_get_file_download_data() {
453        let api_client = ApiClient::new_mocked(|mock| {
454            mock.sends_api
455                .expect_get_send_file_download_data_using_auth()
456                .returning(|file_id, token| {
457                    assert_eq!(file_id, FILE_ID);
458                    assert_eq!(token, ACCESS_TOKEN);
459                    Ok(SendFileDownloadDataResponseModel {
460                        object: Some("send-fileDownload".to_string()),
461                        id: Some(FILE_ID.to_string()),
462                        url: Some("https://example.com/download".to_string()),
463                    })
464                })
465                .once();
466        });
467
468        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN)
469            .await
470            .unwrap();
471
472        assert_eq!(result.id, Some(FILE_ID.to_string()));
473        assert_eq!(result.url, Some("https://example.com/download".to_string()));
474    }
475
476    #[tokio::test]
477    async fn test_get_file_download_data_http_error() {
478        let api_client = ApiClient::new_mocked(|mock| {
479            mock.sends_api
480                .expect_get_send_file_download_data_using_auth()
481                .returning(|_file_id, _token| {
482                    Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
483                        "Simulated error",
484                    )))
485                })
486                .once();
487        });
488
489        let result = get_file_download_data(&api_client, FILE_ID, ACCESS_TOKEN).await;
490
491        assert!(matches!(
492            result.unwrap_err(),
493            GetFileDownloadDataError::Api(_)
494        ));
495    }
496}