1use std::{
2 collections::{HashMap, HashSet},
3 sync::Arc,
4};
5
6use chrono::{Local, NaiveDateTime};
7use reqwest::{header::*, Client};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio::sync::Mutex;
11use url::Url;
12
13use crate::{
14 error::SFError,
15 misc::sha1_hash,
16 session::{reqwest_client, ConnectionOptions, PWHash, Session},
17};
18
19#[derive(Debug)]
20#[allow(dead_code)]
21enum SSOAuthData {
22 SF { pw_hash: PWHash },
23 Google,
24 Steam,
25}
26
27#[derive(Debug)]
28pub struct SFAccount {
29 pub(super) username: String,
30 auth: SSOAuthData,
31 pub(super) session: AccountSession,
32 pub(super) client: Client,
33 pub(super) options: ConnectionOptions,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct AccountSession {
38 pub(super) uuid: String,
39 pub(super) bearer_token: String,
40}
41
42#[derive(Debug)]
45enum APIRequest {
46 Get,
47 Post {
48 parameters: Vec<&'static str>,
49 form_data: HashMap<String, String>,
50 },
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54pub struct SSOCharacter {
55 pub(super) id: String,
56 pub(super) name: String,
57 pub(super) server_id: i32,
58}
59impl SFAccount {
60 #[must_use]
62 pub fn username(&self) -> &str {
63 &self.username
64 }
65
66 pub async fn login(
74 username: String,
75 password: String,
76 ) -> Result<SFAccount, SFError> {
77 Self::login_with_options(
78 username,
79 password,
80 ConnectionOptions::default(),
81 )
82 .await
83 }
84
85 pub async fn login_with_options(
93 username: String,
94 password: String,
95 options: ConnectionOptions,
96 ) -> Result<SFAccount, SFError> {
97 let pw_hash = PWHash::new(&password);
98 Self::login_hashed_with_options(username, pw_hash, options).await
99 }
100
101 pub async fn login_hashed(
108 username: String,
109 pw_hash: PWHash,
110 ) -> Result<SFAccount, SFError> {
111 Self::login_hashed_with_options(
112 username,
113 pw_hash,
114 ConnectionOptions::default(),
115 )
116 .await
117 }
118
119 pub async fn login_hashed_with_options(
126 username: String,
127 pw_hash: PWHash,
128 options: ConnectionOptions,
129 ) -> Result<SFAccount, SFError> {
130 let mut tmp_self = Self {
131 username,
132 auth: SSOAuthData::SF { pw_hash },
133 session: AccountSession {
134 uuid: String::new(),
135 bearer_token: String::new(),
136 },
137 client: reqwest_client(&options).ok_or(SFError::ConnectionError)?,
138 options,
139 };
140
141 tmp_self.refresh_login().await?;
142 Ok(tmp_self)
143 }
144
145 pub async fn refresh_login(&mut self) -> Result<(), SFError> {
154 let SSOAuthData::SF { pw_hash } = &self.auth else {
155 return Err(SFError::InvalidRequest(
158 "Refreshing the SSO-login is only supported for SSO-Accounts",
159 ));
160 };
161
162 let mut form_data = HashMap::new();
163 form_data.insert("username".to_string(), self.username.clone());
164 form_data.insert(
165 "password".to_string(),
166 sha1_hash(&(pw_hash.get().to_string() + "0")),
167 );
168
169 let res = self
170 .send_api_request(
171 "json/login",
172 APIRequest::Post {
173 parameters: vec![
174 "client_id=i43nwwnmfc5tced4jtuk4auuygqghud2yopx",
175 "auth_type=access_token",
176 ],
177 form_data,
178 },
179 )
180 .await?;
181
182 #[allow(clippy::indexing_slicing)]
183 let (Some(bearer_token), Some(uuid)) = (
184 val_to_string(&res["token"]["access_token"]),
185 val_to_string(&res["account"]["uuid"]),
186 ) else {
187 return Err(SFError::ParsingError(
188 "missing auth value in api response",
189 format!("{res:?}"),
190 ));
191 };
192
193 self.session = AccountSession { uuid, bearer_token };
194
195 Ok(())
196 }
197
198 pub async fn characters(
210 self,
211 ) -> Result<Vec<Result<Session, SFError>>, SFError> {
212 let server_lookup =
216 ServerLookup::fetch_with_client(&self.client).await?;
217 let mut res = self
218 .send_api_request("json/client/characters", APIRequest::Get)
219 .await?;
220
221 #[allow(clippy::indexing_slicing)]
222 let characters: Vec<SSOCharacter> =
223 serde_json::from_value(res["characters"].take()).map_err(|_| {
224 SFError::ParsingError("missing json value ", String::new())
225 })?;
226
227 let account = Arc::new(Mutex::new(self));
228
229 let mut chars = vec![];
230 for char in characters {
231 chars.push(
232 Session::from_sso_char(char, account.clone(), &server_lookup)
233 .await,
234 );
235 }
236
237 Ok(chars)
238 }
239
240 async fn send_api_request(
241 &self,
242 endpoint: &str,
243 method: APIRequest,
244 ) -> Result<Value, SFError> {
245 send_api_request(
246 &self.client,
247 &self.session.bearer_token,
248 endpoint,
249 method,
250 )
251 .await
252 }
253}
254
255#[allow(clippy::items_after_statements)]
259async fn send_api_request(
260 client: &Client,
261 bearer_token: &str,
262 endpoint: &str,
263 method: APIRequest,
264) -> Result<Value, SFError> {
265 let mut url = url::Url::parse("https://sso.playa-games.com")
266 .map_err(|_| SFError::ConnectionError)?;
267 url.set_path(endpoint);
268
269 let mut request = match method {
270 APIRequest::Get => client.get(url.as_str()),
271 APIRequest::Post {
272 parameters,
273 form_data,
274 } => {
275 url.set_query(Some(¶meters.join("&")));
276 client.post(url.as_str()).form(&form_data)
277 }
278 };
279
280 if !bearer_token.is_empty() {
282 request = request.bearer_auth(bearer_token);
283 }
284 let mut headers = HeaderMap::new();
285 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
286 headers.insert(
287 REFERER,
288 HeaderValue::from_str(url.authority())
289 .map_err(|_| SFError::ConnectionError)?,
290 );
291
292 let res = request
293 .headers(headers)
294 .send()
295 .await
296 .map_err(|_| SFError::ConnectionError)?;
297 if !res.status().is_success() {
298 return Err(SFError::ConnectionError);
299 }
300 let text = res.text().await.map_err(|_| SFError::ConnectionError)?;
301
302 #[derive(Debug, Serialize, Deserialize)]
303 struct APIResponse {
304 success: bool,
305 status: u8,
306 data: Option<Value>,
307 message: Option<Value>,
308 }
309
310 let resp: APIResponse = serde_json::from_str(&text)
311 .map_err(|_| SFError::ParsingError("API response", text))?;
312
313 if !resp.success {
314 return Err(SFError::ConnectionError);
315 }
316 let data = match resp.data {
317 Some(data) => data,
318 None => match resp.message {
319 Some(message) => message,
320 None => return Err(SFError::ConnectionError),
321 },
322 };
323
324 Ok(data)
325}
326
327#[derive(Debug, Clone)]
328pub struct ServerLookup(HashMap<i32, Url>);
329
330impl ServerLookup {
331 pub async fn fetch() -> Result<ServerLookup, SFError> {
337 Self::fetch_with_client(&reqwest::Client::new()).await
338 }
339
340 #[allow(clippy::items_after_statements)]
342 async fn fetch_with_client(
343 client: &Client,
344 ) -> Result<ServerLookup, SFError> {
345 let res = client
346 .get("https://sfgame.net/config.json")
347 .send()
348 .await
349 .map_err(|_| SFError::ConnectionError)?
350 .text()
351 .await
352 .map_err(|_| SFError::ConnectionError)?;
353
354 #[derive(Debug, Deserialize, Serialize)]
355 struct ServerResp {
356 servers: Vec<ServerInfo>,
357 }
358
359 #[derive(Debug, Deserialize, Serialize)]
360 struct ServerInfo {
361 #[serde(rename = "i")]
362 id: i32,
363 #[serde(rename = "d")]
364 url: String,
365 #[serde(rename = "c")]
366 country_code: String,
367 #[serde(rename = "md")]
368 merged_into: Option<String>,
369 #[serde(rename = "m")]
370 merge_date_time: Option<String>,
371 }
372
373 let resp: ServerResp = serde_json::from_str(&res).map_err(|_| {
374 SFError::ParsingError("server response", res.to_string())
375 })?;
376
377 let servers: HashMap<i32, Url> = resp
378 .servers
379 .into_iter()
380 .filter_map(|s| {
381 let mut server_url = s.url;
382 if let Some(merged_url) = s.merged_into {
383 if let Some(mdt) = s.merge_date_time.and_then(|a| {
384 NaiveDateTime::parse_from_str(&a, "%Y-%m-%d %H:%M:%S")
385 .ok()
386 }) {
387 if Local::now().naive_utc() > mdt {
388 server_url = merged_url;
389 }
390 } else {
391 server_url = merged_url;
392 }
393 }
394
395 Some((s.id, format!("https://{server_url}").parse().ok()?))
396 })
397 .collect();
398 if servers.is_empty() {
399 return Err(SFError::ParsingError("empty server list", res));
400 }
401
402 Ok(ServerLookup(servers))
403 }
404
405 pub fn get(&self, server_id: i32) -> Result<Url, SFError> {
410 self.0
411 .get(&server_id)
412 .cloned()
413 .ok_or(SFError::InvalidRequest("There is no server with this id"))
414 }
415
416 #[must_use]
419 pub fn all(&self) -> HashSet<Url> {
420 self.0.iter().map(|a| a.1.clone()).collect()
421 }
422}
423
424#[derive(Debug)]
425pub enum AuthResponse {
426 Success(SFAccount),
427 NoAuth(SSOAuth),
428}
429
430fn val_to_string(val: &Value) -> Option<String> {
431 val.as_str().map(std::string::ToString::to_string)
432}
433
434#[derive(Debug)]
435pub struct SSOAuth {
436 client: Client,
437 options: ConnectionOptions,
438 auth_url: Url,
439 auth_id: String,
440 provider: SSOProvider,
441}
442
443#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
444pub enum SSOProvider {
445 Google,
446 Steam,
447}
448
449impl SSOProvider {
450 fn endpoint_ident(self) -> &'static str {
451 match self {
452 SSOProvider::Google => "googleauth",
453 SSOProvider::Steam => "steamauth",
454 }
455 }
456}
457
458impl SSOAuth {
459 async fn send_api_request(
460 &self,
461 endpoint: &str,
462 method: APIRequest,
463 ) -> Result<Value, SFError> {
464 send_api_request(&self.client, "", endpoint, method).await
465 }
466
467 #[must_use]
469 pub fn auth_url(&self) -> &Url {
470 &self.auth_url
471 }
472
473 #[allow(clippy::indexing_slicing)]
482 pub async fn try_login(self) -> Result<AuthResponse, SFError> {
483 let endpoint = format!(
484 "/json/sso/{}/check/{}",
485 self.provider.endpoint_ident(),
486 self.auth_id
487 );
488 let resp = self.send_api_request(&endpoint, APIRequest::Get).await?;
489
490 if let Some(message) = val_to_string(&resp) {
491 return match message.as_str() {
492 "SSO_POPUP_STATE_PROCESSING" => Ok(AuthResponse::NoAuth(self)),
493 _ => Err(SFError::ConnectionError),
494 };
495 }
496
497 let id_token =
498 val_to_string(&resp["id_token"]).ok_or(SFError::ConnectionError)?;
499
500 let mut form_data = HashMap::new();
501 form_data.insert("token".to_string(), id_token.clone());
502 form_data.insert("language".to_string(), "en".to_string());
503
504 let res = self
505 .send_api_request(
506 &format!("json/login/sso/{}", self.provider.endpoint_ident()),
507 APIRequest::Post {
508 parameters: vec![
509 "client_id=i43nwwnmfc5tced4jtuk4auuygqghud2yopx",
510 "auth_type=access_token",
511 ],
512 form_data,
513 },
514 )
515 .await?;
516
517 let access_token = val_to_string(&res["token"]["access_token"])
518 .ok_or(SFError::ConnectionError)?;
519 let uuid = val_to_string(&res["account"]["uuid"])
520 .ok_or(SFError::ConnectionError)?;
521 let username = val_to_string(&res["account"]["username"])
522 .ok_or(SFError::ConnectionError)?;
523
524 Ok(AuthResponse::Success(SFAccount {
525 username,
526 client: self.client,
527 session: AccountSession {
528 uuid,
529 bearer_token: access_token,
530 },
531 options: self.options,
532 auth: match self.provider {
533 SSOProvider::Google => SSOAuthData::Google,
534 SSOProvider::Steam => SSOAuthData::Steam,
535 },
536 }))
537 }
538
539 pub async fn new(provider: SSOProvider) -> Result<Self, SFError> {
548 Self::new_with_options(provider, ConnectionOptions::default()).await
549 }
550
551 #[allow(clippy::indexing_slicing)]
557 pub async fn new_with_options(
558 provider: SSOProvider,
559 options: ConnectionOptions,
560 ) -> Result<Self, SFError> {
561 let client =
562 reqwest_client(&options).ok_or(SFError::ConnectionError)?;
563
564 let resp = send_api_request(
565 &client,
566 "",
567 &format!("json/sso/{}", provider.endpoint_ident()),
568 APIRequest::Get,
569 )
570 .await?;
571
572 let auth_url = val_to_string(&resp["redirect"])
573 .and_then(|a| Url::parse(&a).ok())
574 .ok_or(SFError::ConnectionError)?;
575 let auth_id =
576 val_to_string(&resp["id"]).ok_or(SFError::ConnectionError)?;
577 Ok(Self {
578 client,
579 options,
580 auth_url,
581 auth_id,
582 provider,
583 })
584 }
585}