Rain 1 månad sedan
förälder
incheckning
a68550ed66

+ 1 - 0
Cargo.toml

@@ -2,6 +2,7 @@
 resolver = "2"
 members = [
     "client",
+    "client_cli",
     "client_shared",
     "logging",
     "procmacro",

+ 5 - 2
client/src/actions.rs

@@ -97,7 +97,9 @@ pub enum NetworkError {
     UrlError(client_shared::utils::UrlError),
 }
 
-pub fn send_message(siv: &mut Cursive, message: Message) -> Result<(), NetworkError> {
+pub fn send_message(siv: &mut Cursive, mut message: Message) -> Result<(), NetworkError> {
+    message.sanitize();
+
     let appdata = get_appdata(siv);
     let url = fix_url(&appdata.persistent_data.api_endpoint).map_err(|x| NetworkError::UrlError(x))?;
     let password = appdata.persistent_data.api_password;
@@ -163,11 +165,12 @@ pub fn load_messages(siv: &mut Cursive) -> Result<(), NetworkError> {
             continue;
         }
 
-        let Ok(message) = Message::deserialize_checked(message_ser_bin) else {
+        let Ok(mut message) = Message::deserialize_checked(message_ser_bin) else {
             continue;
         };
 
         if message.is_valid() {
+            message.sanitize();
             messages.push(message);
         }
     }

+ 5 - 5
client/src/ui.rs

@@ -253,11 +253,11 @@ impl Labels {
             Labels::NewChannel => ["New Channel", "Nieuw kanaal", "新しいチャンネル"],
             Labels::InvalidChannelNameExplination => {
                 buf = [
-                    r"Invalid channel name. Must match ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
+                    r"Invalid channel name. Must match ^[a-z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
                         .replace("${MAX_USERNAME_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
-                    r"Ongeldige kanaalnaam. Moet overeenkomen met ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
+                    r"Ongeldige kanaalnaam. Moet overeenkomen met ^[a-z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$"
                         .replace("${MAX_USERNAME_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
-                    r"無効なチャンネル名。 ^[a-zA-Z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$ に一致する必要があります"
+                    r"無効なチャンネル名。 ^[a-z0-9#_\-\.]{2,${MAX_USERNAME_LENGTH}}$ に一致する必要があります"
                         .replace("${MAX_USERNAME_LENGTH}", &MAX_USERNAME_LENGTH.to_string()),
                 ];
 
@@ -389,7 +389,7 @@ pub fn visual_update(siv: &mut Cursive) {
         .cloned()
         .map(MessageSanitized::from)
         .collect::<Vec<_>>();
-    messages.sort_by(|a, b| a.time.cmp(&b.time));
+    messages.sort_by(|a, b| b.time.cmp(&a.time));
 
     // Remove blocked phrases
     for message in messages.iter_mut() {
@@ -635,7 +635,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                         })
                         .expect("Failed to retrieve channel creation input.");
 
-                    if message::is_valid_username(&input) {
+                    if message::is_valid_channel_name(&input) {
                         // Valid
                         siv.with_user_data(|appdata: &mut Appdata| {
                             appdata.local_channels.push(input.to_string());

+ 15 - 0
client_cli/Cargo.toml

@@ -0,0 +1,15 @@
+[package]
+name = "client_cli"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+client_shared = { path = "../client_shared" }
+utils = { path = "../utils" }
+logging = { path = "../logging" }
+
+clap = { version = "4.5.60", features = ["derive"] }
+
+# TODO: Write an HTTP client.
+reqwest = { version = "0.12.15", features = [ "blocking" ] }
+json = "0.12.4"

+ 211 - 0
client_cli/src/main.rs

@@ -0,0 +1,211 @@
+use std::{path::PathBuf, time::Duration};
+
+use clap::{Parser, Subcommand};
+use client_shared::{message::Message, utils::{UrlError, fix_url}};
+use utils::{binary::checksum, serialize::Serialize as _, time::time_millis};
+
+#[derive(Parser, Debug)]
+#[command(version, about, long_about = None)]
+struct Args {
+    #[arg(
+        short = 'H', long = "host",
+        id = "HOST",
+        default_value = "localhost:13337",
+    )]
+    host: String,
+
+    #[arg(
+        short = 'p', long = "password",
+        id = "PASSWORD",
+        default_value = "null",
+    )]
+    password: String,
+
+    #[arg(
+        short = 'B',
+        id = "BLOCKED_WORDS_FILE",
+        required = false,
+        help = "A file containing blocked words separated by newlines.\nExample: \n\tWord1\n\tWord2\n\tWord3\n\t...",
+    )]
+    blocked_words: Option<PathBuf>,
+
+    #[arg(
+        long = "timeout",
+        id = "TIMEOUT",
+        default_value = "2",
+        help = "Time out connection to server after given value in seconds."
+    )]
+    timeout_sec: u64,
+
+    #[arg(
+        short = 'c', long = "channel",
+        id = "CHANNEL",
+        default_value = "root",
+    )]
+    channel: String,
+    
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Get {
+        #[arg(
+            long = "json",
+            help = "Return data as JSON."
+        )]
+        json: bool,
+    },
+
+    Send {
+        #[arg(
+            short = 'n', long = "name",
+            id = "NAME",
+            required = true,
+            help = "Name of the author of the message."
+        )]
+        name: String,
+
+        #[arg(
+            short = 'd', long = "content",
+            id = "CONTENT",
+            required = true,
+            help = "Message content."
+        )]
+        content: String,
+    }
+}
+
+
+fn main() {
+    let args = Args::parse();
+
+    match args.command {
+        Commands::Get { json } => {
+            let mut messages = load_messages(&args)
+                .expect("Failed to load messages.");
+
+            messages.sort_by(|a, b| {
+                a.time.cmp(&b.time)
+            });
+
+            if json {
+                let mut messages_json = json::JsonValue::new_array();
+                for msg in &messages {
+                    if msg.channel.to_lowercase() == args.channel.to_lowercase() {
+                        messages_json.push(json::object! {
+                            sender: *msg.sender,
+                            content: *msg.content,
+                            channel: *msg.channel,
+                            time_s: (msg.time / 1000) as u64,
+                        }).expect("Failed to create JSON object.");
+                    }
+                }
+
+                println!("{}", messages_json.pretty(2));
+            } else {
+                todo!()
+            }
+        },
+        Commands::Send { ref name, ref content } => {
+            let mut message = Message {
+                sender: name.clone(),
+                content: content.clone(),
+                channel: args.channel.clone(),
+                time: time_millis(),
+            };
+
+            message.sanitize();
+
+            send_message(&args, message)
+                .expect("Failed to send message.");
+        },
+    }
+}
+
+#[derive(Debug)]
+#[allow(unused)]
+enum ClientError {
+    UrlError(UrlError),
+    ReqwestError(reqwest::Error),
+    StatusCodeError(u16),
+}
+
+fn send_message(args: &Args, message: Message) -> Result<(), ClientError> {
+    let url = fix_url(&args.host)
+        .map_err(|x| ClientError::UrlError(x))?;
+
+    let mut bytes = message.serialize_checked();
+    utils::aes::encrypt_cbc(&mut bytes, &args.password);
+    bytes.push(checksum(&bytes));
+
+    let str = utils::binary::bin2hex(bytes);
+
+    let resp = reqwest::blocking::Client::new()
+        .post(format!("{}", url))
+        .body(str)
+        .timeout(Duration::from_secs(args.timeout_sec))
+        .send()
+        .map_err(|e| ClientError::ReqwestError(e))?;
+
+    if resp.status().is_success() {
+        Ok(())
+    } else {
+        Err(ClientError::StatusCodeError(resp.status().as_u16()))
+    }
+}
+
+fn load_messages(args: &Args) -> Result<Vec<Message>, ClientError> {
+    let url = fix_url(&args.host)
+        .map_err(|x| ClientError::UrlError(x))?;
+
+    let resp = reqwest::blocking::Client::new()
+        .get(url)
+        .timeout(Duration::from_secs(args.timeout_sec))
+        .send()
+        .map_err(|x| ClientError::ReqwestError(x))?;
+
+    if resp.status() != reqwest::StatusCode::OK {
+        return Err(ClientError::StatusCodeError(resp.status().as_u16()));
+    }
+
+    let bytes = resp.bytes()
+        .map_err(|e| ClientError::ReqwestError(e))?;
+
+    let contents = String::from_utf8_lossy(&bytes);
+    let mut messages = vec![];
+
+    for message_ser_hex in contents.split(",") {
+        let Some(mut message_ser_bin) = utils::binary::hex2bin(message_ser_hex) else {
+            continue;
+        };
+
+        if message_ser_bin.len() < 2 {
+            continue;
+        }
+
+        // Checksum before decryption
+        if checksum(&message_ser_bin[..message_ser_bin.len()-1]) != message_ser_bin[message_ser_bin.len()-1] {
+            continue;
+        }
+
+        // Remove checksum
+        message_ser_bin.pop();
+
+        if utils::aes::decrypt_cbc(&mut message_ser_bin, &args.password).is_err() {
+            continue;
+        }
+
+        let Ok(message) = Message::deserialize_checked(message_ser_bin) else {
+            continue;
+        };
+
+        if message.is_valid() {
+            messages.push(message);
+        }
+    }
+
+    Ok(messages)
+}
+

