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