10 Commits b820144de4 ... 0c80211afa

Auteur SHA1 Bericht Datum
  Rain 0c80211afa Client metadata 1 maand geleden
  Rain 2a85e20e49 JSON as default 1 maand geleden
  Rain 40ebec68d4 Move message send/receive functions to shared 1 maand geleden
  Rain a68550ed66 Client CLI 1 maand geleden
  Rain 689ac8a0d1 Fix logging and reorganize code 1 maand geleden
  Rain 08d5398610 Reduce splash screen duration 1 maand geleden
  Rain 63cc4111b4 Better URL handling 1 maand geleden
  Rain ab4741b407 Server side sanity checks 1 maand geleden
  Rain 062a8763f7 Clean up appdata 1 maand geleden
  Rain dad0fdcaff Remove C impl 2 maanden geleden

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
 target/
 Cargo.lock
 savedata.bin
+*-log.txt

+ 2 - 0
Cargo.toml

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

+ 0 - 42
c-write/Makefile

@@ -1,42 +0,0 @@
-CC = gcc
-
-# https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html
-# https://stackoverflow.com/a/3376483
-CFLAGS = \
-	-Wall \
-	-Wextra \
-	-Wfloat-equal \
-	-Wundef \
-	-Wshadow \
-	-Wpointer-arith \
-	-Wcast-align \
-	-Waggregate-return \
-	-Wcast-qual \
-	-Wswitch-default \
-	-Wswitch-enum \
-	-Wconversion \
-	-Wunreachable-code \
-	-std=c11
-	# -Werror \
-
-NAME = program
-TARGET_DIR = ./target
-
-SRC = $(wildcard lib/*.c)
-
-build:
-	mkdir -p $(TARGET_DIR)
-	$(CC) $(CFLAGS) -o $(TARGET_DIR)/$(NAME) $(SRC) main.c
-
-build_test:
-	mkdir -p $(TARGET_DIR)
-	$(CC) $(CFLAGS) -o $(TARGET_DIR)/$(NAME)_test $(SRC) tests.c
-
-run: build
-	$(TARGET_DIR)/$(NAME)
-
-test: build_test
-	$(TARGET_DIR)/$(NAME)_test
-
-clean:
-	rm -r $(TARGET_DIR)

+ 0 - 336
c-write/lib/aes.c

@@ -1,336 +0,0 @@
-#include "aes_internal.h"
-#include "array.h"
-
-uint8_t S_BOX[256] = {
-    0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
-    0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
-    0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
-    0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
-    0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
-    0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
-    0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
-    0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
-    0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
-    0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
-    0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
-    0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
-    0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
-    0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
-    0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
-    0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
-};
-
-uint8_t INV_S_BOX[256] = {
-    0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB,
-    0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB,
-    0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E,
-    0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25,
-    0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92,
-    0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84,
-    0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06,
-    0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B,
-    0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73,
-    0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E,
-    0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B,
-    0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4,
-    0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F,
-    0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF,
-    0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61,
-    0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D,
-};
-
-uint8_t ROUND_CONSTANT[32] = {
-    0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36, 0x6C, 0xD8, 0xAB, 0x4D, 0x9A,
-    0x2F, 0x5E, 0xBC, 0x63, 0xC6, 0x97, 0x35, 0x6A, 0xD4, 0xB3, 0x7D, 0xFA, 0xEF, 0xC5, 0x91, 0x39,
-};
-
-size_t aes__block_get(size_t x, size_t y) {
-    return x * 4 + y;
-}
-
-uint8_t aes__xtime(uint8_t x) {
-    if ((x & 0x80) > 0) {
-        return (uint8_t) ((x << 1) ^ 0x1B) & 0xFF;
-    } else {
-        return (uint8_t) (x << 1) & 0xFF;
-    }
-}
-
-void aes__shift_rows(uint8_t* block, aes_encryption_mode_t mode) {
-    uint8_t* word_original = malloc(sizeof(uint8_t) * WORD_LENGTH);
-
-    for (size_t word_n = 1; word_n < WORD_LENGTH; word_n++) {
-        size_t word_start_idx = word_n * WORD_LENGTH;
-
-        for (size_t word_idx = 0; word_idx < WORD_LENGTH; word_idx++) {
-            word_original[word_idx] = block[word_start_idx + word_idx];
-        }
-
-        for (size_t word_idx = 0; word_idx < WORD_LENGTH; word_idx++) {
-            if (mode == encryption) {
-                block[word_start_idx + word_idx] = word_original[(word_idx + word_n) % WORD_LENGTH];
-            } else {
-                block[word_start_idx + word_idx] = word_original[(word_idx - word_n) % WORD_LENGTH];
-            }
-        }
-    }
-
-    free(word_original);
-}
-
-void aes__mix_words(uint8_t* block, aes_encryption_mode_t mode) {
-    if (mode == decryption) {
-        for (size_t idx = 0; idx < WORD_LENGTH; idx++) {
-            size_t start_idx = aes__block_get(idx, 0);
-
-            uint8_t a = aes__xtime(aes__xtime(block[start_idx] ^ block[start_idx + 2]));
-            uint8_t b = aes__xtime(aes__xtime(block[start_idx + 1] ^ block[start_idx + 3]));
-
-            block[start_idx + 0] ^= a;
-            block[start_idx + 2] ^= a;
-
-            block[start_idx + 1] ^= b;
-            block[start_idx + 3] ^= b;
-        }
-    }
-
-    for (size_t idx = 0; idx < WORD_LENGTH; idx++) {
-        size_t start_idx = aes__block_get(idx, 0);
-
-        uint8_t xor = block[start_idx]
-            ^ block[start_idx + 1]
-            ^ block[start_idx + 2]
-            ^ block[start_idx + 3];
-
-        uint8_t first = block[start_idx];
-
-        for (size_t i = 0; i < 3; i++) {
-            block[start_idx + i] ^= aes__xtime(block[start_idx + i] ^ block[start_idx + i + 1]) ^ xor;
-        }
-
-        block[start_idx + 3] ^= aes__xtime(block[start_idx + 3] ^ first) ^ xor;
-    }
-}
-
-void aes__add_round_key(uint8_t* block, uint8_t* round_key) {
-    xor_arrays(block, round_key, BLOCK_LENGTH);
-}
-
-// NOTE: malloc
-uint8_t* aes__expand_key(uint8_t* key) {
-    // Original key + 10 round keys in an array
-    size_t round_keys_array_size = (ROUNDS + 1) * BLOCK_LENGTH;
-    uint8_t* round_keys = (uint8_t*) malloc(sizeof(uint8_t) * round_keys_array_size);
-    size_t pos = 0;
-    size_t round_constant_idx = 0;
-
-    for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-        round_keys[idx] = key[idx];
-        pos += 1;
-    }
-
-    while (pos < round_keys_array_size) {
-        for (size_t idx = 0; idx < WORD_LENGTH; idx++) {
-            round_keys[idx + pos] = round_keys[idx + pos - WORD_LENGTH];
-        }
-
-        if (pos % BLOCK_LENGTH == 0) {
-            uint8_t first = round_keys[pos];
-            round_keys[pos] = round_keys[pos + 1];
-            round_keys[pos + 1] = round_keys[pos + 2];
-            round_keys[pos + 2] = round_keys[pos + 3];
-            round_keys[pos + 3] = first;
-
-            for (size_t idx = 0; idx < WORD_LENGTH; idx++) {
-                uint8_t value = round_keys[pos + idx];
-                round_keys[pos + idx] = S_BOX[value];
-            }
-
-            round_keys[pos] = round_keys[round_constant_idx];
-            round_constant_idx += 1;
-        }
-
-        uint8_t* previous_word = &round_keys[pos];
-        uint8_t* modifier_word = &round_keys[pos - BLOCK_LENGTH];
-        xor_arrays(previous_word, modifier_word, WORD_LENGTH);
-        pos += WORD_LENGTH;
-    }
-
-    return round_keys;
-}
-
-void aes__encrypt_block(uint8_t* block, uint8_t* round_keys) {
-    aes__add_round_key(block, round_keys);
-    for (size_t round = 1; round <= ROUNDS; round++) {
-        for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-            block[idx] = S_BOX[block[idx]];
-        }
-
-        aes__shift_rows(block, encryption);
-
-        if (round != ROUNDS) {
-            aes__mix_words(block, encryption);
-        }
-
-        aes__add_round_key(block, &round_keys[round * BLOCK_LENGTH]);
-    }
-}
-
-void aes__decrypt_block(uint8_t* block, uint8_t* round_keys) {
-    for (size_t round = ROUNDS; round >= 1; round--) {
-        aes__add_round_key(block, &round_keys[round * BLOCK_LENGTH]);
-
-        if (round != ROUNDS) {
-            aes__mix_words(block, decryption);
-        }
-
-        aes__shift_rows(block, decryption);
-
-        for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-            block[idx] = INV_S_BOX[block[idx]];
-        }
-    }
-
-    aes__add_round_key(block, round_keys);
-}
-
-BOOL aes__is_valid_padding(uint8_t* block) {
-    uint8_t marker = block[BLOCK_LENGTH - 1];
-
-    if (marker == 0) {
-        return FALSE;
-    }
-
-    for (size_t idx = BLOCK_LENGTH - marker; idx < BLOCK_LENGTH; idx++) {
-        if (block[idx] != marker) {
-            return FALSE;
-        }
-    }
-
-    return TRUE;
-}
-
-aes_padded_data_t aes_create_padded_data_container() {
-    aes_padded_data_t out = {
-        .data = NULL,
-        .data_length = 0,
-        .data_length_before_pad = 0,
-        .padded_block = NULL,
-        .length = 0,
-    };
-
-    return out;
-}
-
-// NOTE: Only drops padding, not the original data. Resets struct to defaults and returns the original data pointer.
-uint8_t* aes_drop_padded_data_container(aes_padded_data_t* data) {
-    uint8_t* ptr = data->data;
-    free(data->padded_block);
-
-    data->padded_block = NULL;
-    data->data = NULL;
-    data->data_length = 0;
-    data->data_length_before_pad = 0;
-    data->length = 0;
-
-    return ptr;
-}
-
-// NOTE: malloc
-int aes_pad_data(aes_padded_data_t* ptr, uint8_t* data, size_t length) {
-    if (length == 0) {
-        return 1;
-    }
-
-    uint8_t* padded_block_ptr = malloc(sizeof(uint8_t) * BLOCK_LENGTH);
-    if (padded_block_ptr == NULL) {
-        return 2;
-    }
-
-    ptr->data = data;
-    ptr->data_length = length;
-    ptr->padded_block = padded_block_ptr;
-
-    if (length % BLOCK_LENGTH == 0) {
-        // Fill with marker
-        for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-            padded_block_ptr[idx] = BLOCK_LENGTH;
-        }
-
-        ptr->data_length_before_pad = length;
-    } else {
-        size_t last_block_length = length % BLOCK_LENGTH;
-        uint8_t* last_block = &data[length - last_block_length];
-        size_t marker = BLOCK_LENGTH - last_block_length;
-
-        // Fill with marker
-        for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-            padded_block_ptr[idx] = (uint8_t) (marker & 0xFF);
-        }
-
-        // Insert original data
-        for (size_t idx = 0; idx < last_block_length; idx++) {
-            padded_block_ptr[idx] = last_block[idx];
-        }
-
-        ptr->data_length_before_pad = length - last_block_length;
-    }
-
-    ptr->length = ptr->data_length_before_pad + BLOCK_LENGTH;
-
-    return 0;
-}
-
-void aes_ecb(aes_encryption_mode_t mode, aes_padded_data_t* data, uint8_t* key) {
-    uint8_t* round_keys = aes__expand_key(key);
-
-    for (size_t idx = 0; idx < data->data_length_before_pad; idx += BLOCK_LENGTH) {
-        if (mode == encryption) {
-            aes__encrypt_block(&data->data[idx], round_keys);
-        } else {
-            aes__decrypt_block(&data->data[idx], round_keys);
-        }
-    }
-
-    if (mode == encryption) {
-        aes__encrypt_block(data->padded_block, round_keys);
-    } else {
-        aes__decrypt_block(data->padded_block, round_keys);
-    }
-
-    free(round_keys);
-}
-
-void aes_cbc(aes_encryption_mode_t mode, aes_padded_data_t* data, uint8_t* key) {
-    uint8_t* round_keys = aes__expand_key(key);
-
-    // TODO: Derive this properly
-    uint8_t* previous_block = malloc(sizeof(uint8_t) * BLOCK_LENGTH * 2);
-    for (size_t idx = 0; idx < BLOCK_LENGTH; idx++) {
-        previous_block[idx] = 0;
-    }
-
-    for (size_t idx = 0; idx < data->data_length_before_pad; idx += BLOCK_LENGTH) {
-        if (mode == encryption) {
-            xor_arrays(&data->data[idx], previous_block, BLOCK_LENGTH);
-            aes__encrypt_block(&data->data[idx], round_keys);
-            clone_array(previous_block, &data->data[idx], BLOCK_LENGTH);
-        } else {
-            clone_array(&previous_block[BLOCK_LENGTH], &data->data[idx], BLOCK_LENGTH);
-            aes__decrypt_block(&data->data[idx], round_keys);
-            xor_arrays(&data->data[idx], previous_block, BLOCK_LENGTH);
-            clone_array(previous_block, &previous_block[BLOCK_LENGTH], BLOCK_LENGTH);
-        }
-    }
-
-    if (mode == encryption) {
-        xor_arrays(data->padded_block, previous_block, BLOCK_LENGTH);
-        aes__encrypt_block(data->padded_block, round_keys);
-    } else {
-        aes__decrypt_block(data->padded_block, round_keys);
-        xor_arrays(data->padded_block, previous_block, BLOCK_LENGTH);
-    }
-
-    free(previous_block);
-    free(round_keys);
-}

+ 0 - 34
c-write/lib/aes.h

@@ -1,34 +0,0 @@
-#ifndef AES_H_
-#define AES_H_
-
-#include <stdlib.h>
-
-#define BLOCK_LENGTH 16
-
-typedef struct aes_padded_data_t {
-    /// @brief original data, unmodified.
-    uint8_t* data;
-    /// @brief the last block, modified with padding added.
-    uint8_t* padded_block;
-
-    /// @brief original length.
-    size_t data_length;
-    /// @brief length when excluding the block to be modified.
-    size_t data_length_before_pad;
-    /// @brief `data_length_before_pad` + BLOCK_LENGTH
-    size_t length;
-} aes_padded_data_t;
-
-typedef enum aes_encryption_mode_t {
-    encryption,
-    decryption,
-} aes_encryption_mode_t;
-
-aes_padded_data_t aes_create_padded_data_container();
-uint8_t* aes_drop_padded_data_container(aes_padded_data_t* data);
-int aes_pad_data(aes_padded_data_t* ptr, uint8_t* data, size_t length);
-
-void aes_ecb(aes_encryption_mode_t mode, aes_padded_data_t* data, uint8_t* key);
-void aes_cbc(aes_encryption_mode_t mode, aes_padded_data_t* data, uint8_t* key);
-
-#endif // AES_H_

+ 0 - 23
c-write/lib/aes_internal.h

@@ -1,23 +0,0 @@
-#ifndef AES_INTERNAL_H_
-#define AES_INTERNAL_H_
-
-#include <stdlib.h>
-
-#include "aes.h"
-
-#define WORD_LENGTH 4
-#define ROUNDS 10
-
-#define BOOL int
-#define TRUE 1
-#define FALSE 0
-
-void aes__shift_rows(uint8_t* block, aes_encryption_mode_t mode);
-uint8_t aes__xtime(uint8_t x);
-void aes__mix_words(uint8_t* block, aes_encryption_mode_t mode);
-uint8_t* aes__expand_key(uint8_t* key);
-void aes__encrypt_block(uint8_t* block, uint8_t* round_keys);
-void aes__decrypt_block(uint8_t* block, uint8_t* round_keys);
-int aes__is_valid_padding(uint8_t* block);
-
-#endif // AES_INTERNAL_H_

+ 0 - 143
c-write/lib/aes_tests.c

@@ -1,143 +0,0 @@
-#include <assert.h>
-#include <stdio.h>
-
-#include "aes_internal.h"
-#include "array.h"
-
-#define TEST_DATA { 72, 101, 114, 101, 32, 105, 115, 32, 115, 111, 109, 101, 32, 116, 101, 120, 116, 32, 105, 110, 32, 97, 115, 99, 105, 105, 32, 115, 105, 110, 99, 101, 32, 116, 104, 101, 115, 101, 32, 97, 114, 101, 32, 97, 108, 108, 32, 98, 121, 116, 101, 115, 0 }
-#define TEST_DATA_LENGTH 53
-#define TEST_BLOCK { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, }
-#define TEST_KEY { 130, 191, 5, 162, 175, 104, 200, 14, 32, 0, 97, 170, 10, 83, 159, 90, }
-
-void aes_test_shift_block() {
-    uint8_t original_data[BLOCK_LENGTH] = TEST_BLOCK;
-    uint8_t test_data[BLOCK_LENGTH] = TEST_BLOCK;
-    uint8_t expected_shift[BLOCK_LENGTH] = {
-        0, 1, 2, 3, 5, 6, 7, 4, 10, 11, 8, 9, 15, 12, 13, 14,
-    };
-
-    aes__shift_rows(test_data, encryption);
-    assert(arrays_eq(test_data, expected_shift, BLOCK_LENGTH));
-
-    aes__shift_rows(test_data, decryption);
-    assert(arrays_eq(test_data, original_data, BLOCK_LENGTH));
-}
-
-void aes_test_xtime() {
-    assert(aes__xtime(84) == 168);
-    assert(aes__xtime(255) == 229);
-    assert(aes__xtime(0) == 0);
-    assert(aes__xtime(240) == 251);
-}
-
-void aes_test_mix_words() {
-    uint8_t test_data[BLOCK_LENGTH] = TEST_BLOCK;
-    uint8_t original_data[BLOCK_LENGTH] = TEST_BLOCK;
-
-    aes__mix_words(test_data, encryption);
-    aes__mix_words(test_data, decryption);
-    assert(arrays_eq(test_data, original_data, BLOCK_LENGTH));
-}
-
-void aes_test_expand_key() {
-    uint8_t test_key[BLOCK_LENGTH] = TEST_KEY;
-    uint8_t expected_round_keys[BLOCK_LENGTH * 11] = {
-        130, 191, 5, 162, 175, 104, 200, 14, 32, 0, 97, 170, 10, 83, 159, 90, 110, 100, 187, 197, 193, 12, 115, 203, 225, 12, 18, 97, 235, 95, 141, 59, 163, 57, 89, 44, 98, 53, 42, 231, 131, 57, 56, 134, 104, 102, 181, 189, 148, 236, 35, 105, 246, 217, 9, 142, 117, 224, 49, 8, 29, 134, 132, 181, 216, 179, 246, 205, 46, 106, 255, 67, 91, 138, 206, 75, 70, 12, 74, 254, 54, 101, 77, 151, 24, 15, 178, 212, 67, 133, 124, 159, 5, 137, 54, 97, 177, 96, 162, 252, 169, 111, 16, 40, 234, 234, 108, 183, 239, 99, 90, 214, 10, 222, 84, 35, 163, 177, 68, 11, 73, 91, 40, 188, 166, 56, 114, 106, 141, 158, 86, 7, 46, 47, 18, 12, 103, 116, 58, 176, 193, 76, 72, 218, 191, 204, 1, 127, 145, 227, 19, 115, 246, 151, 41, 195, 55, 219, 97, 25, 48, 35, 213, 229, 161, 192, 198, 150, 87, 87, 239, 85, 96, 140, 142, 76
-    };
-
-    uint8_t* round_keys = aes__expand_key(test_key);
-    int result = arrays_eq(round_keys, expected_round_keys, BLOCK_LENGTH);
-    free(round_keys);
-
-    assert(result);
-}
-
-void aes_test_block_cipher() {
-    uint8_t test_key[BLOCK_LENGTH] = TEST_KEY;
-    uint8_t* round_keys = aes__expand_key(test_key);
-
-    uint8_t original_data[BLOCK_LENGTH] = TEST_BLOCK;
-    uint8_t test_data[BLOCK_LENGTH] = TEST_BLOCK;
-
-    aes__encrypt_block(test_data, round_keys);
-    aes__decrypt_block(test_data, round_keys);
-
-    free(round_keys);
-
-    assert(arrays_eq(test_data, original_data, BLOCK_LENGTH));
-}
-
-void aes_test_padding_validation() {
-    uint8_t data_valid[16] = {
-        12, 63, 3, 76, 10, 92,
-        10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
-    };
-    assert(aes__is_valid_padding(data_valid) == TRUE);
-
-    uint8_t data_invalid[16] = {
-        12, 63, 3, 76, 10, 92,
-        10, 10, 10, 10, 10, 10, 10, 10, 10, 0,
-    };
-    assert(aes__is_valid_padding(data_invalid) == FALSE);
-
-    uint8_t data_valid_all_pad[16] = {
-        16, 16, 16, 16, 16, 16, 16, 16,
-        16, 16, 16, 16, 16, 16, 16, 16,
-    };
-    assert(aes__is_valid_padding(data_valid_all_pad) == TRUE);
-}
-
-void aes_test_create_padding() {
-    uint8_t data[24] = {
-        12, 63,  3, 76, 10, 92, 34, 11,
-        12, 63,  3, 76, 10, 92, 34, 11,
-
-        12, 63,  3, 76, 10, 92, 34, 11,
-    };
-
-    uint8_t expected_padding[BLOCK_LENGTH] = {
-        12, 63,  3, 76, 10, 92, 34, 11,
-        8 ,  8,  8,  8,  8,  8,  8,  8,
-    };
-
-    aes_padded_data_t padded = aes_create_padded_data_container();
-    int code = aes_pad_data(&padded, data, 24);
-    assert(code == 0);
-    assert(padded.data == data);
-    assert(padded.data_length == 24);
-    assert(padded.data_length_before_pad == 16);
-    assert(padded.length == 32);
-    assert(arrays_eq(padded.padded_block, expected_padding, BLOCK_LENGTH));
-
-    uint8_t* data_ret = aes_drop_padded_data_container(&padded);
-    assert(data_ret == data);
-}
-
-// TODO: Doesn't test padded block
-void aes_test_cipher() {
-    uint8_t original[TEST_DATA_LENGTH] = TEST_DATA;
-    uint8_t data[TEST_DATA_LENGTH] = TEST_DATA;
-    uint8_t key[BLOCK_LENGTH] = TEST_KEY;
-
-    aes_padded_data_t padded = aes_create_padded_data_container();
-    int code = aes_pad_data(&padded, data, TEST_DATA_LENGTH);
-    assert(code == 0);
-
-    // ECB
-
-    aes_ecb(encryption, &padded, key);
-    assert(!arrays_eq(padded.data, original, padded.data_length_before_pad));
-
-    aes_ecb(decryption, &padded, key);
-    assert(arrays_eq(padded.data, original, padded.data_length_before_pad));
-
-    // CBC
-
-    aes_cbc(encryption, &padded, key);
-    assert(!arrays_eq(padded.data, original, padded.data_length_before_pad));
-
-    aes_cbc(decryption, &padded, key);
-    assert(arrays_eq(padded.data, original, padded.data_length_before_pad));
-
-    aes_drop_padded_data_container(&padded);
-}

+ 0 - 16
c-write/lib/aes_tests.h

@@ -1,16 +0,0 @@
-#ifndef AES_TESTS_H
-#define AES_TESTS_H
-
-#include <stdint.h>
-#include <stddef.h>
-
-void aes_test_shift_block();
-void aes_test_xtime();
-void aes_test_mix_words();
-void aes_test_expand_key();
-void aes_test_block_cipher();
-void aes_test_padding_validation();
-void aes_test_create_padding();
-void aes_test_cipher();
-
-#endif // AES_TESTS_H

+ 0 - 41
c-write/lib/array.c

@@ -1,41 +0,0 @@
-#include <stdlib.h>
-#include <stdio.h>
-
-int arrays_eq(
-    uint8_t* lhs,
-    uint8_t* rhs,
-    size_t length
-) {
-    for (size_t i = 0; i < length; i++) {
-        int cmp = lhs[i] == rhs[i];
-        if (cmp == 0) {
-            return 0;
-        }
-    }
-
-    return 1;
-}
-
-void xor_arrays(uint8_t* target, uint8_t* modifier, size_t length) {
-    for (size_t idx = 0; idx < length; idx++) {
-        target[idx] ^= modifier[idx];
-    }
-}
-
-void clone_array(uint8_t* target, uint8_t* modifier, size_t length) {
-    for (size_t idx = 0; idx < length; idx++) {
-        target[idx] = modifier[idx];
-    }
-}
-
-void print_int_array(uint8_t* array, size_t length) {
-    printf("{ ");
-    for (size_t i = 0; i < length; i++)
-    {
-        printf("%d", array[i]);
-        if (i != (length - 1)) {
-            printf(", ");
-        }
-    }
-    printf(" }");
-}

+ 0 - 12
c-write/lib/array.h

@@ -1,12 +0,0 @@
-#ifndef ARRAY_H_
-#define ARRAY_H_
-
-#include <stdlib.h>
-
-int arrays_eq(uint8_t* lhs, uint8_t* rhs, size_t length);
-void xor_arrays(uint8_t* target, uint8_t* modifier, size_t length);
-void clone_array(uint8_t* target, uint8_t* modifier, size_t length);
-
-void print_int_array(uint8_t* array, size_t length);
-
-#endif // ARRAY_H_

+ 0 - 5
c-write/main.c

@@ -1,5 +0,0 @@
-#include <stdio.h>
-
-int main() {
-    printf("Hi\n");
-}

+ 0 - 18
c-write/tests.c

@@ -1,18 +0,0 @@
-#include <stdio.h>
-
-#include "lib/aes_tests.h"
-
-int main() {
-    aes_test_shift_block();
-    aes_test_xtime();
-    aes_test_mix_words();
-    aes_test_expand_key();
-    aes_test_block_cipher();
-    aes_test_padding_validation();
-    aes_test_create_padding();
-    aes_test_cipher();
-
-    printf("OK\n");
-
-    return 0;
-}

+ 2 - 3
client/Cargo.toml

@@ -3,13 +3,12 @@ name = "client"
 version = "0.1.0"
 edition = "2021"
 default-run = "client"
+authors = ["Rain <rain@skuld.network>"]
 
 [dependencies]
+client_shared = { path = "../client_shared" }
 utils = { path = "../utils" }
 logging = { path = "../logging" }
 
 cursive = "0.21"
 cli-clipboard = "0.4.0"
-
-# TODO: Write an HTTP client.
-reqwest = { version = "0.12.15", features = [ "blocking" ] }

+ 20 - 87
client/src/actions.rs

@@ -1,24 +1,25 @@
+use client_shared::{API_CONNECTION_TIMEOUT, message::Message, persistence::Appdata, utils::{ConnectionError, rx_messages, tx_message}};
 use cursive::{views::TextArea, Cursive};
-use utils::{hash::Hashable as _, serialize::Serialize as _};
+use utils::hash::Hashable as _;
+
+use crate::{get_appdata, ui};
 
-use crate::{get_appdata, message::Message, ui, Appdata};
 
 // TODO (low): Create proper user objects. Related: add a way to verify a user.
 pub fn on_user_click(siv: &mut Cursive, user: &str) {
-    let language = get_appdata(siv).language;
+    let language = get_appdata(siv).persistent_data.language;
     ui::alert(siv, ui::Labels::User.localize(language), user.into());
 }
 
 pub fn on_message_click(siv: &mut Cursive, message_id: &str) {
     let appdata = get_appdata(siv);
-    let language = appdata.language;
+    let language = appdata.persistent_data.language;
     let message = appdata.messages.get(message_id);
 
     if let Some(message) = message {
         ui::copyable(
             siv,
-            ui::Labels::Message.localize(appdata.language),
-            // TODO: Use labels
+            ui::Labels::Message.localize(appdata.persistent_data.language),
             format!(
                 "{}: {}\n{}: {}\n{}: {}\n{}: {}",
                 ui::Labels::Time.localize(language), message.time,
@@ -30,6 +31,7 @@ pub fn on_message_click(siv: &mut Cursive, message_id: &str) {
     }
 }
 
+// TODO: Sending in progress pop-up for slow connections. Currently just hangs the application.
 pub fn on_input_submit(siv: &mut Cursive, input_field_id: &str) {
     let text = siv
         .call_on_name(input_field_id, |view: &mut TextArea| {
@@ -38,10 +40,10 @@ pub fn on_input_submit(siv: &mut Cursive, input_field_id: &str) {
         .expect("Failed to retrieve input field content.");
 
     let appdata = get_appdata(siv);
-    let language = appdata.language;
-    let channel = appdata.current_channel;
+    let language = appdata.persistent_data.language;
+    let channel = appdata.persistent_data.current_channel;
 
-    let message = Message::new(&appdata.username, &text, &channel);
+    let message = Message::new(&appdata.persistent_data.username, &text, &channel);
     if message.is_valid() {
         // NOTE:
         // If an error was shown during send, it's likely that loading will fail too
@@ -83,91 +85,22 @@ pub fn clear_input_field(siv: &mut Cursive) {
     .expect("Failed to clear input field.");
 }
 
-type StatusCode = u16;
-
-#[allow(dead_code)]
-pub enum NetworkError {
-    ReqwestError(reqwest::Error),
-    StatusCodeError(StatusCode),
-    InvalidUrl,
-    EncryptionError(utils::aes::AesError),
-}
-
-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, mut message: Message) -> Result<(), ConnectionError> {
+    message.sanitize();
 
-pub fn send_message(siv: &mut Cursive, message: Message) -> Result<(), NetworkError> {
     let appdata = get_appdata(siv);
-    let url = fix_url(&appdata.api_endpoint)?;
-    let password = appdata.api_password;
-
-    let mut bytes = message.serialize_checked();
-    utils::aes::encrypt_cbc(&mut bytes, &password);
+    let url = appdata.persistent_data.api_endpoint;
+    let password = appdata.persistent_data.api_password;
 
-    let str = utils::binary::bin2hex(bytes);
-
-    let resp = reqwest::blocking::Client::new()
-        .post(format!("{}", url))
-        .body(str)
-        .send()
-        .map_err(|e| NetworkError::ReqwestError(e))?;
-
-    if resp.status().is_success() {
-        Ok(())
-    } else {
-        Err(NetworkError::StatusCodeError(resp.status().as_u16()))
-    }
+    tx_message(&url, &password, API_CONNECTION_TIMEOUT, message)
 }
 
-pub fn load_messages(siv: &mut Cursive) -> Result<(), NetworkError> {
+pub fn load_messages(siv: &mut Cursive) -> Result<(), ConnectionError> {
     let appdata = get_appdata(siv);
-    let url = fix_url(&appdata.api_endpoint)?;
-    let password = appdata.api_password;
+    let url = appdata.persistent_data.api_endpoint;
+    let password = appdata.persistent_data.api_password;
 
-    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 = resp.bytes().map_err(|e| NetworkError::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 utils::aes::decrypt_cbc(&mut message_ser_bin, &password).is_err() {
-            continue;
-        }
-
-        let Ok(message) = Message::deserialize_checked(message_ser_bin) else {
-            continue;
-        };
-
-        if message.is_valid() {
-            messages.push(message);
-        }
-    }
+    let messages = rx_messages(&url, &password, API_CONNECTION_TIMEOUT)?;
 
     siv.with_user_data(|appdata: &mut Appdata| {
         for message in messages {

+ 18 - 109
client/src/main.rs

@@ -1,137 +1,46 @@
 mod actions;
-mod message;
-mod persistence;
 mod ui;
 
+use client_shared::{LOGO, LOGO_DURATION};
 use cursive::align::Align;
-use cursive::reexports::log;
+use cursive::view::Resizable as _;
 use cursive::views::TextView;
 use cursive::{event, Cursive};
-use cursive::{traits::*, CursiveExt as _};
-use message::Message;
-use std::collections::HashMap;
-use std::io::{Read as _, Write as _};
+use cursive::CursiveExt as _;
+use std::ffi::OsString;
+use std::str::FromStr;
 use std::thread;
 use std::time::Duration;
-use utils::serialize::Serialize;
 
 use ui::INPUT_FIELD_ID;
 
-const MAX_MESSAGE_LENGTH: usize = 512;
-const MAX_USERNAME_LENGTH: usize = 16;
-const DEFAULT_USERNAME_PREFIX: &str = "Myst";
-const DEFAULT_CHANNEL: &str = "Root";
-const DEFAULT_PASSWORD: &str = "null";
-
-const LOGO: &str = include_str!("../assets/logo.txt");
-
-const SAVE_FILE: &str = "savedata.bin";
-const SAVE_FILE_FUZZY: u64 = 0b0110110001101001011001110110110101100001001000000101100001000100;
-
-#[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,
-    // TODO: Support multiple passwords for decryption.
-    pub api_password: String,
-    pub current_channel: String,
-
-    pub messages: HashMap<String, Message>,
-    pub quick_close_window_count: usize,
-    pub local_channels: Vec<String>,
-    pub intro_screen_shown_until: u128,
-}
-
-impl Default for Appdata {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl Appdata {
-    pub fn new() -> Self {
-        Self {
-            messages: HashMap::new(),
-            username: format!(
-                "{}#{}",
-                DEFAULT_USERNAME_PREFIX,
-                utils::rng::random_numeric_string(4)
-            ),
-            language: ui::Language::English,
-            quick_close_window_count: 0,
-            api_endpoint: String::new(),
-            api_refresh_rate: 10,
-            blocked_phrases: Vec::new(),
-            blocked_phrases_censor_char: '*',
-            current_channel: DEFAULT_CHANNEL.to_string(),
-            local_channels: vec![DEFAULT_CHANNEL.to_string()],
-            api_password: DEFAULT_PASSWORD.to_string(),
-            intro_screen_shown_until: 0,
-        }
-    }
-}
+use client_shared::persistence::{Appdata, load_appdata, save_appdata};
 
 pub fn get_appdata(siv: &mut Cursive) -> Appdata {
     siv.with_user_data(|appdata: &mut Appdata| appdata.clone())
         .expect("Failed to retrieve appdata.")
 }
 
-pub fn save_appdata(siv: &mut Cursive) {
-    let appdata = get_appdata(siv);
-
-    let savedata = persistence::Savedata::from(appdata);
-    let bytes = savedata.serialize_checked();
-    let fuzzy_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY);
-
-    let mut file = std::fs::File::create(SAVE_FILE).expect("Failed to create savedata file.");
-    file.write_all(&fuzzy_bytes)
-        .expect("Failed to write savedata file.");
-
-    log::info!("Wrote savadata to file.");
-}
-
-pub fn load_appdata(siv: &mut Cursive) -> std::io::Result<()> {
-    let mut df = logging::warn_deferred("Savedata file not found; using defaults");
-
-    let mut file = std::fs::File::open(SAVE_FILE)?;
-
-    let mut bytes = Vec::new();
-    file.read_to_end(&mut bytes)?;
-    let useful_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY);
-
-    df.cancel();
-
-    let Ok(savedata) = persistence::Savedata::deserialize_checked(useful_bytes) else {
-        // If the file is corrupted, create a new one
-        logging::warn("Savedata file corrupted; using defaults.");
-        return Ok(());
-    };
-
-    let appdata = Appdata::from(savedata);
-    siv.set_user_data(appdata);
-
-    logging::info("Savedata file loaded.");
-    Ok(())
-}
-
 fn main() {
     if cfg!(debug_assertions) {
         logging::set_global_log_level(logging::LogLevel::Trace);
     } else {
-        logging::set_global_log_level(logging::LogLevel::Info);
+        logging::set_global_log_level(logging::LogLevel::Debug);
     }
 
+    logging::set_global_log_path(Some(OsString::from_str("./client-log.txt").unwrap()));
+
     utils::rng::shuffle_rng();
 
     let mut siv = Cursive::default();
-    siv.set_user_data(Appdata::new());
 
     // TODO (low): Add a notice when the file is corrupted.
-    let _ = load_appdata(&mut siv);
+    let appdata = match load_appdata() {
+        Ok(Some(x)) => x,
+        _ => Appdata::new(),
+    };
+
+    siv.set_user_data(appdata);
 
     // Global hotkeys
     siv.add_global_callback(event::Key::Backspace, |siv| {
@@ -168,12 +77,12 @@ fn main() {
 
                         ui::visual_update(siv);
 
-                        if timer % appdata.api_refresh_rate == 0 {
+                        if timer % appdata.persistent_data.api_refresh_rate == 0 {
                             // TODO (low): Add a notice when automatic refresh fails.
                             let _ = actions::load_messages(siv);
                         }
 
-                        save_appdata(siv);
+                        save_appdata(get_appdata(siv));
                     }))
                     .expect("Failed to send callback from background thread.");
 
@@ -184,7 +93,7 @@ fn main() {
     }
 
     siv.with_user_data(|appdata: &mut Appdata| {
-        appdata.intro_screen_shown_until = utils::time::time_millis() + 2000;
+        appdata.intro_screen_shown_until = utils::time::time_millis() + LOGO_DURATION;
     });
     siv.run();
 }

+ 92 - 79
client/src/ui.rs

@@ -1,4 +1,8 @@
 use cli_clipboard::ClipboardProvider as _;
+use client_shared::message::{self, MessageSanitized};
+use client_shared::utils::ConnectionError;
+use client_shared::{DEFAULT_PASSWORD, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
+use client_shared::ui::Language;
 use cursive::view::{Nameable as _, Resizable as _};
 use cursive::views::{
     Button, Dialog, DummyView, EditView, LinearLayout, NamedView, Panel, ScrollView, SelectView,
@@ -7,11 +11,9 @@ use cursive::views::{
 use cursive::{event, Cursive};
 use utils::hash::Hashable as _;
 
-use crate::actions::{self, NetworkError};
-use crate::message::MessageSanitized;
+use crate::actions;
 use crate::{
-    get_appdata, message, save_appdata, Appdata, DEFAULT_PASSWORD, MAX_MESSAGE_LENGTH,
-    MAX_USERNAME_LENGTH,
+    get_appdata, save_appdata, Appdata
 };
 
 pub const USERS_PANEL_ID: &str = "users_view_id";
@@ -29,6 +31,7 @@ pub const CURRENT_USERNAME_PANEL_ID: &str = "current_username_view_id";
 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 QUIT_BUTTON_ID: &str = "quit_button_id";
 pub const BLOCKED_WORDS_BUTTON_ID: &str = "blocked_words_view_id";
 pub const SERVER_SETTINGS_ADDRESS_FIELD_ID: &str = "server_settings_address_field_id";
 pub const SERVER_SETTINGS_REFRESH_FIELD_ID: &str = "server_settings_refresh_field_id";
@@ -38,7 +41,7 @@ pub const INFO_BUTTON_ID: &str = "INFO_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;
+pub const USER_PANEL_SIZE: usize = MAX_USERNAME_LENGTH + 2;
 
 #[allow(unused)]
 pub enum Labels {
@@ -67,8 +70,8 @@ pub enum Labels {
     InvalidMessage,
     Success,
     SuccessfullySentMessage,
-    FailedToSendMessage(NetworkError),
-    FailedToLoadMessages(NetworkError),
+    FailedToSendMessage(ConnectionError),
+    FailedToLoadMessages(ConnectionError),
     RefreshButton,
     BlockedWords,
     ServerSettings,
@@ -79,17 +82,11 @@ pub enum Labels {
     Time,
     Sender,
     Content,
-}
-
-#[derive(Debug, Clone, Copy)]
-pub enum Language {
-    English,
-    Dutch,
-    Japanese,
+    QuitButton,
 }
 
 impl Labels {
-    // TODO: Double check the translations
+    // TODO (low): Double check the translations
     pub fn localize<'a>(&self, language: Language) -> String {
         let buf: [String; 3];
 
@@ -121,14 +118,14 @@ impl Labels {
                 let reason_buf: [String; 3];
 
                 match e {
-                    NetworkError::ReqwestError(_) => {
+                    ConnectionError::ReqwestError(_) => {
                         reason_buf = [
                             "Connection error.".to_string(),
                             "Verbindingsfout.".to_string(),
                             "接続エラー。".to_string(),
                         ];
                     }
-                    NetworkError::StatusCodeError(code) => {
+                    ConnectionError::StatusCodeError(code) => {
                         // NOTE: Needs to match the server
                         if *code == 400 {
                             reason_buf = [
@@ -144,20 +141,21 @@ impl Labels {
                             ]
                         }
                     }
-                    NetworkError::InvalidUrl => {
+                    ConnectionError::UrlError(_) => {
                         reason_buf = [
                             "Invalid URL.".to_string(),
                             "Ongeldig URL.".to_string(),
                             "無効なURL。".to_string(),
                         ];
                     }
-                    NetworkError::EncryptionError(_) => {
-                        reason_buf = [
-                            "Encryption error.".to_string(),
-                            "Encryptiefout.".to_string(),
-                            "暗号化エラー。".to_string(),
-                        ];
-                    }
+                    // TODO: Check if it's safe to remove this.
+                    // ConnectionError::EncryptionError(_) => {
+                    //     reason_buf = [
+                    //         "Encryption error.".to_string(),
+                    //         "Encryptiefout.".to_string(),
+                    //         "暗号化エラー。".to_string(),
+                    //     ];
+                    // }
                 };
 
                 buf = [
@@ -172,34 +170,35 @@ impl Labels {
                 let reason_buf: [String; 3];
 
                 match error {
-                    NetworkError::ReqwestError(_) => {
+                    ConnectionError::ReqwestError(_) => {
                         reason_buf = [
                             "Connection error.".to_string(),
                             "Verbindingsfout.".to_string(),
                             "接続エラー。".to_string(),
                         ];
                     }
-                    NetworkError::StatusCodeError(code) => {
+                    ConnectionError::StatusCodeError(code) => {
                         reason_buf = [
                             format!("Status code: {code}"),
                             format!("Statuscode: {code}"),
                             format!("ステータスコード: {code}"),
                         ];
                     }
-                    NetworkError::InvalidUrl => {
+                    ConnectionError::UrlError(_) => {
                         reason_buf = [
                             "Invalid URL.".to_string(),
                             "Ongeldig URL.".to_string(),
                             "無効なURL。".to_string(),
                         ];
                     }
-                    NetworkError::EncryptionError(_) => {
-                        reason_buf = [
-                            "Decryption error.".to_string(),
-                            "Fout bij het ontcijferen.".to_string(),
-                            "復号エラー".to_string(),
-                        ];
-                    }
+                    // TODO: Check if it's safe to remove this.
+                    // ConnectionError::EncryptionError(_) => {
+                    //     reason_buf = [
+                    //         "Decryption error.".to_string(),
+                    //         "Fout bij het ontcijferen.".to_string(),
+                    //         "復号エラー".to_string(),
+                    //     ];
+                    // }
                 };
 
                 buf = [
@@ -210,6 +209,7 @@ impl Labels {
 
                 [buf[0].as_str(), buf[1].as_str(), buf[2].as_str()]
             }
+            Labels::QuitButton => ["Quit", "Verlaten", "終了する"],
             Labels::RefreshButton => ["Refresh", "Vernieuwen", "更新する"],
             Labels::InvalidUsernameExplination => {
                 buf = [
@@ -256,11 +256,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()),
                 ];
 
@@ -299,7 +299,7 @@ pub fn alert<S>(siv: &mut Cursive, title: S, text: S)
 where
     S: Into<String>,
 {
-    let language = get_appdata(siv).language;
+    let language = get_appdata(siv).persistent_data.language;
 
     keybind_setup_close_once(siv);
 
@@ -317,7 +317,7 @@ pub fn copyable<S>(siv: &mut Cursive, title: S, text: S)
 where
     S: Into<String> + Clone,
 {
-    let language = get_appdata(siv).language;
+    let language = get_appdata(siv).persistent_data.language;
 
     keybind_setup_close_once(siv);
 
@@ -345,7 +345,7 @@ pub fn error<S>(siv: &mut Cursive, text: S)
 where
     S: Into<String>,
 {
-    let language = get_appdata(siv).language;
+    let language = get_appdata(siv).persistent_data.language;
     alert(siv, Labels::Error.localize(language), text.into());
 }
 
@@ -380,23 +380,23 @@ pub fn keybind_close_manual_end(siv: &mut Cursive, close_all: bool) {
 
 pub fn visual_update(siv: &mut Cursive) {
     let appdata = get_appdata(siv);
-    let current_channel = appdata.current_channel;
-    let language = get_appdata(siv).language;
+    let current_channel = appdata.persistent_data.current_channel;
+    let language = get_appdata(siv).persistent_data.language;
 
     // --- Messages ---
 
-    let mut messages: Vec<message::MessageSanitized> = appdata
+    let mut messages: Vec<MessageSanitized> = appdata
         .messages
         .clone()
         .values()
         .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() {
-        message.remove_blocked_phrases(siv);
+        message.remove_blocked_phrases(&appdata.persistent_data.blocked_phrases, appdata.persistent_data.blocked_phrases_censor_char);
     }
 
     siv.call_on_name(
@@ -496,7 +496,7 @@ pub fn visual_update(siv: &mut Cursive) {
         |view: &mut NamedView<Panel<TextView>>| {
             view.get_mut()
                 .get_inner_mut()
-                .set_content(appdata_c.current_channel);
+                .set_content(appdata_c.persistent_data.current_channel);
 
             view.get_mut().set_title(Labels::Channel.localize(language));
         },
@@ -507,7 +507,7 @@ pub fn visual_update(siv: &mut Cursive) {
     siv.call_on_name(
         CURRENT_USERNAME_PANEL_ID,
         |view: &mut NamedView<Panel<TextView>>| {
-            view.get_mut().get_inner_mut().set_content(appdata.username);
+            view.get_mut().get_inner_mut().set_content(appdata.persistent_data.username);
         },
     );
 
@@ -550,6 +550,11 @@ pub fn visual_update(siv: &mut Cursive) {
             .set_label(Labels::SetUsername.localize(language));
     });
 
+    siv.call_on_name(QUIT_BUTTON_ID, |view: &mut NamedView<Button>| {
+        view.get_mut()
+            .set_label(Labels::QuitButton.localize(language));
+    });
+
     siv.call_on_name(REFRESH_BUTTON_ID, |view: &mut NamedView<Button>| {
         view.get_mut()
             .set_label(Labels::RefreshButton.localize(language));
@@ -581,7 +586,7 @@ pub fn visual_update(siv: &mut Cursive) {
 
 pub fn change_language(siv: &mut Cursive, language: Language) {
     siv.with_user_data(|appdata: &mut Appdata| {
-        appdata.language = language;
+        appdata.persistent_data.language = language;
     })
     .expect("Failed to set language.");
 
@@ -598,7 +603,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
         let mut view: SelectView<String> = SelectView::new();
         view.set_on_submit(move |siv, channel_id: &str| {
             siv.with_user_data(|appdata: &mut Appdata| {
-                appdata.current_channel = channel_id.to_string();
+                appdata.persistent_data.current_channel = channel_id.to_string();
             });
 
             visual_update(siv);
@@ -614,7 +619,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
     let channels_new_button = Button::new("", move |siv| {
         keybind_setup_close_once(siv);
 
-        let language = get_appdata(siv).language;
+        let language = get_appdata(siv).persistent_data.language;
 
         siv.add_layer(
             cursive::views::Dialog::new()
@@ -633,14 +638,14 @@ 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());
-                            appdata.current_channel = input.to_string();
+                            appdata.persistent_data.current_channel = input.to_string();
                         });
 
-                        save_appdata(siv);
+                        save_appdata(get_appdata(siv));
                         visual_update(siv);
                         keybind_close_manual_end(siv, false);
                     } else {
@@ -700,13 +705,13 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
     // Language selector
     let language_button = Button::new("", move |siv| {
         // NOTE: This must match the order in the SelectView
-        let current_language_index = match get_appdata(siv).language {
+        let current_language_index = match get_appdata(siv).persistent_data.language {
             Language::English => 0,
             Language::Dutch => 1,
             Language::Japanese => 2,
         };
 
-        let language = get_appdata(siv).language;
+        let language = get_appdata(siv).persistent_data.language;
 
         keybind_setup_close_once(siv);
 
@@ -736,15 +741,15 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
 
     // Current username panel
     let current_username_panel = {
-        let current_username = get_appdata(siv).username.clone();
+        let current_username = get_appdata(siv).persistent_data.username.clone();
         let view = TextView::new(current_username);
         Panel::new(view).with_name(CURRENT_USERNAME_PANEL_ID)
     };
 
     // Set username
     let username_button = Button::new("", move |siv| {
-        let current_name = get_appdata(siv).username.clone();
-        let language = get_appdata(siv).language;
+        let current_name = get_appdata(siv).persistent_data.username.clone();
+        let language = get_appdata(siv).persistent_data.language;
 
         keybind_setup_close_once(siv);
 
@@ -767,15 +772,15 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                     if message::is_valid_username(&input) {
                         // Valid
                         siv.with_user_data(|appdata: &mut Appdata| {
-                            appdata.username = input.to_string();
+                            appdata.persistent_data.username = input.to_string();
                         });
 
-                        save_appdata(siv);
+                        save_appdata(get_appdata(siv));
                         visual_update(siv);
                         keybind_close_manual_end(siv, false);
                     } else {
                         // Invalid
-                        let language = get_appdata(siv).language;
+                        let language = get_appdata(siv).persistent_data.language;
                         keybind_close_manual_end(siv, false); // NOTE: Error dialogue overwrites this one, so end it here beforehand.
                         error(siv, Labels::InvalidUsernameExplination.localize(language));
                     }
@@ -788,10 +793,16 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
     })
     .with_name(USERNAME_BUTTON_ID);
 
+    // Quit button
+    let quit_button = Button::new("", move |siv| {
+        siv.quit();
+    })
+    .with_name(QUIT_BUTTON_ID);
+
     // Refresh button
     let refresh_button = Button::new("", move |siv| {
         if let Err(e) = actions::load_messages(siv) {
-            let language = get_appdata(siv).language;
+            let language = get_appdata(siv).persistent_data.language;
             error(siv, Labels::FailedToLoadMessages(e).localize(language));
         }
         visual_update(siv);
@@ -802,7 +813,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
     let blocked_words_list_button = {
         Button::new("", move |siv| {
             let appdata = get_appdata(siv);
-            let language = appdata.language;
+            let language = appdata.persistent_data.language;
 
             let wordslist_id = utils::new_id();
             let wordslist_id_clone = wordslist_id.clone();
@@ -810,7 +821,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
             let mut wordslist_view =
                 SelectView::<String>::new().on_submit(move |siv, word: &str| {
                     siv.with_user_data(|appdata: &mut Appdata| {
-                        appdata.blocked_phrases.retain(|x| x != word);
+                        appdata.persistent_data.blocked_phrases.retain(|x| x != word);
                     });
 
                     siv.call_on_name(&wordslist_id_clone, |view: &mut NamedView<SelectView>| {
@@ -821,10 +832,10 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                         view.get_mut().remove_item(idx);
                     });
 
-                    save_appdata(siv);
+                    save_appdata(get_appdata(siv));
                     visual_update(siv);
                 });
-            wordslist_view.add_all_str(&appdata.blocked_phrases);
+            wordslist_view.add_all_str(&appdata.persistent_data.blocked_phrases);
 
             let typingarea_id = utils::new_id();
             let typing_area = EditView::new().with_name(&typingarea_id);
@@ -838,12 +849,12 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                     .map(|x| x.to_string())
                     .unwrap_or("".to_string());
 
-                if get_appdata(siv).blocked_phrases.contains(&text) || text.is_empty() {
+                if get_appdata(siv).persistent_data.blocked_phrases.contains(&text) || text.is_empty() {
                     return;
                 }
 
                 siv.with_user_data(|appdata: &mut Appdata| {
-                    appdata.blocked_phrases.push(text.clone());
+                    appdata.persistent_data.blocked_phrases.push(text.clone());
                 });
 
                 siv.call_on_name(
@@ -853,7 +864,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                     },
                 );
 
-                save_appdata(siv);
+                save_appdata(get_appdata(siv));
                 visual_update(siv);
             });
 
@@ -882,7 +893,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
     // Server settings
     let server_settings_button = Button::new("", |siv| {
         let appdata = get_appdata(siv);
-        let language = appdata.language;
+        let language = appdata.persistent_data.language;
 
         keybind_setup_close_once(siv);
 
@@ -900,7 +911,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                                 .child(DummyView.fixed_width(1))
                                 .child(
                                     EditView::new()
-                                        .content(appdata.api_endpoint)
+                                        .content(appdata.persistent_data.api_endpoint)
                                         .with_name(SERVER_SETTINGS_ADDRESS_FIELD_ID)
                                         .min_width(SERVER_SETTINGS_FIELD_SIZE.0)
                                         .max_height(SERVER_SETTINGS_FIELD_SIZE.1),
@@ -915,7 +926,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                                 .child(DummyView.fixed_width(1))
                                 .child(
                                     EditView::new()
-                                        .content(appdata.api_refresh_rate.to_string())
+                                        .content(appdata.persistent_data.api_refresh_rate.to_string())
                                         .with_name(SERVER_SETTINGS_REFRESH_FIELD_ID)
                                         .min_width(SERVER_SETTINGS_FIELD_SIZE.0)
                                         .max_height(SERVER_SETTINGS_FIELD_SIZE.1),
@@ -930,7 +941,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                                 .child(DummyView.fixed_width(1))
                                 .child(
                                     EditView::new()
-                                        .content(appdata.api_password.to_string())
+                                        .content(appdata.persistent_data.api_password.to_string())
                                         .with_name(SERVER_SETTINGS_PASSWORD_FIELD_ID)
                                         .min_width(SERVER_SETTINGS_FIELD_SIZE.0)
                                         .max_height(SERVER_SETTINGS_FIELD_SIZE.1),
@@ -957,20 +968,20 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                         .expect("Failed to retrieve server settings password input.");
 
                     siv.with_user_data(|appdata: &mut Appdata| {
-                        appdata.api_endpoint = input_addr.to_string();
+                        appdata.persistent_data.api_endpoint = input_addr.to_string();
 
                         let mut password = input_password.to_string();
                         if password.is_empty() {
                             password = DEFAULT_PASSWORD.to_string();
                         }
-                        appdata.api_password = password;
+                        appdata.persistent_data.api_password = password;
 
                         if let Ok(rate) = input_refresh.parse() {
-                            appdata.api_refresh_rate = rate;
+                            appdata.persistent_data.api_refresh_rate = rate;
                         }
                     });
 
-                    save_appdata(siv);
+                    save_appdata(get_appdata(siv));
                     visual_update(siv);
                     keybind_close_manual_end(siv, false);
                 })
@@ -984,7 +995,7 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
 
     let info_button = Button::new("", move |siv| {
         let appdata = get_appdata(siv);
-        let language = appdata.language;
+        let language = appdata.persistent_data.language;
 
         alert(siv, Labels::Information.localize(language), "Rei <rain@skuld.network>".to_string());
     })
@@ -1025,6 +1036,8 @@ pub fn setup_ui(siv: &mut Cursive) -> LinearLayout {
                 .child(server_settings_button)
                 .child(DummyView.full_width())
                 .child(refresh_button)
+                .child(DummyView.full_width())
+                .child(quit_button)
                 .full_width(),
         );
 

+ 13 - 0
client_cli/Cargo.toml

@@ -0,0 +1,13 @@
+[package]
+name = "client-cli"
+version = "0.1.0"
+edition = "2024"
+authors = ["Rain <rain@skuld.network>"]
+
+[dependencies]
+client_shared = { path = "../client_shared" }
+utils = { path = "../utils" }
+logging = { path = "../logging" }
+
+clap = { version = "4.5.60", features = ["derive", "cargo"] }
+json = "0.12.4"

+ 126 - 0
client_cli/src/main.rs

@@ -0,0 +1,126 @@
+use clap::{Parser, Subcommand};
+use client_shared::{message::Message, utils::{rx_messages, tx_message}};
+use utils::time::time_millis;
+
+#[derive(Parser, Debug)]
+#[command(
+    version,
+    about,
+    author,
+    long_about = None,
+    help_template = "\
+{before-help}{name} {version}
+{author-with-newline}{about-with-newline}
+{usage-heading} {usage}
+
+{all-args}{after-help}
+"
+)]
+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(
+        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 = "plain",
+            help = "Return data in a human-readable format."
+        )]
+        plaintext: 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 { plaintext } => {
+            let mut messages = rx_messages(&args.host, &args.password, args.timeout_sec)
+                .expect("Failed to load messages.");
+
+            messages.sort_by(|a, b| {
+                a.time.cmp(&b.time)
+            });
+
+            if plaintext {
+                todo!();
+            } else {
+                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));
+            }
+        },
+        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();
+
+            tx_message(&args.host, &args.password, args.timeout_sec, message)
+                .expect("Failed to send message.");
+        },
+    }
+}

+ 13 - 0
client_shared/Cargo.toml

@@ -0,0 +1,13 @@
+[package]
+name = "client_shared"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+logging = { path = "../logging" }
+utils = { path = "../utils" }
+
+# TODO: Write an HTTP client.
+reqwest = { version = "0.12.15", features = [ "blocking" ] }
+
+url = "2.5.8"

+ 0 - 0
client/assets/logo.txt → client_shared/assets/logo.txt


+ 21 - 0
client_shared/src/lib.rs

@@ -0,0 +1,21 @@
+pub mod message;
+pub mod persistence;
+pub mod ui;
+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_PASSWORD: &str = "null";
+
+pub const API_CONNECTION_TIMEOUT: u64 = 2;
+pub const API_DEFAULT_PORT: u16 = 13337;
+
+pub const INVALID_MESSAGE_IDENT: &str = "ERR";
+
+pub const LOGO: &str = include_str!("../assets/logo.txt");
+pub const LOGO_DURATION: u128 = 500;
+
+pub const SAVE_FILE: &str = "savedata.bin";
+pub const SAVE_FILE_FUZZY: u64 = 0b0110110001101001011001110110110101100001001000000101100001000100;

+ 18 - 8
client/src/message.rs → client_shared/src/message.rs

@@ -4,7 +4,7 @@ use utils::{
     time::time_millis,
 };
 
-use crate::{get_appdata, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
+use crate::{INVALID_MESSAGE_IDENT, MAX_MESSAGE_LENGTH, MAX_USERNAME_LENGTH};
 
 #[derive(Debug, Clone)]
 pub struct Message {
@@ -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()
 }
@@ -102,10 +115,7 @@ pub struct MessageSanitized {
 }
 
 impl MessageSanitized {
-    pub fn remove_blocked_phrases(&mut self, siv: &mut cursive::Cursive) {
-        let appdata = get_appdata(siv);
-        let wordlist = appdata.blocked_phrases;
-        let censor_char = appdata.blocked_phrases_censor_char;
+    pub fn remove_blocked_phrases(&mut self, wordlist: &[String], censor_char: char) {
         self.sender = utils::strings::remove_bad_words(&self.sender, &wordlist, censor_char);
         self.content = utils::strings::remove_bad_words(&self.content, &wordlist, censor_char);
         self.channel = utils::strings::remove_bad_words(&self.channel, &wordlist, censor_char);
@@ -117,17 +127,17 @@ impl From<Message> for MessageSanitized {
     fn from(value: Message) -> Self {
         let mut sender = value.sender.clone();
         if !is_valid_username(&sender) {
-            sender = format!("Invl@{}", &value.sender.hash().to_string()[..8]);
+            sender = format!("{}@{}", INVALID_MESSAGE_IDENT, &value.sender.hash().to_string()[..8]);
         }
 
         let mut channel = value.channel.clone();
         if !is_valid_username(&channel) {
-            channel = format!("Invl@{}", &value.channel.hash().to_string()[..8]);
+            channel = format!("{}@{}", INVALID_MESSAGE_IDENT, &value.channel.hash().to_string()[..8]);
         }
 
         let mut content = value.content.clone();
         if !is_valid_message(&value.content) {
-            content = format!("Invalid@{}", &value.content.hash().to_string());
+            content = format!("{}@{}", INVALID_MESSAGE_IDENT, &value.content.hash().to_string());
         }
 
         MessageSanitized {

+ 89 - 19
client/src/persistence.rs → client_shared/src/persistence.rs

@@ -1,7 +1,39 @@
+use std::{collections::HashMap, io::{Read as _, Write as _}};
+
 use utils::serialize::{DeserializationError, Serialize, SetOfSerializedObjects};
 
-use crate::{ui, Appdata};
+use crate::{DEFAULT_CHANNEL, DEFAULT_PASSWORD, DEFAULT_USERNAME_PREFIX, SAVE_FILE, SAVE_FILE_FUZZY, message::Message, ui};
+
+
+#[derive(Clone)]
+pub struct Appdata {
+    pub persistent_data: Savedata,
+
+    pub messages: HashMap<String, Message>,
+    pub quick_close_window_count: usize,
+    pub local_channels: Vec<String>,
+    pub intro_screen_shown_until: u128,
+}
+
+impl Default for Appdata {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Appdata {
+    pub fn new() -> Self {
+        Self {
+            messages: HashMap::new(),
+            quick_close_window_count: 0,
+            local_channels: vec![DEFAULT_CHANNEL.to_string()],
+            intro_screen_shown_until: 0,
+            persistent_data: Savedata::new(),
+        }
+    }
+}
 
+#[derive(Clone)]
 pub struct Savedata {
     pub username: String,
     pub language: ui::Language,
@@ -13,33 +45,36 @@ pub struct Savedata {
     pub api_password: String,
 }
 
-impl From<Appdata> for Savedata {
-    fn from(value: Appdata) -> Self {
+impl Savedata {
+    pub fn new() -> Self {
         Self {
-            username: value.username,
-            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,
-            api_password: value.api_password,
-            current_channel: value.current_channel,
+            username: format!(
+                "{}#{}",
+                DEFAULT_USERNAME_PREFIX,
+                utils::rng::random_numeric_string(4)
+            ),
+            language: ui::Language::English,
+            api_endpoint: String::new(),
+            api_refresh_rate: 10,
+            blocked_phrases: Vec::new(),
+            blocked_phrases_censor_char: '*',
+            current_channel: DEFAULT_CHANNEL.to_string(),
+            api_password: DEFAULT_PASSWORD.to_string(),
         }
     }
 }
 
+impl From<Appdata> for Savedata {
+    fn from(value: Appdata) -> Self {
+        value.persistent_data
+    }
+}
+
 impl From<Savedata> for Appdata {
     fn from(value: Savedata) -> Self {
         let mut s = Self::new();
 
-        s.username = value.username;
-        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.current_channel = value.current_channel;
-        s.api_password = value.api_password;
+        s.persistent_data = value;
 
         s
     }
@@ -132,3 +167,38 @@ impl Serialize for Savedata {
         })
     }
 }
+
+pub fn save_appdata(appdata: Appdata) {
+    let savedata = Savedata::from(appdata);
+    let bytes = savedata.serialize_checked();
+    let fuzzy_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY);
+
+    let mut file = std::fs::File::create(SAVE_FILE).expect("Failed to create savedata file.");
+    file.write_all(&fuzzy_bytes)
+        .expect("Failed to write savedata file.");
+
+    logging::trace("Wrote savadata to file.");
+}
+
+pub fn load_appdata() -> std::io::Result<Option<Appdata>> {
+    let mut df = logging::warn_deferred("Savedata file not found; using defaults");
+
+    let mut file = std::fs::File::open(SAVE_FILE)?;
+
+    let mut bytes = Vec::new();
+    file.read_to_end(&mut bytes)?;
+    let useful_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY);
+
+    df.cancel();
+
+    let Ok(savedata) = Savedata::deserialize_checked(useful_bytes) else {
+        // If the file is corrupted, create a new one
+        logging::warn("Savedata file corrupted; using defaults.");
+        return Ok(None);
+    };
+
+    let appdata = Appdata::from(savedata);
+
+    logging::trace("Savedata file loaded.");
+    Ok(Some(appdata))
+}

+ 7 - 0
client_shared/src/ui.rs

@@ -0,0 +1,7 @@
+
+#[derive(Debug, Clone, Copy)]
+pub enum Language {
+    English,
+    Dutch,
+    Japanese,
+}

+ 122 - 0
client_shared/src/utils.rs

@@ -0,0 +1,122 @@
+
+use std::{str::FromStr as _, time::Duration};
+
+use url::Url;
+use utils::{binary::checksum, serialize::Serialize as _};
+
+use crate::{API_DEFAULT_PORT, message::Message};
+
+#[derive(Debug)]
+pub enum UrlError {
+    ParseError(url::ParseError),
+    DomainBasePortError,
+}
+
+pub fn fix_url(url: &str) -> Result<String, UrlError> {
+    let mut url: String = url.to_string();
+
+    if !url.starts_with("http://") && !url.starts_with("https://") {
+        url = format!("http://{}", url);
+    }
+
+    let mut url = Url::from_str(&url).map_err(|x| UrlError::ParseError(x))?;
+
+    if url.port().is_none() {
+        url.set_port(Some(API_DEFAULT_PORT)).map_err(|_| UrlError::DomainBasePortError)?;
+    }
+
+    let mut url = url.to_string();
+
+    if !url.ends_with('/') {
+        url.push('/');
+    }
+
+    Ok(url)
+}
+
+#[derive(Debug)]
+pub enum ConnectionError {
+    UrlError(UrlError),
+    ReqwestError(reqwest::Error),
+    StatusCodeError(u16),
+}
+
+pub fn tx_message(host: &str, password: &str, timeout_sec: u64, mut message: Message) -> Result<(), ConnectionError> {
+    message.sanitize();
+
+    let url = fix_url(host)
+        .map_err(|x| ConnectionError::UrlError(x))?;
+
+    let mut bytes = message.serialize_checked();
+    utils::aes::encrypt_cbc(&mut bytes, &password);
+    bytes.push(checksum(&bytes));
+
+    let str = utils::binary::bin2hex(bytes);
+
+    let resp = reqwest::blocking::Client::new()
+        .post(url)
+        .body(str)
+        .timeout(Duration::from_secs(timeout_sec))
+        .send()
+        .map_err(|e| ConnectionError::ReqwestError(e))?;
+
+    if resp.status().is_success() {
+        Ok(())
+    } else {
+        Err(ConnectionError::StatusCodeError(resp.status().as_u16()))
+    }
+}
+
+pub fn rx_messages(host: &str, password: &str, timeout_sec: u64) -> Result<Vec<Message>, ConnectionError> {
+    let url = fix_url(host)
+        .map_err(|x| ConnectionError::UrlError(x))?;
+
+    let resp = reqwest::blocking::Client::new()
+        .get(url)
+        .timeout(Duration::from_secs(timeout_sec))
+        .send()
+        .map_err(|x| ConnectionError::ReqwestError(x))?;
+
+    if resp.status() != reqwest::StatusCode::OK {
+        return Err(ConnectionError::StatusCodeError(resp.status().as_u16()));
+    }
+
+    let bytes = resp.bytes()
+        .map_err(|e| ConnectionError::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, &password).is_err() {
+            continue;
+        }
+
+        let Ok(mut message) = Message::deserialize_checked(message_ser_bin) else {
+            continue;
+        };
+
+        if message.is_valid() {
+            message.sanitize();
+            messages.push(message);
+        }
+    }
+
+    Ok(messages)
+}

+ 33 - 9
logging/src/lib.rs

@@ -1,6 +1,7 @@
-use std::sync::Mutex;
+use std::{ffi::OsString, fs::File, io::{self, Write}, sync::Mutex};
 
 static LOG_LEVEL: Mutex<LogLevel> = Mutex::new(LogLevel::Info);
+static LOG_FILE: Mutex<Option<OsString>> = Mutex::new(None);
 
 pub use procmacro::log;
 
@@ -42,8 +43,22 @@ impl Drop for DeferredLog {
     }
 }
 
+fn write(line: &str) -> io::Result<()> {
+    let Some(path) = get_global_log_path() else {
+        println!("{}", line);
+        return Ok(());
+    };
+
+    let mut fio = File::options().append(true).create(true).open(path)?;
+    fio.write_all(line.as_bytes())?;
+    fio.write_all("\n".as_bytes())?;
+    fio.flush()?;
+
+    Ok(())
+}
+
 fn create_log(message: &str, level: LogLevel) {
-    let log_id = utils::rng::random_numeric_string(3);
+    let log_id = utils::rng::random_numeric_string(24);
 
     #[rustfmt::skip]
     let prefix = match level {
@@ -53,19 +68,19 @@ fn create_log(message: &str, level: LogLevel) {
         LogLevel::Info    => "[  INFO   ",
         LogLevel::Debug   => "[  DEBUG  ",
         LogLevel::Trace   => "[  TRACE  ",
-    }.to_string() + &format!("{:0>3}", log_id) + " ]";
+    }.to_string() + &utils::time::time_millis().to_string() + &format!("  {:0>24}", log_id) + " ]";
 
-    print!("{}", prefix);
+    write(&format!("{}", prefix)).expect("Failed to write log.");
 
     for (idx, line) in message.split("\n").enumerate() {
         if idx == 0 {
-            println!(" {line}");
+            write(&format!(" {line}")).expect("Failed to write log.");
         } else {
-            println!(" {idx:>7}  {log_id:0>3} | {line}");
+            write(&format!(" {idx:>7}  {log_id:0>3} | {line}")).expect("Failed to write log.");
         }
     }
 
-    println!();
+    write("").expect("Failed to write log.");
 }
 
 macro_rules! logger_setup {
@@ -90,12 +105,21 @@ logger_setup! { error, error_deferred, LogLevel::Error }
 logger_setup! { fatal, fatal_deferred, LogLevel::Fatal }
 
 pub fn set_global_log_level(level: LogLevel) {
-    let mut guard = LOG_LEVEL.lock().expect("Logging mutex is poisoned.");
+    let mut guard = LOG_LEVEL.lock().expect("Logging level mutex is poisoned.");
     *guard = level;
 }
 
 pub fn get_global_log_level() -> LogLevel {
-    *LOG_LEVEL.lock().expect("Logging mutex is poisoned.")
+    *LOG_LEVEL.lock().expect("Logging level mutex is poisoned.")
+}
+
+pub fn set_global_log_path(path: Option<OsString>) {
+    let mut guard = LOG_FILE.lock().expect("Logging path mutex is poisoned.");
+    *guard = path;
+}
+
+pub fn get_global_log_path() -> Option<OsString> {
+    LOG_FILE.lock().expect("Logging path mutex is poisoned.").clone()
 }
 
 #[cfg(test)]

+ 21 - 4
server/src/main.rs

@@ -1,10 +1,12 @@
+use std::env::args;
+
 use utils::{
-    http::{self, Request, Response},
-    strings::StaticString,
+    binary::checksum, http::{self, Request, Response}, strings::StaticString
 };
 
 const MAX_MESSAGE_LENGTH: usize = 2048;
 const MESSAGE_HISTORY_LENGTH: usize = 64;
+const DEFAULT_PORT: u16 = 13337;
 
 #[derive(Debug)]
 struct Appdata {
@@ -55,6 +57,19 @@ fn index_get_sync(state: &mut Appdata, _: Request) -> Response {
 fn index_post_sync(state: &mut Appdata, request: Request) -> Response {
     let body = request.body;
 
+    let Some(message_ser_bin) = utils::binary::hex2bin(&body) else {
+        return Response::bad_request();
+    };
+
+    if message_ser_bin.len() < 2 {
+        return Response::bad_request();
+    }
+
+    // Checksum before decryption
+    if checksum(&message_ser_bin[..message_ser_bin.len()-1]) != message_ser_bin[message_ser_bin.len()-1] {
+        return Response::bad_request();
+    }
+
     println!("Received ({}): {:#?}", body.len(), body);
 
     match state.insert_message(body.clone()) {
@@ -76,10 +91,12 @@ fn router(state: &mut Appdata, request: Request) -> Response {
 }
 
 fn main() -> http::Result<()> {
-    println!("Starting...");
+    let port = args().nth(1).unwrap_or(DEFAULT_PORT.to_string());
+
+    println!("Starting on port {}", port);
 
     let appdata = Appdata::new();
-    http::run("127.0.0.1:8080", appdata, router)?;
+    http::run(&format!("127.0.0.1:{}", port), appdata, router)?;
 
     Ok(())
 }