1use std::{sync::Arc, time::Duration};
6
7use base64::Engine;
8use parking_lot::Mutex;
9use prost::Message;
10use reqwest::{Response, StatusCode};
11use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
12use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
13
14use crate::retry::KindAwareRetryStrategy;
15use steam_enums::etokenrenewaltype::ETokenRenewalType;
16use steam_protos::messages::auth::{CAuthenticationAccessTokenGenerateForAppRequest, CAuthenticationAccessTokenGenerateForAppResponse, CAuthenticationGetAuthSessionInfoRequest, CAuthenticationGetAuthSessionInfoResponse};
17use steamid::SteamID;
18
19use crate::{error::SteamUserError, session::Session, utils::qr::decode_login_qr_url};
20
21const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
23
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
25const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
26
27const EXTERNAL_TIMEOUT: Duration = Duration::from_secs(60);
31
32#[derive(Debug, Clone)]
34pub(crate) struct InnerHttpClient(ClientWithMiddleware);
35
36#[derive(Debug)]
45pub struct SteamUser {
46 pub(crate) client: InnerHttpClient,
48 pub(crate) external_client: reqwest::Client,
53 pub(crate) no_redirect_client: reqwest::Client,
58 pub session: Session,
60 pub(crate) time_offset: Mutex<Option<i64>>,
63 pub(crate) session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
65}
66
67impl Clone for SteamUser {
68 fn clone(&self) -> Self {
69 Self {
70 client: self.client.clone(),
71 external_client: self.external_client.clone(),
72 no_redirect_client: self.no_redirect_client.clone(),
73 session: self.session.clone(),
74 time_offset: Mutex::new(*self.time_offset.lock()),
75 session_limiter: self.session_limiter.clone(),
76 }
77 }
78}
79
80#[derive(Debug, Default)]
85pub struct SteamUserBuilder {
86 cookies: Vec<String>,
87 rate_limit: Option<(u32, u32)>,
88 timeout: Option<Duration>,
89 connect_timeout: Option<Duration>,
90 external_timeout: Option<Duration>,
91 user_agent: Option<String>,
92 retry_count: Option<u32>,
93}
94
95impl SteamUserBuilder {
96 pub fn cookies<S: AsRef<str>>(mut self, cookies: &[S]) -> Self {
98 self.cookies = cookies.iter().map(|s| s.as_ref().to_string()).collect();
99 self
100 }
101
102 pub fn rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Self {
105 self.rate_limit = Some((requests_per_minute, burst));
106 self
107 }
108
109 pub fn timeout(mut self, timeout: Duration) -> Self {
111 self.timeout = Some(timeout);
112 self
113 }
114
115 pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
117 self.connect_timeout = Some(connect_timeout);
118 self
119 }
120
121 pub fn external_timeout(mut self, external_timeout: Duration) -> Self {
123 self.external_timeout = Some(external_timeout);
124 self
125 }
126
127 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
129 self.user_agent = Some(user_agent.into());
130 self
131 }
132
133 pub fn retry_count(mut self, retry_count: u32) -> Self {
135 self.retry_count = Some(retry_count);
136 self
137 }
138
139 pub fn build(self) -> Result<SteamUser, SteamUserError> {
142 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
143 let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
144 let external_timeout = self.external_timeout.unwrap_or(EXTERNAL_TIMEOUT);
145 let user_agent = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
146 let retry_count = self.retry_count.unwrap_or(3);
147
148 let mut session = Session::new();
149 let cookie_refs: Vec<&str> = self.cookies.iter().map(|s| s.as_str()).collect();
150 session.set_cookies(&cookie_refs)?;
151 session.ensure_session_id();
152
153 let reqwest_client = reqwest::Client::builder().cookie_provider(Arc::clone(&session.jar)).connect_timeout(connect_timeout).timeout(timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
154
155 let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retry_count);
156 let client = ClientBuilder::new(reqwest_client).with(reqwest_tracing::TracingMiddleware::default()).with(RetryTransientMiddleware::new_with_policy_and_strategy(retry_policy, KindAwareRetryStrategy)).build();
157
158 let external_client = reqwest::Client::builder().connect_timeout(connect_timeout).timeout(external_timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
159
160 let no_redirect_client = reqwest::Client::builder().cookie_provider(Arc::clone(&session.jar)).connect_timeout(connect_timeout).timeout(timeout).user_agent(user_agent).gzip(true).min_tls_version(reqwest::tls::Version::TLS_1_2).https_only(true).redirect(reqwest::redirect::Policy::none()).build().map_err(|e| SteamUserError::ClientBuild(e.to_string()))?;
161
162 let session_limiter = match self.rate_limit {
163 Some((rpm, burst)) => {
164 use std::num::NonZeroU32;
165
166 use governor::{Quota, RateLimiter};
167 let rpm = NonZeroU32::new(rpm).ok_or_else(|| SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
168 let burst = NonZeroU32::new(burst).ok_or_else(|| SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
169 Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))))
170 }
171 None => None,
172 };
173
174 Ok(SteamUser { client: InnerHttpClient(client), external_client, no_redirect_client, session, time_offset: Mutex::new(None), session_limiter })
175 }
176}
177
178impl SteamUser {
179 #[tracing::instrument(skip(cookies))]
194 pub fn new(cookies: &[&str]) -> Result<Self, SteamUserError> {
195 Self::builder().cookies(cookies).build()
196 }
197
198 pub fn builder() -> SteamUserBuilder {
204 SteamUserBuilder::default()
205 }
206
207 pub fn with_rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Result<Self, crate::SteamUserError> {
215 use std::num::NonZeroU32;
216
217 use governor::{Quota, RateLimiter};
218
219 let rpm = NonZeroU32::new(requests_per_minute).ok_or_else(|| crate::SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
220 let burst = NonZeroU32::new(burst).ok_or_else(|| crate::SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
221
222 self.session_limiter = Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))));
223 Ok(self)
224 }
225
226 pub fn steam_id(&self) -> Option<SteamID> {
228 self.session.steam_id
229 }
230
231 pub fn get(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
238 self.request(reqwest::Method::GET, url)
241 }
242
243 pub fn post(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
248 self.request(reqwest::Method::POST, url)
249 }
250
251 pub fn get_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
258 self.request_path(reqwest::Method::GET, path)
259 }
260
261 pub fn post_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
268 self.request_path(reqwest::Method::POST, path)
269 }
270
271 fn request_path(&self, method: reqwest::Method, path: impl std::fmt::Display) -> SteamRequestBuilder {
272 let base = crate::endpoint::current_endpoint()
273 .expect("*_path() called outside a #[steam_endpoint] method")
274 .host
275 .base_url();
276 self.request(method, format!("{base}{path}"))
277 }
278
279 pub fn get_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
290 self.request(reqwest::Method::GET, format!("{}{}", host.base_url(), path))
291 }
292
293 pub fn post_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
296 self.request(reqwest::Method::POST, format!("{}{}", host.base_url(), path))
297 }
298
299 pub fn external_get(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
310 self.external_client.get(url)
311 }
312
313 pub fn external_post(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
316 self.external_client.post(url)
317 }
318
319 #[allow(clippy::disallowed_methods)] pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
327 let mut builder = self.client.0.request(method.clone(), url).query(&[("l", "english")]);
328
329 if !self.session.cookie_string.is_empty() {
331 builder = builder.header("Cookie", &self.session.cookie_string);
332 }
333
334 SteamRequestBuilder {
335 builder,
336 session_id: self.session.session_id.clone(),
337 session_limiter: self.session_limiter.clone(),
338 request_body: None,
339 query: None,
343 http_method: method,
344 }
345 }
346
347 fn is_steam_host(host: &str) -> bool {
352 const ALLOWED: &[&str] = &[
353 "steamcommunity.com",
354 "store.steampowered.com",
355 "help.steampowered.com",
356 "api.steampowered.com",
357 "steampowered.com",
358 "s.team",
359 ];
360 for allowed in ALLOWED {
361 if host == *allowed || host.ends_with(&format!(".{}", allowed)) {
362 return true;
363 }
364 }
365 false
366 }
367
368 pub async fn get_with_manual_redirects(&self, url: &str) -> Result<String, SteamUserError> {
375 let no_redirect_client = &self.no_redirect_client;
376
377 let mut current_url = url.to_string();
378 let max_redirects = 10;
379 let mut seen_urls = std::collections::HashSet::new();
380
381 for i in 0..max_redirects {
382 let mut request = no_redirect_client.get(¤t_url);
389 if i == 0 {
390 request = request.query(&[("l", "english")]);
391 }
392
393 let response = request.send().await?;
394 let status = response.status();
395
396 if status.is_redirection() {
397 if let Some(location) = response.headers().get(reqwest::header::LOCATION) {
398 let location_str = location.to_str().map_err(|e| SteamUserError::RedirectError(format!("Invalid Location header: {e}")))?;
399
400 let base = reqwest::Url::parse(¤t_url).map_err(|e| SteamUserError::RedirectError(format!("Invalid base URL: {e}")))?;
402 let next_url = base.join(location_str).map_err(|e| SteamUserError::RedirectError(format!("Invalid redirect URL: {e}")))?;
403
404 let next_host = next_url.host_str().unwrap_or("");
406 if !Self::is_steam_host(next_host) {
407 return Err(SteamUserError::RedirectError(format!(
408 "Redirect to non-Steam host rejected: {}",
409 next_host
410 )));
411 }
412
413 tracing::debug!("[TradeOffers] Following redirect: {} -> {}", current_url, next_url);
414
415 let loop_key = next_url.path().to_string();
417 if !seen_urls.insert(loop_key) {
418 tracing::warn!("[TradeOffers] Redirect loop detected at {}, attempting direct fetch", next_url.path());
419 let direct_response = no_redirect_client.get(next_url.as_str()).send().await?;
420 if direct_response.status().is_success() {
421 return direct_response.text().await.map_err(SteamUserError::HttpError);
422 }
423 return Err(SteamUserError::HttpStatus { status: direct_response.status().as_u16(), url: next_url.to_string() });
424 }
425
426 current_url = next_url.to_string();
427 continue;
428 }
429 return Err(SteamUserError::RedirectError("Redirect without Location header".into()));
430 }
431
432 if status.is_success() {
433 return response.text().await.map_err(SteamUserError::HttpError);
434 }
435
436 return Err(SteamUserError::HttpStatus { status: status.as_u16(), url: current_url.clone() });
437 }
438
439 Err(SteamUserError::RedirectError("Too many redirects".into()))
440 }
441
442 pub fn get_session_id(&mut self) -> &str {
445 self.session.get_session_id()
446 }
447
448 pub fn is_logged_in(&self) -> bool {
454 self.session.is_logged_in()
455 }
456
457 pub fn set_mobile_access_token(&mut self, token: String) {
460 self.session.set_mobile_access_token(token);
461 }
462
463 pub fn set_refresh_token(&mut self, token: String) {
465 self.session.set_refresh_token(token);
466 }
467
468 pub fn set_access_token(&mut self, token: String) {
470 self.session.set_access_token(token);
471 }
472
473 pub fn get_web_cookies(&self) -> String {
478 let steam_id = self.session.steam_id.map(|id| id.steam_id64().to_string()).unwrap_or_default();
479 let access_token = self.session.access_token.as_deref().unwrap_or_default();
480 let session_id = self.session.session_id.as_deref().unwrap_or_default();
481
482 let mut cookies = Vec::new();
483 cookies.push(format!("steamLoginSecure={}||{}", steam_id, access_token));
484 cookies.push(format!("sessionid={}", session_id));
485
486 cookies.join("; ")
492 }
493
494 #[tracing::instrument(skip(self))]
500 #[allow(clippy::disallowed_methods)] pub async fn logged_in(&self) -> Result<(bool, bool), SteamUserError> {
502 let mut request = self.no_redirect_client.get("https://steamcommunity.com/my");
506
507 if !self.session.cookie_string.is_empty() {
509 request = request.header("Cookie", &self.session.cookie_string);
510 }
511
512 let response = request.send().await?;
513
514 let status = response.status();
515
516 if status == StatusCode::FORBIDDEN {
517 return Ok((true, true));
519 }
520
521 if status == StatusCode::FOUND {
522 if let Some(location) = response.headers().get("location") {
525 let loc_str = location.to_str().unwrap_or("");
526 if loc_str.contains("/login") || loc_str.contains("steampowered.com/login") {
528 return Ok((false, false));
529 }
530 if loc_str.contains("/id/") || loc_str.contains("/profiles/") {
532 return Ok((true, false));
533 }
534 }
535 }
536
537 if status == StatusCode::OK {
539 let body = response.text().await.unwrap_or_default();
540 if body.contains("g_rgProfileData") || body.contains("actual_persona_name") {
542 return Ok((true, false));
543 }
544 }
545
546 Ok((false, false))
548 }
549
550 #[tracing::instrument(skip(self))]
554 #[allow(clippy::disallowed_methods)] pub async fn renew_access_token(&mut self) -> Result<(), SteamUserError> {
556 let refresh_token = self.session.refresh_token.clone().ok_or(SteamUserError::MissingCredential { field: "refresh_token" })?;
557
558 let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
559
560 let request = CAuthenticationAccessTokenGenerateForAppRequest {
561 refresh_token: Some(refresh_token.clone()),
562 steamid: Some(steam_id.steam_id64()),
563 renewal_type: Some(ETokenRenewalType::None as i32),
564 };
565
566 let mut body = Vec::new();
567 request.encode(&mut body)?;
568
569 let params = [("origin", "https://store.steampowered.com")];
571
572 let mut builder = self.client.0.post("https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1").query(¶ms).form(&[("input_protobuf_encoded", base64::engine::general_purpose::STANDARD.encode(body))]);
573
574 if let Some(token) = self.session.access_token.as_deref() {
575 builder = builder.bearer_auth(token);
576 }
577
578 let response = builder.send().await?;
579
580 if !response.status().is_success() {
581 let status = response.status();
582 let url = response.url().to_string();
583 let text = response.text().await.unwrap_or_default();
584 tracing::error!("Renew response error: {} - {}", status, text);
585 return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
586 }
587
588 let bytes = response.bytes().await?;
589 let response_proto = CAuthenticationAccessTokenGenerateForAppResponse::decode(bytes)?;
590
591 tracing::debug!("[renew_access_token] Response received:");
593 tracing::debug!(" - access_token present: {}", response_proto.access_token.is_some());
594 tracing::debug!(" - refresh_token present: {}", response_proto.refresh_token.is_some());
595 if let Some(ref token) = response_proto.access_token {
596 tracing::info!(token_len = token.len(), "new access_token acquired");
597 }
598
599 if let Some(new_access_token) = response_proto.access_token {
600 self.session.access_token = Some(new_access_token);
601 } else {
602 tracing::warn!("No new access token returned by Steam!");
603 }
604
605 if let Some(new_refresh_token) = response_proto.refresh_token {
606 self.session.refresh_token = Some(new_refresh_token);
607 }
608
609 Ok(())
610 }
611
612 #[tracing::instrument(skip(self))]
618 #[allow(clippy::disallowed_methods)] pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SteamUserError> {
620 let (client_id, _version) = decode_login_qr_url(qr_challenge_url).ok_or_else(|| SteamUserError::InvalidInput("Invalid QR challenge URL".into()))?;
621
622 let request = CAuthenticationGetAuthSessionInfoRequest { client_id: Some(client_id) };
623
624 let mut body = Vec::new();
625 request.encode(&mut body)?;
626
627 let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
629
630 let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com")];
631
632 let response = self.client.0.post("https://api.steampowered.com/IAuthenticationService/GetAuthSessionInfo/v1/").query(¶ms).multipart(reqwest::multipart::Form::new().part("input_protobuf_encoded", reqwest::multipart::Part::bytes(body))).send().await?;
633
634 self.check_response(&response)?;
635
636 let bytes = response.bytes().await?;
637 let response_proto = CAuthenticationGetAuthSessionInfoResponse::decode(bytes)?;
638
639 Ok(response_proto)
640 }
641
642 #[allow(clippy::disallowed_methods)] pub async fn get_client_js_token(&self) -> Result<ClientJsToken, SteamUserError> {
657 let url = "https://steamcommunity.com/chat/clientjstoken";
658 let response = self.get(url).send().await?;
659 self.check_response(&response)?;
660
661 let token: ClientJsToken = response.json().await?;
662
663 if !token.logged_in {
664 return Err(SteamUserError::NotLoggedIn);
665 }
666
667 Ok(token)
668 }
669
670 pub(crate) fn check_response(&self, response: &Response) -> Result<(), SteamUserError> {
676 let status = response.status();
677
678 let url = response.url();
684 let on_steam_host = url.host_str().is_some_and(|h| {
685 h == "steamcommunity.com" || h.ends_with(".steamcommunity.com") || h == "steampowered.com" || h.ends_with(".steampowered.com")
686 });
687 let path = url.path();
688 if on_steam_host && (path == "/login" || path.starts_with("/login/")) {
689 return Err(SteamUserError::NotLoggedIn);
690 }
691
692 if status.is_success() {
693 return Ok(());
694 }
695
696 if status == StatusCode::FORBIDDEN {
697 return Err(SteamUserError::FamilyViewRestricted);
698 }
699
700 if status.is_client_error() || status.is_server_error() {
701 let url = response.url().to_string();
702 return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
703 }
704
705 Ok(())
706 }
707
708 pub(crate) fn check_json_success<'a>(json: &'a serde_json::Value, error_msg: &str) -> Result<&'a serde_json::Value, SteamUserError> {
710 if let Some(success) = json.get("success") {
711 if let Some(b) = success.as_bool() {
712 if b {
713 return Ok(json);
714 }
715 } else if let Some(i) = success.as_i64() {
716 if i == 1 {
717 return Ok(json);
718 }
719 }
720 }
721
722 if let Some(eresult) = json.get("eresult").and_then(|v| v.as_i64()) {
723 if eresult != 1 {
724 return Err(SteamUserError::from_eresult(eresult as i32));
725 }
726 }
727
728 Err(SteamUserError::SteamError(error_msg.to_string()))
729 }
730}
731
732pub struct SteamRequestBuilder {
735 builder: reqwest_middleware::RequestBuilder,
736 session_id: Option<String>,
737 session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
738 request_body: Option<serde_json::Value>,
739 query: Option<serde_json::Value>,
740 http_method: reqwest::Method,
741}
742
743impl SteamRequestBuilder {
744 pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
749 if let Some(session_id) = &self.session_id {
750 if let Ok(mut value) = serde_json::to_value(form) {
754 if let Some(obj) = value.as_object_mut() {
755 obj.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
756 obj.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
757 self.request_body = Some(serde_json::Value::Object(obj.clone()));
758 self.builder = self.builder.form(&value);
759 return self;
760 } else if let Some(arr) = value.as_array() {
761 let mut map = serde_json::Map::new();
764 for item in arr {
765 if let Some(tuple_arr) = item.as_array() {
766 if tuple_arr.len() == 2 {
767 if let (Some(key), Some(val)) = (tuple_arr[0].as_str(), tuple_arr[1].as_str()) {
768 map.insert(key.to_string(), serde_json::Value::String(val.to_string()));
769 }
770 }
771 }
772 }
773 map.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
774 map.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
775 self.request_body = Some(serde_json::Value::Object(map.clone()));
776 self.builder = self.builder.form(&serde_json::Value::Object(map));
777 return self;
778 }
779 }
780 }
781 self.request_body = serde_json::to_value(form).ok();
782 self.builder = self.builder.form(form);
783 self
784 }
785
786 pub fn multipart(mut self, mut form: reqwest::multipart::Form) -> Self {
791 if let Some(session_id) = &self.session_id {
792 form = form.text("sessionid", session_id.clone()).text("sessionID", session_id.clone());
793 }
794 self.builder = self.builder.multipart(form);
795 self
796 }
797
798 pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
800 if let Ok(value) = serde_json::to_value(query) {
801 if let Some(ref mut existing) = self.query {
802 if let (Some(e_obj), Some(n_obj)) = (existing.as_object_mut(), value.as_object()) {
803 for (k, v) in n_obj {
804 e_obj.insert(k.clone(), v.clone());
805 }
806 } else {
807 self.query = Some(value);
808 }
809 } else {
810 self.query = Some(value);
811 }
812 }
813 self.builder = self.builder.query(query);
814 self
815 }
816
817 pub fn header<K, V>(mut self, key: K, value: V) -> Self
819 where
820 reqwest::header::HeaderName: TryFrom<K>,
821 <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
822 reqwest::header::HeaderValue: TryFrom<V>,
823 <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
824 {
825 self.builder = self.builder.header(key, value);
826 self
827 }
828
829 pub async fn send(self) -> Result<reqwest::Response, reqwest_middleware::Error> {
831 tracing::Span::current().record("http_method", self.http_method.as_str());
834
835 let endpoint = crate::endpoint::current_endpoint();
840
841 if let Some(limiter) = &self.session_limiter {
843 limiter.until_ready().await;
844 }
845
846 crate::limiter::wait_for_permit().await;
851 if let Some(ep) = endpoint {
852 crate::limiter::wait_for_host_permit(ep.host).await;
853 }
854
855 let response = self.builder.send().await?;
857
858 let status = response.status();
859 let url = response.url().clone();
860 let headers = response.headers().clone();
861
862 let safe_url = redact_url_params(&url);
865 tracing::Span::current().record("url", safe_url.as_str());
866
867 tracing::info!(status = %status, url = %safe_url, "steam response");
868
869 if let Some(ep) = endpoint {
872 crate::endpoint::metrics().record_call(ep);
873 }
874
875 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
878 let retry_after = headers
879 .get(reqwest::header::RETRY_AFTER)
880 .and_then(|v| v.to_str().ok())
881 .and_then(|s| {
882 if let Ok(secs) = s.trim().parse::<u64>() {
884 return Some(std::time::Duration::from_secs(secs));
885 }
886 if let Ok(dt) = httpdate::parse_http_date(s) {
888 let now = std::time::SystemTime::now();
889 return dt.duration_since(now).ok();
890 }
891 None
892 })
893 .unwrap_or_else(|| std::time::Duration::from_secs(60));
894 crate::limiter::penalize_abuse(retry_after);
895 }
896
897 if tracing::enabled!(tracing::Level::DEBUG) {
898 if let Some(ref req_body) = self.request_body {
899 tracing::debug!(body = %req_body, "request body");
900 }
901 if let Some(ref query) = self.query {
902 tracing::debug!(query = %query, "request query");
903 }
904 }
905
906 let final_url = response.url().clone();
909
910 let version = response.version();
913 let headers = response.headers().clone();
914 let bytes = response.bytes().await?;
915
916 let content_type = headers
917 .get(reqwest::header::CONTENT_TYPE)
918 .and_then(|h| h.to_str().ok())
919 .unwrap_or("");
920
921 if !content_type.is_empty() {
923 tracing::Span::current().record("content_type", content_type);
924 }
925 let response_type_str = if content_type.contains("json") {
926 "json"
927 } else if content_type.contains("html") {
928 "html"
929 } else if content_type.contains("xml") {
930 "xml"
931 } else if content_type.contains("javascript") || content_type.contains("text") {
932 "text"
933 } else if content_type.contains("protobuf")
934 || content_type.contains("octet-stream")
935 || content_type.contains("image")
936 {
937 "binary"
938 } else {
939 "unknown"
940 };
941 tracing::Span::current().record("response_type", response_type_str);
942
943 if content_type.contains("text")
944 || content_type.contains("json")
945 || content_type.contains("javascript")
946 || content_type.contains("xml")
947 {
948 let body_str = String::from_utf8_lossy(&bytes);
949 tracing::Span::current().record("raw_response", body_str.as_ref());
950 if tracing::enabled!(tracing::Level::DEBUG) {
951 tracing::debug!(body = %body_str, "response body");
952 }
953 } else if tracing::enabled!(tracing::Level::DEBUG) {
954 tracing::debug!(bytes = bytes.len(), content_type, url = %final_url, "response body (binary)");
955 }
956
957 let mut builder = http::Response::builder().status(status).version(version);
958 for (name, value) in headers.iter() {
959 builder = builder.header(name, value);
960 }
961 let http_resp = builder.body(bytes).map_err(|e| {
962 reqwest_middleware::Error::Middleware(anyhow::anyhow!(
963 "Failed to reconstruct response: {e}"
964 ))
965 })?;
966 Ok(reqwest::Response::from(http_resp))
967 }
968
969 pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> Self {
971 self.builder = self.builder.body(body);
972 self
973 }
974}
975
976const REDACTED_PARAMS: &[&str] = &["access_token", "key", "oauth_token", "webapi_token"];
977
978fn redact_url_params(url: &reqwest::Url) -> String {
979 if url.query().is_none() {
980 return url.to_string();
981 }
982
983 let has_sensitive = url
984 .query_pairs()
985 .any(|(k, _)| REDACTED_PARAMS.contains(&k.as_ref()));
986
987 if !has_sensitive {
988 return url.to_string();
989 }
990
991 let mut redacted = url.clone();
992 let filtered: Vec<(String, String)> = url
993 .query_pairs()
994 .map(|(k, v)| {
995 if REDACTED_PARAMS.contains(&k.as_ref()) {
996 (k.into_owned(), "[REDACTED]".to_string())
997 } else {
998 (k.into_owned(), v.into_owned())
999 }
1000 })
1001 .collect();
1002
1003 let qs: String = filtered
1004 .iter()
1005 .map(|(k, v)| format!("{k}={v}"))
1006 .collect::<Vec<_>>()
1007 .join("&");
1008 redacted.set_query(Some(&qs));
1009 redacted.to_string()
1010}
1011
1012#[derive(Debug, serde::Deserialize)]
1014pub struct ClientJsToken {
1015 pub logged_in: bool,
1016 pub steamid: Option<String>,
1017 pub account_name: Option<String>,
1018 pub token: Option<String>,
1019}