use std::{sync::Arc, time::Duration};
use base64::Engine;
use parking_lot::Mutex;
use prost::Message;
use reqwest::{Response, StatusCode};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use crate::retry::KindAwareRetryStrategy;
use steam_enums::etokenrenewaltype::ETokenRenewalType;
use steam_protos::messages::auth::{CAuthenticationAccessTokenGenerateForAppRequest, CAuthenticationAccessTokenGenerateForAppResponse, CAuthenticationGetAuthSessionInfoRequest, CAuthenticationGetAuthSessionInfoResponse};
use steamid::SteamID;
use crate::{error::SteamUserError, session::Session, utils::qr::decode_login_qr_url};
const 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";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const EXTERNAL_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub(crate) struct InnerHttpClient(ClientWithMiddleware);
#[derive(Debug)]
pub struct SteamUser {
pub(crate) client: InnerHttpClient,
pub(crate) external_client: reqwest::Client,
pub(crate) no_redirect_client: reqwest::Client,
pub session: Session,
pub(crate) time_offset: Mutex<Option<i64>>,
pub(crate) session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
}
impl Clone for SteamUser {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
external_client: self.external_client.clone(),
no_redirect_client: self.no_redirect_client.clone(),
session: self.session.clone(),
time_offset: Mutex::new(*self.time_offset.lock()),
session_limiter: self.session_limiter.clone(),
}
}
}
#[derive(Debug, Default)]
pub struct SteamUserBuilder {
cookies: Vec<String>,
rate_limit: Option<(u32, u32)>,
timeout: Option<Duration>,
connect_timeout: Option<Duration>,
external_timeout: Option<Duration>,
user_agent: Option<String>,
retry_count: Option<u32>,
}
impl SteamUserBuilder {
pub fn cookies<S: AsRef<str>>(mut self, cookies: &[S]) -> Self {
self.cookies = cookies.iter().map(|s| s.as_ref().to_string()).collect();
self
}
pub fn rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Self {
self.rate_limit = Some((requests_per_minute, burst));
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
self.connect_timeout = Some(connect_timeout);
self
}
pub fn external_timeout(mut self, external_timeout: Duration) -> Self {
self.external_timeout = Some(external_timeout);
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn retry_count(mut self, retry_count: u32) -> Self {
self.retry_count = Some(retry_count);
self
}
pub fn build(self) -> Result<SteamUser, SteamUserError> {
let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
let external_timeout = self.external_timeout.unwrap_or(EXTERNAL_TIMEOUT);
let user_agent = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
let retry_count = self.retry_count.unwrap_or(3);
let mut session = Session::new();
let cookie_refs: Vec<&str> = self.cookies.iter().map(|s| s.as_str()).collect();
session.set_cookies(&cookie_refs)?;
session.ensure_session_id();
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()))?;
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(retry_count);
let client = ClientBuilder::new(reqwest_client).with(reqwest_tracing::TracingMiddleware::default()).with(RetryTransientMiddleware::new_with_policy_and_strategy(retry_policy, KindAwareRetryStrategy)).build();
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()))?;
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()))?;
let session_limiter = match self.rate_limit {
Some((rpm, burst)) => {
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};
let rpm = NonZeroU32::new(rpm).ok_or_else(|| SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
let burst = NonZeroU32::new(burst).ok_or_else(|| SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))))
}
None => None,
};
Ok(SteamUser { client: InnerHttpClient(client), external_client, no_redirect_client, session, time_offset: Mutex::new(None), session_limiter })
}
}
impl SteamUser {
#[tracing::instrument(skip(cookies))]
pub fn new(cookies: &[&str]) -> Result<Self, SteamUserError> {
Self::builder().cookies(cookies).build()
}
pub fn builder() -> SteamUserBuilder {
SteamUserBuilder::default()
}
pub fn with_rate_limit(mut self, requests_per_minute: u32, burst: u32) -> Result<Self, crate::SteamUserError> {
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};
let rpm = NonZeroU32::new(requests_per_minute).ok_or_else(|| crate::SteamUserError::InvalidInput("`requests_per_minute` must be non-zero".into()))?;
let burst = NonZeroU32::new(burst).ok_or_else(|| crate::SteamUserError::InvalidInput("`burst` must be non-zero".into()))?;
self.session_limiter = Some(Arc::new(RateLimiter::direct(Quota::per_minute(rpm).allow_burst(burst))));
Ok(self)
}
pub fn steam_id(&self) -> Option<SteamID> {
self.session.steam_id
}
pub fn get(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
self.request(reqwest::Method::GET, url)
}
pub fn post(&self, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
self.request(reqwest::Method::POST, url)
}
pub fn get_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
self.request_path(reqwest::Method::GET, path)
}
pub fn post_path(&self, path: impl std::fmt::Display) -> SteamRequestBuilder {
self.request_path(reqwest::Method::POST, path)
}
fn request_path(&self, method: reqwest::Method, path: impl std::fmt::Display) -> SteamRequestBuilder {
let base = crate::endpoint::current_endpoint()
.expect("*_path() called outside a #[steam_endpoint] method")
.host
.base_url();
self.request(method, format!("{base}{path}"))
}
pub fn get_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
self.request(reqwest::Method::GET, format!("{}{}", host.base_url(), path))
}
pub fn post_path_on(&self, host: crate::endpoint::Host, path: impl std::fmt::Display) -> SteamRequestBuilder {
self.request(reqwest::Method::POST, format!("{}{}", host.base_url(), path))
}
pub fn external_get(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
self.external_client.get(url)
}
pub fn external_post(&self, url: impl reqwest::IntoUrl) -> reqwest::RequestBuilder {
self.external_client.post(url)
}
#[allow(clippy::disallowed_methods)] pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> SteamRequestBuilder {
let mut builder = self.client.0.request(method.clone(), url).query(&[("l", "english")]);
if !self.session.cookie_string.is_empty() {
builder = builder.header("Cookie", &self.session.cookie_string);
}
SteamRequestBuilder {
builder,
session_id: self.session.session_id.clone(),
session_limiter: self.session_limiter.clone(),
request_body: None,
query: None,
http_method: method,
}
}
fn is_steam_host(host: &str) -> bool {
const ALLOWED: &[&str] = &[
"steamcommunity.com",
"store.steampowered.com",
"help.steampowered.com",
"api.steampowered.com",
"steampowered.com",
"s.team",
];
for allowed in ALLOWED {
if host == *allowed || host.ends_with(&format!(".{}", allowed)) {
return true;
}
}
false
}
pub async fn get_with_manual_redirects(&self, url: &str) -> Result<String, SteamUserError> {
let no_redirect_client = &self.no_redirect_client;
let mut current_url = url.to_string();
let max_redirects = 10;
let mut seen_urls = std::collections::HashSet::new();
for i in 0..max_redirects {
let mut request = no_redirect_client.get(¤t_url);
if i == 0 {
request = request.query(&[("l", "english")]);
}
let response = request.send().await?;
let status = response.status();
if status.is_redirection() {
if let Some(location) = response.headers().get(reqwest::header::LOCATION) {
let location_str = location.to_str().map_err(|e| SteamUserError::RedirectError(format!("Invalid Location header: {e}")))?;
let base = reqwest::Url::parse(¤t_url).map_err(|e| SteamUserError::RedirectError(format!("Invalid base URL: {e}")))?;
let next_url = base.join(location_str).map_err(|e| SteamUserError::RedirectError(format!("Invalid redirect URL: {e}")))?;
let next_host = next_url.host_str().unwrap_or("");
if !Self::is_steam_host(next_host) {
return Err(SteamUserError::RedirectError(format!(
"Redirect to non-Steam host rejected: {}",
next_host
)));
}
tracing::debug!("[TradeOffers] Following redirect: {} -> {}", current_url, next_url);
let loop_key = next_url.path().to_string();
if !seen_urls.insert(loop_key) {
tracing::warn!("[TradeOffers] Redirect loop detected at {}, attempting direct fetch", next_url.path());
let direct_response = no_redirect_client.get(next_url.as_str()).send().await?;
if direct_response.status().is_success() {
return direct_response.text().await.map_err(SteamUserError::HttpError);
}
return Err(SteamUserError::HttpStatus { status: direct_response.status().as_u16(), url: next_url.to_string() });
}
current_url = next_url.to_string();
continue;
}
return Err(SteamUserError::RedirectError("Redirect without Location header".into()));
}
if status.is_success() {
return response.text().await.map_err(SteamUserError::HttpError);
}
return Err(SteamUserError::HttpStatus { status: status.as_u16(), url: current_url.clone() });
}
Err(SteamUserError::RedirectError("Too many redirects".into()))
}
pub fn get_session_id(&mut self) -> &str {
self.session.get_session_id()
}
pub fn is_logged_in(&self) -> bool {
self.session.is_logged_in()
}
pub fn set_mobile_access_token(&mut self, token: String) {
self.session.set_mobile_access_token(token);
}
pub fn set_refresh_token(&mut self, token: String) {
self.session.set_refresh_token(token);
}
pub fn set_access_token(&mut self, token: String) {
self.session.set_access_token(token);
}
pub fn get_web_cookies(&self) -> String {
let steam_id = self.session.steam_id.map(|id| id.steam_id64().to_string()).unwrap_or_default();
let access_token = self.session.access_token.as_deref().unwrap_or_default();
let session_id = self.session.session_id.as_deref().unwrap_or_default();
let mut cookies = Vec::new();
cookies.push(format!("steamLoginSecure={}||{}", steam_id, access_token));
cookies.push(format!("sessionid={}", session_id));
cookies.join("; ")
}
#[tracing::instrument(skip(self))]
#[allow(clippy::disallowed_methods)] pub async fn logged_in(&self) -> Result<(bool, bool), SteamUserError> {
let mut request = self.no_redirect_client.get("https://steamcommunity.com/my");
if !self.session.cookie_string.is_empty() {
request = request.header("Cookie", &self.session.cookie_string);
}
let response = request.send().await?;
let status = response.status();
if status == StatusCode::FORBIDDEN {
return Ok((true, true));
}
if status == StatusCode::FOUND {
if let Some(location) = response.headers().get("location") {
let loc_str = location.to_str().unwrap_or("");
if loc_str.contains("/login") || loc_str.contains("steampowered.com/login") {
return Ok((false, false));
}
if loc_str.contains("/id/") || loc_str.contains("/profiles/") {
return Ok((true, false));
}
}
}
if status == StatusCode::OK {
let body = response.text().await.unwrap_or_default();
if body.contains("g_rgProfileData") || body.contains("actual_persona_name") {
return Ok((true, false));
}
}
Ok((false, false))
}
#[tracing::instrument(skip(self))]
#[allow(clippy::disallowed_methods)] pub async fn renew_access_token(&mut self) -> Result<(), SteamUserError> {
let refresh_token = self.session.refresh_token.clone().ok_or(SteamUserError::MissingCredential { field: "refresh_token" })?;
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let request = CAuthenticationAccessTokenGenerateForAppRequest {
refresh_token: Some(refresh_token.clone()),
steamid: Some(steam_id.steam_id64()),
renewal_type: Some(ETokenRenewalType::None as i32),
};
let mut body = Vec::new();
request.encode(&mut body)?;
let params = [("origin", "https://store.steampowered.com")];
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))]);
if let Some(token) = self.session.access_token.as_deref() {
builder = builder.bearer_auth(token);
}
let response = builder.send().await?;
if !response.status().is_success() {
let status = response.status();
let url = response.url().to_string();
let text = response.text().await.unwrap_or_default();
tracing::error!("Renew response error: {} - {}", status, text);
return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
}
let bytes = response.bytes().await?;
let response_proto = CAuthenticationAccessTokenGenerateForAppResponse::decode(bytes)?;
tracing::debug!("[renew_access_token] Response received:");
tracing::debug!(" - access_token present: {}", response_proto.access_token.is_some());
tracing::debug!(" - refresh_token present: {}", response_proto.refresh_token.is_some());
if let Some(ref token) = response_proto.access_token {
tracing::info!(token_len = token.len(), "new access_token acquired");
}
if let Some(new_access_token) = response_proto.access_token {
self.session.access_token = Some(new_access_token);
} else {
tracing::warn!("No new access token returned by Steam!");
}
if let Some(new_refresh_token) = response_proto.refresh_token {
self.session.refresh_token = Some(new_refresh_token);
}
Ok(())
}
#[tracing::instrument(skip(self))]
#[allow(clippy::disallowed_methods)] pub async fn get_auth_session_info(&self, qr_challenge_url: &str) -> Result<CAuthenticationGetAuthSessionInfoResponse, SteamUserError> {
let (client_id, _version) = decode_login_qr_url(qr_challenge_url).ok_or_else(|| SteamUserError::InvalidInput("Invalid QR challenge URL".into()))?;
let request = CAuthenticationGetAuthSessionInfoRequest { client_id: Some(client_id) };
let mut body = Vec::new();
request.encode(&mut body)?;
let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com")];
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?;
self.check_response(&response)?;
let bytes = response.bytes().await?;
let response_proto = CAuthenticationGetAuthSessionInfoResponse::decode(bytes)?;
Ok(response_proto)
}
#[allow(clippy::disallowed_methods)] pub async fn get_client_js_token(&self) -> Result<ClientJsToken, SteamUserError> {
let url = "https://steamcommunity.com/chat/clientjstoken";
let response = self.get(url).send().await?;
self.check_response(&response)?;
let token: ClientJsToken = response.json().await?;
if !token.logged_in {
return Err(SteamUserError::NotLoggedIn);
}
Ok(token)
}
pub(crate) fn check_response(&self, response: &Response) -> Result<(), SteamUserError> {
let status = response.status();
let url = response.url();
let on_steam_host = url.host_str().is_some_and(|h| {
h == "steamcommunity.com" || h.ends_with(".steamcommunity.com") || h == "steampowered.com" || h.ends_with(".steampowered.com")
});
let path = url.path();
if on_steam_host && (path == "/login" || path.starts_with("/login/")) {
return Err(SteamUserError::NotLoggedIn);
}
if status.is_success() {
return Ok(());
}
if status == StatusCode::FORBIDDEN {
return Err(SteamUserError::FamilyViewRestricted);
}
if status.is_client_error() || status.is_server_error() {
let url = response.url().to_string();
return Err(SteamUserError::HttpStatus { status: status.as_u16(), url });
}
Ok(())
}
pub(crate) fn check_json_success<'a>(json: &'a serde_json::Value, error_msg: &str) -> Result<&'a serde_json::Value, SteamUserError> {
if let Some(success) = json.get("success") {
if let Some(b) = success.as_bool() {
if b {
return Ok(json);
}
} else if let Some(i) = success.as_i64() {
if i == 1 {
return Ok(json);
}
}
}
if let Some(eresult) = json.get("eresult").and_then(|v| v.as_i64()) {
if eresult != 1 {
return Err(SteamUserError::from_eresult(eresult as i32));
}
}
Err(SteamUserError::SteamError(error_msg.to_string()))
}
}
pub struct SteamRequestBuilder {
builder: reqwest_middleware::RequestBuilder,
session_id: Option<String>,
session_limiter: Option<Arc<crate::limiter::SteamRateLimiter>>,
request_body: Option<serde_json::Value>,
query: Option<serde_json::Value>,
http_method: reqwest::Method,
}
impl SteamRequestBuilder {
pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
if let Some(session_id) = &self.session_id {
if let Ok(mut value) = serde_json::to_value(form) {
if let Some(obj) = value.as_object_mut() {
obj.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
obj.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
self.request_body = Some(serde_json::Value::Object(obj.clone()));
self.builder = self.builder.form(&value);
return self;
} else if let Some(arr) = value.as_array() {
let mut map = serde_json::Map::new();
for item in arr {
if let Some(tuple_arr) = item.as_array() {
if tuple_arr.len() == 2 {
if let (Some(key), Some(val)) = (tuple_arr[0].as_str(), tuple_arr[1].as_str()) {
map.insert(key.to_string(), serde_json::Value::String(val.to_string()));
}
}
}
}
map.insert("sessionid".to_string(), serde_json::Value::String(session_id.clone()));
map.insert("sessionID".to_string(), serde_json::Value::String(session_id.clone()));
self.request_body = Some(serde_json::Value::Object(map.clone()));
self.builder = self.builder.form(&serde_json::Value::Object(map));
return self;
}
}
}
self.request_body = serde_json::to_value(form).ok();
self.builder = self.builder.form(form);
self
}
pub fn multipart(mut self, mut form: reqwest::multipart::Form) -> Self {
if let Some(session_id) = &self.session_id {
form = form.text("sessionid", session_id.clone()).text("sessionID", session_id.clone());
}
self.builder = self.builder.multipart(form);
self
}
pub fn query<T: serde::Serialize + ?Sized>(mut self, query: &T) -> Self {
if let Ok(value) = serde_json::to_value(query) {
if let Some(ref mut existing) = self.query {
if let (Some(e_obj), Some(n_obj)) = (existing.as_object_mut(), value.as_object()) {
for (k, v) in n_obj {
e_obj.insert(k.clone(), v.clone());
}
} else {
self.query = Some(value);
}
} else {
self.query = Some(value);
}
}
self.builder = self.builder.query(query);
self
}
pub fn header<K, V>(mut self, key: K, value: V) -> Self
where
reqwest::header::HeaderName: TryFrom<K>,
<reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
reqwest::header::HeaderValue: TryFrom<V>,
<reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
{
self.builder = self.builder.header(key, value);
self
}
pub async fn send(self) -> Result<reqwest::Response, reqwest_middleware::Error> {
tracing::Span::current().record("http_method", self.http_method.as_str());
let endpoint = crate::endpoint::current_endpoint();
if let Some(limiter) = &self.session_limiter {
limiter.until_ready().await;
}
crate::limiter::wait_for_permit().await;
if let Some(ep) = endpoint {
crate::limiter::wait_for_host_permit(ep.host).await;
}
let response = self.builder.send().await?;
let status = response.status();
let url = response.url().clone();
let headers = response.headers().clone();
let safe_url = redact_url_params(&url);
tracing::Span::current().record("url", safe_url.as_str());
tracing::info!(status = %status, url = %safe_url, "steam response");
if let Some(ep) = endpoint {
crate::endpoint::metrics().record_call(ep);
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = headers
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
if let Ok(secs) = s.trim().parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
}
if let Ok(dt) = httpdate::parse_http_date(s) {
let now = std::time::SystemTime::now();
return dt.duration_since(now).ok();
}
None
})
.unwrap_or_else(|| std::time::Duration::from_secs(60));
crate::limiter::penalize_abuse(retry_after);
}
if tracing::enabled!(tracing::Level::DEBUG) {
if let Some(ref req_body) = self.request_body {
tracing::debug!(body = %req_body, "request body");
}
if let Some(ref query) = self.query {
tracing::debug!(query = %query, "request query");
}
}
let final_url = response.url().clone();
let version = response.version();
let headers = response.headers().clone();
let bytes = response.bytes().await?;
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or("");
if !content_type.is_empty() {
tracing::Span::current().record("content_type", content_type);
}
let response_type_str = if content_type.contains("json") {
"json"
} else if content_type.contains("html") {
"html"
} else if content_type.contains("xml") {
"xml"
} else if content_type.contains("javascript") || content_type.contains("text") {
"text"
} else if content_type.contains("protobuf")
|| content_type.contains("octet-stream")
|| content_type.contains("image")
{
"binary"
} else {
"unknown"
};
tracing::Span::current().record("response_type", response_type_str);
if content_type.contains("text")
|| content_type.contains("json")
|| content_type.contains("javascript")
|| content_type.contains("xml")
{
let body_str = String::from_utf8_lossy(&bytes);
tracing::Span::current().record("raw_response", body_str.as_ref());
if tracing::enabled!(tracing::Level::DEBUG) {
tracing::debug!(body = %body_str, "response body");
}
} else if tracing::enabled!(tracing::Level::DEBUG) {
tracing::debug!(bytes = bytes.len(), content_type, url = %final_url, "response body (binary)");
}
let mut builder = http::Response::builder().status(status).version(version);
for (name, value) in headers.iter() {
builder = builder.header(name, value);
}
let http_resp = builder.body(bytes).map_err(|e| {
reqwest_middleware::Error::Middleware(anyhow::anyhow!(
"Failed to reconstruct response: {e}"
))
})?;
Ok(reqwest::Response::from(http_resp))
}
pub fn body<T: Into<reqwest::Body>>(mut self, body: T) -> Self {
self.builder = self.builder.body(body);
self
}
}
const REDACTED_PARAMS: &[&str] = &["access_token", "key", "oauth_token", "webapi_token"];
fn redact_url_params(url: &reqwest::Url) -> String {
if url.query().is_none() {
return url.to_string();
}
let has_sensitive = url
.query_pairs()
.any(|(k, _)| REDACTED_PARAMS.contains(&k.as_ref()));
if !has_sensitive {
return url.to_string();
}
let mut redacted = url.clone();
let filtered: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| {
if REDACTED_PARAMS.contains(&k.as_ref()) {
(k.into_owned(), "[REDACTED]".to_string())
} else {
(k.into_owned(), v.into_owned())
}
})
.collect();
let qs: String = filtered
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
redacted.set_query(Some(&qs));
redacted.to_string()
}
#[derive(Debug, serde::Deserialize)]
pub struct ClientJsToken {
pub logged_in: bool,
pub steamid: Option<String>,
pub account_name: Option<String>,
pub token: Option<String>,
}