Skip to main content

roblox_api/
validation.rs

1use base64::{Engine, prelude::BASE64_STANDARD};
2use reqwest::{Response, header::HeaderValue};
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    ApiError, Currency, Error,
7    challenge::{
8        CHALLENGE_ID_HEADER, CHALLENGE_METADATA_HEADER, CHALLENGE_TYPE_HEADER, Challenge,
9        ChallengeMetadata, ChallengeType, ChefChallengeMetadata,
10    },
11    client::ClientRequestor,
12    ratelimit::{
13        RATELIMIT_LIMIT_HEADER, RATELIMIT_REMAINING_HEADER, RATELIMIT_RESET_HEADER, Ratelimit,
14    },
15};
16
17const TOKEN_HEADER: &str = "x-csrf-token";
18
19#[derive(Debug, Deserialize, Serialize)]
20pub struct ErrorJson {
21    //code: u8,
22    message: String,
23}
24
25#[derive(Debug, Deserialize, Serialize)]
26pub struct ErrorsJson {
27    errors: Vec<ErrorJson>,
28}
29
30#[derive(Debug, Deserialize, Serialize)]
31pub struct DataErrorJson {
32    //#[serde(rename = "isValid")]
33    //is_valid: bool,
34    //data: Option<String>, // maybe, i always got null,
35    #[serde(rename = "error")]
36    message: String,
37}
38
39fn ratelimit_from_headers(
40    limit: Option<&HeaderValue>,
41    reset: Option<&HeaderValue>,
42    remaining: Option<&HeaderValue>,
43) -> Option<Ratelimit> {
44    if let (Some(_limit), Some(reset), Some(remaining)) = (limit, reset, remaining) {
45        let remaining = remaining.to_str().unwrap().parse::<u32>().unwrap();
46        let reset_in_seconds = reset.to_str().unwrap().parse::<u32>().unwrap();
47
48        Some(Ratelimit {
49            remaining,
50            reset_in_seconds,
51            // TODO: honestly i don't know how to parse this
52            windows: Vec::new(),
53        })
54    } else {
55        None
56    }
57}
58
59fn challenge_from_headers(
60    id: Option<HeaderValue>,
61    kind: Option<HeaderValue>,
62    metadata_b64: Option<HeaderValue>,
63) -> Option<Challenge> {
64    if let (Some(id), Some(kind), Some(metadata_b64)) = (id, kind, metadata_b64) {
65        let kind = ChallengeType::from(kind.to_str().unwrap());
66        match kind {
67            ChallengeType::Chef => {
68                let _metadata: ChefChallengeMetadata = serde_json::from_slice(
69                    BASE64_STANDARD
70                        .decode(metadata_b64.to_str().unwrap())
71                        .unwrap()
72                        .as_slice(),
73                )
74                .unwrap();
75
76                todo!("Unsupported challenge-type: \"chef\"");
77            }
78
79            ChallengeType::Captcha => {
80                todo!("Unsupported challenge-type: \"captcha\"")
81            }
82
83            _ => {
84                let metadata: ChallengeMetadata = serde_json::from_slice(
85                    BASE64_STANDARD
86                        .decode(metadata_b64.to_str().unwrap())
87                        .unwrap()
88                        .as_slice(),
89                )
90                .unwrap();
91
92                Some(Challenge {
93                    id: id.to_str().unwrap().to_string(),
94                    kind,
95                    metadata,
96                })
97            }
98        }
99    } else {
100        None
101    }
102}
103
104impl ClientRequestor {
105    fn set_token(&mut self, token: &str) {
106        self.default_headers
107            .insert(TOKEN_HEADER, HeaderValue::from_str(token).unwrap());
108    }
109
110    pub(crate) async fn validate_response(
111        &mut self,
112        result: Result<Response, reqwest::Error>,
113    ) -> Result<Response, Error> {
114        // remove all challenge headers after validation
115        self.remove_challenge();
116
117        match result {
118            Ok(response) => {
119                let code = response.status().as_u16();
120
121                let token = response.headers().get(TOKEN_HEADER);
122                if let Some(token) = token {
123                    // EVERYTHING must be mutable to do this, perhaps there's another datatype we can use
124                    self.set_token(String::from_utf8_lossy(token.as_bytes()).as_ref());
125                }
126
127                {
128                    let limit = response.headers().get(RATELIMIT_LIMIT_HEADER);
129                    let reset = response.headers().get(RATELIMIT_RESET_HEADER);
130                    let remaining = response.headers().get(RATELIMIT_REMAINING_HEADER);
131
132                    self.ratelimit = ratelimit_from_headers(limit, reset, remaining);
133                }
134
135                // TODO: some apis like the data api can return an error even with status_code 200
136                if code == 200 {
137                    return Ok(response);
138                }
139
140                let challenge_id = response.headers().get(CHALLENGE_ID_HEADER).cloned();
141                let challenge_type = response.headers().get(CHALLENGE_TYPE_HEADER).cloned();
142                let challenge_metadata_b64 =
143                    response.headers().get(CHALLENGE_METADATA_HEADER).cloned();
144
145                let bytes = response.bytes().await.unwrap().to_owned();
146                let errors = if let Ok(errors) = serde_json::from_slice::<ErrorsJson>(&bytes) {
147                    errors
148                } else if let Ok(error) = serde_json::from_slice::<ErrorJson>(&bytes) {
149                    ErrorsJson {
150                        errors: vec![error],
151                    }
152                } else if let Ok(error) = serde_json::from_slice::<DataErrorJson>(&bytes) {
153                    ErrorsJson {
154                        errors: vec![ErrorJson {
155                            //code: 0,
156                            message: error.message,
157                        }],
158                    }
159                } else {
160                    ErrorsJson {
161                        errors: vec![ErrorJson {
162                            //code: 0,
163                            message: String::from_utf8_lossy(&bytes).to_string(),
164                        }],
165                    }
166                };
167
168                match code {
169                    401 => Err(Error::ApiError(ApiError::Unauthorized)),
170                    429 => Err(Error::ApiError(ApiError::Ratelimited)),
171                    500 => Err(Error::ApiError(ApiError::Internal)),
172                    _ => {
173                        let errors: Vec<ApiError> = errors
174                            .errors
175                            .iter()
176                            .map(|x| match x.message.as_str() {
177                                // 400 
178                                "The asset id is invalid." => ApiError::InvalidAssetId,
179                                "Invalid challenge ID." => ApiError::InvalidChallengeId,
180
181                                "User not found." => ApiError::InvalidUser,
182                                "The user is invalid or does not exist." => ApiError::InvalidUser,
183                                "The target user is invalid or does not exist." => ApiError::InvalidUser,
184                                "The user ID is invalid." => ApiError::InvalidUserId,
185
186                                "The gender provided is invalid." => ApiError::InvalidGender,
187                                "The two step verification challenge code is invalid." => {
188                                    ApiError::InvalidTwoStepVerificationCode
189                                }
190
191                                "Invalid display name." => ApiError::InvalidDisplayName,
192
193                                "Request must contain a birthdate" => {
194                                    ApiError::RequestMissingArgument("Birthdate".to_string())
195                                }
196
197                                "Ascending sort order is not supported for user's favorite games." => {
198                                    ApiError::UnsupportedSortOrder
199                                }
200
201                                // 403
202                                "Token Validation Failed"
203                                | "XSRF token invalid"
204                                | "XSRF Token Validation Failed"
205                                | "\"XSRF Token Validation Failed\"" => ApiError::TokenValidation,
206
207                                "Not authorized." => ApiError::Unauthorized,
208
209                                "Incorrect username or password. Please try again." => {
210                                    ApiError::InvalidCredentials
211                                }
212
213                                "You must pass the robot test before logging in." => {
214                                    ApiError::CaptchaFailed
215                                }
216
217                                "Account has been locked. Please request a password reset." => {
218                                    ApiError::AccontLocked
219                                }
220
221                                "Unable to login. Please use Social Network sign on." => {
222                                    ApiError::SocialNetworkLoginRequired
223                                }
224
225                                "Account issue. Please contact Support." => {
226                                    ApiError::AccountIssue
227                                }
228
229                                "Unable to login with provided credentials. Default login is required." => {
230                                    ApiError::DefaultLoginRequired
231                                }
232
233                                "Received credentials are unverified." => {
234                                    ApiError::UnverifiedCredentials
235                                }
236
237                                "Existing login session found. Please log out first." => {
238                                    ApiError::ExistingLoginSession
239                                }
240
241                                "The account is unable to log in. Please log in to the LuoBu app." => {
242                                    ApiError::LuoBuAppLoginRequired
243                                }
244
245                                "Too many attempts. Please wait a bit." => {
246                                    ApiError::Ratelimited
247                                }
248
249                                "The account is unable to login. Please log in with the VNG app." => {
250                                    ApiError::VNGAppLoginRequired
251                                }
252
253                                "PIN is locked." => ApiError::PinIsLocked,
254
255                                "Invalid birthdate change." => ApiError::InvalidBirthdate,
256
257                                "Insufficient Robux funds." => ApiError::NotEnoughFunds(Currency::Robux),
258
259                                // TODO: not sure what this means please use more verbose todo messages
260                                // TODO: add missing challenge duplicate code
261
262                                "Challenge is required to authorize the request" => {
263                                    let challenge = challenge_from_headers(
264                                        challenge_id.clone(),
265                                        challenge_type.clone(),
266                                        challenge_metadata_b64.clone(),
267                                    );
268                                    ApiError::ChallengeRequired(challenge.unwrap())
269                                }
270
271                                "Challenge failed to authorize request" => {
272                                    ApiError::ChallengeFailed
273                                }
274
275                                "You do not have permission to view the owners of this asset." => {
276                                    ApiError::PermissionError
277                                }
278
279                                "Request Context BrowserTrackerID is missing or invalid." => {
280                                    ApiError::InvalidBrowserTrackerId
281                                }
282
283                                "Attempt to add non-friends to a conversation" => ApiError::ConversationUserAddFailed,
284                                "Attempt to create conversations with non-friends" => ApiError::ConversationCreationFailed,
285                                "{\"Error\":\"OneToOne conversations cannot be updated\"}" => ApiError::InvalidConversation,
286
287                                "an internal error occurred" => ApiError::Internal,
288
289                                // 404
290                                "Badge is invalid or does not exist." => ApiError::InvalidBadge,
291
292
293                                // 409
294                                "You are already a member of this group." => ApiError::AlreadyInGroup,
295                                "You have already requested to join this group." => ApiError::AlreadyInGroupRequests,
296
297                                _ => {
298                                    ApiError::Unknown(code, Some(x.message.to_owned()))
299                                },
300                            }).collect();
301
302                        if errors.len() == 1 {
303                            Err(Error::ApiError(errors.first().unwrap().clone()))
304                        } else {
305                            Err(Error::ApiError(ApiError::Multiple(errors)))
306                        }
307                    }
308                }
309            }
310
311            Err(error) => Err(Error::ReqwestError(error)),
312        }
313    }
314}