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, 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 send
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    /// Logges 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 send, 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 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        /// 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 constanty 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(ua) = options.user_agent.clone() {
511        builder = builder.user_agent(ua);
512    }
513    builder.default_headers(headers).build().ok()
514}
515
516/// Options, that change the behaviour of the communication with the server
517#[derive(Debug, Clone)]
518pub struct ConnectionOptions {
519    /// A custom useragent to use, when sending requests to the server
520    pub user_agent: Option<String>,
521    /// The server version, that this API was last tested on
522    pub expected_server_version: u32,
523    /// If this is true, any request to the server will error, if the servers
524    /// version is greater, than `expected_server_version`. This can be useful,
525    /// if you want to make sure you never get surprised by unexpected changes
526    /// on the server
527    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    /// Creates a new `SimpleSession`, by logging in a normal S&F character
558    ///
559    /// # Errors
560    /// Have a look at `send_command` for a full list of possible errors
561    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    ///  Creates new `SimpleSession`s, by logging in the S&S SSO account and
579    /// returning all the characters associated with the account
580    ///
581    /// # Errors
582    /// Have a look at `send_command` for a full list of possible errors
583    #[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    /// Returns a reference to the server URL, that this session is sending
607    /// requests to
608    #[must_use]
609    pub fn server_url(&self) -> &url::Url {
610        self.session.server_url()
611    }
612
613    /// The username of the character, that this session is responsible for
614    #[must_use]
615    pub fn username(&self) -> &str {
616        self.session.username()
617    }
618
619    /// Checks if this session has ever been able to successfully login to the
620    /// server to establish a session id. You should not need to check this, as
621    /// `login()` should return error on unsuccessful logins, but if you want
622    /// to make sure, you can make sure here
623    #[must_use]
624    pub fn has_session_id(&self) -> bool {
625        self.session.has_session_id()
626    }
627
628    /// Returns a reference to the game state, if this `SimpleSession` is
629    /// currently logged in
630    #[must_use]
631    pub fn game_state(&self) -> Option<&GameState> {
632        self.gamestate.as_ref()
633    }
634
635    /// Returns a mutable reference to the game state, if this `SimpleSession`
636    /// is currently logged in
637    #[must_use]
638    pub fn game_state_mut(&mut self) -> Option<&mut GameState> {
639        self.gamestate.as_mut()
640    }
641
642    /// Sends the command and updates the gamestate with the response from the
643    /// server. A mutable reference to the gamestate will be returned. If an
644    /// error is encountered, the gamestate is cleared and the error will be
645    /// returned. If you send a command after that, this function will try to
646    /// login this session again, before sending the provided command
647    ///
648    /// # Errors
649    /// - `EmptyResponse`: If the servers response was empty
650    /// - `InvalidRequest`: If your response was invalid to send in some way
651    /// - `ConnectionError`: If the command could not be send, or the response
652    ///   could not successfully be received
653    /// - `ParsingError`: If the response from the server was unexpected in some
654    ///   way
655    /// - `TooShortResponse` Similar to `ParsingError`, but specific to a
656    ///   response being too short, which would normally trigger a out of bound
657    ///   panic
658    /// - `ServerError`: If the server itself responded with an ingame error
659    ///   like "you do not have enough silver to do that"
660    #[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}