1use crate::{
2 error::{ShiftError, ShiftResult},
3 types::{
4 rewards::{AlertNotice, CodeRedemptionJson, CodeRedemptionPage, RewardForm, RewardsPage},
5 AccountPage, HomePage,
6 },
7};
8use scraper::Html;
9use std::{
10 sync::{Arc, RwLock},
11 time::Duration,
12};
13
14const HOME_URL: &str = "https://shift.gearboxsoftware.com/home";
15const SESSIONS_URL: &str = "https://shift.gearboxsoftware.com/sessions";
16const CODE_REDEMPTIONS_URL: &str = "https://shift.gearboxsoftware.com/code_redemptions";
17const REWARDS_URL: &str = "https://shift.gearboxsoftware.com/rewards";
18const ENTITLEMENT_OFFER_CODES_URL: &str =
19 "https://shift.gearboxsoftware.com/entitlement_offer_codes";
20
21#[derive(Clone)]
22pub struct Client {
23 client: reqwest::Client,
24 client_data: Arc<RwLock<ClientData>>,
25}
26
27impl Client {
28 pub fn new(email: String, password: String) -> Self {
30 Self {
31 client: reqwest::Client::builder()
32 .cookie_store(true)
33 .build()
34 .expect("failed to build reqwest client"),
35
36 client_data: Arc::new(RwLock::new(ClientData { email, password })),
37 }
38 }
39
40 async fn get_home_page(&self) -> ShiftResult<HomePage> {
42 let res = self.client.get(HOME_URL).send().await?;
43 let home_page = res_to_html_transform(res, |html| Ok(HomePage::from_html(&html)?)).await?;
44 Ok(home_page)
45 }
46
47 pub async fn login(&self) -> ShiftResult<AccountPage> {
49 let home_page = self.get_home_page().await?;
50
51 let req = {
52 let lock = self.client_data.read().expect("client data poisoned");
53 self.client.post(SESSIONS_URL).form(&[
54 ("utf8", "✓"),
55 ("authenticity_token", &home_page.csrf_token),
56 ("user[email]", &lock.email),
57 ("user[password]", &lock.password),
58 ("commit", "SIGN IN"),
59 ])
60 };
61 let res = req.send().await?;
62
63 match res.url().as_str() {
64 "https://shift.gearboxsoftware.com/home?redirect_to=false" => {
65 return Err(ShiftError::IncorrectEmailOrPassword);
66 }
67 "https://shift.gearboxsoftware.com/account" => {}
68 url => {
69 return Err(ShiftError::InvalidRedirect(url.into()));
70 }
71 }
72
73 let account_page =
74 res_to_html_transform(res, |html| Ok(AccountPage::from_html(&html)?)).await?;
75 Ok(account_page)
76 }
77
78 pub async fn get_rewards_page(&self) -> ShiftResult<RewardsPage> {
80 let res = self.client.get(REWARDS_URL).send().await?;
81 let page = res_to_html_transform(res, |html| Ok(RewardsPage::from_html(&html)?)).await?;
82 Ok(page)
83 }
84
85 pub async fn get_reward_forms(
86 &self,
87 rewards_page: &RewardsPage,
88 code: &str,
89 ) -> ShiftResult<Vec<RewardForm>> {
90 let res = self
91 .client
92 .get(ENTITLEMENT_OFFER_CODES_URL)
93 .query(&[("code", code)])
94 .header("X-CSRF-Token", &rewards_page.csrf_token)
95 .header("X-Requested-With", "XMLHttpRequest")
96 .send()
97 .await?
98 .error_for_status()?;
99
100 let body = res.text().await?;
101
102 match body.as_str().trim() {
103 "This SHiFT code has expired" => return Err(ShiftError::ExpiredShiftCode),
104 "This SHiFT code does not exist" => return Err(ShiftError::NonExistentShiftCode),
105 "This code is not available for your account" => {
106 return Err(ShiftError::UnavailableShiftCode)
107 }
108 _ => {}
109 }
110
111 let forms = tokio::task::spawn_blocking(move || {
112 let html = Html::parse_document(body.as_str());
113 RewardForm::from_html(&html)
114 })
115 .await??;
116
117 Ok(forms)
118 }
119
120 pub async fn redeem(&self, form: &RewardForm) -> ShiftResult<Option<CodeRedemptionJson>> {
122 let res = self
123 .client
124 .post(CODE_REDEMPTIONS_URL)
125 .form(&form)
126 .send()
127 .await?;
128
129 let url = res.url().as_str();
130 if url.starts_with(REWARDS_URL) {
131 let page =
132 res_to_html_transform(res, |html| Ok(RewardsPage::from_html(&html)?)).await?;
133 let alert_notice = page.alert_notice.ok_or(ShiftError::MissingAlertNotice)?;
134 match alert_notice {
135 AlertNotice::ShiftCodeAlreadyRedeemed => {
136 return Err(ShiftError::ShiftCodeAlreadyRedeemed);
137 }
138 AlertNotice::LaunchShiftGame => {
139 return Err(ShiftError::LaunchShiftGame);
140 }
141 AlertNotice::ShiftCodeRedeemed => {
142 return Ok(None);
143 }
144 AlertNotice::ShiftCodeRedeemFail => {
145 return Err(ShiftError::ShiftCodeRedeemFail);
146 }
147 }
148 }
149
150 if !url.starts_with(CODE_REDEMPTIONS_URL) {
151 return Err(ShiftError::InvalidRedirect(url.into()));
152 }
153
154 let page =
155 res_to_html_transform(res, |html| Ok(CodeRedemptionPage::from_html(&html)?)).await?;
156
157 let res = loop {
158 let json: CodeRedemptionJson = self
159 .client
160 .get(&page.check_redemption_status_url)
161 .header("X-CSRF-Token", &page.csrf_token)
162 .header("X-Requested-With", "XMLHttpRequest")
163 .send()
164 .await?
165 .error_for_status()?
166 .json()
167 .await?;
168
169 tokio::time::sleep(Duration::from_secs(2)).await;
170
171 if !json.in_progress() {
172 break json;
173 }
174 };
175
176 Ok(Some(res))
177 }
178}
179
180struct ClientData {
182 email: String,
183 password: String,
184}
185
186async fn res_to_html_transform<F, T>(res: reqwest::Response, f: F) -> ShiftResult<T>
188where
189 F: Fn(Html) -> ShiftResult<T> + Send + 'static,
190 T: Send + 'static,
191{
192 let text = res.error_for_status()?.text().await?;
193 let ret = tokio::task::spawn_blocking(move || f(Html::parse_document(text.as_str()))).await??;
194 Ok(ret)
195}