+ 2 - 2
client_shared/src/lib.rs

@@ -5,8 +5,8 @@ pub mod utils;
 
 pub const MAX_MESSAGE_LENGTH: usize = 512;
 pub const MAX_USERNAME_LENGTH: usize = 16;
-pub const DEFAULT_USERNAME_PREFIX: &str = "Myst";
-pub const DEFAULT_CHANNEL: &str = "Root";
+pub const DEFAULT_USERNAME_PREFIX: &str = "myst";
+pub const DEFAULT_CHANNEL: &str = "root";
 pub const DEFAULT_PASSWORD: &str = "null";
 
 pub const API_CONNECTION_TIMEOUT: u64 = 2;

+ 13 - 0
client_shared/src/message.rs

@@ -29,6 +29,11 @@ impl Message {
             && is_valid_username(&self.channel)
             && is_valid_message(&self.content)
     }
+
+    pub fn sanitize(&mut self) {
+        self.channel = self.channel.to_lowercase();
+        self.content.truncate(MAX_MESSAGE_LENGTH);
+    }
 }
 
 impl Hashable for Message {
@@ -88,6 +93,14 @@ pub fn is_valid_username(username: &str) -> bool {
             .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '#')
 }
 
+pub fn is_valid_channel_name(channel_name: &str) -> bool {
+    channel_name.len() >= 2
+        && channel_name.len() <= MAX_USERNAME_LENGTH
+        && channel_name
+            .chars()
+            .all(|c| (c.is_ascii_alphanumeric() && !c.is_uppercase()) || c == '_' || c == '-' || c == '.' || c == '#')
+}
+
 pub fn is_valid_message(message: &str) -> bool {
     message.len() <= MAX_MESSAGE_LENGTH && !message.is_empty()
 }

+ 1 - 0
client_shared/src/utils.rs

@@ -5,6 +5,7 @@ use url::Url;
 
 use crate::API_DEFAULT_PORT;
 
+#[derive(Debug)]
 pub enum UrlError {
     ParseError(url::ParseError),
     DomainBasePortError,