Skip to main content

sf_api/
session.rs

1use std::{borrow::Borrow, fmt::Debug, str::FromStr, time::Duration};
2
3use base64::Engine;
4use log::{error, trace, warn};
5use reqwest::{Client, Proxy, 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/// The session, that manages the server communication for a character
23#[derive(Debug, Clone)]
24#[allow(clippy::struct_field_names)]
25pub struct Session {
26    /// The information necessary to log in
27    login_data: LoginData,
28    /// The server this account is on
29    server_url: url::Url,
30    /// The id of our session. This will remain the same as long as our login
31    /// is valid and nobody else logs in
32    session_id: String,
33    /// The amount of commands we have sent
34    player_id: u32,
35    login_count: u32,
36    crypto_id: String,
37    crypto_key: String,
38    // We keep this instead of creating a new one, because as per the reqwest
39    // docs: "The Client holds a connection pool internally, so it is advised
40    // that you create one and reuse it."
41    client: reqwest::Client,
42    options: ConnectionOptions,
43}
44
45/// The password of a character, hashed in the way, that the server expects
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct PWHash(String);
49
50impl PWHash {
51    /// Hashes the password the way the server expects it. You can use this to
52    /// store user passwords safely (not in cleartext)
53    #[must_use]
54    pub fn new(password: &str) -> Self {
55        Self(sha1_hash(&(password.to_string() + HASH_CONST)))
56    }
57    /// If you have access to the hash of the password directly, this method
58    /// lets you construct a `PWHash` directly
59    #[must_use]
60    pub fn from_hash(hash: String) -> Self {
61        Self(hash)
62    }
63
64    /// Gives you the hash of the password directly
65    #[must_use]
66    pub fn get(&self) -> &str {
67        &self.0
68    }
69}
70
71impl Session {
72    /// Constructs a new session for a normal (not SSO) account with the
73    /// credentials provided. To use this session, you should call `login()`
74    /// to actually find out, if the credentials work and to get the initial
75    /// login response
76    #[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    /// Does the same as `new()`, but takes a hashed password directly
86    #[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    /// Resets a session by setting all values related to the server connection
119    /// back to the "not logged in" state. This is basically the equivalent of
120    /// clearing browserdata, to logout
121    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    /// Returns a reference to the server URL, that this session is sending
130    /// requests to
131    #[must_use]
132    pub fn server_url(&self) -> &url::Url {
133        &self.server_url
134    }
135
136    /// Checks if this session has ever been able to successfully login to the
137    /// server to establish a session id. You should not need to check this, as
138    /// `login()` should return error on unsuccessful logins, but if you want
139    /// to make sure, you can make sure here
140    #[must_use]
141    pub fn has_session_id(&self) -> bool {
142        self.session_id.chars().any(|a| a != '0')
143    }
144
145    /// Logs in the session by sending a login response to the server and
146    /// updating the internal cryptography values. If the session is currently
147    /// logged in, this also clears the existing state beforehand.
148    ///
149    /// # Errors
150    /// Look at `send_command()` to get a full overview of all the
151    /// possible errors
152    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    /// Registers a new character on the server. If everything works, the logged
177    /// in character session and its login response will be returned
178    ///
179    /// # Errors
180    /// Look at `send_command()` to get a full overview of all the
181    /// possible errors
182    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        // At this point we are certain, that the server has registered us, so
219        // we `should` be able to login
220        let resp = s.login().await?;
221        Ok((s, resp))
222    }
223
224    /// The internal version `send_command()`. It allows you to send
225    /// requests with only a normal ref, because this version does not
226    /// update the cryptography settings of this session, if the server
227    /// responds with them. If you do not expect the server to send you new
228    /// crypto settings, because you only do predictable simple requests (no
229    /// login, etc), or you want to update them yourself, because that is
230    /// easier to handle for you, you can use this function to increase your
231    /// commands/account/sec speed
232    ///
233    /// # Errors
234    /// Look at `send_command()` to get a full overview of all the
235    /// possible errors
236    #[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}&params={}&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        // Make sure we dont have any weird stuff in our URL
260        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    /// Encode and send a command to the server, decrypts and parses its
317    /// response and returns the response. When this returns an error, the
318    /// Session might be in an invalid state, so you should login again just to
319    /// be safe
320    ///
321    /// # Errors
322    /// - `UnsupportedVersion`: If `error_on_unsupported_version` is set and the
323    ///   server is running an unsupported version
324    /// - `EmptyResponse`: If the servers response was empty
325    /// - `InvalidRequest`: If your response was invalid to send in some way
326    /// - `ConnectionError`: If the command could not be sent, or the response
327    ///   could not successfully be received
328    /// - `ParsingError`: If the response from the server was unexpected in some
329    ///   way
330    /// - `ServerError`: If the server itself responded with an ingame error
331    ///   like "you do not have enough silver to do that"
332    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    /// Manually updates the cryptography setting of this session with the
342    /// response provided
343    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    /// The username of the character, that this session is responsible for
386    #[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    /// Retrieves new sso credentials from its sf account. If the account
399    /// already has new creds stored, these are read, otherwise the account will
400    /// be logged in again
401    ///
402    /// # Errors
403    /// - `InvalidRequest`: If you call this function with anything other than
404    ///   an SSO-Session
405    /// - Other errors, depending on if the session is able to renew the
406    ///   credentials
407    #[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 renew 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        /// A reference to the Account, that owns this character. Used to have
441        /// an easy way of renewing credentials.
442        account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
443        /// The SSO account session. We "cache" this to A, not constantly do a
444        /// mutex lookup and B, because we have to know, if the accounts
445        /// session has changed since we last used it. Otherwise we
446        /// could have multiple characters all seeing an expired
447        /// session error, which has to be met with a renewal request,
448        /// that leads to |characters| many new sessions created. All
449        /// but one of which would be thrown away next request, or
450        /// (depending on their multi device policy) could lead to an
451        /// infinite chain of accounts invalidating their sessions
452        /// against each other
453        session: crate::sso::AccountSession,
454    },
455}
456
457/// Stores all information necessary to talk to the server. Notably, if you
458/// clone this, instead of creating this multiple times for characters on a
459/// server, this will use the same `reqwest::Client`, which can have slight
460/// benefits to performance
461#[derive(Debug, Clone)]
462pub struct ServerConnection {
463    url: url::Url,
464    client: Client,
465    options: ConnectionOptions,
466}
467
468impl ServerConnection {
469    /// Creates a new server instance. This basically just makes sure the URL
470    /// is valid and otherwise tries to make it valid
471    #[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    /// Creates a new server instance with the options provided. This basically
480    /// just makes sure the URL is valid and otherwise tries to make it
481    /// valid
482    #[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(settings) = &options.proxy {
511        let mut proxy = Proxy::https(&settings.url).ok()?;
512        if let Some(username) = &settings.username {
513            let password = settings.password.as_deref().unwrap_or("");
514            proxy = proxy.basic_auth(username, password);
515        }
516        builder = builder.proxy(proxy);
517    }
518
519    let ua = options.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
520    builder = builder.user_agent(ua);
521    builder.default_headers(headers).build().ok()
522}
523
524/// Options, that change the behavior of the communication with the server
525#[derive(Debug, Clone)]
526pub struct ConnectionOptions {
527    /// A custom useragent to use, when sending requests to the server
528    pub user_agent: Option<String>,
529    /// A custom proxy to use for network requests
530    pub proxy: Option<ProxySettings>,
531    /// The server version, that this API was last tested on
532    pub expected_server_version: u32,
533    /// If this is true, any request to the server will error, if the servers
534    /// version is greater, than `expected_server_version`. This can be useful,
535    /// if you want to make sure you never get surprised by unexpected changes
536    /// on the server
537    pub error_on_unsupported_version: bool,
538}
539
540#[derive(Debug, Clone)]
541pub struct ProxySettings {
542    pub url: String,
543    pub username: Option<String>,
544    pub password: Option<String>,
545}
546
547static DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
548                                   AppleWebKit/537.36 (KHTML, like Gecko) \
549                                   Chrome/115.0.0.0 Safari/537.36";
550
551impl Default for ConnectionOptions {
552    fn default() -> Self {
553        Self {
554            user_agent: Some(DEFAULT_USER_AGENT.to_string()),
555            expected_server_version: 2019,
556            error_on_unsupported_version: false,
557            proxy: None,
558        }
559    }
560}
561
562#[derive(Debug)]
563#[allow(clippy::module_name_repetitions)]
564pub struct SimpleSession {
565    session: Session,
566    gamestate: Option<GameState>,
567}
568
569impl SimpleSession {
570    async fn short_sleep() {
571        tokio::time::sleep(Duration::from_millis(fastrand::u64(1000..2000)))
572            .await;
573    }
574
575    /// Creates a new `SimpleSession`, by logging in a normal S&F character
576    ///
577    /// # Errors
578    /// Have a look at `send_command` for a full list of possible errors
579    pub async fn login(
580        username: &str,
581        password: &str,
582        server_url: &str,
583    ) -> Result<Self, SFError> {
584        let connection = ServerConnection::new(server_url)
585            .ok_or(SFError::ConnectionError)?;
586        let mut session = Session::new(username, password, connection);
587        let resp = session.login().await?;
588        let gs = GameState::new(resp)?;
589        Self::short_sleep().await;
590        Ok(Self {
591            session,
592            gamestate: Some(gs),
593        })
594    }
595
596    ///  Creates new `SimpleSession`s, by logging in the S&S SSO account and
597    /// returning all the characters associated with the account
598    ///
599    /// # Errors
600    /// Have a look at `send_command` for a full list of possible errors
601    #[cfg(feature = "sso")]
602    pub async fn login_sf_account(
603        username: &str,
604        password: &str,
605    ) -> Result<Vec<Self>, SFError> {
606        let acc = crate::sso::SFAccount::login(
607            username.to_string(),
608            password.to_string(),
609        )
610        .await?;
611
612        Ok(acc
613            .characters()
614            .await?
615            .into_iter()
616            .flatten()
617            .map(|a| Self {
618                session: a,
619                gamestate: None,
620            })
621            .collect())
622    }
623
624    /// Returns a reference to the server URL, that this session is sending
625    /// requests to
626    #[must_use]
627    pub fn server_url(&self) -> &url::Url {
628        self.session.server_url()
629    }
630
631    /// The username of the character, that this session is responsible for
632    #[must_use]
633    pub fn username(&self) -> &str {
634        self.session.username()
635    }
636
637    /// Checks if this session has ever been able to successfully login to the
638    /// server to establish a session id. You should not need to check this, as
639    /// `login()` should return error on unsuccessful logins, but if you want
640    /// to make sure, you can make sure here
641    #[must_use]
642    pub fn has_session_id(&self) -> bool {
643        self.session.has_session_id()
644    }
645
646    /// Returns a reference to the game state, if this `SimpleSession` is
647    /// currently logged in
648    #[must_use]
649    pub fn game_state(&self) -> Option<&GameState> {
650        self.gamestate.as_ref()
651    }
652
653    /// Returns a mutable reference to the game state, if this `SimpleSession`
654    /// is currently logged in
655    #[must_use]
656    pub fn game_state_mut(&mut self) -> Option<&mut GameState> {
657        self.gamestate.as_mut()
658    }
659
660    /// Sends the command and updates the gamestate with the response from the
661    /// server. A mutable reference to the gamestate will be returned. If an
662    /// error is encountered, the gamestate is cleared and the error will be
663    /// returned. If you send a command after that, this function will try to
664    /// login this session again, before sending the provided command
665    ///
666    /// # Errors
667    /// - `EmptyResponse`: If the servers response was empty
668    /// - `InvalidRequest`: If your response was invalid to send in some way
669    /// - `ConnectionError`: If the command could not be sent, or the response
670    ///   could not successfully be received
671    /// - `ParsingError`: If the response from the server was unexpected in some
672    ///   way
673    /// - `TooShortResponse` Similar to `ParsingError`, but specific to a
674    ///   response being too short, which would normally trigger a out of bound
675    ///   panic
676    /// - `ServerError`: If the server itself responded with an ingame error
677    ///   like "you do not have enough silver to do that"
678    #[allow(clippy::unwrap_used, clippy::missing_panics_doc)]
679    pub async fn send_command<T: Borrow<Command>>(
680        &mut self,
681        cmd: T,
682    ) -> Result<&mut GameState, SFError> {
683        if self.gamestate.is_none() {
684            let resp = self.session.login().await?;
685            let gs = GameState::new(resp)?;
686            self.gamestate = Some(gs);
687            Self::short_sleep().await;
688        }
689
690        let resp = match self.session.send_command(cmd).await {
691            Ok(resp) => resp,
692            Err(err) => {
693                self.gamestate = None;
694                return Err(err);
695            }
696        };
697
698        if let Some(gs) = &mut self.gamestate
699            && let Err(e) = gs.update(resp)
700        {
701            self.gamestate = None;
702            return Err(e);
703        }
704
705        Ok(self.gamestate.as_mut().unwrap())
706    }
707}