1use std::cmp::min;
8use std::io::{Read, Seek, SeekFrom};
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12use md5::{Digest, Md5};
13use reqwest::header::{ACCEPT, AUTHORIZATION, LOCATION};
14use reqwest::{Method, RequestBuilder};
15use secrecy::{ExposeSecret, SecretString};
16use serde::de::DeserializeOwned;
17use serde::Deserialize;
18use tempfile::NamedTempFile;
19use tokio::time::sleep;
20use url::Url;
21
22use crate::endpoint::Endpoint;
23use crate::error::FigshareError;
24use crate::ids::{ArticleId, Doi, FileId};
25use crate::metadata::ArticleMetadata;
26use crate::model::{
27 Article, ArticleCategory, ArticleFile, ArticleLicense, ArticleVersion, UploadSession,
28 UploadStatus,
29};
30use crate::poll::PollOptions;
31use crate::query::ArticleQuery;
32
33#[derive(Clone)]
35pub struct Auth {
36 pub token: Option<SecretString>,
38}
39
40impl Auth {
41 pub const TOKEN_ENV_VAR: &'static str = "FIGSHARE_TOKEN";
43
44 #[must_use]
46 pub fn new(token: impl Into<String>) -> Self {
47 Self {
48 token: Some(SecretString::from(token.into())),
49 }
50 }
51
52 #[must_use]
63 pub fn anonymous() -> Self {
64 Self { token: None }
65 }
66
67 pub fn from_env() -> Result<Self, FigshareError> {
73 Self::from_env_var(Self::TOKEN_ENV_VAR)
74 }
75
76 pub fn from_env_var(name: &str) -> Result<Self, FigshareError> {
82 let token = std::env::var(name).map_err(|source| FigshareError::EnvVar {
83 name: name.to_owned(),
84 source,
85 })?;
86 Ok(Self::new(token))
87 }
88
89 #[must_use]
91 pub fn is_anonymous(&self) -> bool {
92 self.token.is_none()
93 }
94}
95
96impl From<SecretString> for Auth {
97 fn from(token: SecretString) -> Self {
98 Self { token: Some(token) }
99 }
100}
101
102impl std::fmt::Debug for Auth {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 if self.is_anonymous() {
105 f.debug_struct("Auth")
106 .field("token", &"<anonymous>")
107 .finish()
108 } else {
109 f.debug_struct("Auth")
110 .field("token", &"<redacted>")
111 .finish()
112 }
113 }
114}
115
116#[derive(Clone, Debug)]
118pub struct FigshareClientBuilder {
119 auth: Auth,
120 endpoint: Endpoint,
121 poll: PollOptions,
122 user_agent: Option<String>,
123 request_timeout: Option<Duration>,
124 connect_timeout: Option<Duration>,
125}
126
127impl FigshareClientBuilder {
128 #[must_use]
130 pub fn endpoint(mut self, endpoint: Endpoint) -> Self {
131 self.endpoint = endpoint;
132 self
133 }
134
135 #[must_use]
137 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
138 self.user_agent = Some(user_agent.into());
139 self
140 }
141
142 #[must_use]
144 pub fn request_timeout(mut self, timeout: Duration) -> Self {
145 self.request_timeout = Some(timeout);
146 self
147 }
148
149 #[must_use]
151 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
152 self.connect_timeout = Some(timeout);
153 self
154 }
155
156 #[must_use]
158 pub fn poll_options(mut self, poll: PollOptions) -> Self {
159 self.poll = poll;
160 self
161 }
162
163 pub fn build(self) -> Result<FigshareClient, FigshareError> {
169 let user_agent = self
170 .user_agent
171 .unwrap_or_else(|| format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")));
172
173 let mut inner = reqwest::Client::builder().user_agent(&user_agent);
174 if let Some(timeout) = self.request_timeout {
175 inner = inner.timeout(timeout);
176 }
177 if let Some(timeout) = self.connect_timeout {
178 inner = inner.connect_timeout(timeout);
179 }
180 let inner = inner.build()?;
181
182 Ok(FigshareClient {
183 inner,
184 auth: self.auth,
185 endpoint: self.endpoint,
186 poll: self.poll,
187 request_timeout: self.request_timeout,
188 connect_timeout: self.connect_timeout,
189 })
190 }
191}
192
193#[derive(Clone, Debug)]
195pub struct FigshareClient {
196 pub(crate) inner: reqwest::Client,
197 pub(crate) auth: Auth,
198 pub(crate) endpoint: Endpoint,
199 pub(crate) poll: PollOptions,
200 pub(crate) request_timeout: Option<Duration>,
201 pub(crate) connect_timeout: Option<Duration>,
202}
203
204impl FigshareClient {
205 const MAX_PAGE_SIZE: u64 = 1_000;
206
207 #[must_use]
209 pub fn builder(auth: Auth) -> FigshareClientBuilder {
210 FigshareClientBuilder {
211 auth,
212 endpoint: Endpoint::default(),
213 poll: PollOptions::default(),
214 user_agent: None,
215 request_timeout: None,
216 connect_timeout: None,
217 }
218 }
219
220 pub fn new(auth: Auth) -> Result<Self, FigshareError> {
226 Self::builder(auth).build()
227 }
228
229 pub fn with_token(token: impl Into<String>) -> Result<Self, FigshareError> {
235 Self::new(Auth::new(token))
236 }
237
238 pub fn anonymous() -> Result<Self, FigshareError> {
244 Self::new(Auth::anonymous())
245 }
246
247 pub fn from_env() -> Result<Self, FigshareError> {
254 Self::new(Auth::from_env()?)
255 }
256
257 #[must_use]
259 pub fn endpoint(&self) -> &Endpoint {
260 &self.endpoint
261 }
262
263 #[must_use]
265 pub fn poll_options(&self) -> &PollOptions {
266 &self.poll
267 }
268
269 #[must_use]
271 pub fn request_timeout(&self) -> Option<Duration> {
272 self.request_timeout
273 }
274
275 #[must_use]
277 pub fn connect_timeout(&self) -> Option<Duration> {
278 self.connect_timeout
279 }
280
281 pub(crate) fn request(
282 &self,
283 method: Method,
284 path: &str,
285 auth_required: bool,
286 ) -> Result<RequestBuilder, FigshareError> {
287 let url = self.endpoint.base_url()?.join(path)?;
288 self.request_url(method, url, auth_required)
289 }
290
291 pub(crate) fn request_url(
292 &self,
293 method: Method,
294 url: Url,
295 auth_required: bool,
296 ) -> Result<RequestBuilder, FigshareError> {
297 if !self.is_trusted_api_url(&url)? {
298 return Err(FigshareError::InvalidState(format!(
299 "refusing API request to different origin: {url}"
300 )));
301 }
302
303 let mut request = self
304 .inner
305 .request(method, url)
306 .header(ACCEPT, "application/json");
307 if auth_required {
308 request = request.header(
309 AUTHORIZATION,
310 self.authorization_header_value("api request")?,
311 );
312 }
313
314 Ok(request)
315 }
316
317 pub(crate) fn upload_request_url(
318 &self,
319 method: Method,
320 url: Url,
321 ) -> Result<RequestBuilder, FigshareError> {
322 if !self.is_trusted_upload_url(&url)? {
323 return Err(FigshareError::InvalidState(format!(
324 "refusing upload request to different origin: {url}"
325 )));
326 }
327
328 Ok(self.inner.request(method, url).header(
329 AUTHORIZATION,
330 self.authorization_header_value("upload request")?,
331 ))
332 }
333
334 pub(crate) fn download_request_url(
335 &self,
336 method: Method,
337 url: Url,
338 auth_download: bool,
339 ) -> Result<RequestBuilder, FigshareError> {
340 let url = if auth_download {
341 self.with_download_token(url, "private file download")?
342 } else {
343 url
344 };
345 Ok(self.inner.request(method, url))
346 }
347
348 pub(crate) async fn execute_json<T>(&self, request: RequestBuilder) -> Result<T, FigshareError>
349 where
350 T: DeserializeOwned,
351 {
352 let response = request.send().await?;
353 if !response.status().is_success() {
354 return Err(FigshareError::from_response(response).await);
355 }
356
357 let bytes = response.bytes().await?;
358 Ok(serde_json::from_slice(&bytes)?)
359 }
360
361 pub(crate) async fn execute_unit(&self, request: RequestBuilder) -> Result<(), FigshareError> {
362 let response = request.send().await?;
363 if !response.status().is_success() {
364 return Err(FigshareError::from_response(response).await);
365 }
366
367 Ok(())
368 }
369
370 pub(crate) async fn execute_response(
371 &self,
372 request: RequestBuilder,
373 ) -> Result<reqwest::Response, FigshareError> {
374 let response = request.send().await?;
375 if !response.status().is_success() {
376 return Err(FigshareError::from_response(response).await);
377 }
378
379 Ok(response)
380 }
381
382 async fn execute_location(&self, request: RequestBuilder) -> Result<Url, FigshareError> {
383 let response = request.send().await?;
384 if !response.status().is_success() {
385 return Err(FigshareError::from_response(response).await);
386 }
387
388 let response_url = response.url().clone();
389 if let Some(location) = response
390 .headers()
391 .get(LOCATION)
392 .and_then(|value| value.to_str().ok())
393 {
394 return parse_location(&response_url, location);
395 }
396
397 let bytes = response.bytes().await?;
398 if bytes.is_empty() {
399 return Err(FigshareError::InvalidState(
400 "successful Figshare response did not include a location".into(),
401 ));
402 }
403
404 if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
405 if let Some(location) = value.get("location").and_then(serde_json::Value::as_str) {
406 return parse_location(&response_url, location);
407 }
408 if let Some(location) = value.as_str() {
409 return parse_location(&response_url, location);
410 }
411 }
412
413 let text = String::from_utf8_lossy(&bytes);
414 parse_location(&response_url, text.trim())
415 }
416
417 fn is_trusted_api_url(&self, url: &Url) -> Result<bool, FigshareError> {
418 Ok(self.endpoint.base_url()?.origin() == url.origin())
419 }
420
421 fn is_trusted_upload_url(&self, url: &Url) -> Result<bool, FigshareError> {
422 let endpoint = self.endpoint.base_url()?;
423 Ok(endpoint.origin() == url.origin()
424 || url.host_str().is_some_and(is_trusted_figshare_upload_host))
425 }
426
427 fn authorization_header_value(&self, operation: &'static str) -> Result<String, FigshareError> {
428 let token = self
429 .auth
430 .token
431 .as_ref()
432 .ok_or(FigshareError::MissingAuth(operation))?;
433 Ok(format!("token {}", token.expose_secret()))
434 }
435
436 fn with_download_token(
437 &self,
438 mut url: Url,
439 operation: &'static str,
440 ) -> Result<Url, FigshareError> {
441 let token = self
442 .auth
443 .token
444 .as_ref()
445 .ok_or(FigshareError::MissingAuth(operation))?;
446
447 let should_append = url
448 .host_str()
449 .is_some_and(|host| host.eq_ignore_ascii_case("ndownloader.figshare.com"))
450 || self.endpoint.base_url()?.origin() == url.origin();
451
452 if should_append {
453 url.query_pairs_mut()
454 .append_pair("token", token.expose_secret());
455 }
456
457 Ok(url)
458 }
459
460 async fn get_public_article_by_url(&self, url: &Url) -> Result<Article, FigshareError> {
461 self.execute_json(self.request_url(Method::GET, url.clone(), false)?)
462 .await
463 }
464
465 async fn get_own_article_by_url(&self, url: &Url) -> Result<Article, FigshareError> {
466 self.execute_json(self.request_url(Method::GET, url.clone(), true)?)
467 .await
468 }
469
470 async fn get_file_by_url(&self, url: &Url) -> Result<ArticleFile, FigshareError> {
471 self.execute_json(self.request_url(Method::GET, url.clone(), true)?)
472 .await
473 }
474
475 pub async fn list_licenses(&self) -> Result<Vec<ArticleLicense>, FigshareError> {
482 self.execute_json(self.request(Method::GET, "licenses", false)?)
483 .await
484 }
485
486 pub async fn list_categories(&self) -> Result<Vec<ArticleCategory>, FigshareError> {
493 self.execute_json(self.request(Method::GET, "categories", false)?)
494 .await
495 }
496
497 pub async fn list_account_categories(&self) -> Result<Vec<ArticleCategory>, FigshareError> {
504 self.execute_json(self.request(Method::GET, "account/categories", true)?)
505 .await
506 }
507
508 pub async fn list_public_articles(
515 &self,
516 query: &ArticleQuery,
517 ) -> Result<Vec<Article>, FigshareError> {
518 let pairs = query.as_public_list_query_pairs()?;
519 self.execute_json(self.request(Method::GET, "articles", false)?.query(&pairs))
520 .await
521 }
522
523 pub(crate) async fn search_public_articles(
530 &self,
531 query: &ArticleQuery,
532 ) -> Result<Vec<Article>, FigshareError> {
533 let body = query.as_public_search_body()?;
534 self.execute_json(
535 self.request(Method::POST, "articles/search", false)?
536 .json(&body),
537 )
538 .await
539 }
540
541 pub(crate) async fn get_public_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
548 self.execute_json(self.request(Method::GET, &format!("articles/{id}"), false)?)
549 .await
550 }
551
552 pub async fn list_public_article_versions(
559 &self,
560 id: ArticleId,
561 ) -> Result<Vec<ArticleVersion>, FigshareError> {
562 self.execute_json(self.request(Method::GET, &format!("articles/{id}/versions"), false)?)
563 .await
564 }
565
566 pub async fn get_public_article_version(
573 &self,
574 id: ArticleId,
575 version: u64,
576 ) -> Result<Article, FigshareError> {
577 self.execute_json(self.request(
578 Method::GET,
579 &format!("articles/{id}/versions/{version}"),
580 false,
581 )?)
582 .await
583 }
584
585 pub(crate) async fn get_public_article_by_doi(
592 &self,
593 doi: &Doi,
594 ) -> Result<Article, FigshareError> {
595 let hits = self
596 .list_public_articles(&ArticleQuery::builder().doi(doi.as_str()).limit(10).build())
597 .await?;
598 hits.into_iter()
599 .find(|article| article.doi.as_ref() == Some(doi))
600 .ok_or_else(|| {
601 FigshareError::UnsupportedSelector(format!(
602 "no public article matched DOI {}",
603 doi.as_str()
604 ))
605 })
606 }
607
608 pub(crate) async fn resolve_latest_public_article(
615 &self,
616 id: ArticleId,
617 ) -> Result<Article, FigshareError> {
618 let article = self.get_public_article(id).await?;
619 let versions = self.list_public_article_versions(id).await?;
620 let Some(latest) = versions.iter().max_by_key(|version| version.version) else {
621 return Ok(article);
622 };
623
624 if article.version_number() == Some(latest.version) {
625 return Ok(article);
626 }
627
628 self.get_public_article_version(id, latest.version).await
629 }
630
631 pub(crate) async fn resolve_latest_public_article_by_doi(
637 &self,
638 doi: &Doi,
639 ) -> Result<Article, FigshareError> {
640 let article = self.get_public_article_by_doi(doi).await?;
641 self.resolve_latest_public_article(article.id).await
642 }
643
644 pub async fn list_own_articles(
651 &self,
652 query: &ArticleQuery,
653 ) -> Result<Vec<Article>, FigshareError> {
654 let pairs = query.as_own_list_query_pairs()?;
655 self.execute_json(
656 self.request(Method::GET, "account/articles", true)?
657 .query(&pairs),
658 )
659 .await
660 }
661
662 pub async fn search_own_articles(
669 &self,
670 query: &ArticleQuery,
671 ) -> Result<Vec<Article>, FigshareError> {
672 let body = query.as_own_search_body()?;
673 self.execute_json(
674 self.request(Method::POST, "account/articles/search", true)?
675 .json(&body),
676 )
677 .await
678 }
679
680 pub async fn get_own_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
687 self.execute_json(self.request(Method::GET, &format!("account/articles/{id}"), true)?)
688 .await
689 }
690
691 pub(crate) async fn create_article(
698 &self,
699 metadata: &ArticleMetadata,
700 ) -> Result<Article, FigshareError> {
701 let location = self
702 .execute_location(
703 self.request(Method::POST, "account/articles", true)?
704 .json(&metadata.to_payload()),
705 )
706 .await?;
707 self.get_own_article_by_url(&location).await
708 }
709
710 pub(crate) async fn update_article(
717 &self,
718 id: ArticleId,
719 metadata: &ArticleMetadata,
720 ) -> Result<Article, FigshareError> {
721 self.execute_unit(
722 self.request(Method::PUT, &format!("account/articles/{id}"), true)?
723 .json(&metadata.to_payload()),
724 )
725 .await?;
726 self.get_own_article(id).await
727 }
728
729 pub async fn delete_article(&self, id: ArticleId) -> Result<(), FigshareError> {
736 self.execute_unit(self.request(Method::DELETE, &format!("account/articles/{id}"), true)?)
737 .await
738 }
739
740 pub async fn reserve_doi(&self, id: ArticleId) -> Result<Doi, FigshareError> {
747 #[derive(Deserialize)]
748 struct Payload {
749 doi: Doi,
750 }
751
752 let payload: Payload = self
753 .execute_json(self.request(
754 Method::POST,
755 &format!("account/articles/{id}/reserve_doi"),
756 true,
757 )?)
758 .await?;
759 Ok(payload.doi)
760 }
761
762 pub(crate) async fn publish_article(&self, id: ArticleId) -> Result<Article, FigshareError> {
769 let location = self
770 .execute_location(self.request(
771 Method::POST,
772 &format!("account/articles/{id}/publish"),
773 true,
774 )?)
775 .await?;
776 self.wait_for_public_article_by_url(&location).await
777 }
778
779 pub async fn list_files(&self, id: ArticleId) -> Result<Vec<ArticleFile>, FigshareError> {
786 self.list_paginated_files(&format!("account/articles/{id}/files"), true)
787 .await
788 }
789
790 pub async fn list_public_article_version_files(
797 &self,
798 article_id: ArticleId,
799 version: u64,
800 ) -> Result<Vec<ArticleFile>, FigshareError> {
801 self.list_paginated_files(
802 &format!("articles/{article_id}/versions/{version}/files"),
803 false,
804 )
805 .await
806 }
807
808 pub async fn get_file(
815 &self,
816 article_id: ArticleId,
817 file_id: FileId,
818 ) -> Result<ArticleFile, FigshareError> {
819 self.execute_json(self.request(
820 Method::GET,
821 &format!("account/articles/{article_id}/files/{file_id}"),
822 true,
823 )?)
824 .await
825 }
826
827 pub async fn delete_file(
834 &self,
835 article_id: ArticleId,
836 file_id: FileId,
837 ) -> Result<(), FigshareError> {
838 self.execute_unit(self.request(
839 Method::DELETE,
840 &format!("account/articles/{article_id}/files/{file_id}"),
841 true,
842 )?)
843 .await
844 }
845
846 pub async fn initiate_file_upload(
853 &self,
854 article_id: ArticleId,
855 name: &str,
856 size: u64,
857 md5: &str,
858 ) -> Result<ArticleFile, FigshareError> {
859 let payload = serde_json::json!({
860 "name": name,
861 "size": size,
862 "md5": md5,
863 });
864 let location = self
865 .execute_location(
866 self.request(
867 Method::POST,
868 &format!("account/articles/{article_id}/files"),
869 true,
870 )?
871 .json(&payload),
872 )
873 .await?;
874 self.get_file_by_url(&location).await
875 }
876
877 pub async fn initiate_link_file(
884 &self,
885 article_id: ArticleId,
886 link: &str,
887 ) -> Result<ArticleFile, FigshareError> {
888 let payload = serde_json::json!({ "link": link });
889 let location = self
890 .execute_location(
891 self.request(
892 Method::POST,
893 &format!("account/articles/{article_id}/files"),
894 true,
895 )?
896 .json(&payload),
897 )
898 .await?;
899 self.get_file_by_url(&location).await
900 }
901
902 pub async fn get_upload_session(
909 &self,
910 upload_url: &Url,
911 ) -> Result<UploadSession, FigshareError> {
912 self.execute_json(self.upload_request_url(Method::GET, upload_url.clone())?)
913 .await
914 }
915
916 pub async fn upload_part(
923 &self,
924 upload_url: &Url,
925 part_no: u64,
926 bytes: Vec<u8>,
927 ) -> Result<(), FigshareError> {
928 self.execute_unit(
929 self.upload_request_url(Method::PUT, upload_part_url(upload_url, part_no)?)?
930 .body(bytes),
931 )
932 .await
933 }
934
935 pub async fn reset_upload_part(
942 &self,
943 upload_url: &Url,
944 part_no: u64,
945 ) -> Result<(), FigshareError> {
946 self.execute_unit(
947 self.upload_request_url(Method::DELETE, upload_part_url(upload_url, part_no)?)?,
948 )
949 .await
950 }
951
952 pub async fn complete_file_upload(
959 &self,
960 article_id: ArticleId,
961 file_id: FileId,
962 ) -> Result<(), FigshareError> {
963 self.execute_unit(self.request(
964 Method::POST,
965 &format!("account/articles/{article_id}/files/{file_id}"),
966 true,
967 )?)
968 .await
969 }
970
971 pub async fn upload_path(
979 &self,
980 article_id: ArticleId,
981 path: &Path,
982 ) -> Result<ArticleFile, FigshareError> {
983 let filename = path
984 .file_name()
985 .map(|name| name.to_string_lossy().into_owned())
986 .ok_or_else(|| {
987 FigshareError::InvalidState("path has no final file name segment".into())
988 })?;
989 self.upload_path_with_filename(article_id, &filename, path)
990 .await
991 }
992
993 pub async fn upload_reader<R>(
1002 &self,
1003 article_id: ArticleId,
1004 filename: &str,
1005 reader: R,
1006 content_length: u64,
1007 ) -> Result<ArticleFile, FigshareError>
1008 where
1009 R: Read + Send + 'static,
1010 {
1011 let staged = tokio::task::spawn_blocking(move || stage_reader(reader, content_length))
1012 .await
1013 .map_err(|error| {
1014 FigshareError::InvalidState(format!("reader staging task failed: {error}"))
1015 })??;
1016 self.upload_path_with_filename(article_id, filename, staged.path())
1017 .await
1018 }
1019
1020 pub(crate) async fn wait_for_public_article_by_url(
1021 &self,
1022 url: &Url,
1023 ) -> Result<Article, FigshareError> {
1024 let start = Instant::now();
1025 let mut delay = self.poll.initial_delay;
1026
1027 loop {
1028 match self.get_public_article_by_url(url).await {
1029 Ok(article) => return Ok(article),
1030 Err(FigshareError::Http { status, .. })
1031 if status == reqwest::StatusCode::NOT_FOUND
1032 && start.elapsed() < self.poll.max_wait =>
1033 {
1034 sleep(delay).await;
1035 delay = min(delay.saturating_mul(2), self.poll.max_delay);
1036 }
1037 Err(error) => return Err(error),
1038 }
1039
1040 if start.elapsed() >= self.poll.max_wait {
1041 return Err(FigshareError::Timeout("public article publication"));
1042 }
1043 }
1044 }
1045
1046 pub(crate) async fn wait_for_own_article_public(
1047 &self,
1048 article_id: ArticleId,
1049 ) -> Result<Article, FigshareError> {
1050 let start = Instant::now();
1051 let mut delay = self.poll.initial_delay;
1052
1053 loop {
1054 let article = self.get_own_article(article_id).await?;
1055 if article.is_public_article() {
1056 return Ok(article);
1057 }
1058 if start.elapsed() >= self.poll.max_wait {
1059 return Err(FigshareError::Timeout("private article publication"));
1060 }
1061
1062 sleep(delay).await;
1063 delay = min(delay.saturating_mul(2), self.poll.max_delay);
1064 }
1065 }
1066
1067 pub(crate) async fn upload_path_with_filename(
1068 &self,
1069 article_id: ArticleId,
1070 filename: &str,
1071 path: &Path,
1072 ) -> Result<ArticleFile, FigshareError> {
1073 let path = path.to_path_buf();
1074 let checksum_path = path.clone();
1075 let (md5, size) = tokio::task::spawn_blocking(move || checksum_and_size(&checksum_path))
1076 .await
1077 .map_err(|error| {
1078 FigshareError::InvalidState(format!("checksum task failed: {error}"))
1079 })??;
1080
1081 let file = self
1082 .initiate_file_upload(article_id, filename, size, &md5)
1083 .await?;
1084 let result = async {
1085 let upload_url = file
1086 .upload_session_url()
1087 .cloned()
1088 .ok_or(FigshareError::MissingLink("upload_url"))?;
1089 let session = self.get_upload_session(&upload_url).await?;
1090
1091 for part in &session.parts {
1092 let path = path.clone();
1093 let start_offset = part.start_offset;
1094 let len = part.len();
1095 let bytes =
1096 tokio::task::spawn_blocking(move || read_path_range(&path, start_offset, len))
1097 .await
1098 .map_err(|error| {
1099 FigshareError::InvalidState(format!(
1100 "path part read task failed: {error}"
1101 ))
1102 })??;
1103 self.upload_part(&upload_url, part.part_no, bytes).await?;
1104 }
1105
1106 self.complete_file_upload(article_id, file.id).await?;
1107 let final_session = self.wait_for_upload_completion(&upload_url).await?;
1108 if matches!(final_session.status, UploadStatus::Aborted) {
1109 return Err(FigshareError::InvalidState(
1110 "Figshare upload was aborted".into(),
1111 ));
1112 }
1113
1114 self.get_file(article_id, file.id).await
1115 }
1116 .await;
1117
1118 match result {
1119 Ok(file) => Ok(file),
1120 Err(error) => {
1121 let _ = self.delete_file(article_id, file.id).await;
1122 Err(error)
1123 }
1124 }
1125 }
1126
1127 async fn wait_for_upload_completion(
1128 &self,
1129 upload_url: &Url,
1130 ) -> Result<UploadSession, FigshareError> {
1131 let start = Instant::now();
1132 let mut delay = self.poll.initial_delay;
1133
1134 loop {
1135 let session = self.get_upload_session(upload_url).await?;
1136 if session.is_completed() {
1137 return Ok(session);
1138 }
1139 if matches!(session.status, UploadStatus::Aborted) {
1140 return Ok(session);
1141 }
1142 if start.elapsed() >= self.poll.max_wait {
1143 return Err(FigshareError::Timeout("upload completion"));
1144 }
1145
1146 sleep(delay).await;
1147 delay = min(delay.saturating_mul(2), self.poll.max_delay);
1148 }
1149 }
1150
1151 async fn list_paginated_files(
1152 &self,
1153 path: &str,
1154 auth_required: bool,
1155 ) -> Result<Vec<ArticleFile>, FigshareError> {
1156 let max_page_size = usize::try_from(Self::MAX_PAGE_SIZE).map_err(|_| {
1157 FigshareError::InvalidState("configured file page size does not fit usize".into())
1158 })?;
1159 let mut files = Vec::new();
1160 let mut page = 1_u64;
1161
1162 loop {
1163 let batch: Vec<ArticleFile> = self
1164 .execute_json(self.request(Method::GET, path, auth_required)?.query(&[
1165 ("page", page.to_string()),
1166 ("page_size", Self::MAX_PAGE_SIZE.to_string()),
1167 ]))
1168 .await?;
1169 let batch_len = batch.len();
1170 files.extend(batch);
1171
1172 if batch_len < max_page_size {
1173 return Ok(files);
1174 }
1175 page += 1;
1176 }
1177 }
1178}
1179
1180fn parse_location(base: &Url, location: &str) -> Result<Url, FigshareError> {
1181 match Url::parse(location) {
1182 Ok(url) => Ok(url),
1183 Err(url::ParseError::RelativeUrlWithoutBase) => Ok(base.join(location)?),
1184 Err(error) => Err(FigshareError::Url(error)),
1185 }
1186}
1187
1188fn is_trusted_figshare_upload_host(host: &str) -> bool {
1189 let host = host.to_ascii_lowercase();
1190 host == "uploads.figshare.com"
1191 || host
1192 .strip_suffix(".figshare.com")
1193 .is_some_and(|subdomain| subdomain.starts_with("fup-"))
1194}
1195
1196fn upload_part_url(upload_url: &Url, part_no: u64) -> Result<Url, FigshareError> {
1197 let mut url = upload_url.clone();
1198 let mut segments = url.path_segments_mut().map_err(|()| {
1199 FigshareError::InvalidState("upload URL cannot accept part number segments".into())
1200 })?;
1201 segments.pop_if_empty();
1202 segments.push(&part_no.to_string());
1203 drop(segments);
1204 Ok(url)
1205}
1206
1207fn checksum_and_size(path: &Path) -> Result<(String, u64), FigshareError> {
1208 let mut file = std::fs::File::open(path)?;
1209 let mut hasher = Md5::new();
1210 let mut size = 0_u64;
1211 let mut buffer = vec![0_u8; 64 * 1024];
1212
1213 loop {
1214 let read = file.read(&mut buffer)?;
1215 if read == 0 {
1216 break;
1217 }
1218 size += u64::try_from(read).map_err(|_| {
1219 FigshareError::InvalidState("read chunk length does not fit in u64".into())
1220 })?;
1221 hasher.update(&buffer[..read]);
1222 }
1223
1224 Ok((hex::encode(hasher.finalize()), size))
1225}
1226
1227fn read_path_range(path: &Path, offset: u64, len: u64) -> Result<Vec<u8>, FigshareError> {
1228 let mut file = std::fs::File::open(path)?;
1229 file.seek(SeekFrom::Start(offset))?;
1230 let len = usize::try_from(len).map_err(|_| {
1231 std::io::Error::new(
1232 std::io::ErrorKind::InvalidInput,
1233 "requested byte range does not fit in memory on this platform",
1234 )
1235 })?;
1236 let mut bytes = vec![0_u8; len];
1237 file.read_exact(&mut bytes)?;
1238 Ok(bytes)
1239}
1240
1241fn stage_reader<R>(mut reader: R, content_length: u64) -> Result<NamedTempFile, FigshareError>
1242where
1243 R: Read,
1244{
1245 let mut tempfile = NamedTempFile::new()?;
1246 let written = std::io::copy(&mut reader.by_ref().take(content_length), &mut tempfile)?;
1247 if written != content_length {
1248 return Err(FigshareError::InvalidState(format!(
1249 "reader produced {written} bytes but {content_length} were declared"
1250 )));
1251 }
1252 Ok(tempfile)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use std::env::VarError;
1258 use std::io::Cursor;
1259 use std::path::Path;
1260 use std::time::Duration;
1261
1262 use axum::extract::State;
1263 use axum::http::StatusCode;
1264 use axum::routing::{get, post};
1265 use axum::{Json, Router};
1266 use reqwest::header::{AUTHORIZATION, LOCATION};
1267 use reqwest::Method;
1268 use secrecy::ExposeSecret;
1269 use serde_json::json;
1270 use tokio::net::TcpListener;
1271 use tokio::time::sleep;
1272 use url::Url;
1273
1274 use super::{checksum_and_size, parse_location, upload_part_url, Auth, FigshareClient};
1275 use crate::{Endpoint, FigshareError, PollOptions};
1276
1277 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1278
1279 struct EnvVarGuard {
1280 name: &'static str,
1281 previous: Option<String>,
1282 }
1283
1284 impl EnvVarGuard {
1285 fn set(name: &'static str, value: Option<&str>) -> Self {
1286 let previous = std::env::var(name).ok();
1287 match value {
1288 Some(value) => std::env::set_var(name, value),
1289 None => std::env::remove_var(name),
1290 }
1291 Self { name, previous }
1292 }
1293 }
1294
1295 impl Drop for EnvVarGuard {
1296 fn drop(&mut self) {
1297 match &self.previous {
1298 Some(value) => std::env::set_var(self.name, value),
1299 None => std::env::remove_var(self.name),
1300 }
1301 }
1302 }
1303
1304 #[test]
1305 fn auth_helpers_cover_anonymous_and_env_loading() {
1306 let anonymous = Auth::anonymous();
1307 assert!(anonymous.is_anonymous());
1308 assert!(format!("{anonymous:?}").contains("<anonymous>"));
1309
1310 let _lock = ENV_LOCK.lock().unwrap();
1311 let _guard = EnvVarGuard::set(Auth::TOKEN_ENV_VAR, Some("figshare-token"));
1312 assert_eq!(
1313 Auth::from_env().unwrap().token.unwrap().expose_secret(),
1314 "figshare-token"
1315 );
1316 }
1317
1318 #[test]
1319 fn auth_env_missing_is_reported() {
1320 let _lock = ENV_LOCK.lock().unwrap();
1321 let _guard = EnvVarGuard::set(Auth::TOKEN_ENV_VAR, None);
1322 match Auth::from_env().unwrap_err() {
1323 FigshareError::EnvVar { name, source } => {
1324 assert_eq!(name, Auth::TOKEN_ENV_VAR);
1325 assert!(matches!(source, VarError::NotPresent));
1326 }
1327 other => panic!("unexpected error: {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn auth_debug_redacts_tokens_and_custom_env_vars_are_supported() {
1333 const CUSTOM_ENV: &str = "FIGSHARE_RS_TEST_TOKEN";
1334
1335 let auth = Auth::new("secret-token");
1336 assert!(!auth.is_anonymous());
1337 assert!(format!("{auth:?}").contains("<redacted>"));
1338
1339 let _lock = ENV_LOCK.lock().unwrap();
1340 let _guard = EnvVarGuard::set(CUSTOM_ENV, Some("custom-token"));
1341 assert_eq!(
1342 Auth::from_env_var(CUSTOM_ENV)
1343 .unwrap()
1344 .token
1345 .unwrap()
1346 .expose_secret(),
1347 "custom-token"
1348 );
1349 }
1350
1351 #[test]
1352 fn upload_urls_and_locations_are_resolved() {
1353 let base = Url::parse("https://api.figshare.com/v2/account/articles").unwrap();
1354 assert_eq!(
1355 parse_location(&base, "/v2/account/articles/1")
1356 .unwrap()
1357 .as_str(),
1358 "https://api.figshare.com/v2/account/articles/1"
1359 );
1360
1361 let upload_url = Url::parse("https://uploads.figshare.com/upload/token").unwrap();
1362 assert_eq!(
1363 upload_part_url(&upload_url, 7).unwrap().as_str(),
1364 "https://uploads.figshare.com/upload/token/7"
1365 );
1366 }
1367
1368 #[test]
1369 fn checksum_and_reader_staging_cover_local_helpers() {
1370 let dir = tempfile::tempdir().unwrap();
1371 let path = dir.path().join("artifact.bin");
1372 std::fs::write(&path, b"hello").unwrap();
1373
1374 let (md5, size) = checksum_and_size(&path).unwrap();
1375 assert_eq!(size, 5);
1376 assert_eq!(md5, "5d41402abc4b2a76b9719d911017c592");
1377
1378 let staged = super::stage_reader(Cursor::new(b"world".to_vec()), 5).unwrap();
1379 assert_eq!(std::fs::read(staged.path()).unwrap(), b"world");
1380 }
1381
1382 #[tokio::test]
1383 async fn request_timeout_is_enforced_for_http_calls() {
1384 #[derive(Clone)]
1385 struct DelayState {
1386 delay: Duration,
1387 }
1388
1389 async fn delayed_article(
1390 State(state): State<DelayState>,
1391 ) -> (StatusCode, Json<serde_json::Value>) {
1392 sleep(state.delay).await;
1393 (
1394 StatusCode::OK,
1395 Json(json!({
1396 "id": 1,
1397 "title": "slow"
1398 })),
1399 )
1400 }
1401
1402 let app = Router::new()
1403 .route("/v2/articles/1", get(delayed_article))
1404 .with_state(DelayState {
1405 delay: Duration::from_millis(50),
1406 });
1407 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1408 let addr = listener.local_addr().unwrap();
1409 let server = tokio::spawn(async move {
1410 axum::serve(listener, app).await.unwrap();
1411 });
1412
1413 let client = FigshareClient::builder(Auth::anonymous())
1414 .endpoint(Endpoint::Custom(
1415 Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1416 ))
1417 .request_timeout(Duration::from_millis(10))
1418 .poll_options(PollOptions {
1419 max_wait: Duration::from_millis(25),
1420 initial_delay: Duration::from_millis(1),
1421 max_delay: Duration::from_millis(2),
1422 })
1423 .build()
1424 .unwrap();
1425
1426 let error = client
1427 .get_public_article(crate::ArticleId(1))
1428 .await
1429 .unwrap_err();
1430 match error {
1431 FigshareError::Transport(source) => assert!(source.is_timeout()),
1432 other => panic!("unexpected error: {other:?}"),
1433 }
1434
1435 server.abort();
1436 }
1437
1438 #[test]
1439 fn client_builder_preserves_configuration() {
1440 let poll = PollOptions {
1441 max_wait: Duration::from_secs(3),
1442 initial_delay: Duration::from_millis(2),
1443 max_delay: Duration::from_millis(4),
1444 };
1445 let endpoint = Endpoint::Custom(Url::parse("http://localhost:9999/v2/").unwrap());
1446 let client = FigshareClient::builder(Auth::new("token"))
1447 .endpoint(endpoint.clone())
1448 .user_agent("figshare-rs-tests/0.1")
1449 .request_timeout(Duration::from_secs(7))
1450 .connect_timeout(Duration::from_secs(2))
1451 .poll_options(poll.clone())
1452 .build()
1453 .unwrap();
1454
1455 assert_eq!(client.endpoint(), &endpoint);
1456 assert_eq!(client.poll_options(), &poll);
1457 assert_eq!(client.request_timeout(), Some(Duration::from_secs(7)));
1458 assert_eq!(client.connect_timeout(), Some(Duration::from_secs(2)));
1459 assert!(FigshareClient::anonymous().is_ok());
1460 assert!(FigshareClient::with_token("token").is_ok());
1461 }
1462
1463 #[test]
1464 fn private_operations_require_authentication() {
1465 let client = FigshareClient::anonymous().unwrap();
1466 let error = client
1467 .request(Method::GET, "account/articles/1", true)
1468 .unwrap_err();
1469 assert!(matches!(error, FigshareError::MissingAuth("api request")));
1470 let error = client
1471 .with_download_token(
1472 Url::parse("https://ndownloader.figshare.com/files/1").unwrap(),
1473 "private file download",
1474 )
1475 .unwrap_err();
1476 assert!(matches!(
1477 error,
1478 FigshareError::MissingAuth("private file download")
1479 ));
1480 }
1481
1482 #[test]
1483 fn download_token_is_only_added_for_trusted_hosts() {
1484 let client = FigshareClient::with_token("token").unwrap();
1485 let downloader = client
1486 .with_download_token(
1487 Url::parse("https://ndownloader.figshare.com/files/1").unwrap(),
1488 "private file download",
1489 )
1490 .unwrap();
1491 assert_eq!(downloader.query(), Some("token=token"));
1492
1493 let external = client
1494 .with_download_token(
1495 Url::parse("https://example.com/file.bin").unwrap(),
1496 "private file download",
1497 )
1498 .unwrap();
1499 assert_eq!(external.query(), None);
1500 }
1501
1502 #[test]
1503 fn request_helpers_enforce_origin_policies() {
1504 let client = FigshareClient::builder(Auth::new("token"))
1505 .endpoint(Endpoint::Custom(
1506 Url::parse("https://api.example.test/v2/").unwrap(),
1507 ))
1508 .build()
1509 .unwrap();
1510
1511 let api_error = client
1512 .request_url(
1513 Method::GET,
1514 Url::parse("https://evil.example.test/v2/articles").unwrap(),
1515 false,
1516 )
1517 .unwrap_err();
1518 assert!(
1519 matches!(api_error, FigshareError::InvalidState(message) if message.contains("different origin"))
1520 );
1521
1522 let upload_request = client
1523 .upload_request_url(
1524 Method::PUT,
1525 Url::parse("https://uploads.figshare.com/upload/token").unwrap(),
1526 )
1527 .unwrap()
1528 .build()
1529 .unwrap();
1530 assert_eq!(
1531 upload_request.url().host_str(),
1532 Some("uploads.figshare.com")
1533 );
1534 assert_eq!(
1535 upload_request.headers()[AUTHORIZATION].to_str().unwrap(),
1536 "token token"
1537 );
1538
1539 let regional_upload_request = client
1540 .upload_request_url(
1541 Method::PUT,
1542 Url::parse("https://fup-eu-west-1.figshare.com/upload/token").unwrap(),
1543 )
1544 .unwrap()
1545 .build()
1546 .unwrap();
1547 assert_eq!(
1548 regional_upload_request.url().host_str(),
1549 Some("fup-eu-west-1.figshare.com")
1550 );
1551 assert_eq!(
1552 regional_upload_request.headers()[AUTHORIZATION]
1553 .to_str()
1554 .unwrap(),
1555 "token token"
1556 );
1557
1558 let upload_error = client
1559 .upload_request_url(
1560 Method::PUT,
1561 Url::parse("https://evil.example.test/upload/token").unwrap(),
1562 )
1563 .unwrap_err();
1564 assert!(
1565 matches!(upload_error, FigshareError::InvalidState(message) if message.contains("different origin"))
1566 );
1567
1568 let public_download = client
1569 .download_request_url(
1570 Method::GET,
1571 Url::parse("https://downloads.example.test/file.bin").unwrap(),
1572 false,
1573 )
1574 .unwrap()
1575 .build()
1576 .unwrap();
1577 assert_eq!(public_download.url().query(), None);
1578 }
1579
1580 #[tokio::test]
1581 async fn execute_location_supports_multiple_success_shapes() {
1582 let app = Router::new()
1583 .route(
1584 "/v2/location/header",
1585 post(|| async {
1586 (
1587 StatusCode::CREATED,
1588 [(LOCATION, "/v2/account/articles/1")],
1589 Json(json!({ "ignored": true })),
1590 )
1591 }),
1592 )
1593 .route(
1594 "/v2/location/object",
1595 post(|| async {
1596 (
1597 StatusCode::CREATED,
1598 Json(json!({ "location": "/v2/account/articles/2" })),
1599 )
1600 }),
1601 )
1602 .route(
1603 "/v2/location/string",
1604 post(|| async { (StatusCode::CREATED, Json(json!("/v2/account/articles/3"))) }),
1605 )
1606 .route(
1607 "/v2/location/text",
1608 post(|| async { (StatusCode::CREATED, "/v2/account/articles/4") }),
1609 )
1610 .route("/v2/location/empty", post(|| async { StatusCode::CREATED }));
1611 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1612 let addr = listener.local_addr().unwrap();
1613 let server = tokio::spawn(async move {
1614 axum::serve(listener, app).await.unwrap();
1615 });
1616
1617 let client = FigshareClient::builder(Auth::anonymous())
1618 .endpoint(Endpoint::Custom(
1619 Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1620 ))
1621 .build()
1622 .unwrap();
1623
1624 let header = client
1625 .execute_location(
1626 client
1627 .request(Method::POST, "location/header", false)
1628 .unwrap(),
1629 )
1630 .await
1631 .unwrap();
1632 let object = client
1633 .execute_location(
1634 client
1635 .request(Method::POST, "location/object", false)
1636 .unwrap(),
1637 )
1638 .await
1639 .unwrap();
1640 let string = client
1641 .execute_location(
1642 client
1643 .request(Method::POST, "location/string", false)
1644 .unwrap(),
1645 )
1646 .await
1647 .unwrap();
1648 let text = client
1649 .execute_location(
1650 client
1651 .request(Method::POST, "location/text", false)
1652 .unwrap(),
1653 )
1654 .await
1655 .unwrap();
1656 let empty = client
1657 .execute_location(
1658 client
1659 .request(Method::POST, "location/empty", false)
1660 .unwrap(),
1661 )
1662 .await
1663 .unwrap_err();
1664
1665 assert_eq!(header.path(), "/v2/account/articles/1");
1666 assert_eq!(object.path(), "/v2/account/articles/2");
1667 assert_eq!(string.path(), "/v2/account/articles/3");
1668 assert_eq!(text.path(), "/v2/account/articles/4");
1669 assert!(
1670 matches!(empty, FigshareError::InvalidState(message) if message.contains("did not include a location"))
1671 );
1672
1673 server.abort();
1674 }
1675
1676 #[tokio::test]
1677 async fn list_paginated_files_fetches_multiple_pages() {
1678 async fn files_route(
1679 State(()): State<()>,
1680 axum::extract::Query(query): axum::extract::Query<
1681 std::collections::HashMap<String, String>,
1682 >,
1683 ) -> Json<Vec<serde_json::Value>> {
1684 let page = query
1685 .get("page")
1686 .and_then(|value| value.parse::<u64>().ok())
1687 .unwrap_or(1);
1688 let count = if page == 1 { 1_000 } else { 1 };
1689 let start = if page == 1 { 1 } else { 1_001 };
1690 Json(
1691 (start..start + count)
1692 .map(|id| json!({ "id": id, "name": format!("file-{id}.bin"), "size": 1 }))
1693 .collect(),
1694 )
1695 }
1696
1697 let app = Router::new()
1698 .route("/v2/account/articles/1/files", get(files_route))
1699 .with_state(());
1700 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1701 let addr = listener.local_addr().unwrap();
1702 let server = tokio::spawn(async move {
1703 axum::serve(listener, app).await.unwrap();
1704 });
1705
1706 let client = FigshareClient::builder(Auth::new("token"))
1707 .endpoint(Endpoint::Custom(
1708 Url::parse(&format!("http://{addr}/v2/")).unwrap(),
1709 ))
1710 .build()
1711 .unwrap();
1712
1713 let files = client
1714 .list_paginated_files("account/articles/1/files", true)
1715 .await
1716 .unwrap();
1717 assert_eq!(files.len(), 1_001);
1718 assert_eq!(files.first().unwrap().id.0, 1);
1719 assert_eq!(files.last().unwrap().id.0, 1_001);
1720
1721 server.abort();
1722 }
1723
1724 #[tokio::test]
1725 async fn upload_path_rejects_missing_filename() {
1726 let client = FigshareClient::anonymous().unwrap();
1727 let error = client
1728 .upload_path(crate::ArticleId(1), Path::new("/"))
1729 .await
1730 .unwrap_err();
1731 assert!(
1732 matches!(error, FigshareError::InvalidState(message) if message.contains("path has no final file name segment"))
1733 );
1734 }
1735}