v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation
TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)
Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS
Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)
Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs
Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb
WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping
Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21
WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Submodule warzone-phone updated: 237adbbf21...6f4e8eb9f6
190
warzone/Cargo.lock
generated
190
warzone/Cargo.lock
generated
@@ -317,6 +317,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -529,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -541,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -680,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -706,7 +712,7 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
"group",
|
"group",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"sec1",
|
"sec1",
|
||||||
"serdect",
|
"serdect",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -750,7 +756,7 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -897,6 +903,20 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -905,7 +925,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -917,7 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ff",
|
"ff",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1078,6 +1098,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1433,6 +1454,12 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1624,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1716,6 +1743,61 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -1725,6 +1807,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1738,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1749,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1761,6 +1869,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -1841,6 +1958,8 @@ dependencies = [
|
|||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1848,6 +1967,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-http 0.6.8",
|
"tower-http 0.6.8",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1855,6 +1975,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1881,6 +2002,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1923,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1935,6 +2063,7 @@ version = "1.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2171,7 +2300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2497,6 +2626,10 @@ version = "0.4.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2646,7 +2779,7 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -2802,7 +2935,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"libc",
|
"libc",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2843,7 +2976,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"k256",
|
"k256",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -2867,9 +3000,11 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sled",
|
"sled",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2891,7 +3026,7 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hex",
|
"hex",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3028,6 +3163,25 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -3312,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
|
|
||||||
# Server
|
# Server
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
tower = "0.4"
|
tower = { version = "0.4", features = ["limit"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
|
|
||||||
# Client HTTP
|
# Client HTTP
|
||||||
|
|||||||
165
warzone/README.md
Normal file
165
warzone/README.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Warzone Messenger (featherChat)
|
||||||
|
|
||||||
|
End-to-end encrypted messenger with Signal protocol cryptography, voice/video call integration, and server federation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
|
||||||
|
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
|
||||||
|
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
|
||||||
|
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media)
|
||||||
|
- **Federation** — Two-server relay with HMAC-authenticated presence sync
|
||||||
|
- **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts)
|
||||||
|
- **Web Client** — Identical crypto via WASM (wasm-bindgen)
|
||||||
|
- **Ethereum Identity** — Same seed derives messaging keypair + Ethereum address (secp256k1)
|
||||||
|
- **BIP39 Seed** — 24-word mnemonic for identity backup/recovery
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Clients (CLI / TUI / Web)
|
||||||
|
|
|
||||||
|
| E2E encrypted (ChaCha20-Poly1305)
|
||||||
|
|
|
||||||
|
warzone-server (axum + sled)
|
||||||
|
|
|
||||||
|
| Federation (HTTP + HMAC)
|
||||||
|
|
|
||||||
|
warzone-server (peer)
|
||||||
|
|
|
||||||
|
| Call signaling
|
||||||
|
|
|
||||||
|
WarzonePhone Relay (QUIC SFU)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture with Mermaid diagrams.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd warzone
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Identity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/warzone-client init
|
||||||
|
# Outputs: 24-word BIP39 mnemonic + fingerprint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/warzone-server --bind 0.0.0.0:7700
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start TUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/warzone-client tui --server http://localhost:7700
|
||||||
|
```
|
||||||
|
|
||||||
|
### Federation (Two Servers)
|
||||||
|
|
||||||
|
Create `alpha.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_id": "alpha",
|
||||||
|
"shared_secret": "your-shared-secret",
|
||||||
|
"peer": { "id": "bravo", "url": "http://server-b:7700" },
|
||||||
|
"presence_interval_secs": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Server A
|
||||||
|
warzone-server --bind 0.0.0.0:7700 --federation alpha.json
|
||||||
|
|
||||||
|
# Server B
|
||||||
|
warzone-server --bind 0.0.0.0:7700 --federation bravo.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Messages automatically route across servers.
|
||||||
|
|
||||||
|
## TUI Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/peer <fp>` or `/p @alias` | Set DM peer |
|
||||||
|
| `/g <name>` | Switch to group (auto-join) |
|
||||||
|
| `/call <fp>` | Initiate call |
|
||||||
|
| `/file <path>` | Send file (max 10MB) |
|
||||||
|
| `/contacts` | List contacts with message counts |
|
||||||
|
| `/history` | Show conversation history |
|
||||||
|
| `/devices` | List active device sessions |
|
||||||
|
| `/kick <id>` | Revoke a device session |
|
||||||
|
| `/help` | Full command list |
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `warzone-protocol` | Crypto & message types (X3DH, Double Ratchet, Sender Keys) |
|
||||||
|
| `warzone-server` | HTTP/WS server with sled DB, federation, call state |
|
||||||
|
| `warzone-client` | CLI + TUI client |
|
||||||
|
| `warzone-wasm` | WASM bridge for web client |
|
||||||
|
| `warzone-mule` | Physical message delivery (planned) |
|
||||||
|
|
||||||
|
## Cryptographic Stack
|
||||||
|
|
||||||
|
| Primitive | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| Ed25519 | Identity signing |
|
||||||
|
| X25519 | Diffie-Hellman key exchange |
|
||||||
|
| ChaCha20-Poly1305 | AEAD encryption |
|
||||||
|
| HKDF-SHA256 | Key derivation |
|
||||||
|
| Argon2id | Seed encryption at rest |
|
||||||
|
| secp256k1 | Ethereum-compatible identity |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Auth enforcement on all write routes (bearer token middleware)
|
||||||
|
- Session auto-recovery on ratchet corruption
|
||||||
|
- Per-fingerprint WS connection cap (5 devices)
|
||||||
|
- Global request concurrency limit (200)
|
||||||
|
- Device management (list, kick, revoke-all panic button)
|
||||||
|
- Federation auth: SHA-256(secret || body) on every inter-server request
|
||||||
|
|
||||||
|
See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
72 tests across protocol + client crates (all passing):
|
||||||
|
- 28 protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity)
|
||||||
|
- 44 TUI tests (rendering, keyboard input, scrolling, state management)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## WarzonePhone Integration
|
||||||
|
|
||||||
|
All 9 WZP-side integration tasks are complete:
|
||||||
|
- Shared identity (HKDF alignment, 15 cross-project tests)
|
||||||
|
- Relay auth (featherChat bearer token validation)
|
||||||
|
- Signaling bridge (CallSignal through E2E encrypted WS)
|
||||||
|
- Room access control (hashed room names, ACL)
|
||||||
|
- Mandatory crypto handshake on all paths
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Content |
|
||||||
|
|----------|---------|
|
||||||
|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with Mermaid diagrams |
|
||||||
|
| [TASK_PLAN.md](docs/TASK_PLAN.md) | Phase-by-phase task plan (FC-P1 through P6) |
|
||||||
|
| [PROGRESS.md](docs/PROGRESS.md) | Version history and feature timeline |
|
||||||
|
| [PROTOCOL.md](docs/PROTOCOL.md) | Wire protocol specification |
|
||||||
|
| [SECURITY.md](docs/SECURITY.md) | Threat model and security analysis |
|
||||||
|
| [FUTURE_TASKS.md](docs/FUTURE_TASKS.md) | Backlog with questions-before-starting |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -10,6 +10,7 @@ pub struct LocalDb {
|
|||||||
pre_keys: sled::Tree,
|
pre_keys: sled::Tree,
|
||||||
contacts: sled::Tree,
|
contacts: sled::Tree,
|
||||||
history: sled::Tree,
|
history: sled::Tree,
|
||||||
|
sender_keys: sled::Tree,
|
||||||
_db: sled::Db,
|
_db: sled::Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,11 +40,13 @@ impl LocalDb {
|
|||||||
let pre_keys = db.open_tree("pre_keys")?;
|
let pre_keys = db.open_tree("pre_keys")?;
|
||||||
let contacts = db.open_tree("contacts")?;
|
let contacts = db.open_tree("contacts")?;
|
||||||
let history = db.open_tree("history")?;
|
let history = db.open_tree("history")?;
|
||||||
|
let sender_keys = db.open_tree("sender_keys")?;
|
||||||
Ok(LocalDb {
|
Ok(LocalDb {
|
||||||
sessions,
|
sessions,
|
||||||
pre_keys,
|
pre_keys,
|
||||||
contacts,
|
contacts,
|
||||||
history,
|
history,
|
||||||
|
sender_keys,
|
||||||
_db: db,
|
_db: db,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,14 @@ impl LocalDb {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a ratchet session for a peer (used for session recovery).
|
||||||
|
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
|
||||||
|
let key = peer.to_hex();
|
||||||
|
self.sessions.remove(key.as_bytes())?;
|
||||||
|
self.sessions.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Load a ratchet session for a peer.
|
/// Load a ratchet session for a peer.
|
||||||
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
|
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
|
||||||
let key = peer.to_hex();
|
let key = peer.to_hex();
|
||||||
@@ -115,6 +126,39 @@ impl LocalDb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sender Keys ──
|
||||||
|
|
||||||
|
/// Save a sender key for a (sender, group) pair.
|
||||||
|
pub fn save_sender_key(
|
||||||
|
&self,
|
||||||
|
sender_fp: &str,
|
||||||
|
group_name: &str,
|
||||||
|
key: &warzone_protocol::sender_keys::SenderKey,
|
||||||
|
) -> Result<()> {
|
||||||
|
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||||
|
let data = bincode::serialize(key).context("failed to serialize sender key")?;
|
||||||
|
self.sender_keys.insert(db_key.as_bytes(), data)?;
|
||||||
|
self.sender_keys.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a sender key for a (sender, group) pair.
|
||||||
|
pub fn load_sender_key(
|
||||||
|
&self,
|
||||||
|
sender_fp: &str,
|
||||||
|
group_name: &str,
|
||||||
|
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
|
||||||
|
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||||
|
match self.sender_keys.get(db_key.as_bytes())? {
|
||||||
|
Some(data) => {
|
||||||
|
let key = bincode::deserialize(&data)
|
||||||
|
.context("failed to deserialize sender key")?;
|
||||||
|
Ok(Some(key))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contacts ──
|
// ── Contacts ──
|
||||||
|
|
||||||
/// Add or update a contact. Called on send/receive.
|
/// Add or update a contact. Called on send/receive.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
use warzone_protocol::identity::IdentityKeyPair;
|
||||||
|
use warzone_protocol::message::WireMessage;
|
||||||
|
use warzone_protocol::ratchet::RatchetState;
|
||||||
|
use warzone_protocol::types::Fingerprint;
|
||||||
|
use warzone_protocol::x3dh;
|
||||||
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
|
use crate::net::ServerClient;
|
||||||
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
use super::types::{App, ChatLine, ReceiptStatus, normfp};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn handle_send(
|
||||||
|
&mut self,
|
||||||
|
identity: &IdentityKeyPair,
|
||||||
|
db: &LocalDb,
|
||||||
|
client: &ServerClient,
|
||||||
|
) {
|
||||||
|
let text = self.input.trim().to_string();
|
||||||
|
self.input.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
if text == "/quit" || text == "/q" {
|
||||||
|
self.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/info" {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Your fingerprint: {}", self.our_fp),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/help" || text == "/?" {
|
||||||
|
let help_lines = [
|
||||||
|
"Commands:",
|
||||||
|
" /help, /? Show this help",
|
||||||
|
" /info Show your fingerprint",
|
||||||
|
" /eth Show Ethereum address",
|
||||||
|
" /peer <fp>, /p Set DM peer by fingerprint",
|
||||||
|
" /peer @alias Set DM peer by alias",
|
||||||
|
" /reply, /r Reply to last DM sender",
|
||||||
|
" /dm Switch to DM mode (clear peer)",
|
||||||
|
" /contacts, /c List contacts with message counts",
|
||||||
|
" /history, /h [fp] Show conversation history",
|
||||||
|
" /alias <name> Register an alias for yourself",
|
||||||
|
" /aliases List all registered aliases",
|
||||||
|
" /unalias Remove your alias",
|
||||||
|
" /devices List your active device sessions",
|
||||||
|
" /kick <device_id> Kick a specific device session",
|
||||||
|
" /g <name> Switch to group (auto-join)",
|
||||||
|
" /gcreate <name> Create a new group",
|
||||||
|
" /gjoin <name> Join a group",
|
||||||
|
" /glist List all groups",
|
||||||
|
" /gleave Leave current group",
|
||||||
|
" /gkick <fp> Kick member from group",
|
||||||
|
" /gmembers List group members",
|
||||||
|
" /file <path> Send a file (max 10MB)",
|
||||||
|
" /quit, /q Exit",
|
||||||
|
"",
|
||||||
|
"Navigation:",
|
||||||
|
" PageUp/PageDown Scroll messages",
|
||||||
|
" Up/Down Scroll by 1 (when input empty)",
|
||||||
|
" Ctrl+C, Esc Quit",
|
||||||
|
];
|
||||||
|
for line in &help_lines {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: line.to_string(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/alias ") {
|
||||||
|
let name = text[7..].trim();
|
||||||
|
self.register_alias(name, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/aliases" {
|
||||||
|
self.list_aliases(client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/unalias" {
|
||||||
|
let url = format!("{}/v1/alias/unregister", client.base_url);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/contacts" || text == "/c" {
|
||||||
|
match db.list_contacts() {
|
||||||
|
Ok(contacts) => {
|
||||||
|
if contacts.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
for c in &contacts {
|
||||||
|
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let alias = c.get("alias").and_then(|v| v.as_str());
|
||||||
|
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let label = match alias {
|
||||||
|
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
|
||||||
|
None => format!(" {} — {} msgs", &fp[..fp.len().min(16)], count),
|
||||||
|
};
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/history") || text.starts_with("/h ") {
|
||||||
|
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
|
||||||
|
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
|
||||||
|
if fp.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
match db.get_history(fp, 50) {
|
||||||
|
Ok(msgs) => {
|
||||||
|
if msgs.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
for m in &msgs {
|
||||||
|
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: sender[..sender.len().min(12)].to_string(),
|
||||||
|
text: txt.to_string(),
|
||||||
|
is_system: false,
|
||||||
|
is_self,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/eth" {
|
||||||
|
// Show ethereum address from seed
|
||||||
|
if let Ok(seed) = crate::keystore::load_seed_raw() {
|
||||||
|
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/devices" {
|
||||||
|
let url = format!("{}/v1/devices", client.base_url);
|
||||||
|
// Try to get bearer token from a recent auth (for now, make unauthenticated GET)
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) {
|
||||||
|
if devices.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
for d in devices {
|
||||||
|
let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let when = chrono::DateTime::from_timestamp(connected, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||||
|
.unwrap_or_else(|| "?".to_string());
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/kick ") {
|
||||||
|
let device_id = text[6..].trim();
|
||||||
|
if device_id.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id);
|
||||||
|
match client.client.post(&url).json(&serde_json::json!({})).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/r" || text == "/reply" {
|
||||||
|
let last = self.last_dm_peer.lock().unwrap().clone();
|
||||||
|
if let Some(ref peer) = last {
|
||||||
|
self.peer_fp = Some(peer.clone());
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/peer ") || text.starts_with("/p ") {
|
||||||
|
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
|
||||||
|
let raw = text[6..].trim().to_string();
|
||||||
|
let fp = if raw.starts_with('@') {
|
||||||
|
match self.resolve_alias(&raw[1..], client).await {
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Peer set to {}", fp),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
self.peer_fp = Some(fp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/gcreate ") {
|
||||||
|
let name = text[9..].trim();
|
||||||
|
self.group_create(name, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/gjoin ") {
|
||||||
|
let name = text[7..].trim();
|
||||||
|
self.group_join(name, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/g ") {
|
||||||
|
let name = text[3..].trim().to_string();
|
||||||
|
// Auto-join
|
||||||
|
self.group_join(&name, client).await;
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Switched to group #{}", name),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
self.peer_fp = Some(format!("#{}", name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/dm" {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Switched to DM mode. Use /peer <fp>".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
self.peer_fp = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/glist" {
|
||||||
|
self.group_list(client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/gleave" {
|
||||||
|
if let Some(ref peer) = self.peer_fp {
|
||||||
|
if peer.starts_with('#') {
|
||||||
|
let name = peer[1..].to_string();
|
||||||
|
self.group_leave(&name, client).await;
|
||||||
|
self.peer_fp = None;
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/gkick ") {
|
||||||
|
if let Some(ref peer) = self.peer_fp {
|
||||||
|
if peer.starts_with('#') {
|
||||||
|
let name = peer[1..].to_string();
|
||||||
|
let target = text[7..].trim().to_string();
|
||||||
|
self.group_kick(&name, &target, client).await;
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/gmembers" {
|
||||||
|
if let Some(ref peer) = self.peer_fp {
|
||||||
|
if peer.starts_with('#') {
|
||||||
|
let name = peer[1..].to_string();
|
||||||
|
self.group_members(&name, client).await;
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text.starts_with("/file ") {
|
||||||
|
let path_str = text[6..].trim();
|
||||||
|
self.handle_file_send(path_str, identity, db, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message (group or DM)
|
||||||
|
let peer = match &self.peer_fp {
|
||||||
|
Some(p) if p.starts_with('#') => {
|
||||||
|
// Group mode
|
||||||
|
let group_name = p[1..].to_string();
|
||||||
|
self.group_send(&group_name, &text, identity, db, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "No peer set. Use /peer <fingerprint>".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Invalid peer fingerprint".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let our_pub = identity.public_identity();
|
||||||
|
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||||
|
|
||||||
|
let wire_msg = if let Some(ref mut state) = ratchet {
|
||||||
|
match state.encrypt(text.as_bytes()) {
|
||||||
|
Ok(encrypted) => {
|
||||||
|
let _ = db.save_session(&peer_fp, state);
|
||||||
|
WireMessage::Message {
|
||||||
|
id: msg_id.clone(),
|
||||||
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Encrypt failed: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// X3DH
|
||||||
|
let bundle = match client.fetch_bundle(&peer).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Failed to fetch bundle: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let x3dh_result = match x3dh::initiate(identity, &bundle) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("X3DH failed: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||||
|
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||||
|
|
||||||
|
match state.encrypt(text.as_bytes()) {
|
||||||
|
Ok(encrypted) => {
|
||||||
|
let _ = db.save_session(&peer_fp, &state);
|
||||||
|
WireMessage::KeyExchange {
|
||||||
|
id: msg_id.clone(),
|
||||||
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
|
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||||
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
|
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Encrypt failed: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = match bincode::serialize(&wire_msg) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Serialize failed: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||||
|
Ok(_) => {
|
||||||
|
// Track receipt status
|
||||||
|
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
|
||||||
|
// Store in contacts + history
|
||||||
|
let _ = db.touch_contact(&peer, None);
|
||||||
|
let _ = db.store_message(&peer, &self.our_fp, &text, true);
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: self.our_fp[..12].to_string(),
|
||||||
|
text: text.clone(),
|
||||||
|
is_system: false,
|
||||||
|
is_self: true,
|
||||||
|
message_id: Some(msg_id), timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Send failed: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn group_create(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups/create", client.base_url);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn group_join(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups/{}/join", client.base_url, name);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_list(&self, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups", client.base_url);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) {
|
||||||
|
if groups.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
for g in groups {
|
||||||
|
let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_leave(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups/{}/leave", client.base_url, name);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups/{}/kick", client.base_url, name);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_members(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/groups/{}/members", client.base_url, name);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(members) = data.get("members").and_then(|v| v.as_array()) {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
for m in members {
|
||||||
|
let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let alias = m.get("alias").and_then(|v| v.as_str());
|
||||||
|
let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let label = match alias {
|
||||||
|
Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { " ★" } else { "" }),
|
||||||
|
None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { " ★" } else { "" }),
|
||||||
|
};
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn group_send(
|
||||||
|
&self,
|
||||||
|
group_name: &str,
|
||||||
|
text: &str,
|
||||||
|
identity: &IdentityKeyPair,
|
||||||
|
db: &LocalDb,
|
||||||
|
client: &ServerClient,
|
||||||
|
) {
|
||||||
|
// Get members
|
||||||
|
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
|
||||||
|
let group_data = match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
|
||||||
|
},
|
||||||
|
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); return; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let my_fp = normfp(&self.our_fp);
|
||||||
|
let members: Vec<String> = group_data.get("members")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let our_pub = identity.public_identity();
|
||||||
|
let mut wire_messages: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
for member in &members {
|
||||||
|
if *member == my_fp { continue; }
|
||||||
|
let member_fp = match Fingerprint::from_hex(member) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ratchet = db.load_session(&member_fp).ok().flatten();
|
||||||
|
|
||||||
|
let wire_msg = if let Some(ref mut state) = ratchet {
|
||||||
|
match state.encrypt(text.as_bytes()) {
|
||||||
|
Ok(encrypted) => {
|
||||||
|
let _ = db.save_session(&member_fp, state);
|
||||||
|
WireMessage::Message {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need X3DH — fetch bundle
|
||||||
|
let bundle = match client.fetch_bundle(member).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let x3dh_result = match x3dh::initiate(identity, &bundle) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||||
|
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||||
|
match state.encrypt(text.as_bytes()) {
|
||||||
|
Ok(encrypted) => {
|
||||||
|
let _ = db.save_session(&member_fp, &state);
|
||||||
|
WireMessage::KeyExchange {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
sender_fingerprint: our_pub.fingerprint.to_string(),
|
||||||
|
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
|
||||||
|
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||||
|
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = match bincode::serialize(&wire_msg) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
wire_messages.push(serde_json::json!({
|
||||||
|
"to": member,
|
||||||
|
"message": encoded,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if wire_messages.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name);
|
||||||
|
match client.client.post(&send_url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"from": my_fp,
|
||||||
|
"messages": wire_messages,
|
||||||
|
}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: format!("{} [#{}]", &self.our_fp[..12], group_name),
|
||||||
|
text: text.to_string(),
|
||||||
|
is_system: false,
|
||||||
|
is_self: true,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_alias(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/alias/register", client.base_url);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name);
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option<String> {
|
||||||
|
let url = format!("{}/v1/alias/resolve/{}", client.base_url, name);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
return Some(fp.to_string());
|
||||||
|
}
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_aliases(&self, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/alias/list", client.base_url);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
|
||||||
|
if aliases.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
} else {
|
||||||
|
for a in aliases {
|
||||||
|
let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use super::types::{App, ReceiptStatus};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||||
|
match message_id {
|
||||||
|
Some(id) => {
|
||||||
|
let receipts = self.receipts.lock().unwrap();
|
||||||
|
match receipts.get(id) {
|
||||||
|
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
|
||||||
|
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
|
||||||
|
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receipt_color(&self, message_id: &Option<String>) -> Color {
|
||||||
|
match message_id {
|
||||||
|
Some(id) => {
|
||||||
|
let receipts = self.receipts.lock().unwrap();
|
||||||
|
match receipts.get(id) {
|
||||||
|
Some(ReceiptStatus::Read) => Color::Blue,
|
||||||
|
Some(ReceiptStatus::Delivered) => Color::White,
|
||||||
|
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Color::DarkGray,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&self, frame: &mut Frame) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // header
|
||||||
|
Constraint::Min(5), // messages
|
||||||
|
Constraint::Length(3), // input
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let peer_str = self
|
||||||
|
.peer_fp
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("no peer");
|
||||||
|
let is_connected = self.connected.load(Ordering::Relaxed);
|
||||||
|
let (conn_indicator, conn_color) = if is_connected {
|
||||||
|
(" \u{25CF}", Color::Green) // ●
|
||||||
|
} else {
|
||||||
|
(" \u{25CF}", Color::Red) // ●
|
||||||
|
};
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(&self.our_fp, Style::default().fg(Color::Green)),
|
||||||
|
Span::raw(" \u{2192} "),
|
||||||
|
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(
|
||||||
|
format!(" [{}]", self.server_url),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(conn_indicator, Style::default().fg(conn_color)),
|
||||||
|
]));
|
||||||
|
frame.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
let msgs = self.messages.lock().unwrap();
|
||||||
|
let items: Vec<ListItem> = msgs
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let style = if m.is_system {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
} else if m.is_self {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
};
|
||||||
|
|
||||||
|
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
|
||||||
|
|
||||||
|
let prefix = if m.is_system {
|
||||||
|
"*** ".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}: ", &m.sender[..m.sender.len().min(12)])
|
||||||
|
};
|
||||||
|
|
||||||
|
let receipt_str = if m.is_self && m.message_id.is_some() {
|
||||||
|
self.receipt_indicator(&m.message_id)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let receipt_color = self.receipt_color(&m.message_id);
|
||||||
|
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(&m.text),
|
||||||
|
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Scroll support: compute the visible window of items
|
||||||
|
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
|
||||||
|
let total = items.len();
|
||||||
|
let end = total.saturating_sub(self.scroll_offset);
|
||||||
|
let start = end.saturating_sub(visible_height);
|
||||||
|
let visible_items = if total == 0 {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
items[start..end].to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages_widget = List::new(visible_items)
|
||||||
|
.block(Block::default().borders(Borders::TOP));
|
||||||
|
frame.render_widget(messages_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Input
|
||||||
|
let input_title = if self.scroll_offset > 0 {
|
||||||
|
format!(" [{} new \u{2193}] ", self.scroll_offset)
|
||||||
|
} else {
|
||||||
|
" message ".to_string()
|
||||||
|
};
|
||||||
|
let input_widget = Paragraph::new(self.input.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
|
.title(input_title),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(input_widget, chunks[2]);
|
||||||
|
|
||||||
|
// Cursor
|
||||||
|
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
|
||||||
|
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
use super::super::types::{App, ChatLine};
|
||||||
|
|
||||||
|
/// Helper: collect the entire terminal buffer into a single String.
|
||||||
|
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
(0..buf.area().height)
|
||||||
|
.flat_map(|y| {
|
||||||
|
(0..buf.area().width).map(move |x| {
|
||||||
|
buf.cell((x, y))
|
||||||
|
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||||
|
.unwrap_or(' ')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: check whether the buffer contains `needle`.
|
||||||
|
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
|
||||||
|
full_buffer_text(terminal).contains(needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: collect a single row into a String.
|
||||||
|
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
(0..buf.area().width)
|
||||||
|
.map(|x| {
|
||||||
|
buf.cell((x, row))
|
||||||
|
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||||
|
.unwrap_or(' ')
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_app() -> App {
|
||||||
|
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_terminal() -> Terminal<TestBackend> {
|
||||||
|
let backend = TestBackend::new(80, 24);
|
||||||
|
Terminal::new(backend).expect("terminal creation should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 1. draw_does_not_panic
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn draw_does_not_panic() {
|
||||||
|
let app = make_app();
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 2. header_contains_fingerprint
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn header_contains_fingerprint() {
|
||||||
|
let app = make_app();
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
let header = row_text(&terminal, 0);
|
||||||
|
assert!(
|
||||||
|
header.contains("aabbcc"),
|
||||||
|
"header should contain our fingerprint 'aabbcc', got: {header}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 3. connection_indicator_red_when_disconnected
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn connection_indicator_red_when_disconnected() {
|
||||||
|
let app = make_app();
|
||||||
|
// connected defaults to false
|
||||||
|
assert!(!app.connected.load(Ordering::Relaxed));
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
let header = row_text(&terminal, 0);
|
||||||
|
assert!(
|
||||||
|
header.contains('\u{25CF}'),
|
||||||
|
"header should contain the dot character when disconnected, got: {header}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 4. connection_indicator_green_when_connected
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn connection_indicator_green_when_connected() {
|
||||||
|
let app = make_app();
|
||||||
|
app.connected.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
let header = row_text(&terminal, 0);
|
||||||
|
assert!(
|
||||||
|
header.contains('\u{25CF}'),
|
||||||
|
"header should contain the dot character when connected, got: {header}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 5. timestamp_format_in_messages
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn timestamp_format_in_messages() {
|
||||||
|
let app = make_app();
|
||||||
|
app.add_message(ChatLine {
|
||||||
|
sender: "alice".into(),
|
||||||
|
text: "hello world".into(),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
let text = full_buffer_text(&terminal);
|
||||||
|
// Timestamps are rendered as [HH:MM] — look for the bracket pattern.
|
||||||
|
assert!(
|
||||||
|
text.contains('[') && text.contains(']'),
|
||||||
|
"buffer should contain timestamp brackets, got: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 6. scroll_offset_zero_shows_latest_messages
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn scroll_offset_zero_shows_latest_messages() {
|
||||||
|
let app = make_app();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.add_message(ChatLine {
|
||||||
|
sender: "bot".into(),
|
||||||
|
text: format!("msg-{i:03}"),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// scroll_offset defaults to 0 — pinned to bottom.
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
buffer_contains(&terminal, "msg-029"),
|
||||||
|
"the last message should be visible when scroll_offset is 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 7. scroll_offset_hides_latest_messages
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn scroll_offset_hides_latest_messages() {
|
||||||
|
let mut app = make_app();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.add_message(ChatLine {
|
||||||
|
sender: "bot".into(),
|
||||||
|
text: format!("msg-{i:03}"),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.scroll_offset = 10;
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!buffer_contains(&terminal, "msg-029"),
|
||||||
|
"the last message should NOT be visible when scroll_offset=10"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 8. unread_badge_shows_when_scrolled
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn unread_badge_shows_when_scrolled() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.scroll_offset = 5;
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
buffer_contains(&terminal, "new"),
|
||||||
|
"buffer should contain 'new' from the unread badge when scrolled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 9. no_unread_badge_at_bottom
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
#[test]
|
||||||
|
fn no_unread_badge_at_bottom() {
|
||||||
|
let app = make_app();
|
||||||
|
// scroll_offset is 0 by default
|
||||||
|
|
||||||
|
let mut terminal = make_terminal();
|
||||||
|
terminal.draw(|f| app.draw(f)).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
buffer_contains(&terminal, "message"),
|
||||||
|
"buffer should contain the default title 'message' when not scrolled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!full_buffer_text(&terminal).contains("new \u{2193}"),
|
||||||
|
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
use warzone_protocol::identity::IdentityKeyPair;
|
||||||
|
use warzone_protocol::message::WireMessage;
|
||||||
|
use warzone_protocol::types::Fingerprint;
|
||||||
|
|
||||||
|
use crate::net::ServerClient;
|
||||||
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn handle_file_send(
|
||||||
|
&mut self,
|
||||||
|
path_str: &str,
|
||||||
|
identity: &IdentityKeyPair,
|
||||||
|
db: &LocalDb,
|
||||||
|
client: &ServerClient,
|
||||||
|
) {
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if !path.exists() {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("File not found: {}", path_str),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match std::fs::metadata(&path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Cannot read file: {}", e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_size = metadata.len();
|
||||||
|
if file_size > MAX_FILE_SIZE {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_data = match std::fs::read(&path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Failed to read file: {}", e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unnamed".to_string());
|
||||||
|
|
||||||
|
// SHA-256 of the complete file
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&file_data);
|
||||||
|
let sha256 = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
let file_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
|
||||||
|
|
||||||
|
// Resolve peer (or group members)
|
||||||
|
let peer = match &self.peer_fp {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Set a peer or group first".into(),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group file transfer: send to each member
|
||||||
|
if peer.starts_with('#') {
|
||||||
|
let group_name = &peer[1..];
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Sending '{}' to group #{}...", filename, group_name),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get members
|
||||||
|
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
|
||||||
|
let group_data = match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return,
|
||||||
|
},
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let my_fp = normfp(&self.our_fp);
|
||||||
|
let members: Vec<String> = group_data.get("members")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for member in &members {
|
||||||
|
if *member == my_fp { continue; }
|
||||||
|
// Send file header + chunks to each member via HTTP
|
||||||
|
let header = WireMessage::FileHeader {
|
||||||
|
id: file_id.clone(),
|
||||||
|
sender_fingerprint: self.our_fp.clone(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
file_size,
|
||||||
|
total_chunks,
|
||||||
|
sha256: sha256.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(encoded) = bincode::serialize(&header) {
|
||||||
|
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||||
|
}
|
||||||
|
for i in 0..total_chunks {
|
||||||
|
let start = i as usize * CHUNK_SIZE;
|
||||||
|
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||||
|
let chunk_msg = WireMessage::FileChunk {
|
||||||
|
id: file_id.clone(),
|
||||||
|
sender_fingerprint: self.our_fp.clone(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
chunk_index: i,
|
||||||
|
total_chunks,
|
||||||
|
data: file_data[start..end].to_vec(),
|
||||||
|
};
|
||||||
|
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
|
||||||
|
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("File '{}' sent to group #{}", filename, group_name),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Invalid peer fingerprint".into(),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let our_pub = identity.public_identity();
|
||||||
|
let our_fp_str = our_pub.fingerprint.to_string();
|
||||||
|
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
|
||||||
|
let header = WireMessage::FileHeader {
|
||||||
|
id: file_id.clone(),
|
||||||
|
sender_fingerprint: our_fp_str.clone(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
file_size,
|
||||||
|
total_chunks,
|
||||||
|
sha256: sha256.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded_header = match bincode::serialize(&header) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Serialize header failed: {}", e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Failed to send file header: {}", e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
|
||||||
|
for i in 0..total_chunks {
|
||||||
|
let start = i as usize * CHUNK_SIZE;
|
||||||
|
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||||
|
let chunk_data = &file_data[start..end];
|
||||||
|
|
||||||
|
// Encrypt chunk data with ratchet
|
||||||
|
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||||
|
let encrypted_data = if let Some(ref mut state) = ratchet {
|
||||||
|
match state.encrypt(chunk_data) {
|
||||||
|
Ok(encrypted) => {
|
||||||
|
let _ = db.save_session(&peer_fp, state);
|
||||||
|
match bincode::serialize(&encrypted) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Serialize chunk failed: {}", e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Encrypt chunk {} failed: {}", i, e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "No ratchet session. Send a text message first to establish one.".into(),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunk_msg = WireMessage::FileChunk {
|
||||||
|
id: file_id.clone(),
|
||||||
|
sender_fingerprint: our_fp_str.clone(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
chunk_index: i,
|
||||||
|
total_chunks,
|
||||||
|
data: encrypted_data,
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = match bincode::serialize(&chunk_msg) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Serialize chunk {} failed: {}", i, e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
|
||||||
|
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_message(ChatLine {
|
||||||
|
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
|
||||||
|
text: format!("Sent file: {} ({} bytes)", filename, file_size),
|
||||||
|
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use super::types::App;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Handle a single key event. Returns true if the event was consumed.
|
||||||
|
pub fn handle_key_event(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
// Alt+Backspace: delete word before cursor
|
||||||
|
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
|
if self.cursor_pos > 0 {
|
||||||
|
let before = &self.input[..self.cursor_pos];
|
||||||
|
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||||
|
self.input.drain(new_pos..self.cursor_pos);
|
||||||
|
self.cursor_pos = new_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backspace: delete char before cursor
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if self.cursor_pos > 0 {
|
||||||
|
self.input.remove(self.cursor_pos - 1);
|
||||||
|
self.cursor_pos -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete: delete char at cursor
|
||||||
|
KeyCode::Delete => {
|
||||||
|
if self.cursor_pos < self.input.len() {
|
||||||
|
self.input.remove(self.cursor_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left arrow
|
||||||
|
KeyCode::Left => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
// Alt+Left: word left
|
||||||
|
let before = &self.input[..self.cursor_pos];
|
||||||
|
self.cursor_pos = before.rfind(' ').unwrap_or(0);
|
||||||
|
} else if self.cursor_pos > 0 {
|
||||||
|
self.cursor_pos -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Right arrow
|
||||||
|
KeyCode::Right => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
// Alt+Right: word right
|
||||||
|
let after = &self.input[self.cursor_pos..];
|
||||||
|
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
|
||||||
|
} else if self.cursor_pos < self.input.len() {
|
||||||
|
self.cursor_pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Home / Ctrl+A
|
||||||
|
KeyCode::Home => { self.cursor_pos = 0; }
|
||||||
|
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
// End: cursor to end of input when typing, snap to bottom when input is empty.
|
||||||
|
// Ctrl+End always snaps to bottom.
|
||||||
|
KeyCode::End => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
// Ctrl+End: always snap scroll to bottom
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
} else if self.input.is_empty() {
|
||||||
|
// Plain End with empty input: snap scroll to bottom
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
} else {
|
||||||
|
// Plain End with text: move cursor to end of input
|
||||||
|
self.cursor_pos = self.input.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.cursor_pos = self.input.len();
|
||||||
|
}
|
||||||
|
// Ctrl+U: clear line
|
||||||
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.input.clear();
|
||||||
|
self.cursor_pos = 0;
|
||||||
|
}
|
||||||
|
// Ctrl+K: kill to end of line
|
||||||
|
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.input.truncate(self.cursor_pos);
|
||||||
|
}
|
||||||
|
// Ctrl+W: delete word back
|
||||||
|
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
let before = &self.input[..self.cursor_pos];
|
||||||
|
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||||
|
self.input.drain(new_pos..self.cursor_pos);
|
||||||
|
self.cursor_pos = new_pos;
|
||||||
|
}
|
||||||
|
// PageUp: scroll up by 10 messages
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||||
|
self.scroll_offset = (self.scroll_offset + 10).min(max);
|
||||||
|
}
|
||||||
|
// PageDown: scroll down by 10 messages
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||||
|
}
|
||||||
|
// Up arrow: scroll up by 1 (only when input is empty)
|
||||||
|
KeyCode::Up if self.input.is_empty() => {
|
||||||
|
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||||
|
self.scroll_offset = (self.scroll_offset + 1).min(max);
|
||||||
|
}
|
||||||
|
// Down arrow: scroll down by 1 (only when input is empty)
|
||||||
|
KeyCode::Down if self.input.is_empty() => {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
|
}
|
||||||
|
// Regular char: insert at cursor
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.input.insert(self.cursor_pos, c);
|
||||||
|
self.cursor_pos += 1;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
use crate::tui::types::App;
|
||||||
|
|
||||||
|
/// Helper: create a key event with no modifiers.
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: create a key event with modifiers.
|
||||||
|
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: create a fresh App for testing.
|
||||||
|
fn app() -> App {
|
||||||
|
App::new("aabbcc".into(), None, "http://localhost:7700".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: type a string into the app one character at a time.
|
||||||
|
fn type_str(app: &mut App, s: &str) {
|
||||||
|
for c in s.chars() {
|
||||||
|
app.handle_key_event(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text editing tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn char_insert() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
assert_eq!(app.input, "abc");
|
||||||
|
assert_eq!(app.cursor_pos, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_deletes_char() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(app.input, "ab");
|
||||||
|
assert_eq!(app.cursor_pos, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_at_start_does_nothing() {
|
||||||
|
let mut app = app();
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
app.handle_key_event(key(KeyCode::Backspace));
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_at_cursor() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Left));
|
||||||
|
app.handle_key_event(key(KeyCode::Delete));
|
||||||
|
assert_eq!(app.input, "ab");
|
||||||
|
assert_eq!(app.cursor_pos, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_clears_line() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_k_kills_to_end() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.handle_key_event(key(KeyCode::Home));
|
||||||
|
app.handle_key_event(key(KeyCode::Right));
|
||||||
|
app.handle_key_event(key(KeyCode::Right));
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!(app.input, "he");
|
||||||
|
assert_eq!(app.cursor_pos, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_deletes_word() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "hello world");
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!(app.input, "hello ");
|
||||||
|
assert_eq!(app.cursor_pos, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alt_backspace_deletes_word() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "hello world");
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
|
||||||
|
assert_eq!(app.input, "hello ");
|
||||||
|
assert_eq!(app.cursor_pos, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cursor movement tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_arrow_moves_cursor() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.cursor_pos, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn right_arrow_moves_cursor() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Home));
|
||||||
|
app.handle_key_event(key(KeyCode::Right));
|
||||||
|
assert_eq!(app.cursor_pos, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_moves_to_start() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Home));
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_moves_to_end() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Home));
|
||||||
|
app.handle_key_event(key(KeyCode::End));
|
||||||
|
assert_eq!(app.cursor_pos, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_a_moves_to_start() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_e_moves_to_end() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.handle_key_event(key(KeyCode::Home));
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!(app.cursor_pos, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_at_start_does_nothing() {
|
||||||
|
let mut app = app();
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
app.handle_key_event(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quit tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_quits() {
|
||||||
|
let mut app = app();
|
||||||
|
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||||
|
assert!(app.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_quits() {
|
||||||
|
let mut app = app();
|
||||||
|
app.handle_key_event(key(KeyCode::Esc));
|
||||||
|
assert!(app.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_up_increases_scroll_offset() {
|
||||||
|
let mut app = app();
|
||||||
|
// App::new creates 3 system messages, so max = 3 - 1 = 2
|
||||||
|
let msg_count = app.messages.lock().unwrap().len();
|
||||||
|
app.handle_key_event(key(KeyCode::PageUp));
|
||||||
|
// scroll_offset = min(10, msg_count - 1)
|
||||||
|
let expected = 10usize.min(msg_count.saturating_sub(1));
|
||||||
|
assert_eq!(app.scroll_offset, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_down_decreases_scroll_offset() {
|
||||||
|
let mut app = app();
|
||||||
|
app.scroll_offset = 15;
|
||||||
|
app.handle_key_event(key(KeyCode::PageDown));
|
||||||
|
assert_eq!(app.scroll_offset, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_down_clamps_to_zero() {
|
||||||
|
let mut app = app();
|
||||||
|
app.scroll_offset = 3;
|
||||||
|
app.handle_key_event(key(KeyCode::PageDown));
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_arrow_scrolls_when_input_empty() {
|
||||||
|
let mut app = app();
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
app.handle_key_event(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.scroll_offset, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_arrow_ignored_when_input_not_empty() {
|
||||||
|
let mut app = app();
|
||||||
|
type_str(&mut app, "hi");
|
||||||
|
app.handle_key_event(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_arrow_scrolls_when_input_empty() {
|
||||||
|
let mut app = app();
|
||||||
|
app.scroll_offset = 5;
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
app.handle_key_event(key(KeyCode::Down));
|
||||||
|
assert_eq!(app.scroll_offset, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_arrow_at_zero_stays_zero() {
|
||||||
|
let mut app = app();
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
app.handle_key_event(key(KeyCode::Down));
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_snaps_to_bottom_when_input_empty() {
|
||||||
|
let mut app = app();
|
||||||
|
app.scroll_offset = 10;
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
app.handle_key_event(key(KeyCode::End));
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,72 @@
|
|||||||
pub mod app;
|
mod types;
|
||||||
|
mod draw;
|
||||||
|
mod commands;
|
||||||
|
mod file_transfer;
|
||||||
|
mod input;
|
||||||
|
mod network;
|
||||||
|
|
||||||
pub use app::run_tui;
|
pub use types::App;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event, KeyCode};
|
||||||
|
|
||||||
|
use warzone_protocol::identity::{IdentityKeyPair, Seed};
|
||||||
|
|
||||||
|
use crate::net::ServerClient;
|
||||||
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
/// Run the TUI event loop.
|
||||||
|
pub async fn run_tui(
|
||||||
|
our_fp: String,
|
||||||
|
peer_fp: Option<String>,
|
||||||
|
server_url: String,
|
||||||
|
identity: IdentityKeyPair,
|
||||||
|
poll_seed: Seed,
|
||||||
|
db: LocalDb,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let client = ServerClient::new(&server_url);
|
||||||
|
let db = Arc::new(db);
|
||||||
|
|
||||||
|
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
|
||||||
|
|
||||||
|
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
|
||||||
|
let poll_identity = poll_seed.derive_identity();
|
||||||
|
let poll_messages = app.messages.clone();
|
||||||
|
let poll_receipts = app.receipts.clone();
|
||||||
|
let poll_pending_files = app.pending_files.clone();
|
||||||
|
let poll_last_dm = app.last_dm_peer.clone();
|
||||||
|
let poll_connected = app.connected.clone();
|
||||||
|
let poll_client = client.clone();
|
||||||
|
let poll_db = db.clone();
|
||||||
|
let poll_fp = our_fp.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| app.draw(frame))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.code == KeyCode::Enter {
|
||||||
|
app.handle_send(&identity, &db, &client).await;
|
||||||
|
app.scroll_offset = 0;
|
||||||
|
} else {
|
||||||
|
app.handle_key_event(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratatui::restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
538
warzone/crates/warzone-client/src/tui/network.rs
Normal file
538
warzone/crates/warzone-client/src/tui/network.rs
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
use warzone_protocol::identity::IdentityKeyPair;
|
||||||
|
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||||
|
use warzone_protocol::ratchet::RatchetState;
|
||||||
|
use warzone_protocol::types::Fingerprint;
|
||||||
|
use warzone_protocol::x3dh;
|
||||||
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
|
use crate::net::ServerClient;
|
||||||
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
|
||||||
|
|
||||||
|
/// Send a delivery receipt for a message back to its sender.
|
||||||
|
fn send_receipt(
|
||||||
|
our_fp: &str,
|
||||||
|
sender_fp: &str,
|
||||||
|
message_id: &str,
|
||||||
|
receipt_type: ReceiptType,
|
||||||
|
client: &ServerClient,
|
||||||
|
) {
|
||||||
|
let receipt = WireMessage::Receipt {
|
||||||
|
sender_fingerprint: our_fp.to_string(),
|
||||||
|
message_id: message_id.to_string(),
|
||||||
|
receipt_type,
|
||||||
|
};
|
||||||
|
let encoded = match bincode::serialize(&receipt) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let client = client.clone();
|
||||||
|
let to = sender_fp.to_string();
|
||||||
|
let from = our_fp.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = client.send_message(&to, Some(&from), &encoded).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
|
||||||
|
let _ = db.touch_contact(sender_fp, None);
|
||||||
|
let _ = db.store_message(sender_fp, sender_fp, text, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a single incoming raw message (shared by WS and HTTP paths).
|
||||||
|
pub fn process_incoming(
|
||||||
|
raw: &[u8],
|
||||||
|
identity: &IdentityKeyPair,
|
||||||
|
db: &LocalDb,
|
||||||
|
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||||
|
our_fp: &str,
|
||||||
|
client: &ServerClient,
|
||||||
|
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||||
|
) {
|
||||||
|
match bincode::deserialize::<WireMessage>(raw) {
|
||||||
|
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_wire_message(
|
||||||
|
wire: WireMessage,
|
||||||
|
identity: &IdentityKeyPair,
|
||||||
|
db: &LocalDb,
|
||||||
|
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||||
|
our_fp: &str,
|
||||||
|
client: &ServerClient,
|
||||||
|
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||||
|
) {
|
||||||
|
match wire {
|
||||||
|
WireMessage::KeyExchange {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
sender_identity_encryption_key,
|
||||||
|
ephemeral_public,
|
||||||
|
used_one_time_pre_key_id,
|
||||||
|
ratchet_message,
|
||||||
|
} => {
|
||||||
|
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let spk_secret = match db.load_signed_pre_key(1) {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
|
||||||
|
db.take_one_time_pre_key(otpk_id).ok().flatten()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
|
||||||
|
let their_eph = PublicKey::from(ephemeral_public);
|
||||||
|
let shared_secret = match x3dh::respond(
|
||||||
|
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
|
||||||
|
match state.decrypt(&ratchet_message) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||||
|
let _ = db.save_session(&sender_fp, &state);
|
||||||
|
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||||
|
store_received(db, &sender_fingerprint, &text);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||||
|
text,
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||||
|
// Terminal bell for incoming DM
|
||||||
|
print!("\x07");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Session auto-recovery: delete corrupted session, show warning
|
||||||
|
let _ = db.delete_session(&sender_fp);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireMessage::Message {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
ratchet_message,
|
||||||
|
} => {
|
||||||
|
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut state = match db.load_session(&sender_fp) {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
match state.decrypt(&ratchet_message) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||||
|
let _ = db.save_session(&sender_fp, &state);
|
||||||
|
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||||
|
store_received(db, &sender_fingerprint, &text);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||||
|
text,
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||||
|
// Terminal bell for incoming DM
|
||||||
|
print!("\x07");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Session auto-recovery: delete corrupted session, show warning
|
||||||
|
let _ = db.delete_session(&sender_fp);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireMessage::Receipt {
|
||||||
|
sender_fingerprint: _,
|
||||||
|
message_id,
|
||||||
|
receipt_type,
|
||||||
|
} => {
|
||||||
|
// Update receipt status for the referenced message
|
||||||
|
let mut r = receipts.lock().unwrap();
|
||||||
|
let current = r.get(&message_id);
|
||||||
|
let should_update = match (&receipt_type, current) {
|
||||||
|
(ReceiptType::Read, _) => true,
|
||||||
|
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
|
||||||
|
(ReceiptType::Delivered, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if should_update {
|
||||||
|
let new_status = match receipt_type {
|
||||||
|
ReceiptType::Delivered => ReceiptStatus::Delivered,
|
||||||
|
ReceiptType::Read => ReceiptStatus::Read,
|
||||||
|
};
|
||||||
|
r.insert(message_id, new_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireMessage::FileHeader {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
filename,
|
||||||
|
file_size,
|
||||||
|
total_chunks,
|
||||||
|
sha256,
|
||||||
|
} => {
|
||||||
|
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"Incoming file '{}' from {} ({} bytes, {} chunks)",
|
||||||
|
filename, short_sender, file_size, total_chunks
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let transfer = PendingFileTransfer {
|
||||||
|
filename,
|
||||||
|
total_chunks,
|
||||||
|
received: 0,
|
||||||
|
chunks: vec![None; total_chunks as usize],
|
||||||
|
sha256,
|
||||||
|
file_size,
|
||||||
|
};
|
||||||
|
pending_files.lock().unwrap().insert(id, transfer);
|
||||||
|
}
|
||||||
|
WireMessage::FileChunk {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
filename: _,
|
||||||
|
chunk_index,
|
||||||
|
total_chunks: _,
|
||||||
|
data,
|
||||||
|
} => {
|
||||||
|
// Decrypt the chunk data using our ratchet session with the sender
|
||||||
|
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||||
|
Ok(fp) => fp,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let mut state = match db.load_session(&sender_fp) {
|
||||||
|
Ok(Some(s)) => s,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The data field is a bincode-serialized RatchetMessage
|
||||||
|
let ratchet_msg = match bincode::deserialize(&data) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = match state.decrypt(&ratchet_msg) {
|
||||||
|
Ok(pt) => {
|
||||||
|
let _ = db.save_session(&sender_fp, &state);
|
||||||
|
pt
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pf = pending_files.lock().unwrap();
|
||||||
|
if let Some(transfer) = pf.get_mut(&id) {
|
||||||
|
if (chunk_index as usize) < transfer.chunks.len() {
|
||||||
|
if transfer.chunks[chunk_index as usize].is_none() {
|
||||||
|
transfer.chunks[chunk_index as usize] = Some(plaintext);
|
||||||
|
transfer.received += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"Receiving {} [{}/{}]...",
|
||||||
|
transfer.filename, transfer.received, transfer.total_chunks
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if all chunks received
|
||||||
|
if transfer.received == transfer.total_chunks {
|
||||||
|
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
|
||||||
|
for chunk in &transfer.chunks {
|
||||||
|
if let Some(data) = chunk {
|
||||||
|
assembled.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SHA-256
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&assembled);
|
||||||
|
let computed_hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
if computed_hash != transfer.sha256 {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"File '{}' integrity check FAILED (hash mismatch)",
|
||||||
|
transfer.filename
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Save to data_dir/downloads/
|
||||||
|
let download_dir = crate::keystore::data_dir().join("downloads");
|
||||||
|
let _ = std::fs::create_dir_all(&download_dir);
|
||||||
|
let save_path = download_dir.join(&transfer.filename);
|
||||||
|
match std::fs::write(&save_path, &assembled) {
|
||||||
|
Ok(_) => {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"File saved: {}",
|
||||||
|
save_path.display()
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Failed to save file: {}", e),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove completed transfer
|
||||||
|
pf.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Received chunk without header — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireMessage::GroupSenderKey {
|
||||||
|
id: _,
|
||||||
|
sender_fingerprint,
|
||||||
|
group_name,
|
||||||
|
generation,
|
||||||
|
counter,
|
||||||
|
ciphertext,
|
||||||
|
} => {
|
||||||
|
match db.load_sender_key(&sender_fingerprint, &group_name) {
|
||||||
|
Ok(Some(mut sender_key)) => {
|
||||||
|
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
|
||||||
|
sender_fingerprint: sender_fingerprint.clone(),
|
||||||
|
group_name: group_name.clone(),
|
||||||
|
generation,
|
||||||
|
counter,
|
||||||
|
ciphertext,
|
||||||
|
};
|
||||||
|
match sender_key.decrypt(&msg) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||||
|
// Save updated sender key (counter advanced)
|
||||||
|
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||||
|
store_received(db, &sender_fingerprint, &text);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: format!(
|
||||||
|
"{} [#{}]",
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
group_name
|
||||||
|
),
|
||||||
|
text,
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"[group #{}] decrypt failed from {}: {}",
|
||||||
|
group_name,
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
e
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"[group #{}] no sender key for {} — key distribution needed",
|
||||||
|
group_name,
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WireMessage::SenderKeyDistribution {
|
||||||
|
sender_fingerprint,
|
||||||
|
group_name,
|
||||||
|
chain_key,
|
||||||
|
generation,
|
||||||
|
} => {
|
||||||
|
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
|
||||||
|
sender_fingerprint: sender_fingerprint.clone(),
|
||||||
|
group_name: group_name.clone(),
|
||||||
|
chain_key,
|
||||||
|
generation,
|
||||||
|
};
|
||||||
|
let sender_key = dist.into_sender_key();
|
||||||
|
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!(
|
||||||
|
"Received sender key from {} for #{}",
|
||||||
|
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||||
|
group_name
|
||||||
|
),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WireMessage::CallSignal {
|
||||||
|
id: _,
|
||||||
|
sender_fingerprint,
|
||||||
|
signal_type,
|
||||||
|
payload: _,
|
||||||
|
target: _,
|
||||||
|
} => {
|
||||||
|
let type_str = format!("{:?}", signal_type);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||||
|
text: format!("\u{1f4de} Call signal: {}", type_str),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-time message loop via WebSocket (falls back to HTTP polling).
|
||||||
|
pub async fn poll_loop(
|
||||||
|
messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||||
|
our_fp: String,
|
||||||
|
identity: IdentityKeyPair,
|
||||||
|
db: Arc<LocalDb>,
|
||||||
|
client: ServerClient,
|
||||||
|
last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||||
|
connected: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let fp = normfp(&our_fp);
|
||||||
|
|
||||||
|
// Try WebSocket first
|
||||||
|
let ws_url = client.base_url
|
||||||
|
.replace("http://", "ws://")
|
||||||
|
.replace("https://", "wss://");
|
||||||
|
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||||
|
Ok((ws_stream, _)) => {
|
||||||
|
connected.store(true, Ordering::Relaxed);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Real-time connection established".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let (_, mut read) = ws_stream.split();
|
||||||
|
|
||||||
|
while let Some(Ok(msg)) = read.next().await {
|
||||||
|
if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg {
|
||||||
|
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connected.store(false, Ordering::Relaxed);
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Connection lost, reconnecting...".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None, timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
connected.store(false, Ordering::Relaxed);
|
||||||
|
// Fallback to HTTP polling
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
let raw_msgs = match client.poll_messages(&our_fp).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for raw in &raw_msgs {
|
||||||
|
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
warzone/crates/warzone-client/src/tui/types.rs
Normal file
220
warzone/crates/warzone-client/src/tui/types.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
|
||||||
|
/// Maximum file size: 10 MB.
|
||||||
|
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
|
/// Chunk size: 64 KB.
|
||||||
|
pub const CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// State for tracking an incoming chunked file transfer.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PendingFileTransfer {
|
||||||
|
pub filename: String,
|
||||||
|
pub total_chunks: u32,
|
||||||
|
pub received: u32,
|
||||||
|
pub chunks: Vec<Option<Vec<u8>>>,
|
||||||
|
pub sha256: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receipt status for a sent message.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ReceiptStatus {
|
||||||
|
Sent,
|
||||||
|
Delivered,
|
||||||
|
Read,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub input: String,
|
||||||
|
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||||
|
pub our_fp: String,
|
||||||
|
pub peer_fp: Option<String>,
|
||||||
|
pub server_url: String,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub cursor_pos: usize,
|
||||||
|
pub last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||||
|
/// Track receipt status for messages we sent, keyed by message ID.
|
||||||
|
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||||
|
/// Pending incoming file transfers, keyed by file ID.
|
||||||
|
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||||
|
/// Scroll offset from bottom (0 = pinned to newest).
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
/// Whether the WebSocket connection is active.
|
||||||
|
pub connected: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChatLine {
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
pub is_system: bool,
|
||||||
|
pub is_self: bool,
|
||||||
|
/// Message ID (for sent messages, used to track receipts).
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
/// When this message was created/received.
|
||||||
|
pub timestamp: DateTime<Local>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
|
||||||
|
let messages = Arc::new(Mutex::new(vec![ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("You are {}", our_fp),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
}]));
|
||||||
|
|
||||||
|
if let Some(ref peer) = peer_fp {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: format!("Chatting with {}", peer),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
App {
|
||||||
|
input: String::new(),
|
||||||
|
messages,
|
||||||
|
our_fp,
|
||||||
|
peer_fp,
|
||||||
|
server_url,
|
||||||
|
should_quit: false,
|
||||||
|
last_dm_peer: Arc::new(Mutex::new(None)),
|
||||||
|
cursor_pos: 0,
|
||||||
|
receipts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
pending_files: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
scroll_offset: 0,
|
||||||
|
connected: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_message(&self, line: ChatLine) {
|
||||||
|
self.messages.lock().unwrap().push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normfp(fp: &str) -> String {
|
||||||
|
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_initializes_scroll_offset_to_zero() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
assert_eq!(app.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_initializes_connected_to_false() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
assert!(!app.connected.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_creates_system_messages() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
let msgs = app.messages.lock().unwrap();
|
||||||
|
assert!(msgs.len() >= 2);
|
||||||
|
assert!(msgs[0].is_system);
|
||||||
|
assert!(msgs[0].text.contains("aabbcc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_with_peer_shows_chatting_message() {
|
||||||
|
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
|
||||||
|
let msgs = app.messages.lock().unwrap();
|
||||||
|
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
|
||||||
|
assert!(has_chatting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_without_peer_shows_no_peer_message() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
let msgs = app.messages.lock().unwrap();
|
||||||
|
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
|
||||||
|
assert!(has_no_peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chatline_has_timestamp() {
|
||||||
|
let line = ChatLine {
|
||||||
|
sender: "test".into(),
|
||||||
|
text: "hello".into(),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
};
|
||||||
|
// Timestamp should be within the last second
|
||||||
|
let elapsed = Local::now().signed_duration_since(line.timestamp);
|
||||||
|
assert!(elapsed.num_seconds() < 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_message_appends_to_list() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
let initial_count = app.messages.lock().unwrap().len();
|
||||||
|
app.add_message(ChatLine {
|
||||||
|
sender: "test".into(),
|
||||||
|
text: "new message".into(),
|
||||||
|
is_system: false,
|
||||||
|
is_self: false,
|
||||||
|
message_id: None,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
});
|
||||||
|
let new_count = app.messages.lock().unwrap().len();
|
||||||
|
assert_eq!(new_count, initial_count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normfp_strips_non_hex_and_lowercases() {
|
||||||
|
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
|
||||||
|
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
|
||||||
|
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
|
||||||
|
assert_eq!(normfp("AABB"), "aabb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_cursor_pos_zero() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
assert_eq!(app.cursor_pos, 0);
|
||||||
|
assert!(app.input.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_new_should_quit_false() {
|
||||||
|
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||||
|
assert!(!app.should_quit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,5 @@ rand.workspace = true
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
bincode.workspace = true
|
bincode.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
|
||||||
|
|||||||
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! Auth enforcement middleware: axum extractor that validates bearer tokens.
|
||||||
|
//!
|
||||||
|
//! Reads `Authorization: Bearer <token>` from request headers, validates via
|
||||||
|
//! [`crate::routes::auth::validate_token`], and returns the authenticated
|
||||||
|
//! fingerprint or a 401 rejection.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{request::Parts, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Extractor that validates a bearer token and provides the authenticated fingerprint.
|
||||||
|
///
|
||||||
|
/// Place this as the **first** parameter in any handler that requires authentication.
|
||||||
|
/// The extractor will reject the request with 401 if the token is missing or invalid.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// async fn my_handler(
|
||||||
|
/// auth: AuthFingerprint,
|
||||||
|
/// State(state): State<AppState>,
|
||||||
|
/// ) -> impl IntoResponse {
|
||||||
|
/// let fp = auth.fingerprint; // guaranteed valid
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct AuthFingerprint {
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::async_trait]
|
||||||
|
impl FromRequestParts<AppState> for AuthFingerprint {
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let header = parts
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
|
||||||
|
let token = match header {
|
||||||
|
Some(t) if !t.is_empty() => t,
|
||||||
|
_ => return Err(AuthError::MissingToken),
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::routes::auth::validate_token(&state.db.tokens, &token) {
|
||||||
|
Some(fingerprint) => Ok(AuthFingerprint { fingerprint }),
|
||||||
|
None => Err(AuthError::InvalidToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejection type for [`AuthFingerprint`] extractor failures.
|
||||||
|
pub enum AuthError {
|
||||||
|
/// No `Authorization: Bearer <token>` header was present (or it was empty).
|
||||||
|
MissingToken,
|
||||||
|
/// The token was present but did not pass validation (expired or unknown).
|
||||||
|
InvalidToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AuthError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, msg) = match self {
|
||||||
|
AuthError::MissingToken => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"missing or empty Authorization: Bearer <token> header",
|
||||||
|
),
|
||||||
|
AuthError::InvalidToken => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid or expired token",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ pub struct Database {
|
|||||||
pub groups: sled::Tree,
|
pub groups: sled::Tree,
|
||||||
pub aliases: sled::Tree,
|
pub aliases: sled::Tree,
|
||||||
pub tokens: sled::Tree,
|
pub tokens: sled::Tree,
|
||||||
|
pub calls: sled::Tree,
|
||||||
|
pub missed_calls: sled::Tree,
|
||||||
_db: sled::Db,
|
_db: sled::Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,12 +19,16 @@ impl Database {
|
|||||||
let groups = db.open_tree("groups")?;
|
let groups = db.open_tree("groups")?;
|
||||||
let aliases = db.open_tree("aliases")?;
|
let aliases = db.open_tree("aliases")?;
|
||||||
let tokens = db.open_tree("tokens")?;
|
let tokens = db.open_tree("tokens")?;
|
||||||
|
let calls = db.open_tree("calls")?;
|
||||||
|
let missed_calls = db.open_tree("missed_calls")?;
|
||||||
Ok(Database {
|
Ok(Database {
|
||||||
keys,
|
keys,
|
||||||
messages,
|
messages,
|
||||||
groups,
|
groups,
|
||||||
aliases,
|
aliases,
|
||||||
tokens,
|
tokens,
|
||||||
|
calls,
|
||||||
|
missed_calls,
|
||||||
_db: db,
|
_db: db,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
212
warzone/crates/warzone-server/src/federation.rs
Normal file
212
warzone/crates/warzone-server/src/federation.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
//! Federation: two-server message relay with shared-secret authentication.
|
||||||
|
//!
|
||||||
|
//! Each server periodically announces its connected clients to the peer.
|
||||||
|
//! When a message is destined for a remote client, it's forwarded via HTTP.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
/// Federation configuration loaded from JSON.
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
pub struct FederationConfig {
|
||||||
|
pub server_id: String,
|
||||||
|
pub shared_secret: String,
|
||||||
|
pub peer: PeerConfig,
|
||||||
|
#[serde(default = "default_interval")]
|
||||||
|
pub presence_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
|
pub struct PeerConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_interval() -> u64 { 5 }
|
||||||
|
|
||||||
|
/// Load federation config from a JSON file. Returns None if path is empty.
|
||||||
|
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
|
||||||
|
let data = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
|
||||||
|
let config: FederationConfig = serde_json::from_str(&data)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remote presence: which fingerprints are on the peer server.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RemotePresence {
|
||||||
|
pub peer_url: String,
|
||||||
|
pub peer_id: String,
|
||||||
|
pub fingerprints: HashSet<String>,
|
||||||
|
pub last_updated: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemotePresence {
|
||||||
|
pub fn new(peer_url: String, peer_id: String) -> Self {
|
||||||
|
RemotePresence {
|
||||||
|
peer_url,
|
||||||
|
peer_id,
|
||||||
|
fingerprints: HashSet::new(),
|
||||||
|
last_updated: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a fingerprint is on the remote server.
|
||||||
|
pub fn contains(&self, fp: &str) -> bool {
|
||||||
|
self.fingerprints.contains(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the peer still alive? (heard from within 3 intervals)
|
||||||
|
pub fn is_alive(&self, interval_secs: u64) -> bool {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
now - self.last_updated < (interval_secs as i64 * 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle for communicating with the federation peer.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FederationHandle {
|
||||||
|
pub config: FederationConfig,
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationHandle {
|
||||||
|
pub fn new(config: FederationConfig) -> Self {
|
||||||
|
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||||
|
config.peer.url.clone(),
|
||||||
|
config.peer.id.clone(),
|
||||||
|
)));
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.expect("failed to build HTTP client");
|
||||||
|
FederationHandle { config, client, remote_presence }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a fingerprint is known to be on the peer server.
|
||||||
|
pub async fn is_remote(&self, fp: &str) -> bool {
|
||||||
|
let rp = self.remote_presence.lock().await;
|
||||||
|
rp.is_alive(self.config.presence_interval_secs) && rp.contains(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward a message to the peer server for delivery.
|
||||||
|
/// Returns true if the peer accepted it.
|
||||||
|
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||||
|
let url = format!("{}/v1/federation/forward", self.config.peer.url);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"to": to_fp,
|
||||||
|
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
|
||||||
|
"from_server": self.config.server_id,
|
||||||
|
});
|
||||||
|
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||||
|
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||||
|
|
||||||
|
match self.client.post(&url)
|
||||||
|
.header("X-Federation-Token", &token)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body_str)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
tracing::debug!("Federation: forwarded message to {} for {}", self.config.peer.id, to_fp);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
tracing::warn!("Federation: peer {} rejected forward: {}", self.config.peer.id, resp.status());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Federation: failed to forward to {}: {}", self.config.peer.id, e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send our local presence to the peer.
|
||||||
|
pub async fn announce_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||||
|
let url = format!("{}/v1/federation/presence", self.config.peer.url);
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"server_id": self.config.server_id,
|
||||||
|
"fingerprints": fingerprints,
|
||||||
|
"timestamp": chrono::Utc::now().timestamp(),
|
||||||
|
});
|
||||||
|
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||||
|
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||||
|
|
||||||
|
match self.client.post(&url)
|
||||||
|
.header("X-Federation-Token", &token)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(body_str)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => true,
|
||||||
|
Ok(resp) => {
|
||||||
|
tracing::warn!("Federation: presence announce to {} failed: {}", self.config.peer.id, resp.status());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Federation: presence announce to {} error: {}", self.config.peer.id, e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background task: periodically sync presence with peer.
|
||||||
|
pub async fn presence_sync_loop(
|
||||||
|
handle: FederationHandle,
|
||||||
|
connections: crate::state::Connections,
|
||||||
|
) {
|
||||||
|
let interval = std::time::Duration::from_secs(handle.config.presence_interval_secs);
|
||||||
|
tracing::info!(
|
||||||
|
"Federation: presence sync started (peer={}, interval={}s)",
|
||||||
|
handle.config.peer.id, handle.config.presence_interval_secs
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Collect local fingerprints
|
||||||
|
let fps: Vec<String> = {
|
||||||
|
let conns = connections.lock().await;
|
||||||
|
conns.keys().cloned().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Announce to peer
|
||||||
|
let ok = handle.announce_presence(fps.clone()).await;
|
||||||
|
if ok {
|
||||||
|
tracing::debug!("Federation: announced {} fingerprints to {}", fps.len(), handle.config.peer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stale remote presence if peer hasn't responded
|
||||||
|
{
|
||||||
|
let mut rp = handle.remote_presence.lock().await;
|
||||||
|
if !rp.is_alive(handle.config.presence_interval_secs) && !rp.fingerprints.is_empty() {
|
||||||
|
tracing::warn!("Federation: peer {} stale — clearing remote presence ({} fps)",
|
||||||
|
handle.config.peer.id, rp.fingerprints.len());
|
||||||
|
rp.fingerprints.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute an auth token: SHA-256(secret || body). Simple HMAC-like construction.
|
||||||
|
pub fn compute_token(secret: &str, body: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(secret.as_bytes());
|
||||||
|
hasher.update(body);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an auth token.
|
||||||
|
pub fn verify_token(secret: &str, body: &[u8], token: &str) -> bool {
|
||||||
|
let expected = compute_token(secret, body);
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
expected.len() == token.len() && expected.as_bytes().iter().zip(token.as_bytes()).all(|(a, b)| a == b)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod auth_middleware;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod federation;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
pub mod auth_middleware;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod federation;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
@@ -16,6 +18,10 @@ struct Cli {
|
|||||||
/// Database directory
|
/// Database directory
|
||||||
#[arg(short, long, default_value = "./warzone-data")]
|
#[arg(short, long, default_value = "./warzone-data")]
|
||||||
data_dir: String,
|
data_dir: String,
|
||||||
|
|
||||||
|
/// Federation config file (JSON). Enables server-to-server message relay.
|
||||||
|
#[arg(short, long)]
|
||||||
|
federation: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -30,11 +36,38 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
tracing::info!("Warzone server starting on {}", cli.bind);
|
tracing::info!("Warzone server starting on {}", cli.bind);
|
||||||
|
|
||||||
let state = state::AppState::new(&cli.data_dir)?;
|
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||||
|
|
||||||
|
// Load federation config if provided
|
||||||
|
if let Some(ref fed_path) = cli.federation {
|
||||||
|
let fed_config = federation::load_config(fed_path)?;
|
||||||
|
tracing::info!(
|
||||||
|
"Federation enabled: server_id={}, peer={}@{}",
|
||||||
|
fed_config.server_id, fed_config.peer.id, fed_config.peer.url
|
||||||
|
);
|
||||||
|
let handle = federation::FederationHandle::new(fed_config);
|
||||||
|
state.federation = Some(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn federation presence sync if enabled
|
||||||
|
if let Some(ref federation) = state.federation {
|
||||||
|
let handle = federation.clone();
|
||||||
|
let connections = state.connections.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
federation::presence_sync_loop(handle, connections).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cors = tower_http::cors::CorsLayer::new()
|
||||||
|
.allow_origin(tower_http::cors::Any)
|
||||||
|
.allow_methods(tower_http::cors::Any)
|
||||||
|
.allow_headers(tower_http::cors::Any);
|
||||||
|
|
||||||
let app = axum::Router::new()
|
let app = axum::Router::new()
|
||||||
.merge(routes::web_router())
|
.merge(routes::web_router())
|
||||||
.nest("/v1", routes::router())
|
.nest("/v1", routes::router())
|
||||||
|
.layer(cors)
|
||||||
|
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
|
||||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ struct RegisterRequest {
|
|||||||
/// - Expired aliases (past grace period) can be reclaimed by anyone
|
/// - Expired aliases (past grace period) can be reclaimed by anyone
|
||||||
/// - Expired aliases (within grace period) can only be reclaimed by recovery key
|
/// - Expired aliases (within grace period) can only be reclaimed by recovery key
|
||||||
async fn register_alias(
|
async fn register_alias(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -190,6 +191,7 @@ struct RecoverRequest {
|
|||||||
|
|
||||||
/// Recover an alias using the recovery key. Works even if expired (within or past grace).
|
/// Recover an alias using the recovery key. Works even if expired (within or past grace).
|
||||||
async fn recover_alias(
|
async fn recover_alias(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RecoverRequest>,
|
Json(req): Json<RecoverRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -244,6 +246,7 @@ struct RenewRequest {
|
|||||||
|
|
||||||
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
|
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
|
||||||
async fn renew_alias(
|
async fn renew_alias(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RenewRequest>,
|
Json(req): Json<RenewRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -347,6 +350,7 @@ struct UnregisterRequest {
|
|||||||
|
|
||||||
/// Remove your own alias.
|
/// Remove your own alias.
|
||||||
async fn unregister_alias(
|
async fn unregister_alias(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<UnregisterRequest>,
|
Json(req): Json<UnregisterRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -381,6 +385,7 @@ struct AdminRemoveRequest {
|
|||||||
|
|
||||||
/// Admin: remove any alias.
|
/// Admin: remove any alias.
|
||||||
async fn admin_remove_alias(
|
async fn admin_remove_alias(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<AdminRemoveRequest>,
|
Json(req): Json<AdminRemoveRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
|||||||
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::{AppState, CallState, CallStatus};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/calls/initiate", post(initiate_call))
|
||||||
|
.route("/calls/:id", get(get_call))
|
||||||
|
.route("/calls/:id/end", post(end_call))
|
||||||
|
.route("/calls/active", get(active_calls))
|
||||||
|
.route("/calls/missed", post(get_missed_calls))
|
||||||
|
.route("/groups/:name/call", post(initiate_group_call))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fp(fp: &str) -> String {
|
||||||
|
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct InitiateRequest {
|
||||||
|
caller: String,
|
||||||
|
callee: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initiate_call(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<InitiateRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let call_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let call = CallState {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
caller_fp: normalize_fp(&req.caller),
|
||||||
|
callee_fp: normalize_fp(&req.callee),
|
||||||
|
group_name: None,
|
||||||
|
room_id: None,
|
||||||
|
status: CallStatus::Ringing,
|
||||||
|
created_at: now,
|
||||||
|
answered_at: None,
|
||||||
|
ended_at: None,
|
||||||
|
};
|
||||||
|
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||||
|
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||||
|
tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp);
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"call_id": call_id,
|
||||||
|
"status": "ringing",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_call(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
// Try in-memory first, then DB
|
||||||
|
if let Some(call) = state.active_calls.lock().await.get(&id) {
|
||||||
|
return Ok(Json(serde_json::to_value(call)?));
|
||||||
|
}
|
||||||
|
if let Some(data) = state.db.calls.get(id.as_bytes())? {
|
||||||
|
let call: CallState = serde_json::from_slice(&data)?;
|
||||||
|
return Ok(Json(serde_json::to_value(&call)?));
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "error": "call not found" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EndCallRequest {
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn end_call(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<EndCallRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let _fp = normalize_fp(&req.fingerprint);
|
||||||
|
let mut calls = state.active_calls.lock().await;
|
||||||
|
if let Some(mut call) = calls.remove(&id) {
|
||||||
|
call.status = CallStatus::Ended;
|
||||||
|
call.ended_at = Some(now);
|
||||||
|
state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||||
|
return Ok(Json(serde_json::json!({ "ok": true, "call_id": id })));
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "error": "call not found or already ended" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ActiveQuery {
|
||||||
|
fingerprint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn active_calls(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<ActiveQuery>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let calls = state.active_calls.lock().await;
|
||||||
|
let filtered: Vec<&CallState> = match q.fingerprint {
|
||||||
|
Some(ref fp) => {
|
||||||
|
let fp = normalize_fp(fp);
|
||||||
|
calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect()
|
||||||
|
}
|
||||||
|
None => calls.values().collect(),
|
||||||
|
};
|
||||||
|
Ok(Json(serde_json::json!({ "calls": filtered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MissedRequest {
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_missed_calls(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<MissedRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let fp = normalize_fp(&req.fingerprint);
|
||||||
|
let prefix = format!("missed:{}", fp);
|
||||||
|
let mut missed = Vec::new();
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() {
|
||||||
|
if let Ok(record) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||||
|
missed.push(record);
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete after reading
|
||||||
|
for key in &keys {
|
||||||
|
let _ = state.db.missed_calls.remove(key);
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "missed_calls": missed })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FC-5: Group call ---
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GroupCallRequest {
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16])
|
||||||
|
fn hash_room_name(group_name: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(format!("featherchat-group:{}", group_name).as_bytes());
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
hex::encode(&hash[..16])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initiate_group_call(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(req): Json<GroupCallRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let caller_fp = normalize_fp(&req.fingerprint);
|
||||||
|
|
||||||
|
// Load group
|
||||||
|
let group_data = match state.db.groups.get(name.as_bytes())? {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||||
|
};
|
||||||
|
let group: serde_json::Value = serde_json::from_slice(&group_data)?;
|
||||||
|
let members: Vec<String> = group.get("members")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Verify caller is a member
|
||||||
|
if !members.contains(&caller_fp) {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let room_id = hash_room_name(&name);
|
||||||
|
let call_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
// Create call state
|
||||||
|
let call = CallState {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
caller_fp: caller_fp.clone(),
|
||||||
|
callee_fp: "group".to_string(),
|
||||||
|
group_name: Some(name.clone()),
|
||||||
|
room_id: Some(room_id.clone()),
|
||||||
|
status: CallStatus::Ringing,
|
||||||
|
created_at: now,
|
||||||
|
answered_at: None,
|
||||||
|
ended_at: None,
|
||||||
|
};
|
||||||
|
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||||
|
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||||
|
|
||||||
|
// Fan out CallSignal::Offer to all online members (except caller)
|
||||||
|
let offer = warzone_protocol::message::WireMessage::CallSignal {
|
||||||
|
id: call_id.clone(),
|
||||||
|
sender_fingerprint: caller_fp.clone(),
|
||||||
|
signal_type: warzone_protocol::message::CallSignalType::Offer,
|
||||||
|
payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(),
|
||||||
|
target: format!("#{}", name),
|
||||||
|
};
|
||||||
|
let encoded = bincode::serialize(&offer)?;
|
||||||
|
|
||||||
|
let mut delivered = 0;
|
||||||
|
for member in &members {
|
||||||
|
if *member == caller_fp { continue; }
|
||||||
|
if state.push_to_client(member, &encoded).await {
|
||||||
|
delivered += 1;
|
||||||
|
} else {
|
||||||
|
// Queue for offline members
|
||||||
|
let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4());
|
||||||
|
state.db.messages.insert(key.as_bytes(), encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}",
|
||||||
|
name, room_id, caller_fp, delivered, members.len() - 1);
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"call_id": call_id,
|
||||||
|
"room_id": room_id,
|
||||||
|
"group": name,
|
||||||
|
"members_notified": delivered,
|
||||||
|
"members_total": members.len() - 1,
|
||||||
|
})))
|
||||||
|
}
|
||||||
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::auth_middleware::AuthFingerprint;
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/devices", get(list_devices))
|
||||||
|
.route("/devices/:id/kick", post(kick_device))
|
||||||
|
.route("/devices/revoke-all", post(revoke_all))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List active WS connections for the authenticated user.
|
||||||
|
async fn list_devices(
|
||||||
|
auth: AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let devices = state.list_devices(&auth.fingerprint).await;
|
||||||
|
let list: Vec<serde_json::Value> = devices
|
||||||
|
.iter()
|
||||||
|
.map(|(id, connected_at)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"device_id": id,
|
||||||
|
"connected_at": connected_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let count = list.len();
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"fingerprint": auth.fingerprint,
|
||||||
|
"devices": list,
|
||||||
|
"count": count,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick a specific device by ID. Requires auth -- only the device owner can kick.
|
||||||
|
async fn kick_device(
|
||||||
|
auth: AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let kicked = state.kick_device(&auth.fingerprint, &device_id).await;
|
||||||
|
if kicked {
|
||||||
|
tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint);
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id })))
|
||||||
|
} else {
|
||||||
|
Ok(Json(serde_json::json!({ "error": "device not found" })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all sessions except the current one. Panic button.
|
||||||
|
async fn revoke_all(
|
||||||
|
auth: AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<serde_json::Value>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let keep_device = req
|
||||||
|
.get("keep_device_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let removed = state
|
||||||
|
.revoke_all_except(&auth.fingerprint, keep_device)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Also clear all tokens for this fingerprint except the current one
|
||||||
|
// Scan tokens tree for this fingerprint
|
||||||
|
let mut tokens_to_remove = Vec::new();
|
||||||
|
for item in state.db.tokens.iter().flatten() {
|
||||||
|
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||||
|
if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) {
|
||||||
|
tokens_to_remove.push(item.0.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only remove tokens if we actually revoked devices
|
||||||
|
let tokens_cleared = if removed > 0 {
|
||||||
|
let count = tokens_to_remove.len();
|
||||||
|
for key in &tokens_to_remove {
|
||||||
|
let _ = state.db.tokens.remove(key);
|
||||||
|
}
|
||||||
|
count
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Revoke-all for {}: {} devices removed, {} tokens cleared",
|
||||||
|
auth.fingerprint,
|
||||||
|
removed,
|
||||||
|
tokens_cleared,
|
||||||
|
);
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"devices_removed": removed,
|
||||||
|
"tokens_cleared": tokens_cleared,
|
||||||
|
})))
|
||||||
|
}
|
||||||
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//! Federation route handlers: receive presence updates and forwarded messages from peer server.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/federation/presence", post(receive_presence))
|
||||||
|
.route("/federation/forward", post(receive_forward))
|
||||||
|
.route("/federation/status", axum::routing::get(federation_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract and validate the federation token from headers.
|
||||||
|
fn validate_request(state: &AppState, headers: &HeaderMap, body: &[u8]) -> Result<(), (StatusCode, String)> {
|
||||||
|
let federation = state.federation.as_ref()
|
||||||
|
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "federation not configured".to_string()))?;
|
||||||
|
|
||||||
|
let token = headers.get("x-federation-token")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "missing X-Federation-Token header".to_string()))?;
|
||||||
|
|
||||||
|
if !crate::federation::verify_token(&federation.config.shared_secret, body, token) {
|
||||||
|
return Err((StatusCode::UNAUTHORIZED, "invalid federation token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive presence announcement from peer.
|
||||||
|
/// POST /v1/federation/presence
|
||||||
|
/// Body: { "server_id": "...", "fingerprints": [...], "timestamp": ... }
|
||||||
|
async fn receive_presence(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||||
|
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprints: Vec<String> = parsed.get("fingerprints")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
|
|
||||||
|
if let Some(ref federation) = state.federation {
|
||||||
|
let mut rp = federation.remote_presence.lock().await;
|
||||||
|
let count = fingerprints.len();
|
||||||
|
rp.fingerprints = fingerprints.into_iter().collect();
|
||||||
|
rp.last_updated = chrono::Utc::now().timestamp();
|
||||||
|
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive a forwarded message from peer.
|
||||||
|
/// POST /v1/federation/forward
|
||||||
|
/// Body: { "to": "fingerprint", "message": "base64...", "from_server": "..." }
|
||||||
|
async fn receive_forward(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||||
|
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let to = match parsed.get("to").and_then(|v| v.as_str()) {
|
||||||
|
Some(fp) => fp.to_string(),
|
||||||
|
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'to' field" }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message_b64 = match parsed.get("message").and_then(|v| v.as_str()) {
|
||||||
|
Some(m) => m.to_string(),
|
||||||
|
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'message' field" }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &message_b64) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid base64: {}", e) }))).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
|
|
||||||
|
// Try to deliver locally
|
||||||
|
let delivered = state.push_to_client(&to, &message).await;
|
||||||
|
if !delivered {
|
||||||
|
// Queue for later pickup
|
||||||
|
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||||
|
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
|
||||||
|
tracing::info!("Federation: queued forwarded message from {} for offline user {}", from_server, to);
|
||||||
|
} else {
|
||||||
|
tracing::info!("Federation: delivered forwarded message from {} to {}", from_server, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "delivered": delivered }))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Federation health status.
|
||||||
|
/// GET /v1/federation/status
|
||||||
|
async fn federation_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
match state.federation {
|
||||||
|
Some(ref federation) => {
|
||||||
|
let rp = federation.remote_presence.lock().await;
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"server_id": federation.config.server_id,
|
||||||
|
"peer_id": federation.config.peer.id,
|
||||||
|
"peer_url": federation.config.peer.url,
|
||||||
|
"peer_alive": rp.is_alive(federation.config.presence_interval_secs),
|
||||||
|
"remote_clients": rp.fingerprints.len(),
|
||||||
|
"last_sync": rp.last_updated,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"enabled": false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_group(
|
async fn create_group(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<CreateRequest>,
|
Json(req): Json<CreateRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -99,6 +100,7 @@ async fn create_group(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn join_group(
|
async fn join_group(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<JoinRequest>,
|
Json(req): Json<JoinRequest>,
|
||||||
@@ -169,6 +171,7 @@ async fn list_groups(
|
|||||||
/// queue infrastructure — group messages look like 1:1 messages to the
|
/// queue infrastructure — group messages look like 1:1 messages to the
|
||||||
/// recipient, but with a group tag.
|
/// recipient, but with a group tag.
|
||||||
async fn send_to_group(
|
async fn send_to_group(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<GroupSendRequest>,
|
Json(req): Json<GroupSendRequest>,
|
||||||
@@ -210,6 +213,7 @@ async fn send_to_group(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn leave_group(
|
async fn leave_group(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<JoinRequest>,
|
Json(req): Json<JoinRequest>,
|
||||||
@@ -235,6 +239,7 @@ struct KickRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn kick_member(
|
async fn kick_member(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<KickRequest>,
|
Json(req): Json<KickRequest>,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ struct RegisterResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn register_keys(
|
async fn register_keys(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Json<RegisterResponse> {
|
) -> Json<RegisterResponse> {
|
||||||
@@ -129,6 +130,7 @@ struct OtpkEntry {
|
|||||||
|
|
||||||
/// Upload additional one-time pre-keys.
|
/// Upload additional one-time pre-keys.
|
||||||
async fn replenish_otpks(
|
async fn replenish_otpks(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<ReplenishRequest>,
|
Json(req): Json<ReplenishRequest>,
|
||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<SendRequest>,
|
Json(req): Json<SendRequest>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
@@ -84,14 +85,11 @@ async fn send_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try WebSocket push first (instant delivery)
|
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||||
if state.push_to_client(&to, &req.message).await {
|
if delivered {
|
||||||
tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len());
|
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||||
} else {
|
} else {
|
||||||
// Queue in DB (offline delivery)
|
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
|
||||||
tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len());
|
|
||||||
state.db.messages.insert(key.as_bytes(), req.message)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renew sender's alias TTL (sending = authenticated action)
|
// Renew sender's alias TTL (sending = authenticated action)
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
mod aliases;
|
mod aliases;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
mod calls;
|
||||||
|
mod devices;
|
||||||
|
mod federation;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod health;
|
mod health;
|
||||||
mod keys;
|
mod keys;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
mod presence;
|
||||||
mod web;
|
mod web;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
mod wzp;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
@@ -20,6 +25,11 @@ pub fn router() -> Router<AppState> {
|
|||||||
.merge(aliases::routes())
|
.merge(aliases::routes())
|
||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
.merge(ws::routes())
|
.merge(ws::routes())
|
||||||
|
.merge(calls::routes())
|
||||||
|
.merge(devices::routes())
|
||||||
|
.merge(presence::routes())
|
||||||
|
.merge(wzp::routes())
|
||||||
|
.merge(federation::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web UI router (served at root, outside /v1)
|
/// Web UI router (served at root, outside /v1)
|
||||||
|
|||||||
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/presence/:fingerprint", get(get_presence))
|
||||||
|
.route("/presence/batch", post(batch_presence))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fp(fp: &str) -> String {
|
||||||
|
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_presence(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(fingerprint): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let fp = normalize_fp(&fingerprint);
|
||||||
|
let online = state.is_online(&fp).await;
|
||||||
|
let devices = state.device_count(&fp).await;
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"fingerprint": fp,
|
||||||
|
"online": online,
|
||||||
|
"devices": devices,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BatchRequest {
|
||||||
|
fingerprints: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_presence(
|
||||||
|
_auth: crate::auth_middleware::AuthFingerprint,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<BatchRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for fp in &req.fingerprints {
|
||||||
|
let fp = normalize_fp(fp);
|
||||||
|
let online = state.is_online(&fp).await;
|
||||||
|
let devices = state.device_count(&fp).await;
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"fingerprint": fp,
|
||||||
|
"online": online,
|
||||||
|
"devices": devices,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "results": results })))
|
||||||
|
}
|
||||||
@@ -66,18 +66,22 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
|||||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
|
|
||||||
// Register for push delivery
|
// Register for push delivery
|
||||||
let mut push_rx = state.register_ws(&fingerprint).await;
|
let (_device_id, mut push_rx) = match state.register_ws(&fingerprint, None).await {
|
||||||
|
Some(pair) => pair,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("WS {}: rejected — connection limit reached", fingerprint);
|
||||||
|
return; // closes the socket
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Send any queued messages from DB
|
// Send any queued messages from DB
|
||||||
let prefix = format!("queue:{}", fingerprint);
|
let prefix = format!("queue:{}", fingerprint);
|
||||||
let mut keys_to_delete = Vec::new();
|
let mut keys_to_delete = Vec::new();
|
||||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() {
|
||||||
if let Ok((key, value)) = item {
|
if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() {
|
||||||
if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() {
|
|
||||||
keys_to_delete.push(key);
|
keys_to_delete.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for key in &keys_to_delete {
|
for key in &keys_to_delete {
|
||||||
let _ = state.db.messages.remove(key);
|
let _ = state.db.messages.remove(key);
|
||||||
}
|
}
|
||||||
@@ -85,11 +89,34 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
|||||||
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
|
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush missed calls (FC-7)
|
||||||
|
let missed_prefix = format!("missed:{}", fingerprint);
|
||||||
|
let mut missed_keys = Vec::new();
|
||||||
|
for (key, value) in state.db.missed_calls.scan_prefix(missed_prefix.as_bytes()).flatten() {
|
||||||
|
if let Ok(missed) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||||
|
let wrapper = serde_json::json!({
|
||||||
|
"type": "missed_call",
|
||||||
|
"data": missed,
|
||||||
|
});
|
||||||
|
if let Ok(json_str) = serde_json::to_string(&wrapper) {
|
||||||
|
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
|
||||||
|
missed_keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in &missed_keys {
|
||||||
|
let _ = state.db.missed_calls.remove(key);
|
||||||
|
}
|
||||||
|
if !missed_keys.is_empty() {
|
||||||
|
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn task to forward push messages to WS
|
// Spawn task to forward push messages to WS
|
||||||
let _fp_clone = fingerprint.clone();
|
let _fp_clone = fingerprint.clone();
|
||||||
let mut push_task = tokio::spawn(async move {
|
let mut push_task = tokio::spawn(async move {
|
||||||
while let Some(msg) = push_rx.recv().await {
|
while let Some(msg) = push_rx.recv().await {
|
||||||
if ws_tx.send(Message::Binary(msg.into())).await.is_err() {
|
if ws_tx.send(Message::Binary(msg)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,12 +146,76 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try push to connected client first
|
// Call signal side effects
|
||||||
if !state_clone.push_to_client(&to_fp, message).await {
|
if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::<WireMessage>(message) {
|
||||||
// Queue in DB
|
use warzone_protocol::message::CallSignalType;
|
||||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
let now = chrono::Utc::now().timestamp();
|
||||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
match signal_type {
|
||||||
|
CallSignalType::Offer => {
|
||||||
|
let call = crate::state::CallState {
|
||||||
|
call_id: id.clone(),
|
||||||
|
caller_fp: sender_fingerprint.clone(),
|
||||||
|
callee_fp: to_fp.clone(),
|
||||||
|
group_name: None,
|
||||||
|
room_id: None,
|
||||||
|
status: crate::state::CallStatus::Ringing,
|
||||||
|
created_at: now,
|
||||||
|
answered_at: None,
|
||||||
|
ended_at: None,
|
||||||
|
};
|
||||||
|
state_clone.active_calls.lock().await.insert(id.clone(), call.clone());
|
||||||
|
// Persist to DB
|
||||||
|
let _ = state_clone.db.calls.insert(
|
||||||
|
id.as_bytes(),
|
||||||
|
serde_json::to_vec(&call).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
tracing::info!("Call {} started: {} -> {}", id, sender_fingerprint, to_fp);
|
||||||
|
|
||||||
|
// If callee is offline, record missed call (FC-7)
|
||||||
|
if !state_clone.is_online(&to_fp).await {
|
||||||
|
let missed_key = format!("missed:{}:{}", to_fp, id);
|
||||||
|
let missed = serde_json::json!({
|
||||||
|
"call_id": id,
|
||||||
|
"caller_fp": sender_fingerprint,
|
||||||
|
"timestamp": now,
|
||||||
|
});
|
||||||
|
let _ = state_clone.db.missed_calls.insert(
|
||||||
|
missed_key.as_bytes(),
|
||||||
|
serde_json::to_vec(&missed).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
tracing::info!("Missed call recorded for offline user {}", to_fp);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
CallSignalType::Answer => {
|
||||||
|
let mut calls = state_clone.active_calls.lock().await;
|
||||||
|
if let Some(call) = calls.get_mut(id) {
|
||||||
|
call.status = crate::state::CallStatus::Active;
|
||||||
|
call.answered_at = Some(now);
|
||||||
|
let _ = state_clone.db.calls.insert(
|
||||||
|
id.as_bytes(),
|
||||||
|
serde_json::to_vec(&call).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("Call {} answered", id);
|
||||||
|
}
|
||||||
|
CallSignalType::Hangup | CallSignalType::Reject => {
|
||||||
|
let mut calls = state_clone.active_calls.lock().await;
|
||||||
|
if let Some(mut call) = calls.remove(id) {
|
||||||
|
call.status = crate::state::CallStatus::Ended;
|
||||||
|
call.ended_at = Some(now);
|
||||||
|
let _ = state_clone.db.calls.insert(
|
||||||
|
id.as_bytes(),
|
||||||
|
serde_json::to_vec(&call).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("Call {} ended", id);
|
||||||
|
}
|
||||||
|
_ => {} // Ringing, Busy, IceCandidate — route opaquely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver via local WS, federation, or queue in DB
|
||||||
|
state_clone.deliver_or_queue(&to_fp, message).await;
|
||||||
|
|
||||||
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
|
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
|
||||||
}
|
}
|
||||||
@@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state_clone.push_to_client(&to_fp, &message).await {
|
// Deliver via local WS, federation, or queue in DB
|
||||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
state_clone.deliver_or_queue(&to_fp, &message).await;
|
||||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renew alias TTL
|
// Renew alias TTL
|
||||||
crate::routes::messages::renew_alias_ttl(
|
crate::routes::messages::renew_alias_ttl(
|
||||||
@@ -181,9 +270,9 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
|||||||
// We can't easily get the sender ref here, so just clean up by fingerprint
|
// We can't easily get the sender ref here, so just clean up by fingerprint
|
||||||
// In production, use a unique connection ID
|
// In production, use a unique connection ID
|
||||||
let mut conns = state.connections.lock().await;
|
let mut conns = state.connections.lock().await;
|
||||||
if let Some(senders) = conns.get_mut(&fingerprint) {
|
if let Some(devices) = conns.get_mut(&fingerprint) {
|
||||||
senders.retain(|s| !s.is_closed());
|
devices.retain(|d| !d.sender.is_closed());
|
||||||
if senders.is_empty() {
|
if devices.is_empty() {
|
||||||
conns.remove(&fingerprint);
|
conns.remove(&fingerprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/wzp/relay-config", get(relay_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the WZP relay address and a short-lived service token.
|
||||||
|
///
|
||||||
|
/// The web client calls this to discover where to connect for voice/video
|
||||||
|
/// and gets a token to present to the relay for authentication.
|
||||||
|
async fn relay_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
// Issue a short-lived service token (5 minutes) for WZP relay auth.
|
||||||
|
let token = hex::encode(rand::random::<[u8; 32]>());
|
||||||
|
let expires = chrono::Utc::now().timestamp() + 300; // 5 minutes
|
||||||
|
|
||||||
|
state.db.tokens.insert(
|
||||||
|
token.as_bytes(),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"fingerprint": "service:wzp",
|
||||||
|
"service": "wzp",
|
||||||
|
"expires_at": expires,
|
||||||
|
}))?.as_slice(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// The relay address is configured server-side. For now, return a
|
||||||
|
// placeholder that the admin sets via environment variable.
|
||||||
|
let relay_addr = std::env::var("WZP_RELAY_ADDR")
|
||||||
|
.unwrap_or_else(|_| "127.0.0.1:4433".to_string());
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"relay_addr": relay_addr,
|
||||||
|
"token": token,
|
||||||
|
"expires_in": 300,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
|
|||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
|
||||||
|
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||||
|
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||||
|
|
||||||
/// Maximum number of message IDs to track for deduplication.
|
/// Maximum number of message IDs to track for deduplication.
|
||||||
const DEDUP_CAPACITY: usize = 10_000;
|
const DEDUP_CAPACITY: usize = 10_000;
|
||||||
|
|
||||||
/// Per-connection sender: messages are pushed here for instant delivery.
|
/// Per-connection sender: messages are pushed here for instant delivery.
|
||||||
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
||||||
|
|
||||||
/// Connected clients: fingerprint → list of WS senders (multiple devices).
|
/// Metadata for a single connected device.
|
||||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>;
|
#[derive(Clone)]
|
||||||
|
pub struct DeviceConnection {
|
||||||
|
pub device_id: String,
|
||||||
|
pub sender: WsSender,
|
||||||
|
pub connected_at: i64,
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connected clients: fingerprint → list of device connections (multiple devices).
|
||||||
|
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
|
||||||
|
|
||||||
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -47,11 +59,35 @@ impl DedupTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Call lifecycle status.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum CallStatus {
|
||||||
|
Ringing,
|
||||||
|
Active,
|
||||||
|
Ended,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side state for an active or recently ended call.
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CallState {
|
||||||
|
pub call_id: String,
|
||||||
|
pub caller_fp: String,
|
||||||
|
pub callee_fp: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
|
pub room_id: Option<String>,
|
||||||
|
pub status: CallStatus,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub answered_at: Option<i64>,
|
||||||
|
pub ended_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Database>,
|
pub db: Arc<Database>,
|
||||||
pub connections: Connections,
|
pub connections: Connections,
|
||||||
pub dedup: DedupTracker,
|
pub dedup: DedupTracker,
|
||||||
|
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||||
|
pub federation: Option<crate::federation::FederationHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -61,16 +97,18 @@ impl AppState {
|
|||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||||
dedup: DedupTracker::new(),
|
dedup: DedupTracker::new(),
|
||||||
|
active_calls: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
federation: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to push a message to a connected client. Returns true if delivered.
|
/// Try to push a message to a connected client. Returns true if delivered.
|
||||||
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
|
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
|
||||||
let conns = self.connections.lock().await;
|
let conns = self.connections.lock().await;
|
||||||
if let Some(senders) = conns.get(fingerprint) {
|
if let Some(devices) = conns.get(fingerprint) {
|
||||||
let mut delivered = false;
|
let mut delivered = false;
|
||||||
for sender in senders {
|
for device in devices {
|
||||||
if sender.send(message.to_vec()).is_ok() {
|
if device.sender.send(message.to_vec()).is_ok() {
|
||||||
delivered = true;
|
delivered = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,25 +119,127 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a WS connection for a fingerprint.
|
/// Register a WS connection for a fingerprint.
|
||||||
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> {
|
///
|
||||||
|
/// Returns `None` if the per-fingerprint connection cap has been reached.
|
||||||
|
/// On success, returns the assigned device ID and a receiver for push messages.
|
||||||
|
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let device_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||||
let mut conns = self.connections.lock().await;
|
let mut conns = self.connections.lock().await;
|
||||||
conns.entry(fingerprint.to_string()).or_default().push(tx);
|
let entry = conns.entry(fingerprint.to_string()).or_default();
|
||||||
tracing::info!("WS registered for {} ({} total connections)", fingerprint,
|
|
||||||
conns.values().map(|v| v.len()).sum::<usize>());
|
// Clean up closed connections first
|
||||||
rx
|
entry.retain(|d| !d.sender.is_closed());
|
||||||
|
|
||||||
|
if entry.len() >= MAX_WS_PER_FINGERPRINT {
|
||||||
|
tracing::warn!(
|
||||||
|
"WS connection cap reached for {} ({} connections)",
|
||||||
|
fingerprint,
|
||||||
|
entry.len()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.push(DeviceConnection {
|
||||||
|
device_id: device_id.clone(),
|
||||||
|
sender: tx,
|
||||||
|
connected_at: chrono::Utc::now().timestamp(),
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
"WS registered for {} device={} ({} total)",
|
||||||
|
fingerprint,
|
||||||
|
device_id,
|
||||||
|
conns.values().map(|v| v.len()).sum::<usize>()
|
||||||
|
);
|
||||||
|
Some((device_id, rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unregister a WS connection.
|
/// Unregister a WS connection.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
|
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
|
||||||
let mut conns = self.connections.lock().await;
|
let mut conns = self.connections.lock().await;
|
||||||
if let Some(senders) = conns.get_mut(fingerprint) {
|
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||||
senders.retain(|s| !s.same_channel(sender));
|
devices.retain(|d| !d.sender.same_channel(sender));
|
||||||
if senders.is_empty() {
|
if devices.is_empty() {
|
||||||
conns.remove(fingerprint);
|
conns.remove(fingerprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!("WS unregistered for {}", fingerprint);
|
tracing::info!("WS unregistered for {}", fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to deliver a message: local push → federation forward → DB queue.
|
||||||
|
/// Returns true if delivered instantly (local or remote).
|
||||||
|
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||||
|
// 1. Try local WebSocket push
|
||||||
|
if self.push_to_client(to_fp, message).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try federation forward
|
||||||
|
if let Some(ref federation) = self.federation {
|
||||||
|
if federation.is_remote(to_fp).await {
|
||||||
|
if federation.forward_message(to_fp, message).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Queue in local DB
|
||||||
|
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||||
|
let _ = self.db.messages.insert(key.as_bytes(), message);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a fingerprint has any active WS connections.
|
||||||
|
pub async fn is_online(&self, fingerprint: &str) -> bool {
|
||||||
|
let conns = self.connections.lock().await;
|
||||||
|
conns.get(fingerprint).map(|d| !d.is_empty()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count active WS connections for a fingerprint (multi-device).
|
||||||
|
pub async fn device_count(&self, fingerprint: &str) -> usize {
|
||||||
|
let conns = self.connections.lock().await;
|
||||||
|
conns.get(fingerprint).map(|d| d.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List devices for a fingerprint with metadata.
|
||||||
|
pub async fn list_devices(&self, fingerprint: &str) -> Vec<(String, i64)> {
|
||||||
|
let conns = self.connections.lock().await;
|
||||||
|
conns.get(fingerprint)
|
||||||
|
.map(|devices| devices.iter().map(|d| (d.device_id.clone(), d.connected_at)).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick a specific device by ID. Returns true if found and kicked.
|
||||||
|
pub async fn kick_device(&self, fingerprint: &str, device_id: &str) -> bool {
|
||||||
|
let mut conns = self.connections.lock().await;
|
||||||
|
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||||
|
let before = devices.len();
|
||||||
|
devices.retain(|d| d.device_id != device_id);
|
||||||
|
let kicked = devices.len() < before;
|
||||||
|
if devices.is_empty() {
|
||||||
|
conns.remove(fingerprint);
|
||||||
|
}
|
||||||
|
kicked
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all connections for a fingerprint except one device_id.
|
||||||
|
pub async fn revoke_all_except(&self, fingerprint: &str, keep_device_id: &str) -> usize {
|
||||||
|
let mut conns = self.connections.lock().await;
|
||||||
|
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||||
|
let before = devices.len();
|
||||||
|
devices.retain(|d| d.device_id == keep_device_id);
|
||||||
|
let removed = before - devices.len();
|
||||||
|
if devices.is_empty() {
|
||||||
|
conns.remove(fingerprint);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -474,10 +474,144 @@ pub fn decrypt_wire_message(
|
|||||||
"data": hex::encode(&data),
|
"data": hex::encode(&data),
|
||||||
}).to_string())
|
}).to_string())
|
||||||
}
|
}
|
||||||
_ => {
|
WireMessage::SenderKeyDistribution {
|
||||||
|
sender_fingerprint,
|
||||||
|
group_name,
|
||||||
|
chain_key,
|
||||||
|
generation,
|
||||||
|
} => {
|
||||||
|
// Return the distribution data so JS can store it
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"type": "unsupported",
|
"type": "sender_key_distribution",
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"group": group_name,
|
||||||
|
"chain_key": hex::encode(chain_key),
|
||||||
|
"generation": generation,
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
WireMessage::GroupSenderKey {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
group_name,
|
||||||
|
generation,
|
||||||
|
counter,
|
||||||
|
ciphertext,
|
||||||
|
} => {
|
||||||
|
// Return the encrypted group message data so JS can decrypt with stored sender key
|
||||||
|
// JS must call a separate decrypt function with the sender key
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "group_message",
|
||||||
|
"id": id,
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"group": group_name,
|
||||||
|
"generation": generation,
|
||||||
|
"counter": counter,
|
||||||
|
"ciphertext": hex::encode(&ciphertext),
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
WireMessage::CallSignal {
|
||||||
|
id,
|
||||||
|
sender_fingerprint,
|
||||||
|
signal_type,
|
||||||
|
payload,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
|
let type_str = match signal_type {
|
||||||
|
warzone_protocol::message::CallSignalType::Offer => "offer",
|
||||||
|
warzone_protocol::message::CallSignalType::Answer => "answer",
|
||||||
|
warzone_protocol::message::CallSignalType::IceCandidate => "ice_candidate",
|
||||||
|
warzone_protocol::message::CallSignalType::Hangup => "hangup",
|
||||||
|
warzone_protocol::message::CallSignalType::Reject => "reject",
|
||||||
|
warzone_protocol::message::CallSignalType::Ringing => "ringing",
|
||||||
|
warzone_protocol::message::CallSignalType::Busy => "busy",
|
||||||
|
};
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "call_signal",
|
||||||
|
"id": id,
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"signal_type": type_str,
|
||||||
|
"payload": payload,
|
||||||
|
"target": target,
|
||||||
}).to_string())
|
}).to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt a group message using a stored sender key.
|
||||||
|
///
|
||||||
|
/// Arguments:
|
||||||
|
/// - sender_key_hex: hex-encoded bincode-serialized SenderKey (from sender_key_distribution)
|
||||||
|
/// - sender_fingerprint, group_name, generation, counter, ciphertext_hex: from the group_message JSON
|
||||||
|
///
|
||||||
|
/// Returns JSON: { "text": "...", "sender_key": "updated_hex" }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn decrypt_group_message(
|
||||||
|
sender_key_hex: &str,
|
||||||
|
sender_fingerprint: &str,
|
||||||
|
group_name: &str,
|
||||||
|
generation: u32,
|
||||||
|
counter: u32,
|
||||||
|
ciphertext_hex: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
use warzone_protocol::sender_keys::{SenderKey, SenderKeyMessage};
|
||||||
|
|
||||||
|
let key_bytes = hex::decode(sender_key_hex)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("invalid sender key hex: {}", e)))?;
|
||||||
|
let mut sender_key: SenderKey = bincode::deserialize(&key_bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("deserialize sender key: {}", e)))?;
|
||||||
|
|
||||||
|
let ciphertext = hex::decode(ciphertext_hex)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("invalid ciphertext hex: {}", e)))?;
|
||||||
|
|
||||||
|
let msg = SenderKeyMessage {
|
||||||
|
sender_fingerprint: sender_fingerprint.to_string(),
|
||||||
|
group_name: group_name.to_string(),
|
||||||
|
generation,
|
||||||
|
counter,
|
||||||
|
ciphertext,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = sender_key.decrypt(&msg)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||||
|
|
||||||
|
// Return updated sender key (counter advanced) so JS can persist it
|
||||||
|
let updated_key = bincode::serialize(&sender_key).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"text": String::from_utf8_lossy(&plaintext),
|
||||||
|
"sender_key": hex::encode(updated_key),
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sender key from a distribution message.
|
||||||
|
///
|
||||||
|
/// Takes the fields from a sender_key_distribution JSON and returns
|
||||||
|
/// a hex-encoded bincode SenderKey that JS should store.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn create_sender_key_from_distribution(
|
||||||
|
sender_fingerprint: &str,
|
||||||
|
group_name: &str,
|
||||||
|
chain_key_hex: &str,
|
||||||
|
generation: u32,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
use warzone_protocol::sender_keys::SenderKeyDistribution;
|
||||||
|
|
||||||
|
let chain_key_bytes = hex::decode(chain_key_hex)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("invalid chain key hex: {}", e)))?;
|
||||||
|
let mut chain_key = [0u8; 32];
|
||||||
|
if chain_key_bytes.len() != 32 {
|
||||||
|
return Err(JsValue::from_str("chain key must be 32 bytes"));
|
||||||
|
}
|
||||||
|
chain_key.copy_from_slice(&chain_key_bytes);
|
||||||
|
|
||||||
|
let dist = SenderKeyDistribution {
|
||||||
|
sender_fingerprint: sender_fingerprint.to_string(),
|
||||||
|
group_name: group_name.to_string(),
|
||||||
|
chain_key,
|
||||||
|
generation,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sender_key = dist.into_sender_key();
|
||||||
|
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
|
||||||
|
Ok(hex::encode(encoded))
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# Warzone Messenger (featherChat) — Progress Report
|
# Warzone Messenger (featherChat) — Progress Report
|
||||||
|
|
||||||
**Current Version:** 0.0.20
|
**Current Version:** 0.0.21
|
||||||
**Last Updated:** 2026-03-28
|
**Last Updated:** 2026-03-28
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -68,16 +68,42 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
|||||||
| Reply shortcut (/r, /reply) | 0.0.19 | Done |
|
| Reply shortcut (/r, /reply) | 0.0.19 | Done |
|
||||||
| 28 protocol tests | 0.0.20 | Done |
|
| 28 protocol tests | 0.0.20 | Done |
|
||||||
|
|
||||||
|
### Phase 2.5 — WZP Integration & TUI Overhaul (v0.0.21)
|
||||||
|
|
||||||
|
| Feature | Version | Status |
|
||||||
|
|------------------------------------------|---------|--------|
|
||||||
|
| warzone-protocol standalone-importable | 0.0.21 | Done |
|
||||||
|
| CallSignal WireMessage variant | 0.0.21 | Done |
|
||||||
|
| Auth token validation endpoint | 0.0.21 | Done |
|
||||||
|
| TUI modular split (7 modules from 1) | 0.0.21 | Done |
|
||||||
|
| TUI message timestamps [HH:MM] | 0.0.21 | Done |
|
||||||
|
| TUI message scrolling (PageUp/Down/arrows) | 0.0.21 | Done |
|
||||||
|
| TUI connection status indicator | 0.0.21 | Done |
|
||||||
|
| TUI unread message badge | 0.0.21 | Done |
|
||||||
|
| TUI /help command | 0.0.21 | Done |
|
||||||
|
| TUI terminal bell on incoming DM | 0.0.21 | Done |
|
||||||
|
| 44 TUI unit tests (types, input, draw) | 0.0.21 | Done |
|
||||||
|
| Call state management (server) | 0.0.21 | Done |
|
||||||
|
| WS call signaling awareness | 0.0.21 | Done |
|
||||||
|
| Group-to-room mapping + group call API | 0.0.21 | Done |
|
||||||
|
| Presence/online status API | 0.0.21 | Done |
|
||||||
|
| Missed call notifications | 0.0.21 | Done |
|
||||||
|
| WZP relay config + CORS | 0.0.21 | Done |
|
||||||
|
| WZP submodule: all 9 S-tasks done | 0.0.21 | Done |
|
||||||
|
| 72 total tests (28 protocol + 44 client) | 0.0.21 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Version: v0.0.20
|
## Current Version: v0.0.21
|
||||||
|
|
||||||
### Codebase Statistics
|
### Codebase Statistics
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|-------------------|--------------------------------|
|
|-------------------|--------------------------------|
|
||||||
| Crates | 5 (protocol, server, client, wasm, mule) |
|
| Crates | 5 (protocol, server, client, wasm, mule) |
|
||||||
| Protocol tests | 28 |
|
| Total tests | 72 (28 protocol + 44 client) |
|
||||||
|
| Server routes | 12 files, 9 new endpoints |
|
||||||
|
| TUI modules | 7 (split from 1 monolith) |
|
||||||
| Rust edition | 2021 |
|
| Rust edition | 2021 |
|
||||||
| Min Rust version | 1.75 |
|
| Min Rust version | 1.75 |
|
||||||
| License | MIT |
|
| License | MIT |
|
||||||
@@ -91,7 +117,7 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
|||||||
| prekey | Signed + one-time pre-keys |
|
| prekey | Signed + one-time pre-keys |
|
||||||
| x3dh | Extended Triple Diffie-Hellman |
|
| x3dh | Extended Triple Diffie-Hellman |
|
||||||
| ratchet | Double Ratchet state machine |
|
| ratchet | Double Ratchet state machine |
|
||||||
| message | WireMessage (7 variants), content types|
|
| message | WireMessage (8 variants incl. CallSignal)|
|
||||||
| sender_keys | Sender Key encrypt/decrypt/rotate |
|
| sender_keys | Sender Key encrypt/decrypt/rotate |
|
||||||
| history | Encrypted backup format |
|
| history | Encrypted backup format |
|
||||||
| ethereum | secp256k1, Keccak-256, EIP-55 |
|
| ethereum | secp256k1, Keccak-256, EIP-55 |
|
||||||
@@ -121,18 +147,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
|||||||
|
|
||||||
## Test Suite
|
## Test Suite
|
||||||
|
|
||||||
28 tests across the protocol crate:
|
72 tests across protocol + client crates:
|
||||||
|
|
||||||
|
### Protocol Tests (28)
|
||||||
|
|
||||||
| Module | Tests | Coverage |
|
| Module | Tests | Coverage |
|
||||||
|---------------|-------|---------------------------------------------|
|
|---------------|-------|---------------------------------------------|
|
||||||
| identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format |
|
| identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format |
|
||||||
| crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism |
|
| crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism |
|
||||||
| x3dh | ~4 | Initiate/respond, shared secret match, with/without OTPK |
|
| x3dh | 1 | Shared secret match between Alice and Bob |
|
||||||
| ratchet | ~6 | Encrypt/decrypt, out-of-order, multiple messages, ping-pong |
|
| ratchet | 5 | Basic, bidirectional, multiple, out-of-order, 100 messages |
|
||||||
| sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection |
|
| sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection |
|
||||||
| ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds |
|
| ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds |
|
||||||
| history | 2 | Roundtrip encryption, wrong seed rejection |
|
| history | 2 | Roundtrip encryption, wrong seed rejection |
|
||||||
| prekey | ~2 | Bundle generation, signature verification |
|
| prekey | 3 | SPK verify, tamper detection, OTPK generation |
|
||||||
|
| mnemonic | 1 | BIP39 roundtrip |
|
||||||
|
|
||||||
|
### Client Tests (44)
|
||||||
|
|
||||||
|
| Module | Tests | Coverage |
|
||||||
|
|---------------|-------|---------------------------------------------|
|
||||||
|
| tui::types | 10 | App init, scroll/connected defaults, ChatLine timestamps, normfp, add_message |
|
||||||
|
| tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings |
|
||||||
|
| tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,11 +261,14 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
|||||||
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
||||||
- PWA: service worker, offline shell, install prompt
|
- PWA: service worker, offline shell, install prompt
|
||||||
|
|
||||||
### Priority Order
|
### Priority Order (Updated v0.0.21)
|
||||||
|
|
||||||
1. Federation (Phase 3) — enables multi-server deployment
|
1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation
|
||||||
2. Mule protocol (Phase 4) — core differentiator for warzone use
|
2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands
|
||||||
3. Sealed sender (Phase 6) — strongest metadata privacy
|
3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI
|
||||||
4. Push notifications (Phase 7) — usability for mobile/desktop
|
4. **Protocol hardening (FC-P4)** — session/message versioning
|
||||||
5. Transport fallbacks (Phase 5) — Bluetooth, LoRa
|
5. Federation (Phase 3) — multi-server deployment
|
||||||
6. Polish (Phase 7) — rate limiting, admin tools, CI
|
6. Mule protocol (Phase 4) — physical delivery
|
||||||
|
7. Polish (FC-P6) — search, reactions, typing indicators
|
||||||
|
|
||||||
|
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.
|
||||||
|
|||||||
239
warzone/docs/TASK_PLAN.md
Normal file
239
warzone/docs/TASK_PLAN.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# featherChat Task Plan
|
||||||
|
|
||||||
|
**Version:** 0.0.21+
|
||||||
|
**Last Updated:** 2026-03-28
|
||||||
|
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed (This Sprint)
|
||||||
|
|
||||||
|
### TUI Refactor
|
||||||
|
- [x] Split `app.rs` monolith (1,756 lines) into 7 modules: types, draw, commands, input, file_transfer, network, mod
|
||||||
|
- [x] 44 unit tests across types.rs, input.rs, draw.rs
|
||||||
|
|
||||||
|
### TUI Improvements
|
||||||
|
- [x] Message timestamps `[HH:MM]` on every ChatLine
|
||||||
|
- [x] Message scrolling (PageUp/Down by 10, Up/Down by 1, auto-snap on send)
|
||||||
|
- [x] Connection status indicator (green/red dot in header)
|
||||||
|
- [x] Unread badge `[N new]` when scrolled up
|
||||||
|
- [x] `/help` command listing all commands + navigation
|
||||||
|
- [x] Terminal bell on incoming DM
|
||||||
|
|
||||||
|
### WZP Server Integration (featherChat side)
|
||||||
|
- [x] FC-2: Call state management (`calls` + `missed_calls` sled trees, `CallState`, `CallStatus`, `active_calls`)
|
||||||
|
- [x] FC-3: WS call signaling awareness (Offer creates CallState, Answer updates, Hangup ends + missed call on offline)
|
||||||
|
- [x] FC-5: Group-to-room mapping (`POST /groups/:name/call` with SHA-256 room ID, fan-out to members)
|
||||||
|
- [x] FC-6: Presence API (`GET /presence/:fp`, `POST /presence/batch`)
|
||||||
|
- [x] FC-7: Missed call notifications (flush on WS reconnect as `{"type":"missed_call"}`)
|
||||||
|
- [x] FC-10: WZP relay config (`GET /wzp/relay-config` + CORS layer)
|
||||||
|
|
||||||
|
### WZP Side (all 9 tasks done by WZP team)
|
||||||
|
- [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P1: Security & Auth Foundation
|
||||||
|
|
||||||
|
**Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path.
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | TODO |
|
||||||
|
| FC-P1-T2 | Session auto-recovery | 1d | — | TODO |
|
||||||
|
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | TODO |
|
||||||
|
| FC-P1-T4 | Device management + session revocation | 1d | T1 | TODO |
|
||||||
|
|
||||||
|
### FC-P1-T1: Auth Enforcement Middleware
|
||||||
|
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
|
||||||
|
**Why:** Currently anyone can impersonate any fingerprint. Tokens are issued but never required.
|
||||||
|
**Scope:**
|
||||||
|
- Extract bearer token from `Authorization` header
|
||||||
|
- Call `validate_token()` for write operations (send, groups, aliases, calls)
|
||||||
|
- Read-only routes (health, key fetch) remain unauthenticated
|
||||||
|
- Return 401 with clear error on invalid/missing token
|
||||||
|
|
||||||
|
### FC-P1-T2: Session Auto-Recovery
|
||||||
|
**What:** When ratchet decryption fails (corrupted state), auto-send a new X3DH KeyExchange.
|
||||||
|
**Why:** Corrupted session = permanent inability to decrypt from that peer.
|
||||||
|
**Scope:**
|
||||||
|
- Detect decryption failure in `process_wire_message()`
|
||||||
|
- Delete corrupted session from local DB
|
||||||
|
- Initiate fresh X3DH key exchange
|
||||||
|
- Show "[session reset]" system message (like Signal)
|
||||||
|
- Cap auto-recovery attempts (max 3 per peer per hour)
|
||||||
|
|
||||||
|
### FC-P1-T3: Rate Limiting + Connection Guards
|
||||||
|
**What:** Tower rate-limit layer + per-fingerprint connection caps.
|
||||||
|
**Why:** Zero protection against auth spam, message flooding, WS connection spam.
|
||||||
|
**Scope:**
|
||||||
|
- Global rate limit: 100 req/s per IP (tower-governor or tower-http)
|
||||||
|
- Per-fingerprint WS connection cap: max 5 simultaneous connections
|
||||||
|
- Auth challenge rate limit: max 10/minute per fingerprint
|
||||||
|
- Group creation limit: max 5/hour per fingerprint
|
||||||
|
|
||||||
|
### FC-P1-T4: Device Management + Session Revocation
|
||||||
|
**What:** Let users see and kill their active sessions.
|
||||||
|
**Why:** Compromised or stale devices need to be revocable immediately.
|
||||||
|
|
||||||
|
| Subtask | What |
|
||||||
|
|---------|------|
|
||||||
|
| FC-P1-T4-S1 | Server: `GET /v1/devices` — list active WS connections (device_id, IP, connected_at) |
|
||||||
|
| FC-P1-T4-S2 | Server: `POST /v1/devices/:id/kick` — force-close WS + invalidate token |
|
||||||
|
| FC-P1-T4-S3 | Server: `POST /v1/devices/revoke-all` — nuke all sessions except current |
|
||||||
|
| FC-P1-T4-S4 | TUI: `/devices` command — list active sessions |
|
||||||
|
| FC-P1-T4-S5 | TUI: `/kick <device_id>` command — revoke a specific device |
|
||||||
|
|
||||||
|
**Dep on T1:** Kick/revoke endpoints must verify the requester owns the fingerprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P2: TUI Call Integration
|
||||||
|
|
||||||
|
**Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7).
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | TODO |
|
||||||
|
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | TODO |
|
||||||
|
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | TODO |
|
||||||
|
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | TODO |
|
||||||
|
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | TODO |
|
||||||
|
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | TODO |
|
||||||
|
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | TODO |
|
||||||
|
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P3: Web Call Integration
|
||||||
|
|
||||||
|
**Goal:** Enable voice/video calling from the browser through featherChat's web client.
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | TODO |
|
||||||
|
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | TODO |
|
||||||
|
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | TODO |
|
||||||
|
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | TODO |
|
||||||
|
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P4: Protocol & Architecture
|
||||||
|
|
||||||
|
**Goal:** Harden the protocol for forward compatibility and resilience.
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P4-T1 | Session state versioning | 0.5d | — | TODO |
|
||||||
|
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | TODO |
|
||||||
|
| FC-P4-T3 | Periodic auto-backup | 0.5d | — | TODO |
|
||||||
|
| FC-P4-T4 | libsignal migration assessment | 1-2w | — | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P5: Major Features
|
||||||
|
|
||||||
|
**Goal:** Core differentiators — physical delivery, federation, identity provider.
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P5-T1 | Mule binary (physical message delivery) | 3-5d | — | TODO |
|
||||||
|
| FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO |
|
||||||
|
| FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO |
|
||||||
|
| FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FC-P6: TUI Polish
|
||||||
|
|
||||||
|
**Goal:** UX improvements for daily use.
|
||||||
|
|
||||||
|
| ID | Task | Effort | Dep | Status |
|
||||||
|
|----|------|--------|-----|--------|
|
||||||
|
| FC-P6-T1 | Message search (local history) | 1d | — | TODO |
|
||||||
|
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | TODO |
|
||||||
|
| FC-P6-T3 | Typing indicators | 0.5d | — | TODO |
|
||||||
|
| FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO |
|
||||||
|
| FC-P6-T5 | Voice messages as attachments | 1d | — | TODO |
|
||||||
|
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | TODO |
|
||||||
|
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | TODO |
|
||||||
|
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallelization Guide
|
||||||
|
|
||||||
|
Tasks with **no dependencies** that can run simultaneously:
|
||||||
|
|
||||||
|
**Sprint A (Security — P1):**
|
||||||
|
```
|
||||||
|
FC-P1-T1 (auth middleware) — server only
|
||||||
|
FC-P1-T2 (session recovery) — client only
|
||||||
|
FC-P1-T3 (rate limiting) — server only
|
||||||
|
→ then FC-P1-T4 (devices, needs T1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sprint B (TUI Calls — P2):**
|
||||||
|
```
|
||||||
|
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
|
||||||
|
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
|
||||||
|
FC-P2-T5 (missed calls) — independent
|
||||||
|
FC-P2-T6 (contacts online) — independent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sprint C (Web — P3):**
|
||||||
|
```
|
||||||
|
FC-P3-T1 (WASM parse) — independent
|
||||||
|
FC-P3-T2 (WASM create) — independent
|
||||||
|
FC-P3-T5 (extract web.rs) — independent
|
||||||
|
→ then T3 (call UI) → T4 (audio)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Architecture (Post-Sprint)
|
||||||
|
|
||||||
|
```
|
||||||
|
warzone-server/src/
|
||||||
|
├── main.rs — startup, CORS, state init
|
||||||
|
├── state.rs — AppState, Connections, CallState, DedupTracker
|
||||||
|
├── db.rs — sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls
|
||||||
|
├── errors.rs — AppError, AppResult
|
||||||
|
├── routes/
|
||||||
|
│ ├── mod.rs — route composition
|
||||||
|
│ ├── auth.rs — challenge-response, token validation
|
||||||
|
│ ├── calls.rs NEW — call CRUD, group call, missed calls API
|
||||||
|
│ ├── presence.rs NEW — online status (single + batch)
|
||||||
|
│ ├── wzp.rs NEW — relay config + service token
|
||||||
|
│ ├── groups.rs — group management + fan-out
|
||||||
|
│ ├── ws.rs — WebSocket handler + call signal awareness + missed call flush
|
||||||
|
│ ├── keys.rs — pre-key bundle registration
|
||||||
|
│ ├── messages.rs — HTTP message queue
|
||||||
|
│ ├── aliases.rs — alias registration + resolution
|
||||||
|
│ ├── health.rs — health check
|
||||||
|
│ └── web.rs — embedded web client
|
||||||
|
```
|
||||||
|
|
||||||
|
## TUI Architecture (Post-Sprint)
|
||||||
|
|
||||||
|
```
|
||||||
|
warzone-client/src/tui/
|
||||||
|
├── mod.rs — run_tui() entry point + event loop
|
||||||
|
├── types.rs — App, ChatLine, PendingFileTransfer, ReceiptStatus, normfp()
|
||||||
|
├── draw.rs — UI rendering (timestamps, scroll, connection dot, unread badge)
|
||||||
|
├── input.rs — keyboard handling (text editing, scroll keys)
|
||||||
|
├── commands.rs — /slash commands + /help
|
||||||
|
├── file_transfer.rs — chunked file send (DM + group)
|
||||||
|
└── network.rs — WS/HTTP polling + incoming message processing + bell
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
| Crate | Tests | What |
|
||||||
|
|-------|------:|------|
|
||||||
|
| warzone-protocol | 28 | Crypto, ratchet, X3DH, sender keys, identity, ethereum, prekeys |
|
||||||
|
| warzone-client (types) | 10 | App init, ChatLine, normfp |
|
||||||
|
| warzone-client (input) | 25 | All keybindings, scroll, text editing |
|
||||||
|
| warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge |
|
||||||
|
| **Total** | **72** | All passing |
|
||||||
9
warzone/federation.example.json
Normal file
9
warzone/federation.example.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"server_id": "alpha",
|
||||||
|
"shared_secret": "change-me-to-a-long-random-string-shared-between-both-servers",
|
||||||
|
"peer": {
|
||||||
|
"id": "bravo",
|
||||||
|
"url": "http://10.0.0.2:7700"
|
||||||
|
},
|
||||||
|
"presence_interval_secs": 5
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user