sf_api/
misc.rs

1use std::{
2    fmt::{Debug, Display},
3    str::FromStr,
4};
5
6use aho_corasick::AhoCorasick;
7use base64::Engine;
8use chrono::{DateTime, Local};
9use enum_map::{Enum, EnumArray, EnumMap};
10use log::{error, warn};
11use num_traits::FromPrimitive;
12use once_cell::sync::Lazy;
13
14use crate::{error::SFError, gamestate::ServerTime};
15
16pub const HASH_CONST: &str = "ahHoj2woo1eeChiech6ohphoB7Aithoh";
17pub const DEFAULT_CRYPTO_KEY: &str = "[_/$VV&*Qg&)r?~g";
18pub const DEFAULT_CRYPTO_ID: &str = "0-00000000000000";
19pub const DEFAULT_SESSION_ID: &str = "00000000000000000000000000000000";
20pub const CRYPTO_IV: &str = "jXT#/vz]3]5X7Jl\\";
21
22#[must_use]
23pub fn sha1_hash(val: &str) -> String {
24    use sha1::{Digest, Sha1};
25    let mut hasher = Sha1::new();
26    hasher.update(val.as_bytes());
27    let hash = hasher.finalize();
28    let mut result = String::with_capacity(hash.len() * 2);
29    for byte in &hash {
30        result.push_str(&format!("{byte:02x}"));
31    }
32    result
33}
34
35/// Converts a raw value into the appropriate type. If that is not possible,
36/// a warning will be emitted and the given default returned. This is useful
37/// for stuff, that should not crash everything, when there is a weird value and
38/// the silent failure of `as T`, or `unwrap_or_default()` would yield worse
39/// results and/or no warning
40#[inline]
41pub(crate) fn soft_into<B: Display + Copy, T: TryFrom<B>>(
42    val: B,
43    name: &str,
44    default: T,
45) -> T {
46    val.try_into().unwrap_or_else(|_| {
47        log::warn!("Invalid value for {name} in server response: {val}");
48        default
49    })
50}
51
52/// Tries to convert val to T. If that fails a warning is emitted and none is
53/// returned
54#[inline]
55pub(crate) fn warning_try_into<B: Display + Copy, T: TryFrom<B>>(
56    val: B,
57    name: &str,
58) -> Option<T> {
59    val.try_into().ok().or_else(|| {
60        log::warn!("Invalid value for {name} in server response: {val}");
61        None
62    })
63}
64
65/// Converts the value using the function. If that fails, a warning is emitted
66/// and None is returned
67#[inline]
68pub(crate) fn warning_parse<T, F, V: Display + Copy>(
69    val: V,
70    name: &str,
71    conv: F,
72) -> Option<T>
73where
74    F: Fn(V) -> Option<T>,
75{
76    conv(val).or_else(|| {
77        log::warn!("Invalid value for {name} in server response: {val}");
78        None
79    })
80}
81
82#[inline]
83pub(crate) fn warning_from_str<T: FromStr>(val: &str, name: &str) -> Option<T> {
84    val.parse().ok().or_else(|| {
85        log::warn!("Invalid value for {name} in server response: {val}");
86        None
87    })
88}
89
90/// Converts a S&F string from the server to their original unescaped
91/// representation
92#[must_use]
93pub fn from_sf_string(val: &str) -> String {
94    pattern_replace::<true>(val)
95}
96
97/// Makes a user controlled string, like the character description safe to use
98/// in a request
99#[must_use]
100pub fn to_sf_string(val: &str) -> String {
101    pattern_replace::<false>(val)
102}
103
104/// Calling `.replace()` a bunch of times is bad, as that generates a bunch of
105/// strings. regex!() -> `replace_all()`  would be better, as that uses cow<>
106/// irrc, but we can replace pattern with a linear search an one string, using
107/// this extra crate. We call this function a bunch, so optimizing this is
108/// probably worth it
109#[allow(clippy::expect_used)]
110fn pattern_replace<const FROM: bool>(str: &str) -> String {
111    static A: Lazy<(AhoCorasick, &'static [&'static str; 11])> =
112        Lazy::new(|| {
113            let l = sf_str_lookups();
114            (
115                aho_corasick::AhoCorasick::new(l.0)
116                    .expect("const pattern a wrong"),
117                l.1,
118            )
119        });
120
121    static B: Lazy<(AhoCorasick, &'static [&'static str; 11])> =
122        Lazy::new(|| {
123            let l = sf_str_lookups();
124            (
125                aho_corasick::AhoCorasick::new(l.1)
126                    .expect("const pattern b wrong"),
127                l.0,
128            )
129        });
130
131    let (from, to) = if FROM { A.clone() } else { B.clone() };
132    let mut wtr = vec![];
133    from.try_stream_replace_all(str.as_bytes(), &mut wtr, to)
134        .expect("stream_replace_all failed");
135
136    if let Ok(res) = String::from_utf8(wtr) {
137        res
138    } else {
139        error!("replace generated invalid utf8");
140        String::new()
141    }
142}
143
144/// This function is designed for reverse engineering encrypted commands from
145/// the S&F web client. It expects a login response, which is the ~3KB string
146/// response you can see in the network tab of your browser, that starts with
147/// `serverversion` after a login. After that, you can take any URL the client
148/// sends to the server and have it decoded into the actual string command, that
149/// was sent. Note that this function technically only needs the crypto key, not
150/// the full response, but it is way easier to just copy paste the full
151/// response. The command returned here will be `Command::Custom`
152///
153/// # Errors
154///
155/// If either the URL, or the response do not contain the necessary crypto
156/// values, an `InvalidRequest` error will be returned, that mentions the part,
157/// that is missing or malformed. The same goes for the necessary parts of the
158/// decrypted command
159pub fn decrypt_url(
160    encrypted_url: &str,
161    login_resp: Option<&str>,
162) -> Result<crate::command::Command, SFError> {
163    let crypto_key = if let Some(login_resp) = login_resp {
164        login_resp
165            .split('&')
166            .filter_map(|a| a.split_once(':'))
167            .find(|a| a.0 == "cryptokey")
168            .ok_or(SFError::InvalidRequest("No crypto key in login resp"))?
169            .1
170    } else {
171        DEFAULT_CRYPTO_KEY
172    };
173
174    let encrypted = encrypted_url
175        .split_once("req=")
176        .ok_or(SFError::InvalidRequest("url does not contain request"))?
177        .1
178        .rsplit_once("&rnd=")
179        .ok_or(SFError::InvalidRequest("url does not contain rnd"))?
180        .0;
181
182    let resp = encrypted.get(DEFAULT_CRYPTO_ID.len()..).ok_or(
183        SFError::InvalidRequest("encrypted command does not contain crypto id"),
184    )?;
185    let full_resp = decrypt_server_request(resp, crypto_key)?;
186
187    let (_session_id, command) = full_resp.split_once('|').ok_or(
188        SFError::InvalidRequest("decrypted command has no session id"),
189    )?;
190
191    let (cmd_name, args) = command
192        .split_once(':')
193        .ok_or(SFError::InvalidRequest("decrypted command has no name"))?;
194    let args: Vec<_> = args
195        .trim_end_matches('|')
196        .split('/')
197        .map(std::string::ToString::to_string)
198        .collect();
199
200    Ok(crate::command::Command::Custom {
201        cmd_name: cmd_name.to_string(),
202        arguments: args,
203    })
204}
205
206#[allow(clippy::missing_errors_doc)]
207pub fn decrypt_server_request(
208    to_decrypt: &str,
209    key: &str,
210) -> Result<String, SFError> {
211    let text = base64::engine::general_purpose::URL_SAFE
212        .decode(to_decrypt)
213        .map_err(|_| {
214            SFError::InvalidRequest("Value to decode is not base64")
215        })?;
216
217    let mut my_key = [0; 16];
218    my_key.copy_from_slice(
219        key.as_bytes()
220            .get(..16)
221            .ok_or(SFError::InvalidRequest("Key is not 16 bytes long"))?,
222    );
223
224    let mut cipher = libaes::Cipher::new_128(&my_key);
225    cipher.set_auto_padding(false);
226    let decrypted = cipher.cbc_decrypt(CRYPTO_IV.as_bytes(), &text);
227
228    String::from_utf8(decrypted)
229        .map_err(|_| SFError::InvalidRequest("Decrypted value is not UTF8"))
230}
231
232#[cfg(feature = "session")]
233pub(crate) fn encrypt_server_request(
234    to_encrypt: String,
235    key: &str,
236) -> Result<String, SFError> {
237    let mut my_key = [0; 16];
238    my_key.copy_from_slice(
239        key.as_bytes()
240            .get(..16)
241            .ok_or(SFError::InvalidRequest("Invalid crypto key"))?,
242    );
243
244    let mut cipher = libaes::Cipher::new_128(&my_key);
245    cipher.set_auto_padding(false);
246
247    // This feels wrong, but the normal padding does not work. No idea what the
248    // default padding strategy is
249    let mut to_encrypt = to_encrypt.into_bytes();
250    while to_encrypt.len() % 16 != 0 {
251        to_encrypt.push(0);
252    }
253    let encrypted = cipher.cbc_encrypt(CRYPTO_IV.as_bytes(), &to_encrypt);
254
255    Ok(base64::engine::general_purpose::URL_SAFE.encode(encrypted))
256}
257
258pub(crate) fn parse_vec<B: Display + Copy + std::fmt::Debug, T, F>(
259    data: &[B],
260    name: &'static str,
261    func: F,
262) -> Result<Vec<T>, SFError>
263where
264    F: Fn(B) -> Option<T>,
265{
266    data.iter()
267        .map(|a| {
268            func(*a)
269                .ok_or_else(|| SFError::ParsingError(name, format!("{data:?}")))
270        })
271        .collect()
272}
273
274/// The mappings to convert between a normal and an sf string
275const fn sf_str_lookups(
276) -> (&'static [&'static str; 11], &'static [&'static str; 11]) {
277    (
278        &[
279            "$b", "$c", "$P", "$s", "$p", "$+", "$q", "$r", "$C", "$S", "$d",
280        ],
281        &["\n", ":", "%", "/", "|", "&", "\"", "#", ",", ";", "$"],
282    )
283}
284
285fn raw_cget<T: Copy + std::fmt::Debug>(
286    val: &[T],
287    pos: usize,
288    name: &'static str,
289) -> Result<T, SFError> {
290    val.get(pos)
291        .copied()
292        .ok_or_else(|| SFError::TooShortResponse {
293            name,
294            pos,
295            array: format!("{val:?}"),
296        })
297}
298
299pub(crate) trait CGet<T: Copy + std::fmt::Debug> {
300    fn cget(&self, pos: usize, name: &'static str) -> Result<T, SFError>;
301}
302
303impl<T: Copy + std::fmt::Debug + Display> CGet<T> for [T] {
304    fn cget(&self, pos: usize, name: &'static str) -> Result<T, SFError> {
305        raw_cget(self, pos, name)
306    }
307}
308
309#[allow(unused)]
310pub(crate) trait CCGet<T: Copy + std::fmt::Debug + Display, I: TryFrom<T>> {
311    fn csiget(
312        &self,
313        pos: usize,
314        name: &'static str,
315        def: I,
316    ) -> Result<I, SFError>;
317    fn csimget(
318        &self,
319        pos: usize,
320        name: &'static str,
321        def: I,
322        fun: fn(T) -> T,
323    ) -> Result<I, SFError>;
324    fn cwiget(
325        &self,
326        pos: usize,
327        name: &'static str,
328    ) -> Result<Option<I>, SFError>;
329    fn ciget(&self, pos: usize, name: &'static str) -> Result<I, SFError>;
330    fn cimget(
331        &self,
332        pos: usize,
333        name: &'static str,
334        fun: fn(T) -> T,
335    ) -> Result<I, SFError>;
336}
337
338impl<T: Copy + std::fmt::Debug + Display, I: TryFrom<T>> CCGet<T, I> for [T] {
339    fn csiget(
340        &self,
341        pos: usize,
342        name: &'static str,
343        def: I,
344    ) -> Result<I, SFError> {
345        let raw = raw_cget(self, pos, name)?;
346        Ok(soft_into(raw, name, def))
347    }
348
349    fn cwiget(
350        &self,
351        pos: usize,
352        name: &'static str,
353    ) -> Result<Option<I>, SFError> {
354        let raw = raw_cget(self, pos, name)?;
355        Ok(warning_try_into(raw, name))
356    }
357
358    fn csimget(
359        &self,
360        pos: usize,
361        name: &'static str,
362        def: I,
363        fun: fn(T) -> T,
364    ) -> Result<I, SFError> {
365        let raw = raw_cget(self, pos, name)?;
366        let raw = fun(raw);
367        Ok(soft_into(raw, name, def))
368    }
369
370    fn ciget(&self, pos: usize, name: &'static str) -> Result<I, SFError> {
371        let raw = raw_cget(self, pos, name)?;
372        raw.try_into()
373            .map_err(|_| SFError::ParsingError(name, raw.to_string()))
374    }
375
376    fn cimget(
377        &self,
378        pos: usize,
379        name: &'static str,
380        fun: fn(T) -> T,
381    ) -> Result<I, SFError> {
382        let raw = raw_cget(self, pos, name)?;
383        let raw = fun(raw);
384        raw.try_into()
385            .map_err(|_| SFError::ParsingError(name, raw.to_string()))
386    }
387}
388
389pub(crate) trait CSGet<T: FromStr> {
390    fn cfsget(
391        &self,
392        pos: usize,
393        name: &'static str,
394    ) -> Result<Option<T>, SFError>;
395    fn cfsuget(&self, pos: usize, name: &'static str) -> Result<T, SFError>;
396}
397
398impl<T: FromStr> CSGet<T> for [&str] {
399    fn cfsget(
400        &self,
401        pos: usize,
402        name: &'static str,
403    ) -> Result<Option<T>, SFError> {
404        let raw = raw_cget(self, pos, name)?;
405        Ok(warning_from_str(raw, name))
406    }
407
408    fn cfsuget(&self, pos: usize, name: &'static str) -> Result<T, SFError> {
409        let raw = raw_cget(self, pos, name)?;
410        let Some(val) = warning_from_str(raw, name) else {
411            return Err(SFError::ParsingError(name, raw.to_string()));
412        };
413        Ok(val)
414    }
415}
416
417pub(crate) fn update_enum_map<
418    B: Default + TryFrom<i64>,
419    A: enum_map::Enum + enum_map::EnumArray<B>,
420>(
421    map: &mut enum_map::EnumMap<A, B>,
422    vals: &[i64],
423) {
424    for (map_val, val) in map.as_mut_slice().iter_mut().zip(vals) {
425        *map_val = soft_into(*val, "attribute val", B::default());
426    }
427}
428
429/// This is a workaround for clippy index warnings for safe index ops. It
430/// also is more convenient in some cases to use these fundtions if you want
431/// to make sure something is &mut, or &
432pub trait EnumMapGet<K, V> {
433    /// Gets a normal reference to the value
434    fn get(&self, key: K) -> &V;
435    /// Gets a mutable reference to the value
436    fn get_mut(&mut self, key: K) -> &mut V;
437}
438
439impl<K: Enum + EnumArray<V>, V> EnumMapGet<K, V> for EnumMap<K, V> {
440    fn get(&self, key: K) -> &V {
441        #[allow(clippy::indexing_slicing)]
442        &self[key]
443    }
444
445    fn get_mut(&mut self, key: K) -> &mut V {
446        #[allow(clippy::indexing_slicing)]
447        &mut self[key]
448    }
449}
450
451pub(crate) trait ArrSkip<T: Debug> {
452    /// Basically does the equivalent of [pos..], but bounds checked with
453    /// correct errors
454    fn skip(&self, pos: usize, name: &'static str) -> Result<&[T], SFError>;
455}
456
457impl<T: Debug> ArrSkip<T> for [T] {
458    fn skip(&self, pos: usize, name: &'static str) -> Result<&[T], SFError> {
459        if pos > self.len() {
460            return Err(SFError::TooShortResponse {
461                name,
462                pos,
463                array: format!("{self:?}"),
464            });
465        }
466        Ok(self.split_at(pos).1)
467    }
468}
469
470pub(crate) trait CFPGet<T: Into<i64> + Copy + std::fmt::Debug, R: FromPrimitive>
471{
472    fn cfpget(
473        &self,
474        pos: usize,
475        name: &'static str,
476        fun: fn(T) -> T,
477    ) -> Result<Option<R>, SFError>;
478
479    fn cfpuget(
480        &self,
481        pos: usize,
482        name: &'static str,
483        fun: fn(T) -> T,
484    ) -> Result<R, SFError>;
485}
486
487impl<T: Into<i64> + Copy + std::fmt::Debug, R: FromPrimitive> CFPGet<T, R>
488    for [T]
489{
490    fn cfpget(
491        &self,
492        pos: usize,
493        name: &'static str,
494        fun: fn(T) -> T,
495    ) -> Result<Option<R>, SFError> {
496        let raw = raw_cget(self, pos, name)?;
497        let raw = fun(raw);
498        let t: i64 = raw.into();
499        let res = FromPrimitive::from_i64(t);
500        if res.is_none() && t != 0 && t != -1 {
501            warn!("There might be a new {name} -> {t}");
502        }
503        Ok(res)
504    }
505
506    fn cfpuget(
507        &self,
508        pos: usize,
509        name: &'static str,
510        fun: fn(T) -> T,
511    ) -> Result<R, SFError> {
512        let raw = raw_cget(self, pos, name)?;
513        let raw = fun(raw);
514        let t: i64 = raw.into();
515        FromPrimitive::from_i64(t)
516            .ok_or_else(|| SFError::ParsingError(name, t.to_string()))
517    }
518}
519
520pub(crate) trait CSTGet<T: Copy + Debug + Into<i64>> {
521    fn cstget(
522        &self,
523        pos: usize,
524        name: &'static str,
525        server_time: ServerTime,
526    ) -> Result<Option<DateTime<Local>>, SFError>;
527}
528
529impl<T: Copy + Debug + Into<i64>> CSTGet<T> for [T] {
530    fn cstget(
531        &self,
532        pos: usize,
533        name: &'static str,
534        server_time: ServerTime,
535    ) -> Result<Option<DateTime<Local>>, SFError> {
536        let val = raw_cget(self, pos, name)?;
537        let val = val.into();
538        Ok(server_time.convert_to_local(val, name))
539    }
540}