Rain 10 місяців тому
батько
коміт
94a180101c
7 змінених файлів з 343 додано та 73 видалено
  1. 47 11
      client/src/actions.rs
  2. 82 16
      client/src/main.rs
  3. 18 6
      client/src/persistence.rs
  4. 87 25
      client/src/ui.rs
  5. 12 0
      utils/src/hash.rs
  6. 81 13
      utils/src/serialize.rs
  7. 16 2
      utils/src/strings.rs

+ 47 - 11
client/src/actions.rs

@@ -46,8 +46,11 @@ pub fn on_input_submit(siv: &mut Cursive, input_field_id: &str) {
             ui::error(siv, ui::Labels::FailedToSendMessage.localize(language));
         }
 
-        if !error_dialogue_shown && load_messages(siv).is_err() {
-            ui::error(siv, ui::Labels::FailedToLoadMessages.localize(language));
+        let res = load_messages(siv);
+        if let Err(e) = res {
+            if !error_dialogue_shown {
+                ui::error(siv, ui::Labels::FailedToLoadMessages(e).localize(language));
+            }
         }
     } else if !text.is_empty() {
         // Invalid message
@@ -64,25 +67,58 @@ pub fn clear_input_field(siv: &mut Cursive) {
     .expect("Failed to clear input field.");
 }
 
-pub fn send_message(siv: &mut Cursive, message: Message) -> reqwest::Result<()> {
-    let api_endpoint = get_appdata(siv).api_endpoint;
+type StatusCode = u16;
+
+pub enum NetworkError {
+    #[allow(dead_code)]
+    ReqwestError(reqwest::Error),
+    StatusCodeError(StatusCode),
+    InvalidUrl,
+}
+
+fn fix_url(url: &str) -> Result<String, NetworkError> {
+    if url.is_empty() {
+        return Err(NetworkError::InvalidUrl);
+    }
+
+    let mut url = url.to_string();
+    if !url.starts_with("http://") && !url.starts_with("https://") {
+        url = format!("http://{}", url);
+    }
+    if !url.ends_with('/') {
+        url.push('/');
+    }
+
+    Ok(url)
+}
+
+pub fn send_message(siv: &mut Cursive, message: Message) -> Result<(), NetworkError> {
+    let url = fix_url(&get_appdata(siv).api_endpoint)?;
     let str = utils::binary::bin2hex(message.serialize_checked());
 
     reqwest::blocking::Client::new()
-        .post(format!("{}/", api_endpoint))
+        .post(format!("{}", url))
         .body(str)
-        .send()?;
+        .send()
+        .map_err(|e| NetworkError::ReqwestError(e))?;
 
     Ok(())
 }
 
-pub fn load_messages(siv: &mut Cursive) -> reqwest::Result<()> {
+pub fn load_messages(siv: &mut Cursive) -> Result<(), NetworkError> {
     let appdata = get_appdata(siv);
+    let url = fix_url(&appdata.api_endpoint)?;
+
+    let resp = reqwest::blocking::Client::new()
+        .get(url)
+        .send()
+        .map_err(|e| NetworkError::ReqwestError(e))?;
+
+    if resp.status() != reqwest::StatusCode::OK {
+        return Err(NetworkError::StatusCodeError(resp.status().as_u16()));
+    }
 
-    let bytes = reqwest::blocking::Client::new()
-        .get(format!("{}/", appdata.api_endpoint))
-        .send()?
-        .bytes()?;
+    let bytes = resp.bytes().map_err(|e| NetworkError::ReqwestError(e))?;
 
     let contents = String::from_utf8_lossy(&bytes);
     let mut messages = vec![];

+ 82 - 16
client/src/main.rs

@@ -1,5 +1,3 @@
-// TODO: CLI args for save location and server address
-
 mod actions;
 mod message;
 mod persistence;
@@ -29,19 +27,18 @@ const DEFAULT_USERNAME_PREFIX: &str = "Myst";
 
 const SAVE_FILE: &str = "savedata.bin";
 const SAVE_FILE_FUZZY: u64 = 0b0110110001101001011001110110110101100001001000000101100001000100;
-const REMOTE_REFRESH_RATE: u64 = 10;
 
-// TODO: Add server refresh rate
 #[derive(Clone)]
 pub struct Appdata {
     pub username: String,
     pub language: ui::Language,
     pub blocked_phrases: Vec<String>,
     pub blocked_phrases_censor_char: char,
+    pub api_endpoint: String,
+    pub api_refresh_rate: usize,
 
     pub messages: HashMap<String, Message>,
     pub quick_close_window_count: usize,
-    pub api_endpoint: String,
 }
 
 impl Default for Appdata {
@@ -61,7 +58,8 @@ impl Appdata {
             ),
             language: ui::Language::English,
             quick_close_window_count: 0,
-            api_endpoint: "http://127.0.0.1:8080".to_string(),
+            api_endpoint: String::new(),
+            api_refresh_rate: 10,
             blocked_phrases: Vec::new(),
             blocked_phrases_censor_char: '*',
         }
@@ -109,6 +107,7 @@ fn main() {
     let mut siv = Cursive::default();
     siv.set_user_data(Appdata::new());
 
+    // TODO: Add a notice when the file is corrupted.
     load_appdata(&mut siv);
 
     // Global hotkeys
@@ -123,10 +122,12 @@ fn main() {
         thread::spawn(move || loop {
             cb_sink
                 .send(Box::new(move |siv| {
+                    let appdata = get_appdata(siv);
                     ui::visual_update(siv);
 
-                    if timer % REMOTE_REFRESH_RATE == 0 {
-                        actions::load_messages(siv);
+                    if timer % appdata.api_refresh_rate == 0 {
+                        // TODO (low): Add a notice when automatic refresh fails.
+                        let _ = actions::load_messages(siv);
                     }
 
                     save_appdata(siv);
@@ -186,7 +187,7 @@ fn main() {
 
         let language = get_appdata(siv).language;
 
-        ui::keybind_close_once(siv);
+        ui::keybind_setup_close_once(siv);
 
         siv.add_layer(
             cursive::views::Dialog::new()
@@ -224,7 +225,7 @@ fn main() {
         let current_name = get_appdata(siv).username.clone();
         let language = get_appdata(siv).language;
 
-        ui::keybind_close_once(siv);
+        ui::keybind_setup_close_once(siv);
 
         siv.add_layer(
             cursive::views::Dialog::new()
@@ -270,9 +271,9 @@ fn main() {
 
     // Refresh button
     let refresh_button = Button::new("", move |siv| {
-        if actions::load_messages(siv).is_err() {
+        if let Err(e) = actions::load_messages(siv) {
             let language = get_appdata(siv).language;
-            ui::error(siv, ui::Labels::FailedToLoadMessages.localize(language));
+            ui::error(siv, ui::Labels::FailedToLoadMessages(e).localize(language));
         }
         ui::visual_update(siv);
     })
@@ -306,19 +307,18 @@ fn main() {
             wordslist_view.add_all_str(&appdata.blocked_phrases);
 
             let typingarea_id = utils::new_id();
-            let appdata_typingarea_clone = appdata.clone();
             let typing_area = EditView::new().with_name(&typingarea_id);
             let typing_button = Button::new(ui::Labels::Submit.localize(language), move |siv| {
                 let text = siv
                     .call_on_name(&typingarea_id, |view: &mut EditView| {
-                        let s = view.get_content();
+                        let s = utils::strings::insensitive_string(&view.get_content());
                         view.set_content("");
                         s
                     })
                     .map(|x| x.to_string())
                     .unwrap_or("".to_string());
 
-                if appdata_typingarea_clone.blocked_phrases.contains(&text) || text.is_empty() {
+                if get_appdata(siv).blocked_phrases.contains(&text) || text.is_empty() {
                     return;
                 }
 
@@ -352,12 +352,76 @@ fn main() {
                         ),
                 );
 
-            ui::keybind_close_once(siv);
+            ui::keybind_setup_close_once(siv);
             siv.add_layer(d);
         })
         .with_name(ui::BLOCKED_WORDS_BUTTON_ID)
     };
 
+    // Server settings
+    let server_settings_button = Button::new("", |siv| {
+        let appdata = get_appdata(siv);
+        let language = appdata.language;
+
+        ui::keybind_setup_close_once(siv);
+
+        siv.add_layer(
+            cursive::views::Dialog::new()
+                .title(ui::Labels::ServerSettings.localize(language))
+                .content(
+                    LinearLayout::vertical()
+                        .child(
+                            LinearLayout::horizontal()
+                                .child(
+                                    TextView::new(ui::Labels::ServerAddress.localize(language))
+                                        .fixed_width(ui::SERVER_SETTINGS_FIELD_SIZE.0 / 2),
+                                )
+                                .child(DummyView.fixed_width(1))
+                                .child(
+                                    EditView::new()
+                                        .content(appdata.api_endpoint)
+                                        .with_name(ui::SERVER_SETTINGS_FIELD_ID)
+                                        .min_width(ui::SERVER_SETTINGS_FIELD_SIZE.0)
+                                        .max_height(ui::SERVER_SETTINGS_FIELD_SIZE.1),
+                                ),
+                        )
+                        .child(
+                            LinearLayout::horizontal()
+                                .child(
+                                    TextView::new(ui::Labels::ServerRefreshRate.localize(language))
+                                        .fixed_width(ui::SERVER_SETTINGS_FIELD_SIZE.0 / 2),
+                                )
+                                .child(DummyView.fixed_width(1))
+                                .child(
+                                    EditView::new()
+                                        .content(appdata.api_refresh_rate.to_string())
+                                        .min_width(ui::SERVER_SETTINGS_FIELD_SIZE.0)
+                                        .max_height(ui::SERVER_SETTINGS_FIELD_SIZE.1),
+                                ),
+                        ),
+                )
+                .button(ui::Labels::Ok.localize(language), |siv| {
+                    let input = siv
+                        .call_on_name(ui::SERVER_SETTINGS_FIELD_ID, |view: &mut EditView| {
+                            view.get_content()
+                        })
+                        .expect("Failed to retrieve server settings input.");
+
+                    siv.with_user_data(|appdata: &mut Appdata| {
+                        appdata.api_endpoint = input.to_string();
+                    });
+
+                    ui::visual_update(siv);
+                    ui::keybind_close_manual_end(siv, false);
+                })
+                .button(ui::Labels::Cancel.localize(language), |siv| {
+                    ui::keybind_close_manual_end(siv, false);
+                })
+                .min_size(DIALOGUE_MIN_SIZE),
+        );
+    })
+    .with_name(ui::SERVER_SETTINGS_BUTTON_ID);
+
     // Main layout
     let main_layout = LinearLayout::vertical()
         .child(
@@ -381,6 +445,8 @@ fn main() {
                 .child(DummyView.full_width())
                 .child(blocked_words_list_button)
                 .child(DummyView.full_width())
+                .child(server_settings_button)
+                .child(DummyView.full_width())
                 .child(refresh_button)
                 .full_width(),
         );

+ 18 - 6
client/src/persistence.rs

@@ -7,6 +7,8 @@ pub struct Savedata {
     pub language: ui::Language,
     pub blocked_phrases: Vec<String>,
     pub blocked_phrases_censor_char: char,
+    pub api_endpoint: String,
+    pub api_refresh_rate: u64,
 }
 
 impl From<Appdata> for Savedata {
@@ -16,6 +18,8 @@ impl From<Appdata> for Savedata {
             language: value.language,
             blocked_phrases: value.blocked_phrases,
             blocked_phrases_censor_char: value.blocked_phrases_censor_char,
+            api_endpoint: value.api_endpoint,
+            api_refresh_rate: value.api_refresh_rate as u64,
         }
     }
 }
@@ -28,6 +32,8 @@ impl From<Savedata> for Appdata {
         s.language = value.language;
         s.blocked_phrases = value.blocked_phrases;
         s.blocked_phrases_censor_char = value.blocked_phrases_censor_char;
+        s.api_endpoint = value.api_endpoint;
+        s.api_refresh_rate = value.api_refresh_rate as usize;
 
         s
     }
@@ -35,6 +41,7 @@ impl From<Savedata> for Appdata {
 
 impl Serialize for ui::Language {
     const SIGNATURE: &'static str = "Language";
+    const LENGTH: usize = 1;
 
     fn serialize_unchecked(&self) -> Vec<u8> {
         let b = match self {
@@ -53,10 +60,9 @@ impl Serialize for ui::Language {
     {
         let data = data.as_ref();
 
-        if data.len() != 1 {
-            return Err(DeserializationError::InvalidLength);
-        }
-        let byte = data[0];
+        let byte = data
+            .first()
+            .ok_or(DeserializationError::InvalidDataLength)?;
 
         let language = match byte {
             1 => ui::Language::English,
@@ -79,6 +85,8 @@ impl Serialize for Savedata {
         data.push(self.language.serialize_checked());
         data.push(self.blocked_phrases.serialize_checked());
         data.push(self.blocked_phrases_censor_char.serialize_checked());
+        data.push(self.api_endpoint.serialize_checked());
+        data.push(self.api_refresh_rate.serialize_checked());
 
         data.serialize_checked()
     }
@@ -91,20 +99,24 @@ impl Serialize for Savedata {
         let data = data.as_ref();
         let serialized_items = SetOfSerializedObjects::deserialize_checked(data)?;
 
-        if serialized_items.len() != 4 {
-            return Err(DeserializationError::InvalidLength);
+        if serialized_items.len() != 6 {
+            return Err(DeserializationError::InvalidDataLength);
         }
 
         let username = String::deserialize_checked(&serialized_items[0])?;
         let language = ui::Language::deserialize_checked(&serialized_items[1])?;
         let blocked_phrases = Vec::<String>::deserialize_checked(&serialized_items[2])?;
         let blocked_phrases_censor_char = char::deserialize_checked(&serialized_items[3])?;
+        let api_endpoint = String::deserialize_checked(&serialized_items[4])?;
+        let api_refresh_rate = u64::deserialize_checked(&serialized_items[5])?;
 
         Ok(Self {
             username,
             language,
             blocked_phrases,
             blocked_phrases_censor_char,
+            api_endpoint,
+            api_refresh_rate,
         })
     }
 }

+ 87 - 25
client/src/ui.rs

@@ -5,6 +5,7 @@ use cursive::views::{
 use cursive::{event, Cursive};
 use utils::hash::Hashable as _;
 
+use crate::actions::NetworkError;
 use crate::message::MessageSanitized;
 use crate::{get_appdata, message, Appdata, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
 
@@ -20,11 +21,13 @@ pub const USERNAME_FIELD_ID: &str = "username_field_id";
 pub const USERNAME_BUTTON_ID: &str = "username_button_id";
 pub const REFRESH_BUTTON_ID: &str = "refresh_button_id";
 pub const BLOCKED_WORDS_BUTTON_ID: &str = "blocked_words_view_id";
+pub const SERVER_SETTINGS_FIELD_ID: &str = "server_settings_field_id";
+pub const SERVER_SETTINGS_BUTTON_ID: &str = "server_settings_button_id";
 
 pub const DIALOGUE_MIN_SIZE: (u16, u16) = (20, 5);
+pub const SERVER_SETTINGS_FIELD_SIZE: (usize, usize) = (60, 1);
 pub const USER_PANEL_SIZE: usize = crate::MAX_USERNAME_LENGTH + 2;
 
-#[derive(Clone, Copy)]
 pub enum Labels {
     Ok,
     Cancel,
@@ -44,9 +47,12 @@ pub enum Labels {
     Username,
     InvalidMessage,
     FailedToSendMessage,
-    FailedToLoadMessages,
+    FailedToLoadMessages(NetworkError),
     RefreshButton,
     BlockedWords,
+    ServerSettings,
+    ServerAddress,
+    ServerRefreshRate,
 }
 
 #[derive(Debug, Clone, Copy)]
@@ -58,9 +64,11 @@ pub enum Language {
 
 impl Labels {
     // TODO: Double check the translations
-    pub fn localize(&self, language: Language) -> String {
-        let set = match self {
-            Labels::Ok => ["Ok", "OK", "OK"],
+    pub fn localize<'a>(&self, language: Language) -> String {
+        let buf: [String; 3];
+
+        let set: [&str; 3] = match self {
+            Labels::Ok => ["OK", "OK", "OK"],
             Labels::Cancel => ["Cancel", "Annuleren", "キャンセル"],
             Labels::Close => ["Close", "Sluiten", "閉じる"],
             Labels::Error => ["Error", "Fout", "エラー"],
@@ -87,28 +95,79 @@ impl Labels {
                 "メッセージの送信に失敗しました",
             ],
             Labels::SetLanguage => ["Set language", "Stel taal in", "言語を設定する"],
-            Labels::FailedToLoadMessages => [
-                "Failed to load messages",
-                "Berichten laden mislukt",
-                "メッセージの読み込みに失敗しました",
-            ],
+            Labels::FailedToLoadMessages(error) => {
+                let reason_buf: [String; 3];
+
+                match error {
+                    NetworkError::ReqwestError(_) => {
+                        reason_buf = [
+                            "Connection error.".to_string(),
+                            "Verbindingsfout.".to_string(),
+                            "接続エラー。".to_string(),
+                        ];
+                    }
+                    NetworkError::StatusCodeError(code) => {
+                        reason_buf = [
+                            format!("Status code: {code}"),
+                            format!("Statuscode: {code}"),
+                            format!("ステータスコード: {code}"),
+                        ];
+                    }
+                    NetworkError::InvalidUrl => {
+                        reason_buf = [
+                            "Invalid URL.".to_string(),
+                            "Ongeldig URL.".to_string(),
+                            "無効なURL。".to_string(),
+                        ];
+                    }
+                };
+
+                buf = [
+                    format!("Failed to load messages. {}", reason_buf[0]),
+                    format!("Berichten laden mislukt. {}", reason_buf[1]),
+                    format!("メッセージの読み込みに失敗しました。 {}", reason_buf[2]),
+                ];
+
+                [buf[0].as_str(), buf[1].as_str(), buf[2].as_str()]
+            }
             Labels::RefreshButton => ["Refresh", "Vernieuwen", "更新する"],
-            Labels::InvalidUsernameExplination => [
-                r"Invalid username. Must match ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$",
-                r"Ongeldige gebruikersnaam. Moet overeenkomen met ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$",
-                r"無効なユーザー名。 ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$ に一致する必要があります",
-            ],
-            Labels::InvalidMessage => [
-                "Invalid message. Must contain fewer than ${MAX_MESSAGE_LENGTH} characters",
-                "Ongeldig bericht. Moet minder dan ${MAX_MESSAGE_LENGTH} tekens bevatten",
-                "無効なメッセージ。 ${MAX_MESSAGE_LENGTH}文字未満である必要があります",
-            ],
+            Labels::InvalidUsernameExplination => {
+                buf = [
+                    r"Invalid username. Must match ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
+                    r"Ongeldige gebruikersnaam. Moet overeenkomen met ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
+                    r"無効なユーザー名。 ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$ に一致する必要があります"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
+                ];
+
+                [buf[0].as_str(), buf[1].as_str(), buf[2].as_str()]
+            }
+            Labels::InvalidMessage => {
+                buf = [
+                    "Invalid message. Must contain fewer than ${MAX_MESSAGE_LENGTH} characters"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_MESSAGE_LENGTH.to_string()),
+                    "Ongeldig bericht. Moet minder dan ${MAX_MESSAGE_LENGTH} tekens bevatten"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_MESSAGE_LENGTH.to_string()),
+                    "無効なメッセージ。 ${MAX_MESSAGE_LENGTH}文字未満である必要があります"
+                        .replace("${MAX_MESSAGE_LENGTH}", &MAX_MESSAGE_LENGTH.to_string()),
+                ];
+
+                [buf[0].as_str(), buf[1].as_str(), buf[2].as_str()]
+            }
             Labels::BlockedWords => [
                 "Blocked words",
                 "Geblokkeerde woorden",
                 "ブロックされた単語",
             ],
             Labels::Submit => ["Submit", "Indienen", "提出する"],
+            Labels::ServerSettings => ["Server settings", "Serverinstellingen", "サーバー設定"],
+            Labels::ServerAddress => ["Server address", "Serveradres", "サーバーアドレス"],
+            Labels::ServerRefreshRate => [
+                "Update rate (seconds)",
+                "Updatefrequentie (seconden)",
+                "更新率 (秒)",
+            ],
         };
 
         let idx = match language {
@@ -117,9 +176,7 @@ impl Labels {
             Language::Japanese => 2,
         };
 
-        set[idx]
-            .replace("${MAX_MESSAGE_LENGTH}", &MAX_MESSAGE_LENGTH.to_string())
-            .replace("${MAX_USERNAME_LENGTH}", &MAX_USERNAME_LENGTH.to_string())
+        set[idx].to_string()
     }
 }
 
@@ -129,7 +186,7 @@ where
 {
     let language = get_appdata(siv).language;
 
-    keybind_close_once(siv);
+    keybind_setup_close_once(siv);
 
     siv.add_layer(
         Dialog::text(text)
@@ -150,7 +207,7 @@ where
 }
 
 /// Sets up hotkey to close the most recent view
-pub fn keybind_close_once(siv: &mut Cursive) {
+pub fn keybind_setup_close_once(siv: &mut Cursive) {
     siv.with_user_data(|appdata: &mut Appdata| {
         appdata.quick_close_window_count += 1;
     });
@@ -308,6 +365,11 @@ pub fn visual_update(siv: &mut Cursive) {
                 .set_title(Labels::Username.localize(language));
         },
     );
+
+    siv.call_on_name(SERVER_SETTINGS_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::ServerSettings.localize(language));
+    });
 }
 
 pub fn change_language(siv: &mut Cursive, language: Language) {

+ 12 - 0
utils/src/hash.rs

@@ -21,6 +21,18 @@ where
     }
 }
 
+impl Hash {
+    pub fn as_bytes(&self) -> [u8; 32] {
+        self.hash
+    }
+
+    pub fn as_bytes_capped<const N: usize>(&self) -> [u8; N] {
+        let mut hash = [0u8; N];
+        hash.copy_from_slice(&self.hash[..N]);
+        hash
+    }
+}
+
 impl ToString for Hash {
     fn to_string(&self) -> String {
         self.hash.iter().map(|b| format!("{:02x}", b)).collect()

+ 81 - 13
utils/src/serialize.rs

@@ -1,11 +1,14 @@
-use std::error::Error;
+use std::{error::Error, fmt::Debug};
+
+use crate::hash;
 
 pub type Result<T> = std::result::Result<T, DeserializationError>;
 
 #[derive(Debug)]
 pub enum DeserializationError {
     InvalidSignature,
-    InvalidLength,
+    InvalidSignatureLength,
+    InvalidDataLength,
     #[allow(dead_code)]
     InvalidData(&'static str),
     InvalidDataLengthMarker,
@@ -21,6 +24,11 @@ impl std::fmt::Display for DeserializationError {
 
 pub trait Serialize {
     const SIGNATURE: &'static str;
+    const LENGTH: usize = 0;
+
+    fn signature() -> [u8; 8] {
+        hash::Hash::from(Self::SIGNATURE).as_bytes_capped::<8>()
+    }
 
     fn serialize_unchecked(&self) -> Vec<u8>;
     fn deserialize_unchecked<B>(data: B) -> Result<Self>
@@ -29,7 +37,7 @@ pub trait Serialize {
         Self: Sized;
 
     fn serialize_checked(&self) -> Vec<u8> {
-        let mut serialized = Self::SIGNATURE.as_bytes().to_vec();
+        let mut serialized = Self::signature().to_vec();
         serialized.extend_from_slice(&self.serialize_unchecked());
         serialized
     }
@@ -40,20 +48,43 @@ pub trait Serialize {
         Self: Sized,
     {
         let data = data.as_ref();
+        let signature = Self::signature();
 
-        if data.len() < Self::SIGNATURE.len() {
-            return Err(DeserializationError::InvalidLength);
+        if data.len() < signature.len() {
+            return Err(DeserializationError::InvalidSignatureLength);
         }
 
-        let signature = &data[..Self::SIGNATURE.len()];
-        if signature != Self::SIGNATURE.as_bytes() {
+        let encoded_sig = &data[..signature.len()];
+        if encoded_sig != signature {
             return Err(DeserializationError::InvalidSignature);
         }
 
-        Self::deserialize_unchecked(&data[Self::SIGNATURE.len()..])
+        let post_signature_data = &data[signature.len()..];
+        if Self::LENGTH > 0 && post_signature_data.len() != Self::LENGTH as usize {
+            return Err(DeserializationError::InvalidDataLength);
+        }
+
+        Self::deserialize_unchecked(post_signature_data)
     }
 }
 
+#[cfg(test)]
+fn basic_test<T>(value: T)
+where
+    T: Serialize + PartialEq + Debug,
+{
+    let serialized = value.serialize_unchecked();
+    let deserialized =
+        T::deserialize_unchecked(&serialized).expect("deserialize_unchecked failed.");
+
+    let ser_checked = value.serialize_checked();
+    let deserialized_checked =
+        T::deserialize_checked(&ser_checked).expect("deserialize_checked failed.");
+
+    assert_eq!(value, deserialized);
+    assert_eq!(value, deserialized_checked);
+}
+
 pub type SetOfSerializedObjects = Vec<Vec<u8>>;
 type SetOfSerializedObjectsLengthMarker = u16; // NOTE: 65534 bytes is the max length of a serialized object
 const SET_OF_SERIALIZED_OBJECTS_LENGTH_MARKER_BYTES: usize =
@@ -114,7 +145,7 @@ impl Serialize for SetOfSerializedObjects {
             let mut item_data = Vec::new();
             for _ in 0..length {
                 let Some(item_byte) = after_lengths_data.next() else {
-                    return Err(DeserializationError::InvalidLength);
+                    return Err(DeserializationError::InvalidDataLength);
                 };
                 item_data.push(item_byte);
             }
@@ -224,8 +255,14 @@ impl Serialize for String {
     }
 }
 
+#[test]
+fn test_string_serialization() {
+    basic_test(String::from("Hello, world!"));
+}
+
 impl Serialize for char {
     const SIGNATURE: &'static str = "char";
+    const LENGTH: usize = 4;
 
     fn serialize_unchecked(&self) -> Vec<u8> {
         (*self as u32).to_be_bytes().to_vec()
@@ -238,10 +275,6 @@ impl Serialize for char {
     {
         let data = data.as_ref();
 
-        if data.len() != 4 {
-            return Err(DeserializationError::InvalidLength);
-        }
-
         let mut buf = [0u8; 4];
         buf.copy_from_slice(data);
 
@@ -251,3 +284,38 @@ impl Serialize for char {
         Ok(c)
     }
 }
+
+#[test]
+fn test_char_serialization() {
+    basic_test('A');
+}
+
+impl Serialize for u64 {
+    const SIGNATURE: &'static str = "u64";
+    const LENGTH: usize = u64::BITS as usize / 8;
+
+    fn serialize_unchecked(&self) -> Vec<u8> {
+        let b = self.to_be_bytes();
+        b.to_vec()
+    }
+
+    fn deserialize_unchecked<B>(data: B) -> Result<Self>
+    where
+        B: AsRef<[u8]>,
+        Self: Sized,
+    {
+        let data = data.as_ref();
+
+        let mut buf = [0u8; Self::LENGTH];
+        buf.copy_from_slice(data);
+
+        let i = u64::from_be_bytes(buf);
+
+        Ok(i)
+    }
+}
+
+#[test]
+fn test_u64_serialization() {
+    basic_test(1234567890);
+}

+ 16 - 2
utils/src/strings.rs

@@ -92,12 +92,26 @@ impl<const N: usize> StaticString<N> {
     }
 }
 
+pub fn insensitive_string(s: &str) -> String {
+    let out = s.to_lowercase();
+
+    assert_eq!(out.len(), s.len());
+    out
+}
+
 pub fn remove_bad_words(s: &str, wordlist: &[String], censor_char: char) -> String {
     let mut s = s.to_string();
+    let mut s_ins = insensitive_string(&s);
 
     for word in wordlist {
-        let replacement = censor_char.to_string().repeat(word.len());
-        s = s.replace(word, &replacement);
+        let word_len = word.len();
+        let replacement = censor_char.to_string().repeat(word_len);
+
+        while let Some(start) = s_ins.find(word) {
+            let end = start + word_len;
+            s.replace_range(start..end, &replacement);
+            s_ins = insensitive_string(&s);
+        }
     }
 
     s