1use std::{borrow::Borrow, fmt::Debug, str::FromStr, time::Duration};
2
3use base64::Engine;
4use log::{error, trace, warn};
5use reqwest::{Client, header::*};
6use url::Url;
7
8use crate::{
9 command::Command,
10 error::SFError,
11 gamestate::{
12 GameState,
13 character::{Class, Gender, Race},
14 },
15 misc::{
16 DEFAULT_CRYPTO_ID, DEFAULT_CRYPTO_KEY, DEFAULT_SESSION_ID, HASH_CONST,
17 sha1_hash,
18 },
19};
20#[allow(deprecated)]
21pub use crate::{misc::decrypt_url, response::*};
22
23#[derive(Debug, Clone)]
24#[allow(clippy::struct_field_names)]
25pub struct Session {
27 login_data: LoginData,
29 server_url: url::Url,
31 session_id: String,
34 player_id: u32,
36 login_count: u32,
37 crypto_id: String,
38 crypto_key: String,
39 client: reqwest::Client,
43 options: ConnectionOptions,
44}
45
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct PWHash(String);
50
51impl PWHash {
52 #[must_use]
55 pub fn new(password: &str) -> Self {
56 Self(sha1_hash(&(password.to_string() + HASH_CONST)))
57 }
58 #[must_use]
61 pub fn from_hash(hash: String) -> Self {
62 Self(hash)
63 }
64
65 #[must_use]
67 pub fn get(&self) -> &str {
68 &self.0
69 }
70}
71
72impl Session {
73 #[must_use]
78 pub fn new(
79 username: &str,
80 password: &str,
81 server: ServerConnection,
82 ) -> Self {
83 Self::new_hashed(username, PWHash::new(password), server)
84 }
85
86 #[must_use]
88 pub fn new_hashed(
89 username: &str,
90 pw_hash: PWHash,
91 server: ServerConnection,
92 ) -> Self {
93 let ld = LoginData::Basic {
94 username: username.to_string(),
95 pw_hash,
96 };
97 Self::new_full(ld, server.client, server.options, server.url)
98 }
99
100 fn new_full(
101 ld: LoginData,
102 client: Client,
103 options: ConnectionOptions,
104 url: Url,
105 ) -> Self {
106 Self {
107 login_data: ld,
108 server_url: url,
109 client,
110 session_id: DEFAULT_SESSION_ID.to_string(),
111 crypto_id: DEFAULT_CRYPTO_ID.to_string(),
112 crypto_key: DEFAULT_CRYPTO_KEY.to_string(),
113 login_count: 1,
114 options,
115 player_id: 0,
116 }
117 }
118
119 fn logout(&mut self) {
123 self.crypto_key = DEFAULT_CRYPTO_KEY.to_string();
124 self.crypto_id = DEFAULT_CRYPTO_ID.to_string();
125 self.login_count = 1;
126 self.session_id = DEFAULT_SESSION_ID.to_string();
127 self.player_id = 0;
128 }
129
130 #[must_use]
133 pub fn server_url(&self) -> &url::Url {
134 &self.server_url
135 }
136
137 #[must_use]
142 pub fn has_session_id(&self) -> bool {
143 self.session_id.chars().any(|a| a != '0')
144 }
145
146 pub async fn login(&mut self) -> Result<Response, SFError> {
154 self.logout();
155 #[allow(deprecated)]
156 let login_cmd = match self.login_data.clone() {
157 LoginData::Basic { username, pw_hash } => Command::Login {
158 username,
159 pw_hash: pw_hash.get().to_string(),
160 login_count: self.login_count,
161 },
162 #[cfg(feature = "sso")]
163 LoginData::SSO {
164 character_id,
165 session,
166 ..
167 } => Command::SSOLogin {
168 uuid: session.uuid,
169 character_id,
170 bearer_token: session.bearer_token,
171 },
172 };
173
174 self.send_command(&login_cmd).await
175 }
176
177 pub async fn register(
184 username: &str,
185 password: &str,
186 server: ServerConnection,
187 gender: Gender,
188 race: Race,
189 class: Class,
190 ) -> Result<(Self, Response), SFError> {
191 let mut s = Self::new(username, password, server);
192 #[allow(deprecated)]
193 let resp = s
194 .send_command(&Command::Register {
195 username: username.to_string(),
196 password: password.to_string(),
197 gender,
198 race,
199 class,
200 })
201 .await?;
202
203 let Some(tracking) = resp.values().get("tracking") else {
204 error!("Got no tracking response from server after registering");
205 return Err(SFError::ParsingError(
206 "register response",
207 resp.raw_response().to_string(),
208 ));
209 };
210
211 if tracking.as_str() != "signup" {
212 error!("Got something else than signup response during register");
213 return Err(SFError::ParsingError(
214 "register tracking response",
215 tracking.as_str().to_string(),
216 ));
217 }
218
219 let resp = s.login().await?;
222 Ok((s, resp))
223 }
224
225 #[allow(clippy::unwrap_used, clippy::missing_panics_doc)]
238 pub async fn send_command_raw<T: Borrow<Command>>(
239 &self,
240 command: T,
241 ) -> Result<Response, SFError> {
242 let command = command.borrow();
243 trace!("Sending a {command:?} command");
244
245 let old_cmd = command.request_string()?;
246 trace!("Command string: {old_cmd}");
247
248 let (cmd_name, cmd_args) =
249 old_cmd.split_once(':').unwrap_or((old_cmd.as_str(), ""));
250
251 let url = format!(
252 "{}cmd.php?req={cmd_name}¶ms={}&sid={}",
253 self.server_url,
254 base64::engine::general_purpose::URL_SAFE.encode(cmd_args),
255 &self.crypto_id,
256 );
257
258 trace!("Full request url: {url}");
259
260 url::Url::parse(&url).map_err(|_| {
262 SFError::InvalidRequest("Could not parse command url")
263 })?;
264
265 #[allow(unused_mut)]
266 let mut req = self
267 .client
268 .get(&url)
269 .header(REFERER, &self.server_url.to_string());
270
271 #[cfg(feature = "sso")]
272 if let LoginData::SSO { session, .. } = &self.login_data {
273 req = req.bearer_auth(&session.bearer_token);
274 }
275 if self.has_session_id() {
276 req = req.header(
277 HeaderName::from_str("PG-Session").unwrap(),
278 HeaderValue::from_str(&self.session_id).map_err(|_| {
279 SFError::InvalidRequest("Invalid session id")
280 })?,
281 );
282 }
283 req = req.header(
284 HeaderName::from_str("PG-Player").unwrap(),
285 HeaderValue::from_str(&self.player_id.to_string())
286 .map_err(|_| SFError::InvalidRequest("Invalid player id"))?,
287 );
288
289 let resp = req.send().await.map_err(|_| SFError::ConnectionError)?;
290
291 if !resp.status().is_success() {
292 return Err(SFError::ConnectionError);
293 }
294
295 let response_body =
296 resp.text().await.map_err(|_| SFError::ConnectionError)?;
297
298 match response_body {
299 body if body.is_empty() => Err(SFError::EmptyResponse),
300 body => {
301 let resp =
302 Response::parse(body, chrono::Local::now().naive_local())?;
303 if let Some(lc) = resp.values().get("serverversion").copied() {
304 let version: u32 = lc.into("server version")?;
305 if version > self.options.expected_server_version {
306 warn!("Untested S&F Server version: {version}");
307 if self.options.error_on_unsupported_version {
308 return Err(SFError::UnsupportedVersion(version));
309 }
310 }
311 }
312 Ok(resp)
313 }
314 }
315 }
316
317 pub async fn send_command<T: Borrow<Command>>(
334 &mut self,
335 command: T,
336 ) -> Result<Response, SFError> {
337 let res = self.send_command_raw(command).await?;
338 self.update(&res);
339 Ok(res)
340 }
341
342 pub fn update(&mut self, res: &Response) {
345 let data = res.values();
346 if let Some(lc) = data.get("login count") {
347 self.login_count = (*lc).into("login count").unwrap_or_default();
348 }
349 if let Some(lc) = data.get("sessionid") {
350 self.session_id.clear();
351 self.session_id.push_str(lc.as_str());
352 }
353 if let Some(player_id) = data
354 .get("ownplayersave")
355 .and_then(|a| a.as_str().split('/').nth(1))
356 .and_then(|a| a.parse::<u32>().ok())
357 {
358 self.player_id = player_id;
359 }
360 if let Some(lc) = data.get("cryptoid") {
361 self.crypto_id.clear();
362 self.crypto_id.push_str(lc.as_str());
363 }
364 }
365
366 #[cfg(feature = "sso")]
367 pub(super) async fn from_sso_char(
368 character: crate::sso::SSOCharacter,
369 account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
370 server_lookup: &crate::sso::ServerLookup,
371 ) -> Result<Session, SFError> {
372 let url = server_lookup.get(character.server_id)?;
373 let session = account.lock().await.session.clone();
374 let client = account.lock().await.client.clone();
375 let options = account.lock().await.options.clone();
376
377 let ld = LoginData::SSO {
378 username: character.name,
379 character_id: character.id,
380 account,
381 session,
382 };
383 Ok(Session::new_full(ld, client, options, url))
384 }
385
386 #[must_use]
387 pub fn username(&self) -> &str {
389 match &self.login_data {
390 LoginData::Basic { username, .. } => username,
391 #[cfg(feature = "sso")]
392 LoginData::SSO {
393 username: character_name,
394 ..
395 } => character_name,
396 }
397 }
398
399 #[cfg(feature = "sso")]
400 pub async fn renew_sso_creds(&mut self) -> Result<(), SFError> {
410 let LoginData::SSO {
411 account, session, ..
412 } = &mut self.login_data
413 else {
414 return Err(SFError::InvalidRequest(
415 "Can not renow sso credentials for a non-sso account",
416 ));
417 };
418 let mut account = account.lock().await;
419
420 if &account.session == session {
421 account.refresh_login().await?;
422 } else {
423 *session = account.session.clone();
424 }
425 Ok(())
426 }
427}
428
429#[derive(Debug, Clone)]
430#[allow(clippy::upper_case_acronyms)]
431#[non_exhaustive]
432enum LoginData {
433 Basic {
434 username: String,
435 pw_hash: PWHash,
436 },
437 #[cfg(feature = "sso")]
438 SSO {
439 username: String,
440 character_id: String,
441 account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
444 session: crate::sso::AccountSession,
455 },
456}
457
458#[derive(Debug, Clone)]
459pub struct ServerConnection {
464 url: url::Url,
465 client: Client,
466 options: ConnectionOptions,
467}
468
469impl ServerConnection {
470 #[must_use]
473 pub fn new(server_url: &str) -> Option<ServerConnection> {
474 ServerConnection::new_with_options(
475 server_url,
476 ConnectionOptions::default(),
477 )
478 }
479
480 #[must_use]
484 pub fn new_with_options(
485 server_url: &str,
486 options: ConnectionOptions,
487 ) -> Option<ServerConnection> {
488 let url = if server_url.starts_with("http") {
489 server_url.parse().ok()?
490 } else {
491 format!("https://{server_url}").parse().ok()?
492 };
493
494 Some(ServerConnection {
495 url,
496 client: reqwest_client(&options)?,
497 options,
498 })
499 }
500}
501
502pub(crate) fn reqwest_client(
503 options: &ConnectionOptions,
504) -> Option<reqwest::Client> {
505 let mut headers = HeaderMap::new();
506 headers.insert(
507 HeaderName::from_static(ACCEPT_LANGUAGE.as_str()),
508 HeaderValue::from_static("en;q=0.7,en-US;q=0.6"),
509 );
510 let mut builder = reqwest::Client::builder();
511 if let Some(ua) = options.user_agent.clone() {
512 builder = builder.user_agent(ua);
513 }
514 builder.default_headers(headers).build().ok()
515}
516
517#[derive(Debug, Clone)]
518pub struct ConnectionOptions {
520 pub user_agent: Option<String>,
522 pub expected_server_version: u32,
524 pub error_on_unsupported_version: bool,
529}
530
531impl Default for ConnectionOptions {
532 fn default() -> Self {
533 Self {
534 user_agent: Some(
535 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
536 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
537 .to_string(),
538 ),
539 expected_server_version: 2014,
540 error_on_unsupported_version: false,
541 }
542 }
543}
544
545#[derive(Debug)]
546#[allow(clippy::module_name_repetitions)]
547pub struct SimpleSession {
548 session: Session,
549 gamestate: Option<GameState>,
550}
551
552impl SimpleSession {
553 async fn short_sleep() {
554 tokio::time::sleep(Duration::from_millis(fastrand::u64(1000..2000)))
555 .await;
556 }
557
558 pub async fn login(
563 username: &str,
564 password: &str,
565 server_url: &str,
566 ) -> Result<Self, SFError> {
567 let connection = ServerConnection::new(server_url)
568 .ok_or(SFError::ConnectionError)?;
569 let mut session = Session::new(username, password, connection);
570 let resp = session.login().await?;
571 let gs = GameState::new(resp)?;
572 Self::short_sleep().await;
573 Ok(Self {
574 session,
575 gamestate: Some(gs),
576 })
577 }
578
579 #[cfg(feature = "sso")]
580 pub async fn login_sf_account(
586 username: &str,
587 password: &str,
588 ) -> Result<Vec<Self>, SFError> {
589 let acc = crate::sso::SFAccount::login(
590 username.to_string(),
591 password.to_string(),
592 )
593 .await?;
594
595 Ok(acc
596 .characters()
597 .await?
598 .into_iter()
599 .flatten()
600 .map(|a| Self {
601 session: a,
602 gamestate: None,
603 })
604 .collect())
605 }
606
607 #[must_use]
610 pub fn server_url(&self) -> &url::Url {
611 self.session.server_url()
612 }
613
614 #[must_use]
616 pub fn username(&self) -> &str {
617 self.session.username()
618 }
619
620 #[must_use]
625 pub fn has_session_id(&self) -> bool {
626 self.session.has_session_id()
627 }
628
629 #[must_use]
632 pub fn game_state(&self) -> Option<&GameState> {
633 self.gamestate.as_ref()
634 }
635
636 #[must_use]
639 pub fn game_state_mut(&mut self) -> Option<&mut GameState> {
640 self.gamestate.as_mut()
641 }
642
643 #[allow(clippy::unwrap_used, clippy::missing_panics_doc)]
662 pub async fn send_command<T: Borrow<Command>>(
663 &mut self,
664 cmd: T,
665 ) -> Result<&mut GameState, SFError> {
666 if self.gamestate.is_none() {
667 let resp = self.session.login().await?;
668 let gs = GameState::new(resp)?;
669 self.gamestate = Some(gs);
670 Self::short_sleep().await;
671 }
672
673 let resp = match self.session.send_command(cmd).await {
674 Ok(resp) => resp,
675 Err(err) => {
676 self.gamestate = None;
677 return Err(err);
678 }
679 };
680
681 if let Some(gs) = &mut self.gamestate
682 && let Err(e) = gs.update(resp)
683 {
684 self.gamestate = None;
685 return Err(e);
686 }
687
688 Ok(self.gamestate.as_mut().unwrap())
689 }
690}