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
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)]
25/// The session, that manages the server communication for a character
26pub struct Session {
27    /// The information necessary to log in
28    login_data: LoginData,
29    /// The server this account is on
30    server_url: url::Url,
31    /// The id of our session. This will remain the same as long as our login
32    /// is valid and nobody else logs in
33    session_id: String,
34    /// The amount of commands we have send
35    player_id: u32,
36    login_count: u32,
37    crypto_id: String,
38    crypto_key: String,
39    // We keep this instead of creating a new one, because as per the reqwest
40    // docs: "The Client holds a connection pool internally, so it is advised
41    // that you create one and reuse it."
42    client: reqwest::Client,
43    options: ConnectionOptions,
44}
45
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48/// The password of a character, hashed in the way, that the server expects
49pub struct PWHash(String);
50
51impl PWHash {
52    /// Hashes the password the way the server expects it. You can use this to
53    /// store user passwords safely (not in cleartext)
54    #[must_use]
55    pub fn new(password: &str) -> Self {
56        Self(sha1_hash(&(password.to_string() + HASH_CONST)))
57    }
58    /// If you have access to the hash of the password directly, this method
59    /// lets you construct a `PWHash` directly
60    #[must_use]
61    pub fn from_hash(hash: String) -> Self {
62        Self(hash)
63    }
64
65    /// Gives you the hash of the password directly
66    #[must_use]
67    pub fn get(&self) -> &str {
68        &self.0
69    }
70}
71
72impl Session {
73    /// Constructs a new session for a normal (not SSO) account with the
74    /// credentials provided. To use this session, you should call `login()`
75    /// to actually find out, if the credentials work and to get the initial
76    /// login response
77    #[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    /// Does the same as `new()`, but takes a hashed password directly
87    #[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    /// Resets a session by setting all values related to the server connection
120    /// back to the "not logged in" state. This is basically the equivalent of
121    /// clearing browserdata, to logout
122    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    /// Returns a reference to the server URL, that this session is sending
131    /// requests to
132    #[must_use]
133    pub fn server_url(&self) -> &url::Url {
134        &self.server_url
135    }
136
137    /// Checks if this session has ever been able to successfully login to the
138    /// server to establish a session id. You should not need to check this, as
139    /// `login()` should return error on unsuccessful logins, but if you want
140    /// to make sure, you can make sure here
141    #[must_use]
142    pub fn has_session_id(&self) -> bool {
143        self.session_id.chars().any(|a| a != '0')
144    }
145
146    /// Logges in the session by sending a login response to the server and
147    /// updating the internal cryptography values. If the session is currently
148    /// logged in, this also clears the existing state beforehand.
149    ///
150    /// # Errors
151    /// Look at `send_command()` to get a full overview of all the
152    /// possible errors
153    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    /// Registers a new character on the server. If everything works, the logged
178    /// in character session and its login response will be returned
179    ///
180    /// # Errors
181    /// Look at `send_command()` to get a full overview of all the
182    /// possible errors
183    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        // At this point we are certain, that the server has registered us, so
220        // we `should` be able to login
221        let resp = s.login().await?;
222        Ok((s, resp))
223    }
224
225    /// The internal version `send_command()`. It allows you to send
226    /// requests with only a normal ref, because this version does not
227    /// update the cryptography settings of this session, if the server
228    /// responds with them. If you do not expect the server to send you new
229    /// crypto settings, because you only do predictable simple requests (no
230    /// login, etc), or you want to update them yourself, because that is
231    /// easier to handle for you, you can use this function to increase your
232    /// commands/account/sec speed
233    ///
234    /// # Errors
235    /// Look at `send_command()` to get a full overview of all the
236    /// possible errors
237    #[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}&params={}&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        // Make sure we dont have any weird stuff in our URL
261        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    /// Encode and send a command to the server, decrypts and parses its
318    /// response and returns the response. When this returns an error, the
319    /// Session might be in an invalid state, so you should login again just to
320    /// be safe
321    ///
322    /// # Errors
323    /// - `UnsupportedVersion`: If `error_on_unsupported_version` is set and the
324    ///   server is running an unsupported version
325    /// - `EmptyResponse`: If the servers response was empty
326    /// - `InvalidRequest`: If your response was invalid to send in some way
327    /// - `ConnectionError`: If the command could not be send, or the response
328    ///   could not successfully be received
329    /// - `ParsingError`: If the response from the server was unexpected in some
330    ///   way
331    /// - `ServerError`: If the server itself responded with an ingame error
332    ///   like "you do not have enough silver to do that"
333    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    /// Manually updates the cryptography setting of this session with the
343    /// response provided
344    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    /// The username of the character, that this session is responsible for
388    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    /// Retrieves new sso credentials from its sf account. If the account
401    /// already has new creds stored, these are read, otherwise the account will
402    /// be logged in again
403    ///
404    /// # Errors
405    /// - `InvalidRequest`: If you call this function with anything other, than
406    ///   an SSO-Session
407    /// - Other errors, depending on if the session is able to renew the
408    ///   credentials
409    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        /// A reference to the Account, that owns this character. Used to have
442        /// an easy way of renewing credentials.
443        account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
444        /// The SSO account session. We "cache" this to A, not constanty do a
445        /// mutex lookup and B, because we have to know, if the accounts
446        /// session has changed since we last used it. Otherwise we
447        /// could have multiple characters all seeing an expired
448        /// session error, which has to be met with a renewal request,
449        /// that leads to |characters| many new sessions created. All
450        /// but one of which would be thrown away next request, or
451        /// (depending on their multi device policy) could lead to an
452        /// infinite chain of accounts invalidating their sessions
453        /// against each other
454        session: crate::sso::AccountSession,
455    },
456}
457
458#[derive(Debug, Clone)]
459/// Stores all information necessary to talk to the server. Notably, if you
460/// clone this, instead of creating this multiple times for characters on a
461/// server, this will use the same `reqwest::Client`, which can have slight
462/// benefits to performance
463pub struct ServerConnection {
464    url: url::Url,
465    client: Client,
466    options: ConnectionOptions,
467}
468
469impl ServerConnection {
470    /// Creates a new server instance. This basically just makes sure the URL
471    /// is valid and otherwise tries to make it valid
472    #[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    /// Creates a new server instance with the options provided. This basically
481    /// just makes sure the URL is valid and otherwise tries to make it
482    /// valid
483    #[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)]
518/// Options, that change the behaviour of the communication with the server
519pub struct ConnectionOptions {
520    /// A custom useragent to use, when sending requests to the server
521    pub user_agent: Option<String>,
522    /// The server version, that this API was last tested on
523    pub expected_server_version: u32,
524    /// If this is true, any request to the server will error, if the servers
525    /// version is greater, than `expected_server_version`. This can be useful,
526    /// if you want to make sure you never get surprised by unexpected changes
527    /// on the server
528    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    /// Creates a new `SimpleSession`, by logging in a normal S&F character
559    ///
560    /// # Errors
561    /// Have a look at `send_command` for a full list of possible errors
562    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    ///  Creates new `SimpleSession`s, by logging in the S&S SSO account and
581    /// returning all the characters associated with the account
582    ///
583    /// # Errors
584    /// Have a look at `send_command` for a full list of possible errors
585    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    /// Returns a reference to the server URL, that this session is sending
608    /// requests to
609    #[must_use]
610    pub fn server_url(&self) -> &url::Url {
611        self.session.server_url()
612    }
613
614    /// The username of the character, that this session is responsible for
615    #[must_use]
616    pub fn username(&self) -> &str {
617        self.session.username()
618    }
619
620    /// Checks if this session has ever been able to successfully login to the
621    /// server to establish a session id. You should not need to check this, as
622    /// `login()` should return error on unsuccessful logins, but if you want
623    /// to make sure, you can make sure here
624    #[must_use]
625    pub fn has_session_id(&self) -> bool {
626        self.session.has_session_id()
627    }
628
629    /// Returns a reference to the game state, if this `SimpleSession` is
630    /// currently logged in
631    #[must_use]
632    pub fn game_state(&self) -> Option<&GameState> {
633        self.gamestate.as_ref()
634    }
635
636    /// Returns a mutable reference to the game state, if this `SimpleSession`
637    /// is currently logged in
638    #[must_use]
639    pub fn game_state_mut(&mut self) -> Option<&mut GameState> {
640        self.gamestate.as_mut()
641    }
642
643    /// Sends the command and updates the gamestate with the response from the
644    /// server. A mutable reference to the gamestate will be returned. If an
645    /// error is encountered, the gamestate is cleared and the error will be
646    /// returned. If you send a command after that, this function will try to
647    /// login this session again, before sending the provided command
648    ///
649    /// # Errors
650    /// - `EmptyResponse`: If the servers response was empty
651    /// - `InvalidRequest`: If your response was invalid to send in some way
652    /// - `ConnectionError`: If the command could not be send, or the response
653    ///   could not successfully be received
654    /// - `ParsingError`: If the response from the server was unexpected in some
655    ///   way
656    /// - `TooShortResponse` Similar to `ParsingError`, but specific to a
657    ///   response being too short, which would normally trigger a out of bound
658    ///   panic
659    /// - `ServerError`: If the server itself responded with an ingame error
660    ///   like "you do not have enough silver to do that"
661    #[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}