diff --git a/.gitignore b/.gitignore
index ad67955..191779a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,12 @@ target
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+
+
+# Added by cargo
+
+/target
+
+
+*.db
+config.json
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..3226e2c
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,814 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[package]]
+name = "cc"
+version = "1.2.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
+
+[[package]]
+name = "chrono"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
+
+[[package]]
+name = "flate2"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "http"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "js-sys"
+version = "0.3.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.176"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "untrusted",
+ "windows-sys",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
+dependencies = [
+ "bitflags",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.145"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537"
+dependencies = [
+ "base64",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf-8",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "voxa-node"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "chrono",
+ "once_cell",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha1",
+ "ureq",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
+
+[[package]]
+name = "windows-result"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..fffe69e
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "voxa-node"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.145"
+anyhow = "1.0.100"
+base64 = "0.22.1"
+sha1 = "0.10.6"
+once_cell = "1.21.3"
+chrono = "0.4.42"
+ureq = "3.1.2"
+rusqlite = "0.37.0"
diff --git a/README.md b/README.md
index aa3c9e6..c1c35dc 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,39 @@
# voxa-node
+
The server for the DM and notification system of Voxa
+
+### What is a Voxa Node?
+
+A voxa node is a server that handles DMs sent to you and push message notifications, a client will keep a constant connection with their chosen node and when a sender wants to send a DM or a message notification they can make a HTTP request to the same port, it's important to note that a node server only handles the receiving side, unless both parties are in the same node.
+
+A analogy to a node is a email server but real time.
+
+### What will a Voxa Node store?
+
+A voxa node stores messages that are sent to you, additionally push notifications and after 3 days of inactivity notifications will be removed.
+
+### Why Voxa Nodes over traditional PM/DM structure
+
+Traditional structures often are a privacy threat even with End-To-End encryption, additionally they are very resource intensive which in my situation is necessary to reduce as much as possible.
+Another big issue with the traditional single server structure is that it may apply censorship, our biggest enemy.
+
+```mermaid
+sequenceDiagram
+ participant Bob
+ participant AuthServer as Alice's Node (3rd Party Server)
+ participant Alice
+ participant Cloud as Voxa Cloud
+
+ Bob->>Bob: Hash message (SHA-256)
+ Bob->>Bob: Sign hash with Private Key
+ Bob->>AuthServer: Send {message, signature}
+
+ AuthServer->>Alice: Relay {message, signature}
+
+ Alice->>Cloud: Request Bob's Public Key
+ Cloud-->>Alice: Return Bob's Public Key
+
+ Alice->>Alice: Verify signature using Public Key
and recompute SHA-256(message)
+
+ Alice-->>Alice: Accept message if signature is valid
+```
diff --git a/src/macros.rs b/src/macros.rs
new file mode 100644
index 0000000..fd1d31e
--- /dev/null
+++ b/src/macros.rs
@@ -0,0 +1,30 @@
+#[macro_export]
+macro_rules! export_plugin {
+ ($p:expr) => {
+ #[unsafe(no_mangle)]
+ pub extern "C" fn load_plugin() -> $crate::utils::plugin::DynPlugin {
+ $p
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! logger {
+ (const $i:ident $name:expr) => {
+ pub const $i: $crate::once_cell::sync::Lazy<$crate::utils::logger::Logger> =
+ $crate::once_cell::sync::Lazy::new(|| $crate::utils::logger::Logger::new($name));
+ };
+
+ ($i:ident $name:expr) => {
+ pub const $i: $crate::once_cell::sync::Lazy<$crate::utils::logger::Logger> =
+ $crate::once_cell::sync::Lazy::new(|| $crate::utils::logger::Logger::new($name));
+ };
+
+ (const $name:expr) => {
+ $crate::once_cell::sync::Lazy::new(|| $crate::utils::logger::Logger::new($name))
+ };
+
+ ($name:expr) => {
+ $crate::logger::utils::Logger::new($name)
+ };
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..3867824
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,22 @@
+#![allow(dead_code)]
+mod macros;
+mod requests;
+mod server;
+mod types;
+mod utils;
+
+use std::path::PathBuf;
+
+pub use anyhow::Context as ErrorContext;
+pub use anyhow::Result;
+pub use once_cell;
+
+use crate::server::ServerConfig;
+use crate::utils::vfs;
+
+fn main() -> Result<()> {
+ let root = PathBuf::from("");
+ let config: ServerConfig = vfs::read_config(&root.join("config.json"))?;
+ config.build(&root).run()?;
+ Ok(())
+}
diff --git a/src/requests/auth.rs b/src/requests/auth.rs
new file mode 100644
index 0000000..62dd2ba
--- /dev/null
+++ b/src/requests/auth.rs
@@ -0,0 +1,25 @@
+use std::sync::Arc;
+
+use serde::Deserialize;
+
+use crate::{ErrorContext, logger};
+use crate::{server::Server, utils::client::Client};
+
+logger!(LOGGER "Auth");
+
+#[derive(Debug, Deserialize)]
+struct AuthApiRes {
+ user_id: u32,
+}
+
+pub fn auth(_server: &Arc, client: &mut Client, token: &str) -> crate::Result {
+ let mut res = ureq::get(format!(
+ "http://localhost:3000/api/auth?intents=server&token={token}"
+ ))
+ .call()
+ .context("Failed to authenticate")?;
+ let api_res: AuthApiRes = serde_json::from_str(&res.body_mut().read_to_string()?)?;
+ client.set_uuid(api_res.user_id);
+ LOGGER.info(format!("{} successfully authenticated", api_res.user_id));
+ Ok(api_res.user_id)
+}
diff --git a/src/requests/message.rs b/src/requests/message.rs
new file mode 100644
index 0000000..6c08a3a
--- /dev/null
+++ b/src/requests/message.rs
@@ -0,0 +1,77 @@
+use std::sync::Arc;
+
+use crate::{server::Server, types, utils::client::Client};
+
+crate::logger!(LOGGER "Message Manager");
+
+pub fn send(
+ server: &Arc,
+ client: &Client,
+ channel_id: u32,
+ contents: &str,
+) -> crate::Result<()> {
+ LOGGER.info(format!("SendMessage to {channel_id}: {contents}"));
+
+ if contents.is_empty() {
+ client.send(types::ResponseError::InvalidRequest(format!(
+ "Invalid message: empty message"
+ )))?;
+
+ return Ok(());
+ }
+
+ let msg = server.db.insert_message(
+ channel_id,
+ client.get_uuid()?,
+ &contents,
+ chrono::Utc::now().timestamp(),
+ )?;
+
+ let server = server.clone();
+
+ for c in server.clients.lock().unwrap().iter() {
+ let c = c.clone();
+ let server = server.clone();
+ let msg = msg.clone();
+
+ std::thread::spawn(move || {
+ if let Some(uuid) =
+ LOGGER.extract(server.wrap_err(&c, c.get_uuid()), "Unable send message")
+ {
+ if uuid != channel_id {
+ return;
+ }
+
+ LOGGER.extract(
+ server.wrap_err(&c, c.send(types::ServerMessage::MessageCreate(msg))),
+ "Failed to send message",
+ );
+ }
+ });
+ }
+
+ LOGGER.extract(
+ server.wrap_err(
+ &client,
+ client.send(types::ServerMessage::MessageCreate(msg)),
+ ),
+ "Failed to send message",
+ );
+
+ Ok(())
+}
+
+pub fn edit(
+ _server: &Arc,
+ _client: &Client,
+ message_id: usize,
+ new_contents: &str,
+) -> crate::Result<()> {
+ LOGGER.info(format!("EditMessage {message_id}: {new_contents}"));
+ Ok(())
+}
+
+pub fn delete(_server: &Arc, _client: &Client, message_id: usize) -> crate::Result<()> {
+ LOGGER.info(format!("DeleteMessage {message_id}"));
+ Ok(())
+}
diff --git a/src/requests/mod.rs b/src/requests/mod.rs
new file mode 100644
index 0000000..2d053cc
--- /dev/null
+++ b/src/requests/mod.rs
@@ -0,0 +1,48 @@
+pub mod auth;
+pub mod message;
+
+use std::sync::Arc;
+
+use crate::{
+ server::Server,
+ types::{ClientMessage, WsMessage},
+ utils::client::Client,
+};
+
+impl Server {
+ pub fn call_request(
+ self: &Arc,
+ req: &WsMessage,
+ client: &Client,
+ ) -> crate::Result<()> {
+ match req {
+ WsMessage::Message(req) => match req {
+ ClientMessage::SendMessage {
+ channel_id,
+ contents,
+ } => {
+ message::send(self, client, *channel_id, contents)?;
+ }
+
+ ClientMessage::EditMessage {
+ message_id,
+ new_contents,
+ } => message::edit(self, client, *message_id, new_contents)?,
+
+ ClientMessage::DeleteMessage { message_id } => {
+ message::delete(self, client, *message_id)?
+ }
+ },
+
+ WsMessage::Binary(b) => {
+ Self::LOGGER.info(format!("Binary message: {b:?}"));
+ }
+
+ WsMessage::String(s) => {
+ Self::LOGGER.info(format!("String message: {s}"));
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/server.rs b/src/server.rs
new file mode 100644
index 0000000..508df39
--- /dev/null
+++ b/src/server.rs
@@ -0,0 +1,172 @@
+use std::{
+ collections::HashSet,
+ net::{TcpListener, TcpStream},
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex},
+};
+
+use crate::{
+ requests, types,
+ utils::{self, client::Client, database},
+};
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct ServerConfig {
+ server_name: String,
+ server_id: String,
+ port: u16,
+}
+
+#[allow(dead_code)]
+pub struct Server {
+ pub root: PathBuf,
+ pub config: ServerConfig,
+ pub clients: Mutex>,
+ pub db: utils::database::Database,
+}
+
+impl Default for ServerConfig {
+ fn default() -> Self {
+ Self {
+ port: 7090,
+ server_name: format!("Server Name"),
+ server_id: format!("offline-server"),
+ }
+ }
+}
+
+impl ServerConfig {
+ pub fn build(self, root: &Path) -> Arc {
+ Server::new_config(root, self)
+ }
+}
+
+impl Server {
+ crate::logger!(LOGGER "Server");
+
+ pub fn new(root: &Path) -> Arc {
+ Self::new_config(root, ServerConfig::default())
+ }
+
+ pub fn new_config(root: &Path, config: ServerConfig) -> Arc {
+ Arc::new(Self {
+ db: database::Database::new(&config).unwrap(),
+ root: root.to_path_buf(),
+ config,
+ clients: Mutex::new(HashSet::new()),
+ })
+ }
+
+ pub fn run(self: &Arc) -> crate::Result<()> {
+ // Start server
+ let listener = TcpListener::bind(format!("0.0.0.0:{}", self.config.port))?;
+ Self::LOGGER.info(format!("Server listening at 0.0.0.0:{}", self.config.port));
+
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => {
+ std::thread::spawn({
+ let srv = self.clone();
+
+ move || {
+ let Some(client) = Self::LOGGER
+ .extract(srv.init_client(stream), "Failed to initialize client")
+ else {
+ return;
+ };
+
+ Self::LOGGER.extract(
+ srv.wrap_err(&client, srv.handle_client(&client)),
+ "Client handler failed",
+ );
+ }
+ });
+ }
+ Err(e) => {
+ Self::LOGGER.error(format!("Connection failed: {e}"));
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn init_client(self: &Arc, stream: TcpStream) -> anyhow::Result {
+ Self::LOGGER.info(format!("New connection: {}", stream.peer_addr()?));
+ // Initialize client
+ let mut client = Client::new(stream)?;
+
+ // Initialize handshake
+ self.wrap_err(
+ &client,
+ client.send(types::handshake::ServerDetails {
+ name: self.config.server_name.clone(),
+ id: self.config.server_id.clone(),
+ version: format!("0.0.1"),
+ }),
+ )?;
+
+ match self.wrap_err(&client, client.read_t::())? {
+ Some(types::WsMessage::Message(types::handshake::ClientDetails {
+ auth_token,
+ last_message,
+ ..
+ })) => {
+ let auth_res = requests::auth::auth(self, &mut client, &auth_token);
+ let uuid = self.wrap_err(&client, auth_res)?;
+ self.wrap_err(
+ &client,
+ client.send(types::ServerMessage::Authenticated {
+ uuid,
+ messages: if let Some(i) = last_message {
+ self.wrap_err(&client, self.db.get_messages_after_id(uuid, i))?
+ } else {
+ self.wrap_err(&client, self.db.get_messages_after_id(uuid, 0))?
+ },
+ }),
+ )?;
+ }
+ Some(v) => {
+ self.wrap_err(
+ &client,
+ client.send(types::ResponseError::InvalidHandshake(format!(
+ "Invalid handshake: {v:?}"
+ ))),
+ )?;
+ }
+ None => {}
+ }
+
+ // Insert to the set of all connected clients
+ self.clients.lock().unwrap().insert(client.clone());
+
+ Ok(client)
+ }
+
+ fn handle_client(self: &Arc, client: &Client) -> anyhow::Result<()> {
+ // The main req/res loop
+ loop {
+ let req = client.read()?;
+ if let Some(r) = &req {
+ self.wrap_err(&client, self.call_request(r, &client))?;
+ }
+ }
+ }
+
+ /// When there is a error it removes the client
+ pub fn wrap_err(
+ self: &Arc,
+ client: &Client,
+ res: std::result::Result,
+ ) -> std::result::Result {
+ if let Err(e) = &res {
+ self.clients.lock().unwrap().remove(&client);
+ if client
+ .send(types::ResponseError::InternalError(e.to_string()))
+ .is_err()
+ {}
+ }
+
+ res
+ }
+}
diff --git a/src/types.rs b/src/types.rs
new file mode 100644
index 0000000..b291e20
--- /dev/null
+++ b/src/types.rs
@@ -0,0 +1,124 @@
+use serde::{Deserialize, Serialize};
+
+/// Messages sent *from the client* (user’s app) to the server
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", content = "params", rename_all = "snake_case")]
+pub enum ClientMessage {
+ /// Send a message to a channel
+ SendMessage {
+ channel_id: u32,
+ contents: String,
+ },
+
+ /// Edit a message (if allowed)
+ EditMessage {
+ message_id: usize,
+ new_contents: String,
+ },
+
+ /// Delete a message (if allowed)
+ DeleteMessage { message_id: usize },
+}
+
+/// Messages sent *from the server* to the client
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", content = "params", rename_all = "snake_case")]
+pub enum ServerMessage {
+ /// Successful authentication
+ Authenticated {
+ uuid: u32,
+ messages: Vec,
+ },
+
+ TempMessage {
+ message: String,
+ },
+
+ /// A new message in a channel
+ MessageCreate(data::Message),
+
+ /// A message was edited
+ MessageUpdate(data::Message),
+
+ /// A message was deleted
+ MessageDelete {
+ channel_id: u32,
+ message_id: usize,
+ },
+
+ /// Presence updates
+ PresenceUpdate {
+ user_id: String,
+ status: String,
+ },
+
+ /// Typing indicator
+ Typing {
+ user_id: String,
+ channel_id: u32,
+ },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "error", content = "message", rename_all = "snake_case")]
+pub enum ResponseError {
+ InvalidRequest(String),
+ InvalidHandshake(String),
+ Unauthorized(String),
+ NotFound(String),
+ InternalError(String),
+}
+
+/// WebSocket wrapper
+#[derive(Debug, Clone)]
+pub enum WsMessage Deserialize<'de>> {
+ Message(T),
+ Binary(Vec),
+ String(String),
+}
+
+/// Shared data structures
+pub mod data {
+ use serde::{Deserialize, Serialize};
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ pub struct Message {
+ pub id: i64,
+ pub channel_id: u32,
+ pub from: u32,
+ pub contents: String,
+ pub timestamp: i64,
+ }
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ pub struct Channel {
+ pub id: String,
+ pub name: String,
+ pub kind: ChannelKind,
+ }
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ #[serde(rename_all = "snake_case")]
+ pub enum ChannelKind {
+ Text,
+ Voice,
+ }
+}
+
+pub mod handshake {
+ use serde::{Deserialize, Serialize};
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ pub struct ServerDetails {
+ pub version: String,
+ pub name: String,
+ pub id: String,
+ }
+
+ #[derive(Debug, Clone, Serialize, Deserialize)]
+ pub struct ClientDetails {
+ pub version: String,
+ pub auth_token: String,
+ pub last_message: Option,
+ }
+}
diff --git a/src/utils/client.rs b/src/utils/client.rs
new file mode 100644
index 0000000..66626b2
--- /dev/null
+++ b/src/utils/client.rs
@@ -0,0 +1,361 @@
+use std::{
+ hash::{Hash, Hasher},
+ io::{self, Read, Write},
+ net::{SocketAddr, TcpStream},
+ time::Duration,
+};
+
+use anyhow::anyhow;
+use serde::{Deserialize, Serialize};
+
+use crate::types::{ClientMessage, WsMessage};
+
+pub mod handshake {
+ use base64::Engine;
+ use base64::engine::general_purpose::STANDARD as Base64;
+ use sha1::{Digest, Sha1};
+ use std::collections::HashMap;
+ use std::io::{BufRead, BufReader, Write};
+ use std::net::TcpStream;
+
+ const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ pub fn handle_websocket_handshake(stream: &mut TcpStream) -> std::io::Result<()> {
+ let mut reader = BufReader::new(stream.try_clone()?);
+ let mut request_line = String::new();
+ reader.read_line(&mut request_line)?;
+
+ if !request_line.starts_with("GET") {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "Invalid HTTP method",
+ ));
+ }
+
+ let mut headers = HashMap::new();
+ let mut line = String::new();
+ loop {
+ line.clear();
+ let bytes = reader.read_line(&mut line)?;
+ if bytes == 0 || line == "\r\n" {
+ break;
+ }
+ if let Some((k, v)) = line.split_once(':') {
+ headers.insert(k.trim().to_lowercase(), v.trim().to_string());
+ }
+ }
+
+ let key = headers.get("sec-websocket-key").ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing Sec-WebSocket-Key")
+ })?;
+
+ if headers.get("upgrade").map(|v| v.to_lowercase()) != Some("websocket".to_string()) {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "Missing or invalid Upgrade header",
+ ));
+ }
+
+ if !headers
+ .get("connection")
+ .map(|v| v.to_lowercase().contains("upgrade"))
+ .unwrap_or(false)
+ {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "Missing or invalid Connection header",
+ ));
+ }
+
+ // Optional: validate Sec-WebSocket-Version == 13 (most common)
+ if let Some(ver) = headers.get("sec-websocket-version") {
+ if ver.trim() != "13" {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "Unsupported Sec-WebSocket-Version",
+ ));
+ }
+ }
+
+ // Compute accept key
+ let mut hasher = Sha1::new();
+ hasher.update(key.as_bytes());
+ hasher.update(WS_GUID.as_bytes());
+ let hash = hasher.finalize();
+ let accept_key = Base64.encode(hash);
+
+ // Note: include Sec-WebSocket-Protocol handling if you support subprotocols
+ let response = format!(
+ "HTTP/1.1 101 Switching Protocols\r\n\
+ Upgrade: websocket\r\n\
+ Connection: Upgrade\r\n\
+ Sec-WebSocket-Accept: {}\r\n\
+ Sec-WebSocket-Version: 13\r\n\r\n",
+ accept_key
+ );
+
+ stream.write_all(response.as_bytes())?;
+ stream.flush()?;
+ Ok(())
+ }
+}
+
+pub struct Client(TcpStream, Option);
+
+impl Client {
+ /// Create a client with no timeouts
+ pub fn new(mut stream: TcpStream) -> crate::Result {
+ handshake::handle_websocket_handshake(&mut stream)?;
+ Ok(Client(stream, None))
+ }
+
+ /// Create a client and set read/write timeouts (useful in prod)
+ pub fn with_timeouts(
+ mut stream: TcpStream,
+ read_timeout: Option,
+ write_timeout: Option,
+ ) -> crate::Result {
+ if let Some(t) = read_timeout {
+ stream.set_read_timeout(Some(t))?;
+ }
+ if let Some(t) = write_timeout {
+ stream.set_write_timeout(Some(t))?;
+ }
+ handshake::handle_websocket_handshake(&mut stream)?;
+ Ok(Client(stream, None))
+ }
+
+ /// Send a close frame and flush. `code` is a WebSocket close code (e.g., 1000 normal).
+ pub fn send_close(&self, code: u16, reason: &str) -> crate::Result<()> {
+ let mut stream = self.0.try_clone()?;
+
+ // control frames must be <= 125 bytes
+ let mut payload = Vec::new();
+ payload.extend_from_slice(&code.to_be_bytes());
+ payload.extend_from_slice(reason.as_bytes());
+ if payload.len() > 125 {
+ return Err(anyhow!("close reason too long").into());
+ }
+
+ let mut frame = Vec::with_capacity(2 + payload.len());
+ frame.push(0x88); // FIN=1, opcode=0x8 (Close)
+ frame.push(payload.len() as u8); // server->client MUST NOT mask
+ frame.extend_from_slice(&payload);
+
+ stream.write_all(&frame)?;
+ stream.flush()?;
+ Ok(())
+ }
+
+ /// Send a pong with given payload (control frames must be <=125)
+ fn send_pong(&self, payload: &[u8]) -> crate::Result<()> {
+ let mut stream = self.0.try_clone()?;
+
+ if payload.len() > 125 {
+ return Err(anyhow!("pong payload too long").into());
+ }
+ let mut frame = Vec::with_capacity(2 + payload.len());
+ frame.push(0x8A); // FIN=1, opcode=0xA (Pong)
+ frame.push(payload.len() as u8);
+ frame.extend_from_slice(payload);
+ stream.write_all(&frame)?;
+ stream.flush()?;
+ Ok(())
+ }
+
+ /// Send a text/binary frame (server->client must NOT mask)
+ pub fn send(&self, m: T) -> crate::Result<()> {
+ let mut stream = self.0.try_clone()?;
+
+ let payload = serde_json::to_string(&m)?;
+ let payload_bytes = payload.as_bytes();
+ let len = payload_bytes.len();
+
+ let mut header = Vec::new();
+ header.push(0x81); // FIN=1, opcode=0x1 (text)
+
+ if len < 126 {
+ header.push(len as u8);
+ } else if len <= 65535 {
+ header.push(126);
+ header.extend_from_slice(&(len as u16).to_be_bytes());
+ } else {
+ header.push(127);
+ header.extend_from_slice(&(len as u64).to_be_bytes());
+ }
+
+ stream.write_all(&header)?;
+ stream.write_all(payload_bytes)?;
+ stream.flush()?;
+ Ok(())
+ }
+
+ /// Read a full WebSocket message, handling fragmentation and control frames.
+ ///
+ /// Returns:
+ /// - Ok(Some(WsMessage)) on an application message (text/binary)
+ /// - Ok(None) if the connection should be closed (close received / read EOF)
+ /// - Err on protocol or IO errors.
+ pub fn read_t Deserialize<'de>>(
+ &self,
+ ) -> crate::Result