sf_api/
misc.rs

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