shift_client/
client.rs

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    /// Make a new shift client, not logged in
29    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    /// Get the home page. Does not need authentication.
41    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    /// Logs in and allows making other requests
48    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    /// Get the [`RewardsPage`]
79    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    /// Redeem a code
121    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
180/// Client data
181struct ClientData {
182    email: String,
183    password: String,
184}
185
186/// Convert a response to html, then feed it to the given transform function
187async 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}