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 let Some(new_access_token) = response_proto.access_token else {
600 tracing::warn!("Renewal rejected: Steam returned no access token (refresh token no longer renewable)");
607 return Err(SteamUserError::RenewalRejected);
608 };
609 self.session.access_token = Some(new_access_token);
610
611 if let Some(new_refresh_token) = response_proto.refresh_token {
612 self.session.refresh_token = Some(new_refresh_token);
613 }
614
615 Ok(())
616 }
617
618 #[tracing::instrument(skip(self))]
624 #[allow(clippy::disallowed_methods)] pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SteamUserError> {
626 let (client_id, _version) = decode_login_qr_url(qr_challenge_url).ok_or_else(|| SteamUserError::InvalidInput("Invalid QR challenge URL".into()))?;
627
628 let request = CAuthenticationGetAuthSessionInfoRequest { client_id: Some(client_id) };
629
630 let mut body = Vec::new();
631 request.encode(&mut body)?;
632
633 let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
635
636 let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com")];
637
638 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?;
639
640 self.check_response(&response)?;
641
642 let bytes = response.bytes().await?;
643 let response_proto = CAuthenticationGetAuthSessionInfoResponse::decode(bytes)?;
644
645 Ok(response_proto)
646 }
647
648 #[allow(clippy::disallowed_methods)] pub async fn get_client_js_token(&self) -> Result<ClientJsToken, SteamUserError> {
663 let url = "https://steamcommunity.com/chat/clientjstoken";
664 let response = self.get(url).send().await?;
665 self.check_response(&response)?;
666
667 let token: ClientJsToken = response.json().await?;
668
669 if !token.logged_in {
670 return Err(SteamUserError::NotLoggedIn);
671 }
672
673 Ok(token)
674 }
675
676 pub(crate) fn check_response(&self, response: &Response) -> Result<(), SteamUserError> {
682 let status = response.status();
683
684 let url = response.url();
690 let on_steam_host = url.host_str().is_some_and(|h| {
691 h == "steamcommunity.com" || h.ends_with(".steamcommunity.com") || h == "steampowered.com" || h.ends_with(".steampowered.com")
692 });
693 let path = url.path();
694 if on_steam_host && (path == "/login" || path.starts_with("/login/")) {
695 return Err(SteamUserError::NotLoggedIn);
696 }
697
698 if status.is_success() {
699 return Ok(());
700 }
701
702 if status == StatusCode::FORBIDDEN {
703 return Err(SteamUserError::FamilyViewRestricted);
704 }
705
706 if status.is_client_error() || status.is_server_error() {
707 let url = response.url().to_string();
708 return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
709 }
710
711 Ok(())
712 }
713
714 pub(crate) fn check_json_success<'a>(json: &'a serde_json::Value, error_msg: &str) -> Result<&'a serde_json::Value, SteamUserError> {
716 if let Some(success) = json.get("success") {
717 if let Some(b) = success.as_bool() {
718 if b {
719 return Ok(json);
720 }
721 } else if let Some(i) = success.as_i64() {
722 if i == 1 {
723 return Ok(json);
724 }
725 }
726 }
727
728 if let Some(eresult) = json.get("eresult").and_then(|v| v.as_i64()) {
729 if eresult != 1 {
730 return Err(SteamUserError::from_eresult(eresult as i32));
731 }
732 }
733
734 Err(SteamUserError::SteamError(error_msg.to_string()))
735 }
736}
737
738pub struct SteamRequestBuilder {
741 builder: reqwest_middleware::RequestBuilder,
742 session_id: Option<String>,
743 session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
744 request_body: Option<serde_json::Value>,
745 query: Option<serde_json::Value>,
746 http_method: reqwest::Method,
747}
748
749impl SteamRequestBuilder {
750 pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
755 if let Some(session_id) = &self.session_id {
756 if let Ok(mut value) = serde_json::to_value(form) {
760 if let Some(obj) = value.as_object_mut() {
761 obj.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
762 obj.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
763 self.request_body = Some(serde_json::Value::Object(obj.clone()));
764 self.builder = self.builder.form(&value);
765 return self;
766 } else if let Some(arr) = value.as_array() {
767 let mut map = serde_json::Map::new();
770 for item in arr {
771 if let Some(tuple_arr) = item.as_array() {
772 if tuple_arr.len() == 2 {
773 if let (Some(key), Some(val)) = (tuple_arr[0].as_str(), tuple_arr[1].as_str()) {
774 map.insert(key.to_string(), serde_json::Value::String(val.to_string()));
775 }
776 }
777 }
778 }
779 map.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
780 map.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
781 self.request_body = Some(serde_json::Value::Object(map.clone()));
782 self.builder = self.builder.form(&serde_json::Value::Object(map));
783 return self;
784 }
785 }
786 }
787 self.request_body = serde_json::to_value(form).ok();
788 self.builder = self.builder.form(form);
789 self
790 }
791
792 pub fn multipart(mut self, mut form: reqwest::multipart::Form) -> Self {
797 if let Some(session_id) = &self.session_id {
798 form = form.text("sessionid", session_id.clone()).text("sessionID", session_id.clone());
799 }
800 self.builder = self.builder.multipart(form);
801 self
802 }
803
804 pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
806 if let Ok(value) = serde_json::to_value(query) {
807 if let Some(ref mut existing) = self.query {
808 if let (Some(e_obj), Some(n_obj)) = (existing.as_object_mut(), value.as_object()) {
809 for (k, v) in n_obj {
810 e_obj.insert(k.clone(), v.clone());
811 }
812 } else {
813 self.query = Some(value);
814 }
815 } else {
816 self.query = Some(value);
817 }
818 }
819 self.builder = self.builder.query(query);
820 self
821 }
822
823 pub fn header<K, V>(mut self, key: K, value: V) -> Self
825 where
826 reqwest::header::HeaderName: TryFrom<K>,
827 <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
828 reqwest::header::HeaderValue: TryFrom<V>,
829 <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
830 {
831 self.builder = self.builder.header(key, value);
832 self
833 }
834
835 pub async fn send(self) -> Result<reqwest::Response, reqwest_middleware::Error> {
837 tracing::Span::current().record("http_method", self.http_method.as_str());
840
841 let endpoint = crate::endpoint::current_endpoint();
846
847 if let Some(limiter) = &self.session_limiter {
849 limiter.until_ready().await;
850 }
851
852 crate::limiter::wait_for_permit().await;
857 if let Some(ep) = endpoint {
858 crate::limiter::wait_for_host_permit(ep.host).await;
859 }
860
861 let response = self.builder.send().await?;
863
864 let status = response.status();
865 let url = response.url().clone();
866 let headers = response.headers().clone();
867
868 let safe_url = redact_url_params(&url);
871 tracing::Span::current().record("url", safe_url.as_str());
872
873 tracing::info!(status = %status, url = %safe_url, "steam response");
874
875 if let Some(ep) = endpoint {
878 crate::endpoint::metrics().record_call(ep);
879 }
880
881 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
884 let retry_after = headers
885 .get(reqwest::header::RETRY_AFTER)
886 .and_then(|v| v.to_str().ok())
887 .and_then(|s| {
888 if let Ok(secs) = s.trim().parse::<u64>() {
890 return Some(std::time::Duration::from_secs(secs));
891 }
892 if let Ok(dt) = httpdate::parse_http_date(s) {
894 let now = std::time::SystemTime::now();
895 return dt.duration_since(now).ok();
896 }
897 None
898 })
899 .unwrap_or_else(|| std::time::Duration::from_secs(60));
900 crate::limiter::penalize_abuse(retry_after);
901 }
902
903 if tracing::enabled!(tracing::Level::DEBUG) {
904 if let Some(ref req_body) = self.request_body {
905 tracing::debug!(body = %req_body, "request body");
906 }
907 if let Some(ref query) = self.query {
908 tracing::debug!(query = %query, "request query");
909 }
910 }
911
912 let final_url = response.url().clone();
915
916 let version = response.version();
919 let headers = response.headers().clone();
920 let bytes = response.bytes().await?;
921
922 let content_type = headers
923 .get(reqwest::header::CONTENT_TYPE)
924 .and_then(|h| h.to_str().ok())
925 .unwrap_or("");
926
927 if !content_type.is_empty() {
929 tracing::Span::current().record("content_type", content_type);
930 }
931 let response_type_str = if content_type.contains("json") {
932 "json"
933 } else if content_type.contains("html") {
934 "html"
935 } else if content_type.contains("xml") {
936 "xml"
937 } else if content_type.contains("javascript") || content_type.contains("text") {
938 "text"
939 } else if content_type.contains("protobuf")
940 || content_type.contains("octet-stream")
941 || content_type.contains("image")
942 {
943 "binary"
944 } else {
945 "unknown"
946 };
947 tracing::Span::current().record("response_type", response_type_str);
948
949 if content_type.contains("text")
950 || content_type.contains("json")
951 || content_type.contains("javascript")
952 || content_type.contains("xml")
953 {
954 let body_str = String::from_utf8_lossy(&bytes);
955 tracing::Span::current().record("raw_response", body_str.as_ref());
956 if tracing::enabled!(tracing::Level::DEBUG) {
957 tracing::debug!(body = %body_str, "response body");
958 }
959 } else if tracing::enabled!(tracing::Level::DEBUG) {
960 tracing::debug!(bytes = bytes.len(), content_type, url = %final_url, "response body (binary)");
961 }
962
963 let mut builder = http::Response::builder().status(status).version(version);
964 for (name, value) in headers.iter() {
965 builder = builder.header(name, value);
966 }
967 let http_resp = builder.body(bytes).map_err(|e| {
968 reqwest_middleware::Error::Middleware(anyhow::anyhow!(
969 "Failed to reconstruct response: {e}"
970 ))
971 })?;
972 Ok(reqwest::Response::from(http_resp))
973 }
974
975 pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> Self {
977 self.builder = self.builder.body(body);
978 self
979 }
980}
981
982const REDACTED_PARAMS: &[&str] = &["access_token", "key", "oauth_token", "webapi_token"];
983
984fn redact_url_params(url: &reqwest::Url) -> String {
985 if url.query().is_none() {
986 return url.to_string();
987 }
988
989 let has_sensitive = url
990 .query_pairs()
991 .any(|(k, _)| REDACTED_PARAMS.contains(&k.as_ref()));
992
993 if !has_sensitive {
994 return url.to_string();
995 }
996
997 let mut redacted = url.clone();
998 let filtered: Vec<(String, String)> = url
999 .query_pairs()
1000 .map(|(k, v)| {
1001 if REDACTED_PARAMS.contains(&k.as_ref()) {
1002 (k.into_owned(), "[REDACTED]".to_string())
1003 } else {
1004 (k.into_owned(), v.into_owned())
1005 }
1006 })
1007 .collect();
1008
1009 let qs: String = filtered
1010 .iter()
1011 .map(|(k, v)| format!("{k}={v}"))
1012 .collect::<Vec<_>>()
1013 .join("&");
1014 redacted.set_query(Some(&qs));
1015 redacted.to_string()
1016}
1017
1018#[derive(Debug, serde::Deserialize)]
1020pub struct ClientJsToken {
1021 pub logged_in: bool,
1022 pub steamid: Option<String>,
1023 pub account_name: Option<String>,
1024 pub token: Option<String>,
1025}