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
33const 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 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 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 self.remaining_requests > 0 {
88 self.remaining_requests -= 1;
89 return None;
90 }
91
92 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#[derive(Debug, Clone)]
144pub struct Client {
145 pub client: reqwest::Client,
147
148 state: Arc<ClientState>,
150}
151
152impl Client {
153 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 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 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 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 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 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 pub fn get_cookie_store(&self) -> &Arc<CookieStoreMutex> {
281 &self.state.cookie_store
282 }
283
284 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 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 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 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 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 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 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 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 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 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 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 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}