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
64error_mapper! {
67 get_attachment_upload_form_errors:
68 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, )?
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 #[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 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}