Skip to main content

steam_user/services/
web_api.rs

1//! Steam Web API key management.
2
3use scraper::{Html, Selector};
4use steamid::SteamID;
5
6use crate::{
7    client::SteamUser,
8    endpoint::steam_endpoint,
9    error::SteamUserError,
10    types::{OnlineState, PrivacyState, PublicProfileSummary, TradeBanState},
11};
12
13impl SteamUser {
14    /// Retrieves the current Steam Web API key, registering one if none exists.
15    ///
16    /// # Arguments
17    ///
18    /// * `domain` - The domain name to register (e.g., "localhost").
19    #[steam_endpoint(GET, host = Community, path = "/dev/apikey", kind = Read)]
20    pub async fn get_web_api_key(&self, domain: &str) -> Result<String, SteamUserError> {
21        let response = self.get_path("/dev/apikey").send().await?;
22        self.check_response(&response)?;
23        let html = response.text().await?;
24
25        if html.contains("You will be granted access to Steam Web API keys when you have games in your Steam account.") {
26            return Err(SteamUserError::LimitedAccount("Account needs games to register API key".into()));
27        }
28
29        let key = parse_web_api_key(&html);
30        if let Some(k) = key {
31            return Ok(k);
32        }
33
34        // Check if we need to register
35        let document = Html::parse_document(&html);
36        let form_selector = Selector::parse("form[action*='/dev/registerkey']").map_err(|_| SteamUserError::Other("Failed to parse selector".into()))?;
37
38        if document.select(&form_selector).next().is_some() {
39            // Register
40            let response = self.post_path("/dev/registerkey").form(&[("domain", domain), ("agreeToTerms", "agreed"), ("Submit", "Register")]).send().await?;
41            self.check_response(&response)?;
42
43            let html = response.text().await?;
44            // JS implementation recurses or re-parses. We just re-parse.
45            if let Some(k) = parse_web_api_key(&html) {
46                return Ok(k);
47            }
48
49            // Check for specific registration errors if possible, or generic fail
50            return Err(SteamUserError::SteamError("Failed to register API key".into()));
51        }
52
53        Err(SteamUserError::SteamError("Failed to retrieve or register API key".into()))
54    }
55
56    /// Revokes the current Steam Web API key for the authenticated user.
57    #[steam_endpoint(POST, host = Community, path = "/dev/revokekey", kind = Write)]
58    pub async fn revoke_web_api_key(&self) -> Result<(), SteamUserError> {
59        let response = self.post_path("/dev/revokekey").form(&[("Revoke", "Revoke My Steam Web API Key")]).send().await?;
60        self.check_response(&response)?;
61
62        // JS implementation parses the result to confirm revocation (should mean key is
63        // null) We can just check status or parse. JS returns the parse result.
64        // If we want to return success/fail, we can parse it.
65        let html = response.text().await?;
66        if parse_web_api_key(&html).is_none() {
67            Ok(())
68        } else {
69            Err(SteamUserError::SteamError("Failed to revoke API key".into()))
70        }
71    }
72
73    /// Resolves a vanity URL (e.g. "gabelogannewell") to a SteamID using the
74    /// Web API.
75    ///
76    /// # Arguments
77    ///
78    /// * `api_key` - A valid Steam Web API key.
79    /// * `vanity_url_name` - The vanity name to resolve.
80    #[steam_endpoint(GET, host = Api, path = "/ISteamUser/ResolveVanityURL/v0001/", kind = Read)]
81    pub async fn resolve_vanity_url(&self, api_key: &str, vanity_url_name: &str) -> Result<steamid::SteamID, SteamUserError> {
82        let params = [("key", api_key), ("vanityurl", vanity_url_name)];
83
84        let response = self.get_path("/ISteamUser/ResolveVanityURL/v0001/").query(&params).send().await?;
85        self.check_response(&response)?;
86
87        let text = response.text().await?;
88        let json: ResolveVanityUrlResponse = serde_json::from_str(&text).map_err(|e| SteamUserError::Other(format!("Failed to parse ResolveVanityURL response: {}", e)))?;
89
90        if json.response.success == 1 {
91            if let Some(steamid_str) = json.response.steamid {
92                return steamid_str.parse::<steamid::SteamID>().map_err(|e| SteamUserError::Other(format!("Failed to parse SteamID from response: {}", e)));
93            }
94        }
95
96        Err(SteamUserError::Other(json.response.message.unwrap_or_else(|| "Failed to resolve vanity URL".to_string())))
97    }
98
99    /// Resolves a vanity URL (e.g. "gabelogannewell") and returns a profile
100    /// snapshot, by scraping the public profile XML feed at
101    /// `https://steamcommunity.com/id/{vanity}/?xml=1`.
102    ///
103    /// Anonymous; no API key required. Subject to per-IP community-page rate
104    /// limits. For an authenticated / quota-managed alternative that returns
105    /// only the SteamID, see [`Self::resolve_vanity_url`].
106    ///
107    /// # Arguments
108    ///
109    /// * `vanity` - The vanity name to resolve (alphanumerics, `_`, `-`).
110    #[steam_endpoint(GET, host = Community, path = "/id/{vanity}/", kind = Read)]
111    pub async fn resolve_vanity_url_public(&self, vanity: &str) -> Result<PublicProfileSummary, SteamUserError> {
112        if vanity.is_empty() || !vanity.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
113            return Err(SteamUserError::InvalidInput(format!("invalid vanity slug: {vanity:?}")));
114        }
115        let path = format!("/id/{vanity}/?xml=1");
116        self.fetch_public_profile_xml(&path, vanity).await
117    }
118
119    /// Fetches a profile snapshot for a known SteamID via the public
120    /// `/profiles/{id}/?xml=1` feed. Anonymous; no API key required.
121    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
122    pub async fn fetch_public_profile_by_id(&self, steam_id: SteamID) -> Result<PublicProfileSummary, SteamUserError> {
123        let id_str = steam_id.to_string();
124        let path = format!("/profiles/{id_str}/?xml=1");
125        self.fetch_public_profile_xml(&path, &id_str).await
126    }
127
128    // generic helper — relies on caller's #[steam_endpoint] context
129    #[tracing::instrument(skip(self), fields(path = %path, identifier = %identifier))]
130    async fn fetch_public_profile_xml(&self, path: &str, identifier: &str) -> Result<PublicProfileSummary, SteamUserError> {
131        let response = self.get_path(path).send().await?;
132        self.check_response(&response)?;
133        let body = response.text().await?;
134        parse_public_profile_xml(&body).ok_or_else(|| SteamUserError::Other(format!("profile not found: {identifier}")))
135    }
136}
137
138/// Extracts the inner text of `<tag>...</tag>`, stripping `<![CDATA[...]]>`
139/// wrappers if present. Returns `None` if the tag is absent.
140fn extract_tag<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
141    let open = format!("<{tag}>");
142    let close = format!("</{tag}>");
143    let start = body.find(&open)? + open.len();
144    let end = body[start..].find(&close)?;
145    let inner = body[start..start + end].trim();
146    inner.strip_prefix("<![CDATA[").and_then(|s| s.strip_suffix("]]>")).map(str::trim).or(Some(inner))
147}
148
149/// Same as [`extract_tag`] but returns `None` for empty values, so optional
150/// fields don't surface as `Some("")`.
151fn extract_tag_opt<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
152    extract_tag(body, tag).filter(|s| !s.is_empty())
153}
154
155fn parse_public_profile_xml(body: &str) -> Option<PublicProfileSummary> {
156    // <steamID64> presence is the success discriminator: error responses
157    // return <response><error>...</error></response> with no profile tags.
158    let steam_id = extract_tag(body, "steamID64")?.parse::<SteamID>().ok()?;
159
160    // Steam emits both <privacyState> ("public"/"friendsonly"/"private") and
161    // <visibilityState> (1/2/3). They mean the same thing; trust the integer.
162    let privacy_state = extract_tag(body, "visibilityState").and_then(|s| s.parse::<i32>().ok()).map_or(PrivacyState::default(), PrivacyState::from);
163
164    Some(PublicProfileSummary {
165        steam_id,
166        persona_name: extract_tag(body, "steamID").unwrap_or("").to_owned(),
167        online_state: OnlineState::from_xml(extract_tag(body, "onlineState").unwrap_or("")),
168        state_message: extract_tag(body, "stateMessage").unwrap_or("").to_owned(),
169        privacy_state,
170        avatar_icon: extract_tag(body, "avatarIcon").unwrap_or("").to_owned(),
171        avatar_medium: extract_tag(body, "avatarMedium").unwrap_or("").to_owned(),
172        avatar_full: extract_tag(body, "avatarFull").unwrap_or("").to_owned(),
173        vac_banned: extract_tag(body, "vacBanned").is_some_and(|s| s == "1"),
174        trade_ban_state: TradeBanState::from_xml(extract_tag(body, "tradeBanState").unwrap_or("None")),
175        is_limited_account: extract_tag(body, "isLimitedAccount").is_some_and(|s| s == "1"),
176        custom_url: extract_tag_opt(body, "customURL").map(str::to_owned),
177        // Client pins l=english, so this parses as the U.S. long form. Steam
178        // reports day granularity; promote to midnight UTC so the value is
179        // unambiguous downstream.
180        member_since: extract_tag_opt(body, "memberSince").and_then(|s| chrono::NaiveDate::parse_from_str(s, "%B %d, %Y").ok()).and_then(|d| d.and_hms_opt(0, 0, 0)).map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)),
181        headline: extract_tag_opt(body, "headline").map(str::to_owned),
182        location: extract_tag_opt(body, "location").map(str::to_owned),
183        real_name: extract_tag_opt(body, "realname").map(str::to_owned),
184        summary: extract_tag_opt(body, "summary").map(str::to_owned),
185        hours_played_2wk: extract_tag_opt(body, "hoursPlayed2Wk").and_then(|s| s.parse().ok()),
186    })
187}
188
189/// Parses the Steam Web API Key from the developer page HTML.
190fn parse_web_api_key(html: &str) -> Option<String> {
191    if html.contains("Revoke My Steam Web API Key") {
192        let fragment = Html::parse_fragment(html);
193        let p_selector = Selector::parse("#bodyContents_ex > p").ok()?;
194
195        for element in fragment.select(&p_selector) {
196            let text = element.text().collect::<String>();
197            if text.contains("Key:") {
198                let parts: Vec<&str> = text.split("Key:").collect();
199                if parts.len() > 1 {
200                    return Some(parts[1].trim().to_string());
201                }
202            }
203        }
204    }
205    None
206}
207
208#[derive(serde::Deserialize)]
209struct ResolveVanityUrlResponse {
210    response: ResolveVanityUrlResponseInner,
211}
212
213#[derive(serde::Deserialize)]
214struct ResolveVanityUrlResponseInner {
215    steamid: Option<String>,
216    success: i32,
217    message: Option<String>,
218}