1#![warn(missing_docs, clippy::empty_docs, rustdoc::broken_intra_doc_links)]
2#![doc = include_str!("../README.md")]
3
4use futures_util::TryStreamExt;
5use std::collections::HashMap;
6use tokio::io::{self, AsyncWriteExt};
7use tosho_common::{
8 ToshoAuthError, ToshoClientError, ToshoError, ToshoResult, bail_on_error, make_error,
9 parse_json_response,
10};
11
12use crate::models::UserAccount;
13pub use config::*;
14use constants::{API_HOST, BASE_API, IMAGE_HOST, TOKEN_AUTH};
15use models::{
16 ChapterDetailsResponse, ChapterListResponse, ChapterPageDetailsResponse, HomeResponse, Manga,
17 MangaListResponse, Publisher, ReadingListItem, SortOption,
18};
19use serde_json::json;
20
21pub mod config;
22pub mod constants;
23pub mod models;
24
25const PATTERN: [u8; 1] = [174];
26
27#[derive(Clone, Debug)]
44pub struct RBClient {
45 inner: reqwest::Client,
46 config: RBConfig,
47 constants: &'static crate::constants::Constants,
48 token: String,
49 expiry_at: Option<i64>,
50}
51
52impl RBClient {
53 pub fn new(config: RBConfig) -> ToshoResult<Self> {
58 Self::make_client(config, None)
59 }
60
61 pub fn with_proxy(&self, proxy: reqwest::Proxy) -> ToshoResult<Self> {
68 Self::make_client(self.config.clone(), Some(proxy))
69 }
70
71 fn make_client(config: RBConfig, proxy: Option<reqwest::Proxy>) -> ToshoResult<Self> {
73 let constants = crate::constants::get_constants(config.platform() as u8);
74 let mut headers = reqwest::header::HeaderMap::new();
75
76 headers.insert(
77 reqwest::header::USER_AGENT,
78 reqwest::header::HeaderValue::from_static(constants.ua),
79 );
80 headers.insert(
81 reqwest::header::HOST,
82 reqwest::header::HeaderValue::from_static(API_HOST),
83 );
84 headers.insert(
85 "public",
86 reqwest::header::HeaderValue::from_static(constants.public),
87 );
88 headers.insert(
89 "x-user-token",
90 config.token().parse().map_err(|_| {
91 ToshoClientError::HeaderParseError(format!("x-user-token for {}", config.token()))
92 })?,
93 );
94
95 let client = reqwest::Client::builder()
96 .http2_adaptive_window(true)
97 .use_rustls_tls()
98 .default_headers(headers);
99
100 let client = match proxy {
101 Some(proxy) => client
102 .proxy(proxy)
103 .build()
104 .map_err(ToshoClientError::BuildError),
105 None => client.build().map_err(ToshoClientError::BuildError),
106 }?;
107
108 Ok(Self {
109 inner: client,
110 config: config.clone(),
111 constants,
112 token: config.token().to_string(),
113 expiry_at: None,
114 })
115 }
116
117 pub fn set_expiry_at(&mut self, expiry_at: Option<i64>) {
121 self.expiry_at = expiry_at;
122 }
123
124 pub async fn refresh_token(&mut self) -> ToshoResult<()> {
131 if let Some(expiry_at) = self.expiry_at
133 && expiry_at > chrono::Utc::now().timestamp()
134 {
135 return Ok(());
136 }
137
138 let json_data = json!({
139 "grantType": "refresh_token",
140 "refreshToken": self.config.refresh_token(),
141 });
142
143 let client = reqwest::Client::builder()
144 .http2_adaptive_window(true)
145 .use_rustls_tls()
146 .build()
147 .map_err(ToshoClientError::BuildError)?;
148 let request = client
149 .post("https://securetoken.googleapis.com/v1/token")
150 .header(reqwest::header::USER_AGENT, self.constants.image_ua)
151 .query(&[("key", TOKEN_AUTH.to_string())])
152 .json(&json_data)
153 .send()
154 .await?;
155
156 let response = request
157 .json::<crate::models::accounts::google::SecureTokenResponse>()
158 .await?;
159
160 self.token.clone_from(&response.access_token().to_string());
161 self.config.set_token(response.access_token());
162 let expiry_in = response.expires_in().parse::<i64>().map_err(|e| {
163 make_error!(
164 "Failed to parse expiry time: {}, error: {}",
165 response.expires_in(),
166 e
167 )
168 })?;
169 self.expiry_at = Some(chrono::Utc::now().timestamp() + expiry_in - 3);
171
172 Ok(())
173 }
174
175 pub fn get_token(&self) -> &str {
177 &self.token
178 }
179
180 pub fn get_expiry_at(&self) -> Option<i64> {
182 self.expiry_at
183 }
184
185 async fn request<T>(
189 &mut self,
190 method: reqwest::Method,
191 url: &str,
192 json_body: Option<HashMap<String, String>>,
193 ) -> ToshoResult<T>
194 where
195 T: serde::de::DeserializeOwned,
196 {
197 self.refresh_token().await?;
198
199 let endpoint = format!("{}{}", BASE_API, url);
200
201 let request = match json_body {
202 Some(json_body) => self.inner.request(method, endpoint).json(&json_body),
203 None => self.inner.request(method, endpoint),
204 };
205
206 let response = request.send().await?;
207
208 if response.status().is_success() {
209 let json_de = parse_json_response::<T>(response).await?;
210 Ok(json_de)
211 } else {
212 Err(ToshoError::from(response.status()))
213 }
214 }
215
216 pub async fn get_user(&mut self) -> ToshoResult<UserAccount> {
222 self.request(reqwest::Method::GET, "/user/v0", None).await
223 }
224
225 pub async fn get_reading_list(&mut self) -> ToshoResult<Vec<ReadingListItem>> {
227 self.request(reqwest::Method::GET, "/user/reading_list/v0", None)
228 .await
229 }
230
231 pub async fn get_manga(&mut self, uuid: impl AsRef<str>) -> ToshoResult<Manga> {
240 self.request(
241 reqwest::Method::GET,
242 &format!("/manga/{}/v0", uuid.as_ref()),
243 None,
244 )
245 .await
246 }
247
248 pub async fn get_manga_filters(&mut self) -> ToshoResult<Manga> {
250 self.request(reqwest::Method::GET, "/manga/filters/v0", None)
251 .await
252 }
253
254 pub async fn get_chapter_list(
259 &mut self,
260 uuid: impl AsRef<str>,
261 ) -> ToshoResult<ChapterListResponse> {
262 self.request(
263 reqwest::Method::GET,
264 &format!(
265 "/mangas/{}/chapters/v4?order=asc&count=9999&offset=0",
266 uuid.as_ref()
267 ),
268 None,
269 )
270 .await
271 }
272
273 pub async fn get_chapter(
278 &mut self,
279 uuid: impl AsRef<str>,
280 ) -> ToshoResult<ChapterDetailsResponse> {
281 self.request(
282 reqwest::Method::GET,
283 &format!("/chapters/{}/v2", uuid.as_ref()),
284 None,
285 )
286 .await
287 }
288
289 pub async fn get_chapter_viewer(
294 &mut self,
295 uuid: impl AsRef<str>,
296 ) -> ToshoResult<ChapterPageDetailsResponse> {
297 self.request(
298 reqwest::Method::GET,
299 &format!("/chapters/{}/pages/v1", uuid.as_ref()),
300 None,
301 )
302 .await
303 }
304
305 pub async fn search(
313 &mut self,
314 query: impl AsRef<str>,
315 offset: Option<u32>,
316 count: Option<u32>,
317 sort: Option<SortOption>,
318 ) -> ToshoResult<MangaListResponse> {
319 let offset = offset.unwrap_or(0);
320 let count = count.unwrap_or(999);
321 let sort = sort.unwrap_or(SortOption::Alphabetical);
322
323 let query_param = format!(
324 "sort={}&offset={}&count={}&tags=&search_string={}&publisher_slug=",
325 sort,
326 offset,
327 count,
328 query.as_ref()
329 );
330
331 self.request(
332 reqwest::Method::GET,
333 &format!("/mangas/v1?{query_param}"),
334 None,
335 )
336 .await
337 }
338
339 pub async fn get_home_page(&mut self) -> ToshoResult<HomeResponse> {
341 self.request(reqwest::Method::GET, "/home/v0", None).await
342 }
343
344 pub async fn get_publisher(&mut self, slug: impl AsRef<str>) -> ToshoResult<Publisher> {
349 self.request(
350 reqwest::Method::GET,
351 &format!("/publisher/slug/{}/v0", slug.as_ref()),
352 None,
353 )
354 .await
355 }
356
357 pub fn modify_url_for_highres(url: impl AsRef<str>) -> ToshoResult<String> {
364 let url = url.as_ref();
365 let mut parsed_url = url
366 .parse::<reqwest::Url>()
367 .map_err(|e| make_error!("Failed to parse URL: {}, error: {}", url, e))?;
368
369 let path = parsed_url.path();
371 let mut path_split = path.split('/').collect::<Vec<&str>>();
372 let last_part = match path_split.pop() {
373 Some(last_part) => last_part,
374 None => {
375 bail_on_error!("Invalid URL path: {}", path);
376 }
377 };
378
379 let filename = last_part.split_once('.');
380 let (_, extension) = match filename {
381 Some((filename, extension)) => (filename, extension),
382 None => {
383 bail_on_error!(
384 "Invalid filename: {}, expected something like 0000.jpg",
385 last_part
386 );
387 }
388 };
389
390 let hi_res = format!("2000.{extension}");
391 let new_path = format!("{}/{}", path_split.join("/"), hi_res);
392 parsed_url.set_path(&new_path);
393
394 Ok(parsed_url.to_string())
395 }
396
397 pub async fn stream_download(
405 &self,
406 url: impl AsRef<str>,
407 mut writer: impl io::AsyncWrite + Unpin,
408 ) -> ToshoResult<()> {
409 let res = self
410 .inner
411 .get(url.as_ref())
412 .query(&[("drm", "1")])
413 .headers({
414 let mut headers = reqwest::header::HeaderMap::new();
415 headers.insert(
416 reqwest::header::USER_AGENT,
417 reqwest::header::HeaderValue::from_static(self.constants.image_ua),
418 );
419 headers.insert(
420 reqwest::header::HOST,
421 reqwest::header::HeaderValue::from_static(IMAGE_HOST),
422 );
423 headers
424 })
425 .send()
426 .await?;
427
428 if !res.status().is_success() {
429 Err(ToshoError::from(res.status()))
430 } else {
431 let x_drm = res.headers().get(crate::constants::X_DRM_HEADER);
433 let is_drm = match x_drm {
434 Some(x_drm) => x_drm == "true",
435 None => false,
436 };
437
438 let mut stream = res.bytes_stream();
439 while let Some(item) = stream.try_next().await? {
440 let dedrmed = if is_drm {
441 decrypt_image(&item)
442 } else {
443 item.to_vec()
444 };
445
446 writer.write_all(&dedrmed).await?;
447 writer.flush().await?;
448 }
449
450 Ok(())
451 }
452 }
453
454 pub async fn test_high_res(&self, url: impl AsRef<str>) -> ToshoResult<bool> {
458 let url_mod = Self::modify_url_for_highres(url)?;
460
461 let res = self
462 .inner
463 .head(url_mod)
464 .query(&[("drm", "1")])
465 .headers({
466 let mut headers = reqwest::header::HeaderMap::new();
467 headers.insert(
468 reqwest::header::USER_AGENT,
469 reqwest::header::HeaderValue::from_static(self.constants.image_ua),
470 );
471 headers.insert(
472 reqwest::header::HOST,
473 reqwest::header::HeaderValue::from_static(IMAGE_HOST),
474 );
475 headers
476 })
477 .send()
478 .await?;
479
480 let success = res.status().is_success();
481 let mimetype = res.headers().get(reqwest::header::CONTENT_TYPE);
482 let good_mimetype = match mimetype {
484 Some(mimetype) => mimetype == "image/jpeg" || mimetype == "image/webp",
485 None => false,
486 };
487
488 Ok(success && good_mimetype)
489 }
490
491 pub async fn login(
502 email: impl AsRef<str>,
503 password: impl AsRef<str>,
504 platform: RBPlatform,
505 ) -> ToshoResult<RBLoginResponse> {
506 let constants = crate::constants::get_constants(platform as u8);
507
508 let mut headers = reqwest::header::HeaderMap::new();
509 headers.insert(
510 reqwest::header::USER_AGENT,
511 reqwest::header::HeaderValue::from_static(constants.image_ua),
512 );
513
514 let client_type = match platform {
515 RBPlatform::Android => Some("CLIENT_TYPE_ANDROID"),
516 RBPlatform::Apple => Some("CLIENT_TYPE_IOS"),
517 _ => None,
518 };
519
520 let email = email.as_ref();
521 let password = password.as_ref();
522
523 let mut json_data = json!({
524 "email": email,
525 "password": password,
526 "returnSecureToken": true,
527 });
528 if let Some(client_type) = client_type {
529 json_data["clientType"] = client_type.into();
530 }
531
532 let client = reqwest::Client::builder()
533 .http2_adaptive_window(true)
534 .use_rustls_tls()
535 .default_headers(headers)
536 .build()
537 .map_err(ToshoClientError::BuildError)?;
538
539 let key_param = &[("key", TOKEN_AUTH.to_string())];
540
541 let request = client
543 .post("https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword")
544 .query(key_param)
545 .json(&json_data)
546 .send()
547 .await?;
548
549 let verify_resp = request
550 .json::<crate::models::accounts::google::IdentityToolkitVerifyPasswordResponse>()
551 .await?;
552
553 let json_data = json!({
555 "idToken": verify_resp.id_token(),
556 });
557
558 let request = client
559 .post("https://www.googleapis.com/identitytoolkit/v3/relyingparty/getAccountInfo")
560 .query(key_param)
561 .json(&json_data)
562 .send()
563 .await?;
564
565 let acc_info_resp = request
566 .json::<crate::models::accounts::google::IdentityToolkitAccountInfoResponse>()
567 .await?;
568
569 let goog_user = acc_info_resp
571 .users()
572 .iter()
573 .find(|&user| user.local_id() == verify_resp.local_id())
574 .ok_or(ToshoAuthError::UnknownSession)?;
575
576 let json_data = json!({
578 "grantType": "refresh_token",
579 "refreshToken": verify_resp.refresh_token(),
580 });
581
582 let request = client
583 .post("https://securetoken.googleapis.com/v1/token")
584 .query(key_param)
585 .json(&json_data)
586 .send()
587 .await?;
588
589 let secure_token_resp = request
590 .json::<crate::models::accounts::google::SecureTokenResponse>()
591 .await?;
592
593 let expires_in = secure_token_resp.expires_in().parse::<i64>().map_err(|e| {
594 make_error!(
595 "Failed to parse expiry time: {}, error: {}",
596 secure_token_resp.expires_in(),
597 e
598 )
599 })?;
600 let expiry_at = chrono::Utc::now().timestamp() + expires_in - 3;
601
602 let request = client
604 .get(format!("{}/user/v0", BASE_API))
605 .headers({
606 let mut headers = reqwest::header::HeaderMap::new();
607 headers.insert(
608 reqwest::header::USER_AGENT,
609 reqwest::header::HeaderValue::from_static(constants.ua),
610 );
611 headers.insert(
612 "public",
613 reqwest::header::HeaderValue::from_static(constants.public),
614 );
615 headers.insert(
616 "x-user-token",
617 reqwest::header::HeaderValue::from_str(secure_token_resp.access_token())
618 .map_err(|_| {
619 ToshoClientError::HeaderParseError(format!(
620 "x-user-token for {}",
621 secure_token_resp.access_token()
622 ))
623 })?,
624 );
625 headers
626 })
627 .send()
628 .await?;
629
630 let user_resp = request.json::<UserAccount>().await?;
631
632 Ok(RBLoginResponse {
633 token: secure_token_resp.access_token().to_string(),
634 refresh_token: secure_token_resp.refresh_token().to_string(),
635 platform,
636 user: user_resp,
637 google_account: goog_user.clone(),
638 expiry: expiry_at,
639 })
640 }
641}
642
643#[derive(Debug, Clone)]
649pub struct RBLoginResponse {
650 pub token: String,
652 pub refresh_token: String,
654 pub platform: RBPlatform,
656 pub user: UserAccount,
658 pub google_account: crate::models::accounts::google::IdentityToolkitAccountInfo,
660 pub expiry: i64,
662}
663
664pub fn decrypt_image(data: &[u8]) -> Vec<u8> {
669 let mut internal: Vec<u8> = Vec::with_capacity(data.len());
670 internal.extend_from_slice(data);
671 internal.iter_mut().for_each(|v| *v ^= PATTERN[0]);
672 internal
673}