sf_api/
session.rs

1use std::{
2    borrow::Borrow,
3    fmt::Debug,
4    sync::{atomic::AtomicU32, Arc},
5    time::Duration,
6};
7
8use log::{error, trace, warn};
9use reqwest::{header::*, Client};
10use url::Url;
11
12use crate::{
13    command::Command,
14    error::SFError,
15    gamestate::{
16        character::{Class, Gender, Race},
17        GameState,
18    },
19    misc::{
20        encrypt_server_request, sha1_hash, DEFAULT_CRYPTO_ID,
21        DEFAULT_CRYPTO_KEY, DEFAULT_SESSION_ID, HASH_CONST,
22    },
23};
24pub use crate::{misc::decrypt_url, response::*};
25
26#[derive(Debug, Clone)]
27/// The session, that manages the server communication for a character
28pub struct Session {
29    /// The information necessary to log in
30    login_data: LoginData,
31    /// The server this account is on
32    server_url: url::Url,
33    /// The id of our session. This will remain the same as long as our login
34    /// is valid and nobody else logs in
35    session_id: String,
36    /// The amount of commands we have send
37    command_count: Arc<AtomicU32>,
38    login_count: u32,
39    crypto_id: String,
40    crypto_key: String,
41    // We keep this instead of creating a new one, because as per the reqwest
42    // docs: "The Client holds a connection pool internally, so it is advised
43    // that you create one and reuse it."
44    client: reqwest::Client,
45    options: ConnectionOptions,
46}
47
48#[derive(Debug, Clone)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50/// The password of a character, hashed in the way, that the server expects
51pub struct PWHash(String);
52
53impl PWHash {
54    /// Hashes the password the way the server expects it. You can use this to
55    /// store user passwords safely (not in cleartext)
56    #[must_use]
57    pub fn new(password: &str) -> Self {
58        Self(sha1_hash(&(password.to_string() + HASH_CONST)))
59    }
60    /// If you have access to the hash of the password directly, this method
61    /// lets you construct a `PWHash` directly
62    #[must_use]
63    pub fn from_hash(hash: String) -> Self {
64        Self(hash)
65    }
66
67    /// Gives you the hash of the password directly
68    #[must_use]
69    pub fn get(&self) -> &str {
70        &self.0
71    }
72}
73
74impl Session {
75    /// Constructs a new session for a normal (not SSO) account with the
76    /// credentials provided. To use this session, you should call `login()`
77    /// to actually find out, if the credentials work and to get the initial
78    /// login response
79    #[must_use]
80    pub fn new(
81        username: &str,
82        password: &str,
83        server: ServerConnection,
84    ) -> Self {
85        Self::new_hashed(username, PWHash::new(password), server)
86    }
87
88    /// Does the same as `new()`, but takes a hashed password directly
89    #[must_use]
90    pub fn new_hashed(
91        username: &str,
92        pw_hash: PWHash,
93        server: ServerConnection,
94    ) -> Self {
95        let ld = LoginData::Basic {
96            username: username.to_string(),
97            pw_hash,
98        };
99        Self::new_full(ld, server.client, server.options, server.url)
100    }
101
102    fn new_full(
103        ld: LoginData,
104        client: Client,
105        options: ConnectionOptions,
106        url: Url,
107    ) -> Self {
108        Self {
109            login_data: ld,
110            server_url: url,
111            client,
112            session_id: DEFAULT_SESSION_ID.to_string(),
113            crypto_id: DEFAULT_CRYPTO_ID.to_string(),
114            crypto_key: DEFAULT_CRYPTO_KEY.to_string(),
115            command_count: Arc::new(AtomicU32::new(0)),
116            login_count: 1,
117            options,
118        }
119    }
120
121    /// Resets a session by setting all values related to the server connection
122    /// back to the "not logged in" state. This is basically the equivalent of
123    /// clearing browserdata, to logout
124    fn logout(&mut self) {
125        self.crypto_key = DEFAULT_CRYPTO_KEY.to_string();
126        self.crypto_id = DEFAULT_CRYPTO_ID.to_string();
127        self.login_count = 1;
128        self.command_count = Arc::new(AtomicU32::new(0));
129        self.session_id = DEFAULT_SESSION_ID.to_string();
130    }
131
132    /// Returns a reference to the server URL, that this session is sending
133    /// requests to
134    #[must_use]
135    pub fn server_url(&self) -> &url::Url {
136        &self.server_url
137    }
138
139    /// Checks if this session has ever been able to successfully login to the
140    /// server to establish a session id. You should not need to check this, as
141    /// `login()` should return error on unsuccessful logins, but if you want
142    /// to make sure, you can make sure here
143    #[must_use]
144    pub fn has_session_id(&self) -> bool {
145        self.session_id.chars().any(|a| a != '0')
146    }
147
148    /// Logges in the session by sending a login response to the server and
149    /// updating the internal cryptography values. If the session is currently
150    /// logged in, this also clears the existing state beforehand.
151    ///
152    /// # Errors
153    /// Look at `send_command()` to get a full overview of all the
154    /// possible errors
155    pub async fn login(&mut self) -> Result<Response, SFError> {
156        self.logout();
157        #[allow(deprecated)]
158        let login_cmd = match self.login_data.clone() {
159            LoginData::Basic { username, pw_hash } => Command::Login {
160                username,
161                pw_hash: pw_hash.get().to_string(),
162                login_count: self.login_count,
163            },
164            #[cfg(feature = "sso")]
165            LoginData::SSO {
166                character_id,
167                session,
168                ..
169            } => Command::SSOLogin {
170                uuid: session.uuid,
171                character_id,
172                bearer_token: session.bearer_token,
173            },
174        };
175
176        self.send_command(&login_cmd).await
177    }
178
179    /// Registers a new character on the server. If everything works, the logged
180    /// in character session and its login response will be returned
181    ///
182    /// # Errors
183    /// Look at `send_command()` to get a full overview of all the
184    /// possible errors
185    pub async fn register(
186        username: &str,
187        password: &str,
188        server: ServerConnection,
189        gender: Gender,
190        race: Race,
191        class: Class,
192    ) -> Result<(Self, Response), SFError> {
193        let mut s = Self::new(username, password, server);
194        #[allow(deprecated)]
195        let resp = s
196            .send_command(&Command::Register {
197                username: username.to_string(),
198                password: password.to_string(),
199                gender,
200                race,
201                class,
202            })
203            .await?;
204
205        let Some(tracking) = resp.values().get("tracking") else {
206            error!("Got no tracking response from server after registering");
207            return Err(SFError::ParsingError(
208                "register response",
209                resp.raw_response().to_string(),
210            ));
211        };
212
213        if tracking.as_str() != "signup" {
214            error!("Got something else than signup response during register");
215            return Err(SFError::ParsingError(
216                "register tracking response",
217                tracking.as_str().to_string(),
218            ));
219        }
220
221        // At this point we are certain, that the server has registered us, so
222        // we `should` be able to login
223        let resp = s.login().await?;
224        Ok((s, resp))
225    }
226
227    /// The internal version `send_command()`. It allows you to send
228    /// requests with only a normal ref, because this version does not
229    /// update the cryptography settings of this session, if the server
230    /// responds with them. If you do not expect the server to send you new
231    /// crypto settings, because you only do predictable simple requests (no
232    /// login, etc), or you want to update them yourself, because that is
233    /// easier to handle for you, you can use this function to increase your
234    /// commands/account/sec speed
235    ///
236    /// # Errors
237    /// Look at `send_command()` to get a full overview of all the
238    /// possible errors
239    pub async fn send_command_raw<T: Borrow<Command>>(
240        &self,
241        command: T,
242    ) -> Result<Response, SFError> {
243        let command = command.borrow();
244        trace!("Sending a {command:?} command");
245
246        let mut command_str =
247            format!("{}|{}", self.session_id, command.request_string()?);
248
249        while command_str.len() % 16 > 0 {
250            command_str.push('|');
251        }
252
253        trace!("Command string: {command_str}");
254        let url = format!(
255            "{}req.php?req={}{}&rnd={:.7}&c={}",
256            self.server_url,
257            &self.crypto_id,
258            encrypt_server_request(command_str, &self.crypto_key)?,
259            fastrand::f64(), // Pretty sure this is just cache busting
260            self.command_count
261                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
262        );
263        trace!("Full request url: {url}");
264
265        // Make sure we dont have any weird stuff in our URL
266        url::Url::parse(&url).map_err(|_| {
267            SFError::InvalidRequest("Could not parse command url")
268        })?;
269
270        #[allow(unused_mut)]
271        let mut req = self
272            .client
273            .get(&url)
274            .header(REFERER, &self.server_url.to_string());
275
276        #[cfg(feature = "sso")]
277        if let LoginData::SSO { session, .. } = &self.login_data {
278            req = req.bearer_auth(&session.bearer_token);
279        }
280
281        let resp = req.send().await.map_err(|_| SFError::ConnectionError)?;
282
283        if !resp.status().is_success() {
284            return Err(SFError::ConnectionError);
285        }
286
287        let response_body =
288            resp.text().await.map_err(|_| SFError::ConnectionError)?;
289
290        match response_body {
291            body if body.is_empty() => Err(SFError::EmptyResponse),
292            body => {
293                let resp =
294                    Response::parse(body, chrono::Local::now().naive_local())?;
295                if let Some(lc) = resp.values().get("serverversion").copied() {
296                    let version: u32 = lc.into("server version")?;
297                    if version > self.options.expected_server_version {
298                        warn!("Untested S&F Server version: {version}");
299                        if self.options.error_on_unsupported_version {
300                            return Err(SFError::UnsupportedVersion(version));
301                        }
302                    }
303                }
304                Ok(resp)
305            }
306        }
307    }
308
309    /// Encode and send a command to the server, decrypts and parses its
310    /// response and returns the response. When this returns an error, the
311    /// Session might be in an invalid state, so you should login again just to
312    /// be safe
313    ///
314    /// # Errors
315    /// - `UnsupportedVersion`: If `error_on_unsupported_version` is set and the
316    ///   server is running an unsupported version
317    /// - `EmptyResponse`: If the servers response was empty
318    /// - `InvalidRequest`: If your response was invalid to send in some way
319    /// - `ConnectionError`: If the command could not be send, or the response
320    ///   could not successfully be received
321    /// - `ParsingError`: If the response from the server was unexpected in some
322    ///   way
323    /// - `ServerError`: If the server itself responded with an ingame error
324    ///   like "you do not have enough silver to do that"
325    pub async fn send_command<T: Borrow<Command>>(
326        &mut self,
327        command: T,
328    ) -> Result<Response, SFError> {
329        let res = self.send_command_raw(command).await?;
330        self.update(&res);
331        Ok(res)
332    }
333
334    /// Manually updates the cryptography setting of this session with the
335    /// response provided
336    pub fn update(&mut self, res: &Response) {
337        let data = res.values();
338        if let Some(lc) = data.get("login count") {
339            self.login_count = (*lc).into("login count").unwrap_or_default();
340        }
341        if let Some(lc) = data.get("sessionid") {
342            self.session_id.clear();
343            self.session_id.push_str(lc.as_str());
344        }
345        if let Some(lc) = data.get("cryptokey") {
346            self.crypto_key.clear();
347            self.crypto_key.push_str(lc.as_str());
348        }
349        if let Some(lc) = data.get("cryptoid") {
350            self.crypto_id.clear();
351            self.crypto_id.push_str(lc.as_str());
352        }
353    }
354
355    #[cfg(feature = "sso")]
356    pub(super) async fn from_sso_char(
357        character: crate::sso::SSOCharacter,
358        account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
359        server_lookup: &crate::sso::ServerLookup,
360    ) -> Result<Session, SFError> {
361        let url = server_lookup.get(character.server_id)?;
362        let session = account.lock().await.session.clone();
363        let client = account.lock().await.client.clone();
364        let options = account.lock().await.options.clone();
365
366        let ld = LoginData::SSO {
367            username: character.name,
368            character_id: character.id,
369            account,
370            session,
371        };
372        Ok(Session::new_full(ld, client, options, url))
373    }
374
375    #[must_use]
376    /// The username of the character, that this session is responsible for
377    pub fn username(&self) -> &str {
378        match &self.login_data {
379            LoginData::Basic { username, .. } => username,
380            #[cfg(feature = "sso")]
381            LoginData::SSO {
382                username: character_name,
383                ..
384            } => character_name,
385        }
386    }
387
388    #[cfg(feature = "sso")]
389    /// Retrieves new sso credentials from its sf account. If the account
390    /// already has new creds stored, these are read, otherwise the account will
391    /// be logged in again
392    ///
393    /// # Errors
394    /// - `InvalidRequest`: If you call this function with anything other, than
395    ///   an SSO-Session
396    /// - Other errors, depending on if the session is able to renew the
397    ///   credentials
398    pub async fn renew_sso_creds(&mut self) -> Result<(), SFError> {
399        let LoginData::SSO {
400            account, session, ..
401        } = &mut self.login_data
402        else {
403            return Err(SFError::InvalidRequest(
404                "Can not renow sso credentials for a non-sso account",
405            ));
406        };
407        let mut account = account.lock().await;
408
409        if &account.session == session {
410            account.refresh_login().await?;
411        } else {
412            *session = account.session.clone();
413        }
414        Ok(())
415    }
416}
417
418#[derive(Debug, Clone)]
419#[allow(clippy::upper_case_acronyms)]
420#[non_exhaustive]
421enum LoginData {
422    Basic {
423        username: String,
424        pw_hash: PWHash,
425    },
426    #[cfg(feature = "sso")]
427    SSO {
428        username: String,
429        character_id: String,
430        /// A reference to the Account, that owns this character. Used to have
431        /// an easy way of renewing credentials.
432        account: std::sync::Arc<tokio::sync::Mutex<crate::sso::SFAccount>>,
433        /// The SSO account session. We "cache" this to A, not constanty do a
434        /// mutex lookup and B, because we have to know, if the accounts
435        /// session has changed since we last used it. Otherwise we
436        /// could have multiple characters all seeing an expired
437        /// session error, which has to be met with a renewal request,
438        /// that leads to |characters| many new sessions created. All
439        /// but one of which would be thrown away next request, or
440        /// (depending on their multi device policy) could lead to an
441        /// infinite chain of accounts invalidating their sessions
442        /// against each other
443        session: crate::sso::AccountSession,
444    },
445}
446
447#[derive(Debug, Clone)]
448/// Stores all information necessary to talk to the server. Notably, if you
449/// clone this, instead of creating this multiple times for characters on a
450/// server, this will use the same `reqwest::Client`, which can have slight
451/// benefits to performance
452pub struct ServerConnection {
453    url: url::Url,
454    client: Client,
455    options: ConnectionOptions,
456}
457
458impl ServerConnection {
459    /// Creates a new server instance. This basically just makes sure the URL
460    /// is valid and otherwise tries to make it valid
461    #[must_use]
462    pub fn new(server_url: &str) -> Option<ServerConnection> {
463        ServerConnection::new_with_options(
464            server_url,
465            ConnectionOptions::default(),
466        )
467    }
468
469    /// Creates a new server instance with the options provided. This basically
470    /// just makes sure the URL is valid and otherwise tries to make it
471    /// valid
472    #[must_use]
473    pub fn new_with_options(
474        server_url: &str,
475        options: ConnectionOptions,
476    ) -> Option<ServerConnection> {
477        let url = if server_url.starts_with("http") {
478            server_url.parse().ok()?
479        } else {
480            format!("https://{server_url}").parse().ok()?
481        };
482
483        Some(ServerConnection {
484            url,
485            client: reqwest_client(&options)?,
486            options,
487        })
488    }
489}
490
491pub(crate) fn reqwest_client(
492    options: &ConnectionOptions,
493) -> Option<reqwest::Client> {
494    let mut headers = HeaderMap::new();
495    headers.insert(
496        HeaderName::from_static(ACCEPT_LANGUAGE.as_str()),
497        HeaderValue::from_static("en;q=0.7,en-US;q=0.6"),
498    );
499    let mut builder = reqwest::Client::builder();
500    if let Some(ua) = options.user_agent.clone() {
501        builder = builder.user_agent(ua);
502    }
503    builder.default_headers(headers).build().ok()
504}
505
506#[derive(Debug, Clone)]
507/// Options, that change the behaviour of the communication with the server
508pub struct ConnectionOptions {
509    /// A custom useragent to use, when sending requests to the server
510    pub user_agent: Option<String>,
511    /// The server version, that this API was last tested on
512    pub expected_server_version: u32,
513    /// If this is true, any request to the server will error, if the servers
514    /// version is greater, than `expected_server_version`. This can be useful,
515    /// if you want to make sure you never get surprised by unexpected changes
516    /// on the server
517    pub error_on_unsupported_version: bool,
518}
519
520impl Default for ConnectionOptions {
521    fn default() -> Self {
522        Self {
523            user_agent: Some(
524                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
525                 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
526                    .to_string(),
527            ),
528            expected_server_version: 2005,
529            error_on_unsupported_version: false,
530        }
531    }
532}
533
534#[derive(Debug)]
535#[allow(clippy::module_name_repetitions)]
536pub struct SimpleSession {
537    session: Session,
538    gamestate: Option<GameState>,
539}
540
541impl SimpleSession {
542    async fn short_sleep() {
543        tokio::time::sleep(Duration::from_millis(fastrand::u64(1000..2000)))
544            .await;
545    }
546
547    /// Creates a new `SimpleSession`, by logging in a normal S&F character
548    ///
549    /// # Errors
550    /// Have a look at `send_command` for a full list of possible errors
551    pub async fn login(
552        username: &str,
553        password: &str,
554        server_url: &str,
555    ) -> Result<Self, SFError> {
556        let connection = ServerConnection::new(server_url)
557            .ok_or(SFError::ConnectionError)?;
558        let mut session = Session::new(username, password, connection);
559        let resp = session.login().await?;
560        let gs = GameState::new(resp)?;
561        Self::short_sleep().await;
562        Ok(Self {
563            session,
564            gamestate: Some(gs),
565        })
566    }
567
568    #[cfg(feature = "sso")]
569    ///  Creates new `SimpleSession`s, by logging in the S&S SSO account and
570    /// returning all the characters associated with the account
571    ///
572    /// # Errors
573    /// Have a look at `send_command` for a full list of possible errors
574    pub async fn login_sf_account(
575        username: &str,
576        password: &str,
577    ) -> Result<Vec<Self>, SFError> {
578        let acc = crate::sso::SFAccount::login(
579            username.to_string(),
580            password.to_string(),
581        )
582        .await?;
583
584        Ok(acc
585            .characters()
586            .await?
587            .into_iter()
588            .flatten()
589            .map(|a| Self {
590                session: a,
591                gamestate: None,
592            })
593            .collect())
594    }
595
596    /// Returns a reference to the game state, if this `SimpleSession` is
597    /// currently logged in
598    #[must_use]
599    pub fn game_state(&self) -> Option<&GameState> {
600        self.gamestate.as_ref()
601    }
602
603    /// Returns a mutable reference to the game state, if this `SimpleSession`
604    /// is currently logged in
605    #[must_use]
606    pub fn game_state_mut(&mut self) -> Option<&mut GameState> {
607        self.gamestate.as_mut()
608    }
609
610    /// Sends the command and updates the gamestate with the response from the
611    /// server. A mutable reference to the gamestate will be returned. If an
612    /// error is encountered, the gamestate is cleared and the error will be
613    /// returned. If you send a command after that, this function will try to
614    /// login this session again, before sending the provided command
615    ///
616    /// # Errors
617    /// - `EmptyResponse`: If the servers response was empty
618    /// - `InvalidRequest`: If your response was invalid to send in some way
619    /// - `ConnectionError`: If the command could not be send, or the response
620    ///   could not successfully be received
621    /// - `ParsingError`: If the response from the server was unexpected in some
622    ///   way
623    /// - `TooShortResponse` Similar to `ParsingError`, but specific to a
624    ///   response being too short, which would normally trigger a out of bound
625    ///   panic
626    /// - `ServerError`: If the server itself responded with an ingame error
627    ///   like "you do not have enough silver to do that"
628    #[allow(clippy::unwrap_used, clippy::missing_panics_doc)]
629    pub async fn send_command<T: Borrow<Command>>(
630        &mut self,
631        cmd: T,
632    ) -> Result<&mut GameState, SFError> {
633        if self.gamestate.is_none() {
634            let resp = self.session.login().await?;
635            let gs = GameState::new(resp)?;
636            self.gamestate = Some(gs);
637            Self::short_sleep().await;
638        }
639
640        let resp = match self.session.send_command(cmd).await {
641            Ok(resp) => resp,
642            Err(err) => {
643                self.gamestate = None;
644                return Err(err);
645            }
646        };
647
648        if let Some(gs) = &mut self.gamestate {
649            if let Err(e) = gs.update(resp) {
650                self.gamestate = None;
651                return Err(e);
652            }
653        }
654
655        Ok(self.gamestate.as_mut().unwrap())
656    }
657}