Skip to main content

libsignal_service/push_service/
cdn.rs

1use std::{
2    collections::HashMap,
3    io::{self, Read, SeekFrom},
4};
5
6use futures::TryStreamExt;
7use reqwest::{
8    header::{CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE},
9    multipart::Part,
10    Method, StatusCode,
11};
12use serde::Deserialize;
13use tracing::{debug, trace};
14use url::Url;
15
16use super::response::error_mapper;
17use crate::{
18    configuration::Endpoint, prelude::AttachmentIdentifier,
19    proto::AttachmentPointer, push_service::HttpAuthOverride,
20};
21
22use super::{response::SignalServiceResponse, PushService, ServiceError};
23
24#[derive(Debug, serde::Deserialize, Default)]
25#[serde(rename_all = "camelCase")]
26pub struct AttachmentV2UploadAttributes {
27    key: String,
28    credential: String,
29    acl: String,
30    algorithm: String,
31    date: String,
32    policy: String,
33    signature: String,
34}
35
36#[derive(Debug, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct AttachmentUploadForm {
39    pub cdn: u32,
40    pub key: String,
41    pub headers: HashMap<String, String>,
42    pub signed_upload_location: Url,
43}
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct AttachmentDigest {
48    pub digest: Vec<u8>,
49    pub incremental_digest: Option<Vec<u8>>,
50    pub incremental_mac_chunk_size: u64,
51}
52
53#[derive(Debug)]
54pub struct ResumeInfo {
55    pub content_range: Option<String>,
56    pub content_start: u64,
57}
58
59pub struct AttachmentDownload<R> {
60    pub stream: R,
61    pub content_length: Option<u64>,
62}
63
64// Signal-Server: controllers/AttachmentControllerV4.java:102
65// (GET /v4/attachments/form/upload)
66error_mapper! {
67    get_attachment_upload_form_errors:
68        // 413: attachment too large, AttachmentControllerV4.java:111
69        PAYLOAD_TOO_LARGE => AttachmentTooLarge,
70}
71
72impl PushService {
73    pub async fn get_attachment(
74        &mut self,
75        ptr: &AttachmentPointer,
76    ) -> Result<
77        AttachmentDownload<impl futures::io::AsyncRead + Send + Unpin>,
78        ServiceError,
79    > {
80        let path = match ptr.attachment_identifier.as_ref() {
81            Some(AttachmentIdentifier::CdnId(id)) => {
82                format!("attachments/{}", id)
83            },
84            Some(AttachmentIdentifier::CdnKey(key)) => {
85                format!("attachments/{}", key)
86            },
87            None => {
88                return Err(ServiceError::InvalidFrame {
89                    reason: "no attachment identifier in pointer",
90                });
91            },
92        };
93        self.get_from_cdn(ptr.cdn_number(), &path).await
94    }
95
96    #[tracing::instrument(skip(self))]
97    pub(crate) async fn get_from_cdn(
98        &mut self,
99        cdn_id: u32,
100        path: &str,
101    ) -> Result<
102        AttachmentDownload<impl futures::io::AsyncRead + Send + Unpin>,
103        ServiceError,
104    > {
105        let response = self
106            .request(
107                Method::GET,
108                Endpoint::cdn(cdn_id, path),
109                HttpAuthOverride::Unidentified, // CDN requests are always without authentication
110            )?
111            .send()
112            .await?
113            .error_for_status()?;
114        let content_length = match response.headers().get(CONTENT_LENGTH) {
115            Some(value) => {
116                match value.to_str().ok().and_then(|value| value.parse().ok()) {
117                    Some(value) => Some(value),
118                    None => {
119                        tracing::warn!("invalid Content-Length header");
120                        None
121                    },
122                }
123            },
124            None => None,
125        };
126        let response_stream = response
127            .bytes_stream()
128            .map_err(io::Error::other)
129            .into_async_read();
130
131        Ok(AttachmentDownload {
132            stream: response_stream,
133            content_length,
134        })
135    }
136
137    pub(crate) async fn get_attachment_v4_upload_attributes(
138        &mut self,
139    ) -> Result<AttachmentUploadForm, ServiceError> {
140        self.request(
141            Method::GET,
142            Endpoint::service("/v4/attachments/form/upload"),
143            HttpAuthOverride::NoOverride,
144        )?
145        .send()
146        .await?
147        .service_error_for_status_with(get_attachment_upload_form_errors)
148        .await?
149        .json()
150        .await
151        .map_err(Into::into)
152    }
153
154    #[tracing::instrument(skip(self), level=tracing::Level::TRACE)]
155    pub(crate) async fn get_attachment_resumable_upload_url(
156        &mut self,
157        attachment_upload_form: &AttachmentUploadForm,
158    ) -> Result<Url, ServiceError> {
159        let mut request = self.request(
160            Method::POST,
161            Endpoint::Absolute(
162                attachment_upload_form.signed_upload_location.clone(),
163            ),
164            HttpAuthOverride::Unidentified,
165        )?;
166
167        for (key, value) in &attachment_upload_form.headers {
168            request = request.header(key, value);
169        }
170        request = request.header(CONTENT_LENGTH, "0");
171
172        if attachment_upload_form.cdn == 2 {
173            request = request.header(CONTENT_TYPE, "application/octet-stream");
174        } else if attachment_upload_form.cdn == 3 {
175            request = request
176                .header("Upload-Defer-Length", "1")
177                .header("Tus-Resumable", "1.0.0");
178        } else {
179            return Err(ServiceError::UnknownCdnVersion(
180                attachment_upload_form.cdn,
181            ));
182        };
183
184        Ok(request
185            .send()
186            .await?
187            .error_for_status()?
188            .headers()
189            .get("location")
190            .ok_or(ServiceError::InvalidFrame {
191                reason: "missing location header in HTTP response",
192            })?
193            .to_str()
194            .map_err(|_| ServiceError::InvalidFrame {
195                reason: "invalid location header bytes in HTTP response",
196            })?
197            .parse()?)
198    }
199
200    #[tracing::instrument(skip(self))]
201    async fn get_attachment_resume_info_cdn2(
202        &mut self,
203        resumable_url: &Url,
204        content_length: u64,
205    ) -> Result<ResumeInfo, ServiceError> {
206        let response = self
207            .request(
208                Method::PUT,
209                Endpoint::cdn_url(2, resumable_url),
210                HttpAuthOverride::Unidentified,
211            )?
212            .header(CONTENT_RANGE, format!("bytes */{content_length}"))
213            .send()
214            .await?
215            .error_for_status()?;
216
217        let status = response.status();
218
219        if status.is_success() {
220            Ok(ResumeInfo {
221                content_range: None,
222                content_start: content_length,
223            })
224        } else if status == StatusCode::PERMANENT_REDIRECT {
225            let offset =
226                match response.headers().get(RANGE) {
227                    Some(range) => range
228                        .to_str()
229                        .map_err(|_| ServiceError::InvalidFrame {
230                            reason: "invalid format for Range HTTP header",
231                        })?
232                        .split('-')
233                        .nth(1)
234                        .ok_or(ServiceError::InvalidFrame {
235                            reason:
236                                "invalid value format for Range HTTP header",
237                        })?
238                        .parse::<u64>()
239                        .map_err(|_| ServiceError::InvalidFrame {
240                            reason:
241                                "invalid number format for Range HTTP header",
242                        })?
243                        + 1,
244                    None => 0,
245                };
246
247            Ok(ResumeInfo {
248                content_range: Some(format!(
249                    "bytes {}-{}/{}",
250                    offset,
251                    content_length - 1,
252                    content_length
253                )),
254                content_start: offset,
255            })
256        } else {
257            Err(ServiceError::InvalidFrame {
258                reason: "failed to get resumable upload data from CDN2",
259            })
260        }
261    }
262
263    #[tracing::instrument(skip(self))]
264    async fn get_attachment_resume_info_cdn3(
265        &mut self,
266        resumable_url: &Url,
267        headers: &HashMap<String, String>,
268    ) -> Result<ResumeInfo, ServiceError> {
269        let mut request = self
270            .request(
271                Method::HEAD,
272                Endpoint::cdn_url(3, resumable_url),
273                HttpAuthOverride::Unidentified,
274            )?
275            .header("Tus-Resumable", "1.0.0");
276
277        for (key, value) in headers {
278            request = request.header(key, value);
279        }
280
281        let response = request.send().await?.error_for_status()?;
282
283        let upload_offset = response
284            .headers()
285            .get("upload-offset")
286            .ok_or(ServiceError::InvalidFrame {
287                reason: "no Upload-Offset header in response",
288            })?
289            .to_str()
290            .map_err(|_| ServiceError::InvalidFrame {
291                reason: "invalid upload-offset header bytes in HTTP response",
292            })?
293            .parse()
294            .map_err(|_| ServiceError::InvalidFrame {
295                reason: "invalid integer value for Upload-Offset header",
296            })?;
297
298        Ok(ResumeInfo {
299            content_range: None,
300            content_start: upload_offset,
301        })
302    }
303
304    /// Upload attachment
305    ///
306    /// Returns attachment ID and the attachment digest
307    #[tracing::instrument(skip(self, headers, content))]
308    pub(crate) async fn upload_attachment_v4(
309        &mut self,
310        cdn_id: u32,
311        resumable_url: &Url,
312        content_length: u64,
313        headers: HashMap<String, String>,
314        content: impl std::io::Read + std::io::Seek + Send,
315    ) -> Result<AttachmentDigest, ServiceError> {
316        if cdn_id == 2 {
317            self.upload_to_cdn2(resumable_url, content_length, content)
318                .await
319        } else {
320            self.upload_to_cdn3(
321                resumable_url,
322                &headers,
323                content_length,
324                content,
325            )
326            .await
327        }
328    }
329
330    #[tracing::instrument(skip(self, upload_attributes, reader))]
331    pub async fn upload_to_cdn0(
332        &mut self,
333        path: &str,
334        upload_attributes: AttachmentV2UploadAttributes,
335        filename: String,
336        mut reader: impl Read + Send,
337    ) -> Result<(), ServiceError> {
338        let mut buf = Vec::new();
339        reader
340            .read_to_end(&mut buf)
341            .expect("infallible Read instance");
342
343        // Amazon S3 expects multipart fields in a very specific order
344        // DO NOT CHANGE THIS (or do it, but feel the wrath of the gods)
345        let form = reqwest::multipart::Form::new()
346            .text("acl", upload_attributes.acl)
347            .text("key", upload_attributes.key)
348            .text("policy", upload_attributes.policy)
349            .text("Content-Type", "application/octet-stream")
350            .text("x-amz-algorithm", upload_attributes.algorithm)
351            .text("x-amz-credential", upload_attributes.credential)
352            .text("x-amz-date", upload_attributes.date)
353            .text("x-amz-signature", upload_attributes.signature)
354            .part(
355                "file",
356                Part::stream(buf)
357                    .mime_str("application/octet-stream")?
358                    .file_name(filename),
359            );
360
361        let response = self
362            .request(
363                Method::POST,
364                Endpoint::cdn(0, path),
365                HttpAuthOverride::NoOverride,
366            )?
367            .multipart(form)
368            .send()
369            .await?
370            .error_for_status()?;
371
372        debug!("HyperPushService::PUT response: {:?}", response);
373
374        Ok(())
375    }
376
377    #[tracing::instrument(skip(self, content))]
378    async fn upload_to_cdn2(
379        &mut self,
380        resumable_url: &Url,
381        content_length: u64,
382        mut content: impl std::io::Read + std::io::Seek + Send,
383    ) -> Result<AttachmentDigest, ServiceError> {
384        let resume_info = self
385            .get_attachment_resume_info_cdn2(resumable_url, content_length)
386            .await?;
387
388        let mut digester =
389            crate::digeststream::DigestingReader::new(&mut content);
390
391        let mut buf = Vec::new();
392        digester.read_to_end(&mut buf)?;
393
394        trace!("digested content");
395
396        let mut request = self.request(
397            Method::PUT,
398            Endpoint::cdn_url(2, resumable_url),
399            HttpAuthOverride::Unidentified,
400        )?;
401
402        if let Some(content_range) = resume_info.content_range {
403            request = request.header(CONTENT_RANGE, content_range);
404        }
405
406        request.body(buf).send().await?.error_for_status()?;
407
408        Ok(AttachmentDigest {
409            digest: digester.finalize(),
410            incremental_digest: None,
411            incremental_mac_chunk_size: 0,
412        })
413    }
414
415    #[tracing::instrument(skip(self, content))]
416    async fn upload_to_cdn3(
417        &mut self,
418        resumable_url: &Url,
419        headers: &HashMap<String, String>,
420        content_length: u64,
421        mut content: impl std::io::Read + std::io::Seek + Send,
422    ) -> Result<AttachmentDigest, ServiceError> {
423        let resume_info = self
424            .get_attachment_resume_info_cdn3(resumable_url, headers)
425            .await?;
426
427        trace!(?resume_info, "got resume info");
428
429        if resume_info.content_start == content_length {
430            let mut digester =
431                crate::digeststream::DigestingReader::new(&mut content);
432            let mut buf = Vec::new();
433            digester.read_to_end(&mut buf)?;
434            return Ok(AttachmentDigest {
435                digest: digester.finalize(),
436                incremental_digest: None,
437                incremental_mac_chunk_size: 0,
438            });
439        }
440
441        let mut digester =
442            crate::digeststream::DigestingReader::new(&mut content);
443        digester.seek(SeekFrom::Start(resume_info.content_start))?;
444
445        let mut buf = Vec::new();
446        digester.read_to_end(&mut buf)?;
447
448        trace!("digested content");
449
450        let mut request = self.request(
451            Method::PATCH,
452            Endpoint::cdn(3, resumable_url.path()),
453            HttpAuthOverride::Unidentified,
454        )?;
455
456        for (key, value) in headers {
457            request = request.header(key, value);
458        }
459
460        request
461            .header("Tus-Resumable", "1.0.0")
462            .header("Upload-Offset", resume_info.content_start)
463            .header("Upload-Length", buf.len())
464            .header(CONTENT_TYPE, "application/offset+octet-stream")
465            .body(buf)
466            .send()
467            .await?
468            .error_for_status()?;
469
470        trace!("attachment uploaded");
471
472        Ok(AttachmentDigest {
473            digest: digester.finalize(),
474            incremental_digest: None,
475            incremental_mac_chunk_size: 0,
476        })
477    }
478}