1use std::{future::Future, sync::OnceLock, time::Duration};
9
10use regex::Regex;
11use scraper::{Html, Selector};
12
13static SEL_ACCOUNT_BLOCK: OnceLock<Selector> = OnceLock::new();
14fn sel_account_block() -> &'static Selector {
15 SEL_ACCOUNT_BLOCK.get_or_init(|| Selector::parse(".account_setting_block").expect("valid CSS selector"))
16}
17
18static SEL_ACCOUNT_LABEL: OnceLock<Selector> = OnceLock::new();
19fn sel_account_label() -> &'static Selector {
20 SEL_ACCOUNT_LABEL.get_or_init(|| Selector::parse(".account_manage_label").expect("valid CSS selector"))
21}
22
23static SEL_ACCOUNT_FIELD: OnceLock<Selector> = OnceLock::new();
24fn sel_account_field() -> &'static Selector {
25 SEL_ACCOUNT_FIELD.get_or_init(|| Selector::parse(".account_data_field").expect("valid CSS selector"))
26}
27
28static SEL_CLIENT_CONN_MACHINE: OnceLock<Selector> = OnceLock::new();
29fn sel_client_conn_machine() -> &'static Selector {
30 SEL_CLIENT_CONN_MACHINE.get_or_init(|| Selector::parse(".clientConnMachineText").expect("valid CSS selector"))
31}
32
33static SEL_HELP_WIZARD_BUTTON: OnceLock<Selector> = OnceLock::new();
34fn sel_help_wizard_button() -> &'static Selector {
35 SEL_HELP_WIZARD_BUTTON.get_or_init(|| Selector::parse("a.help_wizard_button").expect("valid CSS selector"))
36}
37
38static SEL_FORGOT_LOGIN_FORM: OnceLock<Selector> = OnceLock::new();
39fn sel_forgot_login_form() -> &'static Selector {
40 SEL_FORGOT_LOGIN_FORM.get_or_init(|| Selector::parse("#forgot_login_code_form").expect("valid CSS selector"))
41}
42
43static RE_SESSION_ID: OnceLock<Regex> = OnceLock::new();
44fn re_session_id() -> &'static Regex {
45 RE_SESSION_ID.get_or_init(|| Regex::new(r#"var g_sessionID = "([^"]+)";"#).expect("valid regex"))
46}
47
48static RE_WIZARD_PARAMS: OnceLock<Regex> = OnceLock::new();
49fn re_wizard_params() -> &'static Regex {
50 RE_WIZARD_PARAMS.get_or_init(|| Regex::new(r"g_rgDefaultWizardPageParams = (\{.*?\});").expect("valid regex"))
51}
52
53use crate::{
54 client::SteamUser,
55 endpoint::{steam_endpoint, Host},
56 error::SteamUserError,
57 types::{AccountRecoveryStatus, ChangeEmailResult, ConfirmEmailResponse, SendRecoveryCodeResponse, SubmitEmailResponse, WizardDefaultParams, WizardIssue, WizardPageParams},
58};
59
60impl SteamUser {
61 #[steam_endpoint(GET, host = Store, path = "/account/", kind = Read)]
85 pub async fn get_account_email(&self) -> Result<String, SteamUserError> {
86 let response = self.get_path("/account/").send().await?.text().await?;
87
88 let document = Html::parse_document(&response);
89
90 for block in document.select(sel_account_block()) {
91 if let Some(label) = block.select(sel_account_label()).next() {
93 let label_text = label.text().collect::<String>();
94 if label_text.trim() == "Email address:" {
95 if let Some(field) = block.select(sel_account_field()).next() {
97 return Ok(field.text().collect::<String>().trim().to_string());
98 }
99 }
100 }
101 }
102
103 Ok(String::new())
104 }
105
106 #[steam_endpoint(GET, host = Community, path = "/my/games/", kind = Read)]
130 pub async fn get_current_steam_login(&self) -> Result<String, SteamUserError> {
131 let response = self.get_path("/my/games/?tab=all").send().await?.text().await?;
132
133 let document = Html::parse_document(&response);
134
135 if let Some(el) = document.select(sel_client_conn_machine()).next() {
136 let text = el.text().collect::<String>();
137 if let Some(pos) = text.rfind('|') {
139 return Ok(text[..pos].trim().to_string());
140 }
141 return Ok(text.trim().to_string());
142 }
143
144 Ok(String::new())
145 }
146
147 #[tracing::instrument(skip(self, identity_secret, get_email_otp, new_email))]
199 pub async fn change_email<F, Fut>(&self, new_email: &str, identity_secret: &str, get_email_otp: F) -> Result<ChangeEmailResult, SteamUserError>
200 where
201 F: Fn() -> Fut,
202 Fut: Future<Output = Option<Vec<String>>>,
203 {
204 let account = self.get_miniprofile_id();
205
206 let help_link = match self.get_email_help_link().await? {
208 Some(link) => link,
209 None => return Ok(ChangeEmailResult::Error("Can't get help link".into())),
210 };
211
212 let wizard_params = match self.send_email_app_confirmation(&help_link).await? {
214 Some(params) => params,
215 None => return Ok(ChangeEmailResult::Error("Can't send app confirmation".into())),
216 };
217
218 let issue = &wizard_params.issue;
219 let default_params = &wizard_params.default_params;
220
221 let enter_code_path = format!(
223 "/en/wizard/HelpWithLoginInfoEnterCode?s={}&account={}&reset={}&lost={}&issueid={}&wizard_ajax=1&gamepad=0",
224 urlencoding::encode(&issue.s),
225 account,
226 urlencoding::encode(&issue.reset),
227 urlencoding::encode(&issue.lost),
228 urlencoding::encode(&issue.issueid),
229 );
230 let _ = self.get_path_on(Host::Help, &enter_code_path).send().await;
231
232 if !self.send_email_recovery_code(issue, default_params, &help_link).await? {
234 return Ok(ChangeEmailResult::Error("Can't send app recovery code".into()));
235 }
236
237 tokio::time::sleep(Duration::from_millis(1000)).await;
239
240 let confirmations = self.get_confirmations(identity_secret, None).await;
241 let confirmations = match confirmations {
242 Ok(c) => c,
243 Err(e) => {
244 tracing::warn!(error = %e, "change_email: first get_confirmations failed; retrying after 2s");
245 tokio::time::sleep(Duration::from_millis(2000)).await;
246 self.get_confirmations(identity_secret, None).await?
247 }
248 };
249
250 if confirmations.is_empty() {
251 return Ok(ChangeEmailResult::Error("Can't get app recovery code".into()));
252 }
253
254 for confirmation in &confirmations {
255 let creator_id = confirmation.creator.parse::<u64>().map_err(|_| SteamUserError::InvalidInput(format!("Invalid confirmation creator ID: {:?}", confirmation.creator)))?;
256 self.accept_confirmation_for_object(identity_secret, creator_id).await?;
257 }
258
259 let mut checking_ok = AccountRecoveryStatus { r#continue: true, success: false, error: None };
261
262 for _ in 0..10 {
263 checking_ok = self.poll_account_recovery_confirmation(issue, default_params, &help_link).await?;
264
265 if checking_ok.r#continue {
266 tokio::time::sleep(Duration::from_millis(5000)).await;
267 } else {
268 break;
269 }
270 }
271
272 if !checking_ok.success {
273 return Ok(ChangeEmailResult::Error("Can't confirm app recovery code".into()));
274 }
275
276 let reset_path = format!(
278 "/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
279 urlencoding::encode(&issue.s),
280 account,
281 urlencoding::encode(&issue.reset),
282 urlencoding::encode(&issue.issueid),
283 );
284 let _ = self.get_path_on(Host::Help, &reset_path).send().await;
285
286 let submit_result = self.submit_new_email(issue, default_params, account, new_email).await?;
288
289 if !submit_result.error_msg.is_empty() {
290 return Ok(ChangeEmailResult::Error(format!("submitNewEmail Failed: {}", submit_result.error_msg)));
291 }
292
293 for _ in 0..5 {
295 if let Some(codes) = get_email_otp().await {
296 for code in codes {
297 let confirm_result = self.confirm_new_email(issue, default_params, account, new_email, &code).await?;
298
299 if confirm_result.hash.contains("HelpWithLoginInfoComplete") {
300 return Ok(ChangeEmailResult::Success);
301 }
302
303 tokio::time::sleep(Duration::from_millis(1000)).await;
304 }
305 } else {
306 tokio::time::sleep(Duration::from_millis(5000)).await;
307 }
308 }
309
310 Ok(ChangeEmailResult::Error("Can't confirm new email code".into()))
311 }
312
313 #[steam_endpoint(GET, host = Help, path = "/en/wizard/HelpChangeEmail", kind = Recovery)]
315 async fn get_email_help_link(&self) -> Result<Option<String>, SteamUserError> {
316 let response = self.get_path("/en/wizard/HelpChangeEmail?redir=store/account/").send().await?.text().await?;
317
318 let document = Html::parse_document(&response);
319
320 for button in document.select(sel_help_wizard_button()) {
321 let text = button.text().collect::<String>();
322 if text.trim() == "Send a confirmation to my Steam Mobile app" {
323 return Ok(button.value().attr("href").map(|s| s.to_string()));
324 }
325 }
326
327 Ok(None)
328 }
329
330 #[tracing::instrument(skip(self, help_link))]
333 async fn send_email_app_confirmation(&self, help_link: &str) -> Result<Option<WizardPageParams>, SteamUserError> {
334 let help_path = help_link.strip_prefix("https://help.steampowered.com").or_else(|| help_link.strip_prefix("http://help.steampowered.com")).unwrap_or(help_link);
338 let response = self.get_path_on(Host::Help, help_path).send().await?.text().await?;
339
340 if !response.contains("For security, verify that the code in the box below matches the code we display on the confirmations page.") {
342 return Ok(None);
343 }
344
345 Ok(Self::parse_wizard_page_params(&response))
346 }
347
348 fn parse_wizard_page_params(html: &str) -> Option<WizardPageParams> {
350 let document = Html::parse_document(html);
351
352 let form = document.select(sel_forgot_login_form()).next()?;
353
354 let get_input_value = |name: &str| -> String {
355 let selector = Selector::parse(&format!("input[name=\"{}\"]", name)).expect("valid CSS selector");
356 form.select(&selector).next().and_then(|el| el.value().attr("value")).unwrap_or("").to_string()
357 };
358
359 let issue = WizardIssue {
360 s: get_input_value("s"),
361 reset: get_input_value("reset"),
362 lost: get_input_value("lost"),
363 method: get_input_value("method"),
364 issueid: get_input_value("issueid"),
365 };
366
367 let session_id = re_session_id().captures(html).map(|c| c[1].to_string()).unwrap_or_default();
369
370 let default_params = re_wizard_params().captures(html).and_then(|c| serde_json::from_str::<WizardDefaultParams>(&c[1]).ok()).unwrap_or_default();
372
373 Some(WizardPageParams { session_id, issue, default_params })
374 }
375
376 #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxSendAccountRecoveryCode", kind = Recovery)]
378 async fn send_email_recovery_code(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<bool, SteamUserError> {
379 let params = Self::merge_params(default_params, &[("s", &issue.s), ("method", &issue.method), ("link", "")]);
380
381 let response: SendRecoveryCodeResponse = self.post_path("/en/wizard/AjaxSendAccountRecoveryCode").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(¶ms).send().await?.json().await?;
382
383 Ok(response.success)
384 }
385
386 #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxPollAccountRecoveryConfirmation", kind = Recovery)]
388 async fn poll_account_recovery_confirmation(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<AccountRecoveryStatus, SteamUserError> {
389 let params = Self::merge_params(default_params, &[("s", &issue.s), ("reset", &issue.reset), ("lost", &issue.lost), ("method", &issue.method), ("issueid", &issue.issueid)]);
390
391 let response: AccountRecoveryStatus = self.post_path("/en/wizard/AjaxPollAccountRecoveryConfirmation").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(¶ms).send().await?.json().await?;
392
393 Ok(response)
394 }
395
396 #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryChangeEmail/", kind = Recovery)]
398 async fn submit_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str) -> Result<SubmitEmailResponse, SteamUserError> {
399 let referer = format!(
400 "https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
401 urlencoding::encode(&issue.s),
402 account,
403 urlencoding::encode(&issue.reset),
404 urlencoding::encode(&issue.issueid),
405 );
406
407 let account_str = account.to_string();
408 let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email)]);
409
410 let response: SubmitEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(¶ms).send().await?.json().await?;
411
412 Ok(response)
413 }
414
415 #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/", kind = Recovery)]
417 async fn confirm_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str, code: &str) -> Result<ConfirmEmailResponse, SteamUserError> {
418 let referer = format!(
419 "https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
420 urlencoding::encode(&issue.s),
421 account,
422 urlencoding::encode(&issue.reset),
423 urlencoding::encode(&issue.issueid),
424 );
425
426 let account_str = account.to_string();
427 let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email), ("email_change_code", code)]);
428
429 let response: ConfirmEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(¶ms).send().await?.json().await?;
430
431 Ok(response)
432 }
433
434 fn merge_params(default_params: &WizardDefaultParams, specific_params: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
436 let mut map = default_params.extra.clone();
437 if let Some(acc) = default_params.account {
438 map.insert("account".to_string(), acc.to_string());
439 }
440 if let Some(wiz) = &default_params.wizard {
441 map.insert("wizard".to_string(), wiz.clone());
442 }
443 for (k, v) in specific_params {
444 map.insert(k.to_string(), v.to_string());
445 }
446 map
447 }
448
449 fn get_miniprofile_id(&self) -> u32 {
451 self.steam_id().map(|id| id.account_id).unwrap_or(0)
452 }
453}