imgchest/
client.rs

1mod builder;
2
3pub use self::builder::CreatePostBuilder;
4pub use self::builder::ListPostsBuilder;
5pub use self::builder::SortOrder;
6pub use self::builder::UpdatePostBuilder;
7pub use self::builder::UploadPostFile;
8use crate::ApiCompletedResponse;
9use crate::ApiResponse;
10use crate::ApiUpdateFilesBulkRequest;
11use crate::Error;
12use crate::FileUpdate;
13use crate::ListPostsPost;
14use crate::Post;
15use crate::PostFile;
16use crate::ScrapedPost;
17use crate::ScrapedUser;
18use crate::User;
19use jiff::RoundMode;
20use jiff::SignedDuration;
21use jiff::Timestamp;
22use jiff::TimestampRound;
23use jiff::Unit;
24use reqwest::header::AUTHORIZATION;
25use reqwest::multipart::Form;
26use reqwest::Url;
27use reqwest_cookie_store::CookieStore;
28use reqwest_cookie_store::CookieStoreMutex;
29use scraper::Html;
30use std::sync::Arc;
31use std::time::Duration;
32
33// Should be 60, but that still triggers the ratelimit.
34// Add some leeway.
35const REQUESTS_PER_MINUTE: u8 = 40;
36const ONE_MINUTE: SignedDuration = SignedDuration::from_secs(60);
37const API_BASE: &str = "https://api.imgchest.com";
38
39fn bool_to_str(b: bool) -> &'static str {
40    if b {
41        "true"
42    } else {
43        "false"
44    }
45}
46
47fn minute_trunc_round_config() -> TimestampRound {
48    TimestampRound::new()
49        .smallest(Unit::Minute)
50        .mode(RoundMode::Trunc)
51}
52
53#[derive(Debug)]
54struct RatelimitState {
55    last_refreshed: Timestamp,
56    remaining_requests: u8,
57}
58
59impl RatelimitState {
60    fn new() -> Self {
61        let last_refreshed = Timestamp::now()
62            .round(minute_trunc_round_config())
63            .expect("invalid round config");
64
65        Self {
66            last_refreshed,
67            remaining_requests: REQUESTS_PER_MINUTE,
68        }
69    }
70    /// Get the time needed to sleep to respect the ratelimit.
71    ///
72    /// # Returns
73    /// Returns `None` is a request can be made.
74    /// Otherwise, returns the time needed to sleep before calling this again.
75    fn get_sleep_duration(&mut self) -> Option<Duration> {
76        let now = Timestamp::now()
77            .round(minute_trunc_round_config())
78            .expect("invalid round config");
79
80        // Refresh the number of requests each minute.
81        if self.last_refreshed.duration_until(now) >= ONE_MINUTE {
82            self.last_refreshed = now;
83            self.remaining_requests = REQUESTS_PER_MINUTE;
84        }
85
86        // If we are allowed to make a request now, make it.
87        if self.remaining_requests > 0 {
88            self.remaining_requests -= 1;
89            return None;
90        }
91
92        // Otherwise, sleep until the next refresh and try again.
93        let duration = ONE_MINUTE.saturating_sub(self.last_refreshed.duration_until(now));
94        let duration = Duration::try_from(duration).unwrap_or(Duration::ZERO);
95
96        Some(duration)
97    }
98}
99
100#[derive(Debug)]
101struct ClientState {
102    token: std::sync::RwLock<Option<Arc<str>>>,
103    ratelimit_state: std::sync::Mutex<RatelimitState>,
104
105    cookie_store: Arc<CookieStoreMutex>,
106}
107
108impl ClientState {
109    fn new() -> Self {
110        let token = std::sync::RwLock::new(None);
111        let ratelimit_state = std::sync::Mutex::new(RatelimitState::new());
112
113        let cookie_store = CookieStore::new();
114        let cookie_store = CookieStoreMutex::new(cookie_store);
115        let cookie_store = Arc::new(cookie_store);
116
117        Self {
118            token,
119            ratelimit_state,
120
121            cookie_store,
122        }
123    }
124
125    async fn ratelimit(&self) {
126        loop {
127            let maybe_sleep_duration = self
128                .ratelimit_state
129                .lock()
130                .expect("ratelimit state mutex poisoned")
131                .get_sleep_duration();
132            match maybe_sleep_duration {
133                Some(sleep_duration) => {
134                    tokio::time::sleep(sleep_duration).await;
135                }
136                None => return,
137            }
138        }
139    }
140}
141
142/// The client
143#[derive(Debug, Clone)]
144pub struct Client {
145    /// The inner http client
146    pub client: reqwest::Client,
147
148    /// Inner client state
149    state: Arc<ClientState>,
150}
151
152impl Client {
153    /// Make a new client
154    pub fn new() -> Self {
155        let state = Arc::new(ClientState::new());
156
157        let client = reqwest::Client::builder()
158            .cookie_provider(state.cookie_store.clone())
159            .build()
160            .expect("failed to build client");
161
162        Self { client, state }
163    }
164
165    /// Scrape a post from a post id.
166    ///
167    /// # Authorization
168    /// This function does NOT require the use of a token.
169    ///
170    /// # Warning
171    /// This is a scraping-based function.
172    pub async fn get_scraped_post(&self, id: &str) -> Result<ScrapedPost, Error> {
173        let url = format!("https://imgchest.com/p/{id}");
174        let text = self
175            .client
176            .get(url)
177            .send()
178            .await?
179            .error_for_status()?
180            .text()
181            .await?;
182
183        let post = tokio::task::spawn_blocking(move || {
184            let html = Html::parse_document(text.as_str());
185            ScrapedPost::from_html(&html)
186        })
187        .await??;
188
189        Ok(post)
190    }
191
192    /// Scrape a user from a username.
193    ///
194    /// # Authorization
195    /// This function does NOT require the use of a token.
196    ///
197    /// # Warning
198    /// This is a scraping-based function.
199    pub async fn get_scraped_user(&self, name: &str) -> Result<ScrapedUser, Error> {
200        let url = format!("https://imgchest.com/u/{name}");
201        let text = self
202            .client
203            .get(url)
204            .send()
205            .await?
206            .error_for_status()?
207            .text()
208            .await?;
209
210        let user = tokio::task::spawn_blocking(move || {
211            let html = Html::parse_document(text.as_str());
212            ScrapedUser::from_html(&html)
213        })
214        .await??;
215
216        Ok(user)
217    }
218
219    /// List posts from various sources.
220    ///
221    /// # Authorization
222    /// This function does NOT require the use of a token.
223    ///
224    /// # Warning
225    /// This api call is undocumented.
226    pub async fn list_posts(&self, builder: ListPostsBuilder) -> Result<Vec<ListPostsPost>, Error> {
227        let mut url = Url::parse("https://imgchest.com/api/posts").unwrap();
228        {
229            let mut query_pairs = url.query_pairs_mut();
230
231            let sort_str = match builder.sort {
232                SortOrder::Popular => "popular",
233                SortOrder::New => "new",
234                SortOrder::Old => "old",
235            };
236            query_pairs.append_pair("sort", sort_str);
237
238            query_pairs.append_pair("page", itoa::Buffer::new().format(builder.page));
239
240            if let Some(username) = builder.username.as_deref() {
241                query_pairs.append_pair("username", username);
242            }
243
244            if builder.profile {
245                query_pairs.append_pair("profile", "true");
246            }
247        }
248
249        let response = self.client.get(url.as_str()).send().await?;
250
251        let posts: ApiResponse<_> = response.error_for_status()?.json().await?;
252
253        Ok(posts.data)
254    }
255
256    /// Set the token to use for future requests.
257    ///
258    /// This allows the use of functions that require authorization.
259    pub fn set_token<T>(&self, token: T)
260    where
261        T: AsRef<str>,
262    {
263        *self
264            .state
265            .token
266            .write()
267            .unwrap_or_else(|error| error.into_inner()) = Some(token.as_ref().into());
268    }
269
270    /// Get the current token.
271    fn get_token(&self) -> Option<Arc<str>> {
272        self.state
273            .token
274            .read()
275            .unwrap_or_else(|error| error.into_inner())
276            .clone()
277    }
278
279    /// Get the cookie store.
280    pub fn get_cookie_store(&self) -> &Arc<CookieStoreMutex> {
281        &self.state.cookie_store
282    }
283
284    /// Get a post by id.
285    ///
286    /// # Authorization
287    /// This function REQUIRES a token.
288    pub async fn get_post(&self, id: &str) -> Result<Post, Error> {
289        let token = self.get_token().ok_or(Error::MissingToken)?;
290        let url = format!("{API_BASE}/v1/post/{id}");
291
292        self.state.ratelimit().await;
293
294        let response = self
295            .client
296            .get(url)
297            .header(AUTHORIZATION, format!("Bearer {token}"))
298            .send()
299            .await?;
300
301        let post: ApiResponse<_> = response.error_for_status()?.json().await?;
302
303        Ok(post.data)
304    }
305
306    /// Create a post.
307    ///
308    /// # Authorization
309    /// This function REQUIRES a token.
310    pub async fn create_post(&self, data: CreatePostBuilder) -> Result<Post, Error> {
311        let token = self.get_token().ok_or(Error::MissingToken)?;
312        let url = format!("{API_BASE}/v1/post");
313
314        let mut form = Form::new();
315
316        if let Some(title) = data.title {
317            if title.len() < 3 {
318                return Err(Error::TitleTooShort);
319            }
320
321            form = form.text("title", title);
322        }
323
324        if let Some(privacy) = data.privacy {
325            form = form.text("privacy", privacy.as_str());
326        }
327
328        if let Some(anonymous) = data.anonymous {
329            form = form.text("anonymous", bool_to_str(anonymous));
330        }
331
332        if let Some(nsfw) = data.nsfw {
333            form = form.text("nsfw", bool_to_str(nsfw));
334        }
335
336        if data.images.is_empty() {
337            return Err(Error::MissingImages);
338        }
339
340        for file in data.images {
341            let part = reqwest::multipart::Part::stream(file.body).file_name(file.file_name);
342
343            form = form.part("images[]", part);
344        }
345
346        self.state.ratelimit().await;
347
348        let response = self
349            .client
350            .post(url)
351            .header(AUTHORIZATION, format!("Bearer {token}"))
352            .multipart(form)
353            .send()
354            .await?;
355
356        let post: ApiResponse<_> = response.error_for_status()?.json().await?;
357
358        Ok(post.data)
359    }
360
361    /// Update a post.
362    ///
363    /// # Authorization
364    /// This function REQUIRES a token.
365    pub async fn update_post(&self, id: &str, data: UpdatePostBuilder) -> Result<Post, Error> {
366        let token = self.get_token().ok_or(Error::MissingToken)?;
367        let url = format!("{API_BASE}/v1/post/{id}");
368
369        let mut form = Vec::new();
370
371        if let Some(title) = data.title.as_ref() {
372            if title.len() < 3 {
373                return Err(Error::TitleTooShort);
374            }
375
376            form.push(("title", title.as_str()));
377        }
378
379        if let Some(privacy) = data.privacy {
380            form.push(("privacy", privacy.as_str()));
381        }
382
383        if let Some(nsfw) = data.nsfw {
384            form.push(("nsfw", bool_to_str(nsfw)));
385        }
386
387        self.state.ratelimit().await;
388
389        // Not using a multipart form here is intended.
390        // Even though we use a multipart form for creating a post,
391        // the server will silently ignore requests that aren't form-urlencoded.
392        let response = self
393            .client
394            .patch(url)
395            .header(AUTHORIZATION, format!("Bearer {token}"))
396            .form(&form)
397            .send()
398            .await?;
399
400        let post: ApiResponse<_> = response.error_for_status()?.json().await?;
401
402        Ok(post.data)
403    }
404
405    /// Delete a post.
406    ///
407    /// # Authorization
408    /// This function REQUIRES a token.
409    pub async fn delete_post(&self, id: &str) -> Result<(), Error> {
410        let token = self.get_token().ok_or(Error::MissingToken)?;
411        let url = format!("{API_BASE}/v1/post/{id}");
412
413        self.state.ratelimit().await;
414
415        let response = self
416            .client
417            .delete(url)
418            .header(AUTHORIZATION, format!("Bearer {token}"))
419            .send()
420            .await?;
421
422        let response: ApiCompletedResponse = response.error_for_status()?.json().await?;
423        if !response.success {
424            return Err(Error::ApiOperationFailed);
425        }
426
427        Ok(())
428    }
429
430    /// Favorite or unfavorite a post.
431    ///
432    /// # Returns
433    /// Returns true if the favorite was added.
434    /// Returns false if the favorite was removed.
435    ///
436    /// # Authorization
437    /// This function REQUIRES a token.
438    pub async fn favorite_post(&self, id: &str) -> Result<bool, Error> {
439        let token = self.get_token().ok_or(Error::MissingToken)?;
440        let url = format!("{API_BASE}/v1/post/{id}/favorite");
441
442        self.state.ratelimit().await;
443
444        let response = self
445            .client
446            .post(url)
447            .header(AUTHORIZATION, format!("Bearer {token}"))
448            .send()
449            .await?;
450
451        let response: ApiCompletedResponse = response.error_for_status()?.json().await?;
452        if !response.success {
453            return Err(Error::ApiOperationFailed);
454        }
455
456        let message = response.message.ok_or(Error::ApiResponseMissingMessage)?;
457        match &*message {
458            "Favorite added." => Ok(true),
459            "Favorite removed." => Ok(false),
460            _ => Err(Error::ApiResponseUnknownMessage { message }),
461        }
462    }
463
464    /// Add images to a post.
465    ///
466    /// # Authorization
467    /// This function REQUIRES a token.
468    pub async fn add_post_images<I>(&self, id: &str, images: I) -> Result<Post, Error>
469    where
470        I: IntoIterator<Item = UploadPostFile>,
471    {
472        let token = self.get_token().ok_or(Error::MissingToken)?;
473        let url = format!("{API_BASE}/v1/post/{id}/add");
474
475        let mut form = Form::new();
476
477        let mut num_images = 0;
478        for file in images {
479            let part = reqwest::multipart::Part::stream(file.body).file_name(file.file_name);
480
481            form = form.part("images[]", part);
482            num_images += 1;
483        }
484
485        if num_images == 0 {
486            return Err(Error::MissingImages);
487        }
488
489        self.state.ratelimit().await;
490
491        let response = self
492            .client
493            .post(url)
494            .header(AUTHORIZATION, format!("Bearer {token}"))
495            .multipart(form)
496            .send()
497            .await?;
498
499        let post: ApiResponse<_> = response.error_for_status()?.json().await?;
500
501        Ok(post.data)
502    }
503
504    /// Get a user by username.
505    ///
506    /// # Authorization
507    /// This function REQUIRES a token.
508    pub async fn get_user(&self, username: &str) -> Result<User, Error> {
509        let token = self.get_token().ok_or(Error::MissingToken)?;
510        let url = format!("{API_BASE}/v1/user/{username}");
511
512        self.state.ratelimit().await;
513
514        let response = self
515            .client
516            .get(url)
517            .header(AUTHORIZATION, format!("Bearer {token}"))
518            .send()
519            .await?;
520
521        let user: ApiResponse<_> = response.error_for_status()?.json().await?;
522
523        Ok(user.data)
524    }
525
526    /// Get a file by id.
527    ///
528    /// Currently, this is implemented according to the API spec,
529    /// but the API will always return no data for some reason.
530    /// It is likely that this endpoint is disabled.
531    /// As a result, this function is currently useless.
532    ///
533    /// # Authorization
534    /// This function REQUIRES a token.
535    pub async fn get_file(&self, id: &str) -> Result<PostFile, Error> {
536        let token = self.get_token().ok_or(Error::MissingToken)?;
537        let url = format!("{API_BASE}/v1/file/{id}");
538
539        self.state.ratelimit().await;
540
541        let response = self
542            .client
543            .get(url)
544            .header(AUTHORIZATION, format!("Bearer {token}"))
545            .send()
546            .await?;
547
548        let file: ApiResponse<_> = response.error_for_status()?.json().await?;
549
550        Ok(file.data)
551    }
552
553    /// Update a file.
554    ///
555    /// # Authorization
556    /// This function REQUIRES a token.
557    pub async fn update_file(&self, id: &str, description: &str) -> Result<(), Error> {
558        let token = self.get_token().ok_or(Error::MissingToken)?;
559        let url = format!("{API_BASE}/v1/file/{id}");
560
561        if description.is_empty() {
562            return Err(Error::MissingDescription);
563        }
564
565        self.state.ratelimit().await;
566
567        let response = self
568            .client
569            .patch(url)
570            .form(&[("description", description)])
571            .header(AUTHORIZATION, format!("Bearer {token}"))
572            .send()
573            .await?;
574
575        let response: ApiCompletedResponse = response.error_for_status()?.json().await?;
576        if !response.success {
577            return Err(Error::ApiOperationFailed);
578        }
579
580        Ok(())
581    }
582
583    /// Delete a file.
584    ///
585    /// # Authorization
586    /// This function REQUIRES a token.
587    pub async fn delete_file(&self, id: &str) -> Result<(), Error> {
588        let token = self.get_token().ok_or(Error::MissingToken)?;
589        let url = format!("{API_BASE}/v1/file/{id}");
590
591        self.state.ratelimit().await;
592
593        let response = self
594            .client
595            .delete(url)
596            .header(AUTHORIZATION, format!("Bearer {token}"))
597            .send()
598            .await?;
599
600        let response: ApiCompletedResponse = response.error_for_status()?.json().await?;
601        if !response.success {
602            return Err(Error::ApiOperationFailed);
603        }
604
605        Ok(())
606    }
607
608    /// Update files in bulk.
609    pub async fn update_files_bulk<I>(&self, files: I) -> Result<Vec<PostFile>, Error>
610    where
611        I: IntoIterator<Item = FileUpdate>,
612    {
613        let token = self.get_token().ok_or(Error::MissingToken)?;
614        let url = format!("{API_BASE}/v1/files");
615
616        let data = files
617            .into_iter()
618            .map(|file| {
619                if file.description.is_empty() {
620                    return Err(Error::MissingDescription);
621                }
622                Ok(file)
623            })
624            .collect::<Result<Vec<_>, _>>()?;
625        let data = ApiUpdateFilesBulkRequest { data };
626
627        self.state.ratelimit().await;
628
629        let response = self
630            .client
631            .patch(url)
632            .header(AUTHORIZATION, format!("Bearer {token}"))
633            .json(&data)
634            .send()
635            .await?;
636
637        let file: ApiResponse<_> = response.error_for_status()?.json().await?;
638
639        Ok(file.data)
640    }
641}
642
643impl Default for Client {
644    fn default() -> Self {
645        Self::new()
646    }
647}