First commit in rust
This commit is contained in:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git -C /c/Users/btaylor/Git/psg-backchannel log --oneline -10)",
|
||||||
|
"WebFetch(domain:crates.io)",
|
||||||
|
"Bash(cargo check:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(~/.cargo/bin/cargo check:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/target/
|
||||||
|
**/*.db
|
||||||
|
**/*.db-shm
|
||||||
|
**/*.db-wal
|
||||||
|
.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
/backchannel-server/backchannel.db
|
||||||
|
/backchannel-web/node_modules/
|
||||||
|
/backchannel-web/.vite/
|
||||||
3532
Cargo.lock
generated
Normal file
3532
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
Cargo.toml
Normal file
42
Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"backchannel-common",
|
||||||
|
"backchannel-server",
|
||||||
|
"backchannel-client",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# IDs / time
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Encoding
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
rand = "0.8"
|
||||||
|
argon2 = "0.5"
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
tokio-tungstenite = "0.21"
|
||||||
33
backchannel-client/Cargo.toml
Normal file
33
backchannel-client/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "backchannel-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "backchannel"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
backchannel-common = { path = "../backchannel-common" }
|
||||||
|
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
x25519-dalek = { workspace = true }
|
||||||
|
chacha20poly1305 = { workspace = true }
|
||||||
|
hkdf = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-native-roots"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
ratatui = "0.26"
|
||||||
|
crossterm = "0.27"
|
||||||
24
backchannel-client/src/config.rs
Normal file
24
backchannel-client/src/config.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// Runtime configuration for the BackChannel client.
|
||||||
|
pub struct ClientConfig {
|
||||||
|
/// WebSocket server URL, e.g. `ws://127.0.0.1:7777` or `wss://server:7777`.
|
||||||
|
pub server_url: String,
|
||||||
|
|
||||||
|
/// Path to the keystore JSON file (holds the Ed25519 identity keypair).
|
||||||
|
pub keystore_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let server_url = std::env::var("BC_SERVER_URL")
|
||||||
|
.unwrap_or_else(|_| "ws://127.0.0.1:7777".into());
|
||||||
|
|
||||||
|
let keystore_path = std::env::var("BC_KEYSTORE").unwrap_or_else(|_| {
|
||||||
|
let home = std::env::var("USERPROFILE")
|
||||||
|
.or_else(|_| std::env::var("HOME"))
|
||||||
|
.unwrap_or_else(|_| ".".into());
|
||||||
|
format!("{}/.backchannel/keystore.json", home)
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { server_url, keystore_path }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backchannel-client/src/crypto/dm.rs
Normal file
36
backchannel-client/src/crypto/dm.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use backchannel_common::crypto::{aead, ecdh};
|
||||||
|
use x25519_dalek::EphemeralSecret;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Generate an ephemeral X25519 keypair to initiate a DM key exchange.
|
||||||
|
///
|
||||||
|
/// Returns the `EphemeralSecret` (store in `Keystore::pending_ecdh`) and the
|
||||||
|
/// base64-encoded public key to send to the server.
|
||||||
|
pub fn generate_exchange() -> (EphemeralSecret, String) {
|
||||||
|
let (secret, pubkey) = ecdh::generate_ephemeral();
|
||||||
|
let pubkey_b64 = ecdh::pubkey_to_b64(&pubkey);
|
||||||
|
(secret, pubkey_b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a key exchange given our ephemeral secret and the peer's base64
|
||||||
|
/// public key. Returns the 32-byte derived DM session key.
|
||||||
|
///
|
||||||
|
/// The `EphemeralSecret` is consumed here — it cannot be reused.
|
||||||
|
pub fn complete_exchange(our_secret: EphemeralSecret, their_pubkey_b64: &str) -> Result<[u8; 32]> {
|
||||||
|
let their_pubkey = ecdh::b64_to_pubkey(their_pubkey_b64)?;
|
||||||
|
let raw_secret = ecdh::diffie_hellman(our_secret, &their_pubkey);
|
||||||
|
Ok(aead::derive_dm_key(&raw_secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a plaintext DM using a derived session key.
|
||||||
|
/// Returns `(ciphertext_b64, nonce_b64)`.
|
||||||
|
pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<(String, String)> {
|
||||||
|
aead::encrypt(key, plaintext.as_bytes()).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a received DM ciphertext using a derived session key.
|
||||||
|
pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<String> {
|
||||||
|
let bytes = aead::decrypt(key, nonce_b64, ciphertext_b64)?;
|
||||||
|
Ok(String::from_utf8(bytes)?)
|
||||||
|
}
|
||||||
1
backchannel-client/src/crypto/mod.rs
Normal file
1
backchannel-client/src/crypto/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod dm;
|
||||||
30
backchannel-client/src/error.rs
Normal file
30
backchannel-client/src/error.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, ClientError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ClientError {
|
||||||
|
#[error("not authenticated")]
|
||||||
|
NotAuthenticated,
|
||||||
|
|
||||||
|
#[error("server error {code}: {message}")]
|
||||||
|
Server { code: u16, message: String },
|
||||||
|
|
||||||
|
#[error("no DM key for peer — initiate key exchange first")]
|
||||||
|
NoDmKey,
|
||||||
|
|
||||||
|
#[error("crypto error: {0}")]
|
||||||
|
Crypto(#[from] backchannel_common::BackchannelError),
|
||||||
|
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("UTF-8 error: {0}")]
|
||||||
|
Utf8(#[from] std::string::FromUtf8Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
91
backchannel-client/src/keystore.rs
Normal file
91
backchannel-client/src/keystore.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use x25519_dalek::EphemeralSecret;
|
||||||
|
|
||||||
|
use backchannel_common::crypto::identity;
|
||||||
|
|
||||||
|
use crate::error::{ClientError, Result};
|
||||||
|
|
||||||
|
/// On-disk representation of the keystore.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct KeystoreFile {
|
||||||
|
/// Base64-encoded Ed25519 signing key (32 bytes).
|
||||||
|
signing_key_b64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds cryptographic material for the local client.
|
||||||
|
pub struct Keystore {
|
||||||
|
pub signing_key: SigningKey,
|
||||||
|
pub verifying_key: VerifyingKey,
|
||||||
|
|
||||||
|
/// Derived DM session keys cached in memory: peer_user_id → 32-byte key.
|
||||||
|
/// Populated after completing an X25519 key exchange.
|
||||||
|
pub dm_keys: HashMap<Uuid, [u8; 32]>,
|
||||||
|
|
||||||
|
/// Ephemeral secrets awaiting the remote party's public key.
|
||||||
|
/// Keyed by the peer's user UUID. Consumed on exchange completion.
|
||||||
|
pub pending_ecdh: HashMap<Uuid, EphemeralSecret>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keystore {
|
||||||
|
/// Load an existing keystore from `path`, or generate a fresh one and save it.
|
||||||
|
pub fn load_or_create(path: &str) -> Result<Self> {
|
||||||
|
if Path::new(path).exists() {
|
||||||
|
let contents = fs::read_to_string(path)?;
|
||||||
|
let file: KeystoreFile =
|
||||||
|
serde_json::from_str(&contents).map_err(ClientError::Json)?;
|
||||||
|
let signing_key = identity::b64_to_signing_key(&file.signing_key_b64)?;
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
tracing::debug!("Loaded keystore from {}", path);
|
||||||
|
Ok(Self::from_signing_key(signing_key, verifying_key))
|
||||||
|
} else {
|
||||||
|
let (signing_key, verifying_key) = identity::generate_keypair();
|
||||||
|
let file = KeystoreFile {
|
||||||
|
signing_key_b64: identity::signing_key_to_b64(&signing_key),
|
||||||
|
};
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(path, serde_json::to_string_pretty(&file)?)?;
|
||||||
|
tracing::info!("Generated new identity keypair, saved to {}", path);
|
||||||
|
Ok(Self::from_signing_key(signing_key, verifying_key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_signing_key(signing_key: SigningKey, verifying_key: VerifyingKey) -> Self {
|
||||||
|
Self {
|
||||||
|
signing_key,
|
||||||
|
verifying_key,
|
||||||
|
dm_keys: HashMap::new(),
|
||||||
|
pending_ecdh: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base64-encoded Ed25519 public key, suitable for sending to the server.
|
||||||
|
pub fn identity_pubkey_b64(&self) -> String {
|
||||||
|
identity::pubkey_to_b64(&self.verifying_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an `EphemeralSecret` while waiting for the remote peer's response.
|
||||||
|
pub fn store_pending_ecdh(&mut self, peer_id: Uuid, secret: EphemeralSecret) {
|
||||||
|
self.pending_ecdh.insert(peer_id, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve and remove a pending ephemeral secret (consumed by ECDH).
|
||||||
|
pub fn take_pending_ecdh(&mut self, peer_id: Uuid) -> Option<EphemeralSecret> {
|
||||||
|
self.pending_ecdh.remove(&peer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_dm_key(&mut self, peer_id: Uuid, key: [u8; 32]) {
|
||||||
|
self.dm_keys.insert(peer_id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dm_key(&self, peer_id: &Uuid) -> Option<&[u8; 32]> {
|
||||||
|
self.dm_keys.get(peer_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backchannel-client/src/main.rs
Normal file
53
backchannel-client/src/main.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
mod config;
|
||||||
|
mod crypto;
|
||||||
|
mod error;
|
||||||
|
mod keystore;
|
||||||
|
mod net;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "backchannel=info".into()),
|
||||||
|
)
|
||||||
|
.with_writer(std::io::stderr) // keep TUI output clean
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cfg = config::ClientConfig::from_env();
|
||||||
|
|
||||||
|
let keystore = keystore::Keystore::load_or_create(&cfg.keystore_path)?;
|
||||||
|
|
||||||
|
tracing::info!("Connecting to {}", cfg.server_url);
|
||||||
|
let (net_tx, mut server_rx) = net::connection::connect(&cfg.server_url).await?;
|
||||||
|
|
||||||
|
let app = &mut tui::app::App::new(net_tx, keystore);
|
||||||
|
|
||||||
|
// ── Terminal setup ────────────────────────────────────────────────────
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
terminal.clear()?;
|
||||||
|
|
||||||
|
// ── Main event loop ───────────────────────────────────────────────────
|
||||||
|
let result = tui::events::run(app, &mut terminal, &mut server_rx).await;
|
||||||
|
|
||||||
|
// ── Cleanup (always run, even on error) ───────────────────────────────
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
66
backchannel-client/src/net/connection.rs
Normal file
66
backchannel-client/src/net/connection.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
|
||||||
|
|
||||||
|
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
|
/// Connect to the BackChannel server and return a pair of mpsc channels:
|
||||||
|
///
|
||||||
|
/// - `UnboundedSender<ClientMessage>` — send messages **to** the server
|
||||||
|
/// - `UnboundedReceiver<ServerMessage>` — receive messages **from** the server
|
||||||
|
///
|
||||||
|
/// Two background tasks handle the actual WebSocket I/O so the caller never
|
||||||
|
/// touches the raw stream.
|
||||||
|
pub async fn connect(
|
||||||
|
server_url: &str,
|
||||||
|
) -> Result<(
|
||||||
|
mpsc::UnboundedSender<ClientMessage>,
|
||||||
|
mpsc::UnboundedReceiver<ServerMessage>,
|
||||||
|
)> {
|
||||||
|
let (ws_stream, _) = connect_async(server_url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to connect to {}", server_url))?;
|
||||||
|
|
||||||
|
let (mut ws_sink, mut ws_source) = ws_stream.split();
|
||||||
|
|
||||||
|
let (client_tx, mut client_rx) = mpsc::unbounded_channel::<ClientMessage>();
|
||||||
|
let (server_tx, server_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||||
|
|
||||||
|
// Outbound: client_rx → WS sink
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = client_rx.recv().await {
|
||||||
|
match serde_json::to_string(&msg) {
|
||||||
|
Ok(json) => {
|
||||||
|
if ws_sink.send(WsMessage::Text(json)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("Failed to serialize outbound message: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inbound: WS source → server_tx
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(item) = ws_source.next().await {
|
||||||
|
match item {
|
||||||
|
Ok(WsMessage::Text(text)) => {
|
||||||
|
match serde_json::from_str::<ServerMessage>(&text) {
|
||||||
|
Ok(msg) => {
|
||||||
|
if server_tx.send(msg).is_err() {
|
||||||
|
break; // receiver dropped; shut down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Failed to parse server message: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(WsMessage::Close(_)) | Err(_) => break,
|
||||||
|
Ok(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!("Connection to server closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((client_tx, server_rx))
|
||||||
|
}
|
||||||
1
backchannel-client/src/net/mod.rs
Normal file
1
backchannel-client/src/net/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod connection;
|
||||||
351
backchannel-client/src/tui/app.rs
Normal file
351
backchannel-client/src/tui/app.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
|
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||||
|
use chrono::Local;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::keystore::Keystore;
|
||||||
|
|
||||||
|
const MAX_MESSAGES: usize = 500;
|
||||||
|
|
||||||
|
/// What the TUI is currently displaying.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum View {
|
||||||
|
Login,
|
||||||
|
Channel,
|
||||||
|
Dm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which input field is focused on the login screen.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum LoginField {
|
||||||
|
Username,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single chat message for display.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub author: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All mutable state for the TUI application.
|
||||||
|
pub struct App {
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────
|
||||||
|
pub view: View,
|
||||||
|
pub should_quit: bool,
|
||||||
|
|
||||||
|
// ── Login screen ─────────────────────────────────────────────────────
|
||||||
|
pub login_username: String,
|
||||||
|
pub login_password: String,
|
||||||
|
pub login_field: LoginField,
|
||||||
|
pub login_register: bool, // toggle between Login and Register mode
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
|
||||||
|
// ── Auth state ────────────────────────────────────────────────────────
|
||||||
|
pub my_user_id: Option<Uuid>,
|
||||||
|
pub my_username: Option<String>,
|
||||||
|
|
||||||
|
// ── Channel state ─────────────────────────────────────────────────────
|
||||||
|
pub channels: Vec<(Uuid, String)>, // (id, name)
|
||||||
|
pub selected_channel: usize,
|
||||||
|
pub channel_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
|
||||||
|
|
||||||
|
// ── DM state ──────────────────────────────────────────────────────────
|
||||||
|
pub dm_peer_id: Option<Uuid>,
|
||||||
|
pub dm_peer_name: Option<String>,
|
||||||
|
pub dm_messages: HashMap<Uuid, VecDeque<ChatMessage>>,
|
||||||
|
|
||||||
|
// ── Text input (shared between channel + DM views) ────────────────────
|
||||||
|
pub input: String,
|
||||||
|
|
||||||
|
// ── Online users ──────────────────────────────────────────────────────
|
||||||
|
pub online_users: Vec<(Uuid, String)>,
|
||||||
|
|
||||||
|
// ── Network ───────────────────────────────────────────────────────────
|
||||||
|
/// Send channel messages to the server connection task.
|
||||||
|
pub net_tx: mpsc::UnboundedSender<ClientMessage>,
|
||||||
|
|
||||||
|
/// Keystore for crypto operations.
|
||||||
|
pub keystore: Keystore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(net_tx: mpsc::UnboundedSender<ClientMessage>, keystore: Keystore) -> Self {
|
||||||
|
Self {
|
||||||
|
view: View::Login,
|
||||||
|
should_quit: false,
|
||||||
|
|
||||||
|
login_username: String::new(),
|
||||||
|
login_password: String::new(),
|
||||||
|
login_field: LoginField::Username,
|
||||||
|
login_register: false,
|
||||||
|
status_msg: None,
|
||||||
|
|
||||||
|
my_user_id: None,
|
||||||
|
my_username: None,
|
||||||
|
|
||||||
|
channels: Vec::new(),
|
||||||
|
selected_channel: 0,
|
||||||
|
channel_messages: HashMap::new(),
|
||||||
|
|
||||||
|
dm_peer_id: None,
|
||||||
|
dm_peer_name: None,
|
||||||
|
dm_messages: HashMap::new(),
|
||||||
|
|
||||||
|
input: String::new(),
|
||||||
|
|
||||||
|
online_users: Vec::new(),
|
||||||
|
|
||||||
|
net_tx,
|
||||||
|
keystore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network message handling ──────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn handle_server_message(&mut self, msg: ServerMessage) {
|
||||||
|
match msg {
|
||||||
|
ServerMessage::AuthSuccess { user_id, username, .. } => {
|
||||||
|
self.my_user_id = Some(user_id);
|
||||||
|
self.my_username = Some(username.clone());
|
||||||
|
self.status_msg = None;
|
||||||
|
self.view = View::Channel;
|
||||||
|
self.status_msg = Some(format!("Logged in as {}", username));
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::AuthError { reason } => {
|
||||||
|
self.status_msg = Some(format!("Auth failed: {}", reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::ChannelMessage {
|
||||||
|
channel_id,
|
||||||
|
author_username,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let entry = self
|
||||||
|
.channel_messages
|
||||||
|
.entry(channel_id)
|
||||||
|
.or_default();
|
||||||
|
|
||||||
|
entry.push_back(ChatMessage {
|
||||||
|
author: author_username,
|
||||||
|
content,
|
||||||
|
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
|
||||||
|
});
|
||||||
|
if entry.len() > MAX_MESSAGES {
|
||||||
|
entry.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::ChannelCreated { channel_id, name, .. } => {
|
||||||
|
if !self.channels.iter().any(|(id, _)| *id == channel_id) {
|
||||||
|
self.channels.push((channel_id, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::ChannelDeleted { channel_id } => {
|
||||||
|
self.channels.retain(|(id, _)| *id != channel_id);
|
||||||
|
self.channel_messages.remove(&channel_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::DirectMessage {
|
||||||
|
sender_id,
|
||||||
|
sender_username,
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
timestamp,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Determine the peer UUID (the other party in the conversation).
|
||||||
|
let peer_id = if Some(sender_id) == self.my_user_id {
|
||||||
|
self.dm_peer_id.unwrap_or(sender_id)
|
||||||
|
} else {
|
||||||
|
sender_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to decrypt; fall back to a placeholder if the key is missing.
|
||||||
|
let plaintext = self.keystore
|
||||||
|
.get_dm_key(&peer_id)
|
||||||
|
.and_then(|key| {
|
||||||
|
crate::crypto::dm::decrypt(key, &nonce, &ciphertext).ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "[encrypted]".into());
|
||||||
|
|
||||||
|
let entry = self.dm_messages.entry(peer_id).or_default();
|
||||||
|
entry.push_back(ChatMessage {
|
||||||
|
author: sender_username,
|
||||||
|
content: plaintext,
|
||||||
|
timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(),
|
||||||
|
});
|
||||||
|
if entry.len() > MAX_MESSAGES {
|
||||||
|
entry.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::DmKeyExchangeRequest {
|
||||||
|
initiator_id,
|
||||||
|
initiator_username,
|
||||||
|
sender_ephemeral_pubkey,
|
||||||
|
} => {
|
||||||
|
// Bob receives this — generate our ephemeral pair and respond.
|
||||||
|
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
|
||||||
|
|
||||||
|
// Derive the shared key now (Bob has both keys).
|
||||||
|
if let Ok(key) = crate::crypto::dm::complete_exchange(secret, &sender_ephemeral_pubkey) {
|
||||||
|
self.keystore.cache_dm_key(initiator_id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.net_tx.send(ClientMessage::AcceptDmKeyExchange {
|
||||||
|
initiator_id,
|
||||||
|
recipient_ephemeral_pubkey: our_pubkey_b64,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.status_msg = Some(format!("{} wants to DM you", initiator_username));
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::DmKeyExchangeResponse {
|
||||||
|
recipient_id,
|
||||||
|
recipient_ephemeral_pubkey,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Alice receives this — complete the exchange.
|
||||||
|
if let Some(secret) = self.keystore.take_pending_ecdh(recipient_id) {
|
||||||
|
if let Ok(key) =
|
||||||
|
crate::crypto::dm::complete_exchange(secret, &recipient_ephemeral_pubkey)
|
||||||
|
{
|
||||||
|
self.keystore.cache_dm_key(recipient_id, key);
|
||||||
|
self.status_msg = Some("DM key exchange complete — you can now send encrypted messages".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::UserOnline { user_id, username } => {
|
||||||
|
if !self.online_users.iter().any(|(id, _)| *id == user_id) {
|
||||||
|
self.online_users.push((user_id, username));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::UserOffline { user_id, .. } => {
|
||||||
|
self.online_users.retain(|(id, _)| *id != user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerMessage::Error { code, message } => {
|
||||||
|
self.status_msg = Some(format!("Error {}: {}", code, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other messages (history, role events, etc.) are acknowledged but
|
||||||
|
// not yet wired into the TUI — they will be handled in future iterations.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Submit the current channel text input.
|
||||||
|
pub fn submit_channel_message(&mut self) {
|
||||||
|
let content = self.input.trim().to_string();
|
||||||
|
if content.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((channel_id, _)) = self.channels.get(self.selected_channel) {
|
||||||
|
let _ = self.net_tx.send(ClientMessage::SendChannelMessage {
|
||||||
|
channel_id: *channel_id,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit the current DM text input (encrypted).
|
||||||
|
pub fn submit_dm_message(&mut self) {
|
||||||
|
let content = self.input.trim().to_string();
|
||||||
|
if content.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(peer_id) = self.dm_peer_id else { return };
|
||||||
|
|
||||||
|
match self.keystore.get_dm_key(&peer_id) {
|
||||||
|
Some(key) => {
|
||||||
|
let key = *key;
|
||||||
|
match crate::crypto::dm::encrypt(&key, &content) {
|
||||||
|
Ok((ciphertext, nonce)) => {
|
||||||
|
let _ = self.net_tx.send(ClientMessage::SendDm {
|
||||||
|
recipient_id: peer_id,
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.status_msg = Some(format!("Encryption error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.status_msg = Some(
|
||||||
|
"No DM key — initiate key exchange with /dm @user first".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiate a DM key exchange with a peer by their UUID.
|
||||||
|
pub fn initiate_dm(&mut self, peer_id: Uuid, peer_name: String) {
|
||||||
|
let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange();
|
||||||
|
self.keystore.store_pending_ecdh(peer_id, secret);
|
||||||
|
|
||||||
|
let _ = self.net_tx.send(ClientMessage::InitDmKeyExchange {
|
||||||
|
recipient_id: peer_id,
|
||||||
|
sender_ephemeral_pubkey: our_pubkey_b64,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.dm_peer_id = Some(peer_id);
|
||||||
|
self.dm_peer_name = Some(peer_name);
|
||||||
|
self.view = View::Dm;
|
||||||
|
self.status_msg = Some("Key exchange initiated — waiting for peer...".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit the login/register form.
|
||||||
|
pub fn submit_login(&mut self) {
|
||||||
|
let username = self.login_username.trim().to_string();
|
||||||
|
let password = self.login_password.trim().to_string();
|
||||||
|
if username.is_empty() || password.is_empty() {
|
||||||
|
self.status_msg = Some("Username and password are required".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity_pubkey = self.keystore.identity_pubkey_b64();
|
||||||
|
|
||||||
|
let msg = if self.login_register {
|
||||||
|
ClientMessage::Register { username, password, identity_pubkey }
|
||||||
|
} else {
|
||||||
|
ClientMessage::Login { username, password, identity_pubkey }
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = self.net_tx.send(msg);
|
||||||
|
self.status_msg = Some("Connecting...".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current channel (Uuid, name), if any channel is selected.
|
||||||
|
pub fn current_channel(&self) -> Option<&(Uuid, String)> {
|
||||||
|
self.channels.get(self.selected_channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages for the currently viewed channel.
|
||||||
|
pub fn current_channel_messages(&self) -> Option<&VecDeque<ChatMessage>> {
|
||||||
|
self.current_channel()
|
||||||
|
.and_then(|(id, _)| self.channel_messages.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages for the current DM peer.
|
||||||
|
pub fn current_dm_messages(&self) -> Option<&VecDeque<ChatMessage>> {
|
||||||
|
self.dm_peer_id
|
||||||
|
.and_then(|id| self.dm_messages.get(&id))
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backchannel-client/src/tui/events.rs
Normal file
116
backchannel-client/src/tui/events.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use std::io::Stdout;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::tui::app::{App, LoginField, View};
|
||||||
|
|
||||||
|
/// The main event loop. Runs until `app.should_quit` is set.
|
||||||
|
///
|
||||||
|
/// Every ~50 ms we:
|
||||||
|
/// 1. Drain any pending server messages from `server_rx`.
|
||||||
|
/// 2. Poll for a crossterm keyboard event.
|
||||||
|
/// 3. Redraw the terminal.
|
||||||
|
pub async fn run(
|
||||||
|
app: &mut App,
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
server_rx: &mut tokio::sync::mpsc::UnboundedReceiver<backchannel_common::protocol::ServerMessage>,
|
||||||
|
) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
// Drain server messages (non-blocking).
|
||||||
|
while let Ok(msg) = server_rx.try_recv() {
|
||||||
|
app.handle_server_message(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw.
|
||||||
|
terminal.draw(|f| crate::tui::views::draw(f, app))?;
|
||||||
|
|
||||||
|
// Poll keyboard events with a short timeout so server messages stay responsive.
|
||||||
|
if event::poll(Duration::from_millis(50))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
handle_key(app, key.code, key.modifiers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
|
||||||
|
// Ctrl-C or Ctrl-Q always quit.
|
||||||
|
if modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& (code == KeyCode::Char('c') || code == KeyCode::Char('q'))
|
||||||
|
{
|
||||||
|
app.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.view {
|
||||||
|
View::Login => handle_login_key(app, code),
|
||||||
|
View::Channel => handle_channel_key(app, code),
|
||||||
|
View::Dm => handle_dm_key(app, code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_login_key(app: &mut App, code: KeyCode) {
|
||||||
|
match code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
app.login_field = match app.login_field {
|
||||||
|
LoginField::Username => LoginField::Password,
|
||||||
|
LoginField::Password => LoginField::Username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Enter => app.submit_login(),
|
||||||
|
KeyCode::Char('t') if app.login_username.is_empty() && app.login_password.is_empty() => {
|
||||||
|
// Toggle register/login mode only when both fields are empty
|
||||||
|
app.login_register = !app.login_register;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => match app.login_field {
|
||||||
|
LoginField::Username => app.login_username.push(c),
|
||||||
|
LoginField::Password => app.login_password.push(c),
|
||||||
|
},
|
||||||
|
KeyCode::Backspace => match app.login_field {
|
||||||
|
LoginField::Username => { app.login_username.pop(); }
|
||||||
|
LoginField::Password => { app.login_password.pop(); }
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_channel_key(app: &mut App, code: KeyCode) {
|
||||||
|
match code {
|
||||||
|
KeyCode::Enter => app.submit_channel_message(),
|
||||||
|
KeyCode::Char(c) => app.input.push(c),
|
||||||
|
KeyCode::Backspace => { app.input.pop(); }
|
||||||
|
// Channel navigation
|
||||||
|
KeyCode::Up => {
|
||||||
|
if app.selected_channel > 0 {
|
||||||
|
app.selected_channel -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if app.selected_channel + 1 < app.channels.len() {
|
||||||
|
app.selected_channel += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => app.status_msg = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_dm_key(app: &mut App, code: KeyCode) {
|
||||||
|
match code {
|
||||||
|
KeyCode::Enter => app.submit_dm_message(),
|
||||||
|
KeyCode::Char(c) => app.input.push(c),
|
||||||
|
KeyCode::Backspace => { app.input.pop(); }
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.view = View::Channel;
|
||||||
|
app.input.clear();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backchannel-client/src/tui/mod.rs
Normal file
3
backchannel-client/src/tui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod events;
|
||||||
|
pub mod views;
|
||||||
147
backchannel-client/src/tui/views/channel.rs
Normal file
147
backchannel-client/src/tui/views/channel.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::App;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.size();
|
||||||
|
|
||||||
|
// Split into sidebar | main column
|
||||||
|
let columns = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
draw_sidebar(frame, app, columns[0]);
|
||||||
|
draw_main(frame, app, columns[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_sidebar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(6)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// ── Channel list ────────────────────────────────────────────────────
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (_, name))| {
|
||||||
|
let style = if i == app.selected_channel {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
ListItem::new(format!("# {}", name)).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut state = ListState::default();
|
||||||
|
state.select(Some(app.selected_channel));
|
||||||
|
|
||||||
|
let channels = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(" Channels ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
|
)
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
frame.render_stateful_widget(channels, rows[0], &mut state);
|
||||||
|
|
||||||
|
// ── Online users ─────────────────────────────────────────────────────
|
||||||
|
let user_items: Vec<ListItem> = app
|
||||||
|
.online_users
|
||||||
|
.iter()
|
||||||
|
.map(|(_, name)| ListItem::new(format!("● {}", name)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let online = List::new(user_items).block(
|
||||||
|
Block::default()
|
||||||
|
.title(" Online ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Green)),
|
||||||
|
);
|
||||||
|
frame.render_widget(online, rows[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_main(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // channel title bar
|
||||||
|
Constraint::Min(0), // messages
|
||||||
|
Constraint::Length(3), // input box
|
||||||
|
Constraint::Length(1), // status bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// ── Title bar ────────────────────────────────────────────────────────
|
||||||
|
let title = app
|
||||||
|
.current_channel()
|
||||||
|
.map(|(_, name)| format!(" # {} ", name))
|
||||||
|
.unwrap_or_else(|| " No channel selected ".into());
|
||||||
|
|
||||||
|
let title_bar = Paragraph::new(title)
|
||||||
|
.style(Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD));
|
||||||
|
frame.render_widget(title_bar, rows[0]);
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────────
|
||||||
|
let msgs: Vec<ListItem> = app
|
||||||
|
.current_channel_messages()
|
||||||
|
.map(|deque| {
|
||||||
|
deque
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", m.timestamp),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{}: ", m.author),
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(m.content.clone()),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let message_count = msgs.len();
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
if message_count > 0 {
|
||||||
|
list_state.select(Some(message_count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = List::new(msgs)
|
||||||
|
.block(Block::default().borders(Borders::LEFT))
|
||||||
|
.highlight_style(Style::default());
|
||||||
|
frame.render_stateful_widget(messages, rows[1], &mut list_state);
|
||||||
|
|
||||||
|
// ── Input box ─────────────────────────────────────────────────────────
|
||||||
|
let input = Paragraph::new(app.input.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Message (Enter to send) "),
|
||||||
|
);
|
||||||
|
frame.render_widget(input, rows[2]);
|
||||||
|
|
||||||
|
// ── Status bar ────────────────────────────────────────────────────────
|
||||||
|
let status_text = app
|
||||||
|
.status_msg
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Ctrl-Q: quit | ↑↓: channels | Esc: dismiss");
|
||||||
|
let status = Paragraph::new(status_text)
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
frame.render_widget(status, rows[3]);
|
||||||
|
}
|
||||||
86
backchannel-client/src/tui/views/dm.rs
Normal file
86
backchannel-client/src/tui/views/dm.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::App;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.size();
|
||||||
|
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // title bar
|
||||||
|
Constraint::Min(0), // messages
|
||||||
|
Constraint::Length(3), // input
|
||||||
|
Constraint::Length(1), // status
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// ── Title bar ─────────────────────────────────────────────────────────
|
||||||
|
let peer_name = app
|
||||||
|
.dm_peer_name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
|
||||||
|
let title = Paragraph::new(format!(" DM: {} (E2EE) ", peer_name))
|
||||||
|
.style(Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD));
|
||||||
|
frame.render_widget(title, rows[0]);
|
||||||
|
|
||||||
|
// ── Messages ──────────────────────────────────────────────────────────
|
||||||
|
let msgs: Vec<ListItem> = app
|
||||||
|
.current_dm_messages()
|
||||||
|
.map(|deque| {
|
||||||
|
deque
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let is_self = app.my_username.as_deref() == Some(m.author.as_str());
|
||||||
|
let author_style = if is_self {
|
||||||
|
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", m.timestamp),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
Span::styled(format!("{}: ", m.author), author_style),
|
||||||
|
Span::raw(m.content.clone()),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let msg_count = msgs.len();
|
||||||
|
let mut state = ListState::default();
|
||||||
|
if msg_count > 0 {
|
||||||
|
state.select(Some(msg_count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = List::new(msgs).block(Block::default().borders(Borders::LEFT));
|
||||||
|
frame.render_stateful_widget(messages, rows[1], &mut state);
|
||||||
|
|
||||||
|
// ── Input box ─────────────────────────────────────────────────────────
|
||||||
|
let input = Paragraph::new(app.input.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Encrypted message (Enter: send Esc: back) "),
|
||||||
|
);
|
||||||
|
frame.render_widget(input, rows[2]);
|
||||||
|
|
||||||
|
// ── Status bar ────────────────────────────────────────────────────────
|
||||||
|
let status_text = app
|
||||||
|
.status_msg
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("🔒 End-to-end encrypted");
|
||||||
|
let status = Paragraph::new(status_text)
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
frame.render_widget(status, rows[3]);
|
||||||
|
}
|
||||||
98
backchannel-client/src/tui/views/login.rs
Normal file
98
backchannel-client/src/tui/views/login.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::{App, LoginField};
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.size();
|
||||||
|
|
||||||
|
// Centre a 50×12 box on screen.
|
||||||
|
let v = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
Constraint::Length(14),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let h = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
])
|
||||||
|
.split(v[1]);
|
||||||
|
|
||||||
|
let box_area = h[1];
|
||||||
|
|
||||||
|
let mode_label = if app.login_register { "Register" } else { "Login" };
|
||||||
|
|
||||||
|
let outer = Block::default()
|
||||||
|
.title(format!(" BackChannel — {} ", mode_label))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
frame.render_widget(outer, box_area);
|
||||||
|
|
||||||
|
let inner = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // padding
|
||||||
|
Constraint::Length(3), // username
|
||||||
|
Constraint::Length(3), // password
|
||||||
|
Constraint::Length(1), // padding
|
||||||
|
Constraint::Length(1), // hint
|
||||||
|
Constraint::Length(1), // status
|
||||||
|
])
|
||||||
|
.margin(1)
|
||||||
|
.split(box_area);
|
||||||
|
|
||||||
|
// Username field
|
||||||
|
let username_style = if app.login_field == LoginField::Username {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
let username_widget = Paragraph::new(app.login_username.as_str())
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Username "))
|
||||||
|
.style(username_style);
|
||||||
|
frame.render_widget(username_widget, inner[1]);
|
||||||
|
|
||||||
|
// Password field (mask with asterisks)
|
||||||
|
let password_style = if app.login_field == LoginField::Password {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
let masked: String = "*".repeat(app.login_password.len());
|
||||||
|
let password_widget = Paragraph::new(masked.as_str())
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Password "))
|
||||||
|
.style(password_style);
|
||||||
|
frame.render_widget(password_widget, inner[2]);
|
||||||
|
|
||||||
|
// Hints
|
||||||
|
let hint = Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw("Tab: switch field | Enter: submit | "),
|
||||||
|
Span::styled(
|
||||||
|
if app.login_register { "T: switch to Login" } else { "T: switch to Register" },
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(hint, inner[4]);
|
||||||
|
|
||||||
|
// Status / error message
|
||||||
|
if let Some(msg) = &app.status_msg {
|
||||||
|
let status = Paragraph::new(msg.as_str())
|
||||||
|
.style(Style::default().fg(Color::Red))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(status, inner[5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backchannel-client/src/tui/views/mod.rs
Normal file
16
backchannel-client/src/tui/views/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pub mod channel;
|
||||||
|
pub mod dm;
|
||||||
|
pub mod login;
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::tui::app::{App, View};
|
||||||
|
|
||||||
|
/// Top-level draw dispatcher — renders the correct view based on `app.view`.
|
||||||
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
match app.view {
|
||||||
|
View::Login => login::draw(frame, app),
|
||||||
|
View::Channel => channel::draw(frame, app),
|
||||||
|
View::Dm => dm::draw(frame, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backchannel-common/Cargo.toml
Normal file
18
backchannel-common/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "backchannel-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
x25519-dalek = { workspace = true }
|
||||||
|
chacha20poly1305 = { workspace = true }
|
||||||
|
hkdf = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
59
backchannel-common/src/crypto/aead.rs
Normal file
59
backchannel-common/src/crypto/aead.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
ChaCha20Poly1305, Key, Nonce,
|
||||||
|
};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use rand::RngCore;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
use crate::error::{BackchannelError, Result};
|
||||||
|
|
||||||
|
const HKDF_INFO: &[u8] = b"backchannel-dm-v1";
|
||||||
|
const HKDF_SALT: &[u8] = b"backchannel-salt-v1";
|
||||||
|
|
||||||
|
/// Derive a 32-byte ChaCha20-Poly1305 key from a raw X25519 shared secret
|
||||||
|
/// using HKDF-SHA256. The raw DH output should never be used directly.
|
||||||
|
pub fn derive_dm_key(raw_secret: &[u8]) -> [u8; 32] {
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), raw_secret);
|
||||||
|
let mut okm = [0u8; 32];
|
||||||
|
hk.expand(HKDF_INFO, &mut okm)
|
||||||
|
.expect("32-byte output always fits HKDF-SHA256");
|
||||||
|
okm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `plaintext` with a 32-byte key.
|
||||||
|
///
|
||||||
|
/// Returns `(ciphertext_b64, nonce_b64)`. A fresh random nonce is generated
|
||||||
|
/// for every call — callers must transmit both values to the recipient.
|
||||||
|
pub fn encrypt(key_bytes: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> {
|
||||||
|
let key = Key::from_slice(key_bytes);
|
||||||
|
let cipher = ChaCha20Poly1305::new(key);
|
||||||
|
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from(nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext)
|
||||||
|
.map_err(|_| BackchannelError::Crypto("encryption failed".into()))?;
|
||||||
|
|
||||||
|
Ok((B64.encode(&ciphertext), B64.encode(nonce_bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt `ciphertext_b64` using `key_bytes` and `nonce_b64`.
|
||||||
|
pub fn decrypt(key_bytes: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||||
|
let key = Key::from_slice(key_bytes);
|
||||||
|
let cipher = ChaCha20Poly1305::new(key);
|
||||||
|
|
||||||
|
let nonce_bytes = B64.decode(nonce_b64)?;
|
||||||
|
let nonce_arr: [u8; 12] = nonce_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BackchannelError::InvalidKeyLength)?;
|
||||||
|
let nonce = Nonce::from(nonce_arr);
|
||||||
|
|
||||||
|
let ciphertext = B64.decode(ciphertext_b64)?;
|
||||||
|
let plaintext = cipher.decrypt(&nonce, ciphertext.as_ref())?;
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
37
backchannel-common/src/crypto/ecdh.rs
Normal file
37
backchannel-common/src/crypto/ecdh.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||||
|
|
||||||
|
use crate::error::{BackchannelError, Result};
|
||||||
|
|
||||||
|
/// Generate an X25519 ephemeral keypair for one DM key exchange.
|
||||||
|
///
|
||||||
|
/// The `EphemeralSecret` is consumed by `diffie_hellman`, ensuring it cannot
|
||||||
|
/// be reused. Call `derive_dm_key` (in `aead`) on the resulting shared secret.
|
||||||
|
pub fn generate_ephemeral() -> (EphemeralSecret, PublicKey) {
|
||||||
|
let secret = EphemeralSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
(secret, public)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an X25519 public key to base64.
|
||||||
|
pub fn pubkey_to_b64(key: &PublicKey) -> String {
|
||||||
|
B64.encode(key.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base64 string to an X25519 public key.
|
||||||
|
pub fn b64_to_pubkey(s: &str) -> Result<PublicKey> {
|
||||||
|
let bytes = B64.decode(s)?;
|
||||||
|
let arr: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BackchannelError::InvalidKeyLength)?;
|
||||||
|
Ok(PublicKey::from(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform X25519 Diffie-Hellman and return the 32-byte shared secret.
|
||||||
|
///
|
||||||
|
/// `secret` is consumed (ephemeral), preventing reuse.
|
||||||
|
pub fn diffie_hellman(secret: EphemeralSecret, their_pubkey: &PublicKey) -> [u8; 32] {
|
||||||
|
let shared = secret.diffie_hellman(their_pubkey);
|
||||||
|
*shared.as_bytes()
|
||||||
|
}
|
||||||
67
backchannel-common/src/crypto/identity.rs
Normal file
67
backchannel-common/src/crypto/identity.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
use crate::error::{BackchannelError, Result};
|
||||||
|
|
||||||
|
/// Generate a new Ed25519 identity keypair.
|
||||||
|
pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
|
||||||
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
(signing_key, verifying_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an Ed25519 public key to a base64 string (32 bytes → ~44 chars).
|
||||||
|
pub fn pubkey_to_b64(key: &VerifyingKey) -> String {
|
||||||
|
B64.encode(key.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base64 string back to an Ed25519 public key.
|
||||||
|
pub fn b64_to_pubkey(s: &str) -> Result<VerifyingKey> {
|
||||||
|
let bytes = B64.decode(s)?;
|
||||||
|
let arr: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BackchannelError::InvalidKeyLength)?;
|
||||||
|
VerifyingKey::from_bytes(&arr).map_err(|e| BackchannelError::Crypto(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a signing key (private key bytes) to base64 for keystore persistence.
|
||||||
|
pub fn signing_key_to_b64(key: &SigningKey) -> String {
|
||||||
|
B64.encode(key.to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base64 string back to a signing key.
|
||||||
|
pub fn b64_to_signing_key(s: &str) -> Result<SigningKey> {
|
||||||
|
let bytes = B64.decode(s)?;
|
||||||
|
let arr: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BackchannelError::InvalidKeyLength)?;
|
||||||
|
Ok(SigningKey::from_bytes(&arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a message with an Ed25519 signing key.
|
||||||
|
pub fn sign(key: &SigningKey, message: &[u8]) -> Signature {
|
||||||
|
use ed25519_dalek::Signer;
|
||||||
|
key.sign(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an Ed25519 signature.
|
||||||
|
pub fn verify(key: &VerifyingKey, message: &[u8], sig: &Signature) -> Result<()> {
|
||||||
|
use ed25519_dalek::Verifier;
|
||||||
|
key.verify(message, sig)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a `Signature` to base64.
|
||||||
|
pub fn sig_to_b64(sig: &Signature) -> String {
|
||||||
|
B64.encode(sig.to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base64 string back to a `Signature`.
|
||||||
|
pub fn b64_to_sig(s: &str) -> Result<Signature> {
|
||||||
|
let bytes = B64.decode(s)?;
|
||||||
|
let arr: [u8; 64] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BackchannelError::InvalidKeyLength)?;
|
||||||
|
Ok(Signature::from_bytes(&arr))
|
||||||
|
}
|
||||||
3
backchannel-common/src/crypto/mod.rs
Normal file
3
backchannel-common/src/crypto/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod aead;
|
||||||
|
pub mod ecdh;
|
||||||
|
pub mod identity;
|
||||||
39
backchannel-common/src/error.rs
Normal file
39
backchannel-common/src/error.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, BackchannelError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum BackchannelError {
|
||||||
|
#[error("cryptographic error: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
|
||||||
|
#[error("base64 decode error: {0}")]
|
||||||
|
Base64(#[from] base64::DecodeError),
|
||||||
|
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("invalid key length")]
|
||||||
|
InvalidKeyLength,
|
||||||
|
|
||||||
|
#[error("decryption failed: authentication tag mismatch")]
|
||||||
|
DecryptionFailed,
|
||||||
|
|
||||||
|
#[error("signature verification failed")]
|
||||||
|
InvalidSignature,
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chacha20poly1305::Error> for BackchannelError {
|
||||||
|
fn from(_: chacha20poly1305::Error) -> Self {
|
||||||
|
BackchannelError::DecryptionFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ed25519_dalek::SignatureError> for BackchannelError {
|
||||||
|
fn from(_: ed25519_dalek::SignatureError) -> Self {
|
||||||
|
BackchannelError::InvalidSignature
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backchannel-common/src/lib.rs
Normal file
6
backchannel-common/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod crypto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use error::{BackchannelError, Result};
|
||||||
121
backchannel-common/src/protocol/client.rs
Normal file
121
backchannel-common/src/protocol/client.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Every message the client can send to the server over the WebSocket.
|
||||||
|
///
|
||||||
|
/// Serialised as JSON with an inline `"type"` discriminant, e.g.:
|
||||||
|
/// `{"type":"login","username":"alice","password":"...","identity_pubkey":"..."}`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
// ── Authentication ────────────────────────────────────────────────────
|
||||||
|
/// First-time authentication with credentials.
|
||||||
|
Login {
|
||||||
|
username: String,
|
||||||
|
/// Sent in plaintext; the TLS layer protects this in transit.
|
||||||
|
password: String,
|
||||||
|
/// Base64-encoded Ed25519 public key (32 bytes).
|
||||||
|
/// Stored/updated server-side as the user's cryptographic identity.
|
||||||
|
identity_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Register a new account.
|
||||||
|
Register {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
identity_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Re-establish an authenticated session using a previously issued JWT.
|
||||||
|
/// Avoids re-entering the password on reconnect.
|
||||||
|
ResumeSession {
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Gracefully end the session; server invalidates the JWT.
|
||||||
|
Logout,
|
||||||
|
|
||||||
|
// ── Channel messaging ─────────────────────────────────────────────────
|
||||||
|
/// Post a plaintext message to a channel.
|
||||||
|
SendChannelMessage {
|
||||||
|
channel_id: Uuid,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Request recent message history for a channel.
|
||||||
|
FetchChannelHistory {
|
||||||
|
channel_id: Uuid,
|
||||||
|
/// Pagination: fetch messages older than this ID.
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Direct messages (E2EE) ────────────────────────────────────────────
|
||||||
|
/// Phase 1 of DM key exchange: initiator sends their X25519 ephemeral
|
||||||
|
/// public key to the server, addressed to the target recipient.
|
||||||
|
InitDmKeyExchange {
|
||||||
|
recipient_id: Uuid,
|
||||||
|
/// Base64-encoded X25519 ephemeral public key (32 bytes).
|
||||||
|
sender_ephemeral_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Phase 2 of DM key exchange: recipient responds with their ephemeral
|
||||||
|
/// public key. Both sides can now derive the same shared secret.
|
||||||
|
AcceptDmKeyExchange {
|
||||||
|
initiator_id: Uuid,
|
||||||
|
/// Base64-encoded X25519 ephemeral public key (32 bytes).
|
||||||
|
recipient_ephemeral_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Send an E2EE DM. The server relays the ciphertext without decrypting.
|
||||||
|
SendDm {
|
||||||
|
recipient_id: Uuid,
|
||||||
|
/// Base64-encoded ChaCha20-Poly1305 ciphertext.
|
||||||
|
ciphertext: String,
|
||||||
|
/// Base64-encoded nonce (12 bytes).
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Request DM history with a specific peer.
|
||||||
|
FetchDmHistory {
|
||||||
|
peer_id: Uuid,
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Channel management ────────────────────────────────────────────────
|
||||||
|
CreateChannel {
|
||||||
|
name: String,
|
||||||
|
topic: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
DeleteChannel {
|
||||||
|
channel_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Role & permission management ──────────────────────────────────────
|
||||||
|
CreateRole {
|
||||||
|
name: String,
|
||||||
|
/// Permission bitmask; see `PermissionFlags` in `types.rs`.
|
||||||
|
permissions: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
AssignRole {
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
RevokeRole {
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Key directory ─────────────────────────────────────────────────────
|
||||||
|
/// Look up another user's registered Ed25519 identity public key.
|
||||||
|
QueryIdentityKey {
|
||||||
|
user_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Keepalive ─────────────────────────────────────────────────────────
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
5
backchannel-common/src/protocol/mod.rs
Normal file
5
backchannel-common/src/protocol/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
pub use client::ClientMessage;
|
||||||
|
pub use server::{HistoricChannelMessage, HistoricDm, ServerMessage};
|
||||||
141
backchannel-common/src/protocol/server.rs
Normal file
141
backchannel-common/src/protocol/server.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Every message the server can push to a client over the WebSocket.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
// ── Auth responses ────────────────────────────────────────────────────
|
||||||
|
AuthSuccess {
|
||||||
|
user_id: Uuid,
|
||||||
|
username: String,
|
||||||
|
/// Signed JWT. Client stores this for reconnect via `ResumeSession`.
|
||||||
|
session_token: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
AuthError {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Channel events ────────────────────────────────────────────────────
|
||||||
|
/// Broadcast to all connected clients when a channel message is posted.
|
||||||
|
ChannelMessage {
|
||||||
|
message_id: Uuid,
|
||||||
|
channel_id: Uuid,
|
||||||
|
author_id: Uuid,
|
||||||
|
author_username: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Response to `FetchChannelHistory`.
|
||||||
|
ChannelHistory {
|
||||||
|
channel_id: Uuid,
|
||||||
|
messages: Vec<HistoricChannelMessage>,
|
||||||
|
},
|
||||||
|
|
||||||
|
ChannelCreated {
|
||||||
|
channel_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
topic: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
ChannelDeleted {
|
||||||
|
channel_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DM key exchange relay ─────────────────────────────────────────────
|
||||||
|
/// Server relays the initiator's ephemeral pubkey to the recipient.
|
||||||
|
DmKeyExchangeRequest {
|
||||||
|
initiator_id: Uuid,
|
||||||
|
initiator_username: String,
|
||||||
|
sender_ephemeral_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Server relays the recipient's ephemeral pubkey back to the initiator.
|
||||||
|
DmKeyExchangeResponse {
|
||||||
|
recipient_id: Uuid,
|
||||||
|
recipient_username: String,
|
||||||
|
recipient_ephemeral_pubkey: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DM messages ───────────────────────────────────────────────────────
|
||||||
|
/// Relayed E2EE DM. Server cannot read `ciphertext`.
|
||||||
|
DirectMessage {
|
||||||
|
message_id: Uuid,
|
||||||
|
sender_id: Uuid,
|
||||||
|
sender_username: String,
|
||||||
|
ciphertext: String,
|
||||||
|
nonce: String,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
},
|
||||||
|
|
||||||
|
DmHistory {
|
||||||
|
peer_id: Uuid,
|
||||||
|
messages: Vec<HistoricDm>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Presence ──────────────────────────────────────────────────────────
|
||||||
|
Pong,
|
||||||
|
|
||||||
|
UserOnline {
|
||||||
|
user_id: Uuid,
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
UserOffline {
|
||||||
|
user_id: Uuid,
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Role events ───────────────────────────────────────────────────────
|
||||||
|
RoleCreated {
|
||||||
|
role_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
permissions: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
RoleAssigned {
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
RoleRevoked {
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Key directory ─────────────────────────────────────────────────────
|
||||||
|
IdentityKeyResponse {
|
||||||
|
user_id: Uuid,
|
||||||
|
/// Base64-encoded Ed25519 public key, or null if unknown.
|
||||||
|
identity_pubkey: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Generic error ─────────────────────────────────────────────────────
|
||||||
|
Error {
|
||||||
|
code: u16,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel message returned in history responses.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HistoricChannelMessage {
|
||||||
|
pub message_id: Uuid,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub author_username: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A DM returned in history responses. Ciphertext is opaque to the server.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HistoricDm {
|
||||||
|
pub message_id: Uuid,
|
||||||
|
pub sender_id: Uuid,
|
||||||
|
pub ciphertext: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
72
backchannel-common/src/types.rs
Normal file
72
backchannel-common/src/types.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Typed UUID wrappers to prevent accidental mixing of IDs.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct UserId(pub Uuid);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelId(pub Uuid);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct MessageId(pub Uuid);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct RoleId(pub Uuid);
|
||||||
|
|
||||||
|
impl UserId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
pub fn inner(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
pub fn inner(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
pub fn inner(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoleId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
pub fn inner(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission bit-flags for server roles.
|
||||||
|
/// The `ADMINISTRATOR` flag bypasses all individual permission checks.
|
||||||
|
pub struct PermissionFlags;
|
||||||
|
|
||||||
|
impl PermissionFlags {
|
||||||
|
pub const READ_MESSAGES: u64 = 1 << 0;
|
||||||
|
pub const SEND_MESSAGES: u64 = 1 << 1;
|
||||||
|
pub const MANAGE_CHANNELS: u64 = 1 << 2;
|
||||||
|
pub const MANAGE_ROLES: u64 = 1 << 3;
|
||||||
|
pub const KICK_MEMBERS: u64 = 1 << 4;
|
||||||
|
pub const BAN_MEMBERS: u64 = 1 << 5;
|
||||||
|
/// Bypasses all permission checks.
|
||||||
|
pub const ADMINISTRATOR: u64 = 1 << 63;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether `perms` satisfies a required flag, honoring ADMINISTRATOR.
|
||||||
|
pub fn has_permission(perms: u64, required: u64) -> bool {
|
||||||
|
(perms & PermissionFlags::ADMINISTRATOR) != 0 || (perms & required) == required
|
||||||
|
}
|
||||||
36
backchannel-server/Cargo.toml
Normal file
36
backchannel-server/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "backchannel-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "backchannel-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
backchannel-common = { path = "../backchannel-common" }
|
||||||
|
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
argon2 = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-tungstenite = { workspace = true }
|
||||||
|
|
||||||
|
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "migrate", "chrono", "uuid"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
futures-util = "0.3"
|
||||||
|
axum = "0.7"
|
||||||
|
rust-embed = "8"
|
||||||
|
mime_guess = "2"
|
||||||
2
backchannel-server/src/auth/mod.rs
Normal file
2
backchannel-server/src/auth/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod password;
|
||||||
|
pub mod token;
|
||||||
31
backchannel-server/src/auth/password.rs
Normal file
31
backchannel-server/src/auth/password.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
/// Hash a plaintext password using Argon2id.
|
||||||
|
///
|
||||||
|
/// The returned string is the PHC-format encoded hash, suitable for storage in
|
||||||
|
/// the `users.password_hash` column. Includes the salt, so no separate salt
|
||||||
|
/// storage is needed.
|
||||||
|
pub fn hash(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)?
|
||||||
|
.to_string();
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a stored PHC-format hash.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` on success, `Err(ServerError::Unauthorized)` on mismatch.
|
||||||
|
pub fn verify(password: &str, stored_hash: &str) -> Result<()> {
|
||||||
|
let parsed = PasswordHash::new(stored_hash)
|
||||||
|
.map_err(|e| ServerError::PasswordHash(e.to_string()))?;
|
||||||
|
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.map_err(|_| ServerError::Unauthorized)
|
||||||
|
}
|
||||||
110
backchannel-server/src/auth/token.rs
Normal file
110
backchannel-server/src/auth/token.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
/// Claims embedded in every BackChannel JWT.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// Subject: user UUID as a string.
|
||||||
|
pub sub: String,
|
||||||
|
pub username: String,
|
||||||
|
/// JWT ID — stored in the `sessions` table for revocation support.
|
||||||
|
pub jti: String,
|
||||||
|
/// Issued-at (Unix seconds).
|
||||||
|
pub iat: i64,
|
||||||
|
/// Expiry (Unix seconds).
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn user_id(&self) -> Result<Uuid> {
|
||||||
|
Uuid::parse_str(&self.sub).map_err(|e| ServerError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_TTL_SECS: i64 = 7 * 24 * 3600; // 7 days
|
||||||
|
|
||||||
|
/// Issue a signed JWT and persist its JTI in the `sessions` table.
|
||||||
|
pub async fn issue(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
secret: &[u8],
|
||||||
|
) -> Result<String> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let jti = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
username: username.to_string(),
|
||||||
|
jti: jti.clone(),
|
||||||
|
iat: now,
|
||||||
|
exp: now + TOKEN_TTL_SECS,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(), // HS256
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Persist JTI for revocation checks.
|
||||||
|
let expires_at = Utc::now()
|
||||||
|
.checked_add_signed(chrono::Duration::seconds(TOKEN_TTL_SECS))
|
||||||
|
.unwrap_or_else(Utc::now)
|
||||||
|
.to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (jti, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&jti)
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate a JWT, then check the `sessions` table for revocation.
|
||||||
|
pub async fn validate(pool: &SqlitePool, token: &str, secret: &[u8]) -> Result<Claims> {
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(secret),
|
||||||
|
&validation,
|
||||||
|
)
|
||||||
|
.map_err(|_| ServerError::Unauthorized)?;
|
||||||
|
|
||||||
|
let claims = token_data.claims;
|
||||||
|
|
||||||
|
// Check revocation.
|
||||||
|
let row: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT revoked FROM sessions WHERE jti = ?")
|
||||||
|
.bind(&claims.jti)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => return Err(ServerError::Unauthorized), // JTI unknown
|
||||||
|
Some((revoked,)) if revoked != 0 => return Err(ServerError::Unauthorized),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a JWT as revoked (logout / forced expiry).
|
||||||
|
pub async fn revoke(pool: &SqlitePool, jti: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE sessions SET revoked = 1 WHERE jti = ?")
|
||||||
|
.bind(jti)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
84
backchannel-server/src/config.rs
Normal file
84
backchannel-server/src/config.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Runtime configuration for the BackChannel server.
|
||||||
|
/// Loaded from environment variables with sensible development defaults.
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// TCP address to bind, e.g. `0.0.0.0:7777`
|
||||||
|
pub bind_addr: String,
|
||||||
|
|
||||||
|
/// HTTP address for the embedded web UI, e.g. `0.0.0.0:8080`
|
||||||
|
pub http_bind_addr: String,
|
||||||
|
|
||||||
|
/// SQLite connection string, e.g. `sqlite:backchannel.db`
|
||||||
|
pub db_url: String,
|
||||||
|
|
||||||
|
/// Raw bytes for HMAC-SHA256 JWT signing. In production, set
|
||||||
|
/// `BC_JWT_SECRET` to a long, random hex string.
|
||||||
|
pub jwt_secret: Vec<u8>,
|
||||||
|
|
||||||
|
/// Enable TLS on the WebSocket listener. Requires `tls_cert_path`
|
||||||
|
/// and `tls_key_path` when true.
|
||||||
|
pub tls_enabled: bool,
|
||||||
|
|
||||||
|
/// Path to the PEM-encoded TLS certificate chain.
|
||||||
|
pub tls_cert_path: Option<String>,
|
||||||
|
|
||||||
|
/// Path to the PEM-encoded private key.
|
||||||
|
pub tls_key_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
/// Load configuration from environment variables.
|
||||||
|
///
|
||||||
|
/// | Variable | Default |
|
||||||
|
/// |------------------|-----------------------------|
|
||||||
|
/// | `BC_BIND_ADDR` | `0.0.0.0:7777` |
|
||||||
|
/// | `BC_HTTP_ADDR` | `0.0.0.0:8080` |
|
||||||
|
/// | `BC_DB_URL` | `sqlite:backchannel.db` |
|
||||||
|
/// | `BC_JWT_SECRET` | *insecure dev placeholder* |
|
||||||
|
/// | `BC_TLS` | `false` |
|
||||||
|
/// | `BC_TLS_CERT` | *(none)* |
|
||||||
|
/// | `BC_TLS_KEY` | *(none)* |
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let bind_addr = std::env::var("BC_BIND_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:7777".into());
|
||||||
|
let http_bind_addr = std::env::var("BC_HTTP_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8080".into());
|
||||||
|
|
||||||
|
let db_url = std::env::var("BC_DB_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:backchannel.db".into());
|
||||||
|
|
||||||
|
let jwt_secret = match std::env::var("BC_JWT_SECRET") {
|
||||||
|
Ok(s) => s.into_bytes(),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"BC_JWT_SECRET not set — using insecure dev placeholder. \
|
||||||
|
Set this env var before deploying."
|
||||||
|
);
|
||||||
|
b"CHANGE-ME-dev-only-jwt-secret-32b".to_vec()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tls_enabled = std::env::var("BC_TLS")
|
||||||
|
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let tls_cert_path = std::env::var("BC_TLS_CERT").ok();
|
||||||
|
let tls_key_path = std::env::var("BC_TLS_KEY").ok();
|
||||||
|
|
||||||
|
if tls_enabled {
|
||||||
|
anyhow::ensure!(tls_cert_path.is_some(), "BC_TLS_CERT must be set when BC_TLS=true");
|
||||||
|
anyhow::ensure!(tls_key_path.is_some(), "BC_TLS_KEY must be set when BC_TLS=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
bind_addr,
|
||||||
|
http_bind_addr,
|
||||||
|
db_url,
|
||||||
|
jwt_secret,
|
||||||
|
tls_enabled,
|
||||||
|
tls_cert_path,
|
||||||
|
tls_key_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backchannel-server/src/db/channels.rs
Normal file
78
backchannel-server/src/db/channels.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct ChannelRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub topic: Option<String>,
|
||||||
|
pub created_by: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelRow {
|
||||||
|
pub fn uuid(&self) -> Result<Uuid> {
|
||||||
|
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
name: &str,
|
||||||
|
topic: Option<&str>,
|
||||||
|
created_by: Uuid,
|
||||||
|
) -> Result<Uuid> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO channels (id, name, topic, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(name)
|
||||||
|
.bind(topic)
|
||||||
|
.bind(created_by.to_string())
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool) -> Result<Vec<ChannelRow>> {
|
||||||
|
let rows = sqlx::query_as::<_, ChannelRow>(
|
||||||
|
"SELECT id, name, topic, created_by, created_at FROM channels ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<ChannelRow>> {
|
||||||
|
let row = sqlx::query_as::<_, ChannelRow>(
|
||||||
|
"SELECT id, name, topic, created_by, created_at FROM channels WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<bool> {
|
||||||
|
let result = sqlx::query("DELETE FROM channels WHERE id = ?")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn name_exists(pool: &SqlitePool, name: &str) -> Result<bool> {
|
||||||
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0 > 0)
|
||||||
|
}
|
||||||
17
backchannel-server/src/db/keys.rs
Normal file
17
backchannel-server/src/db/keys.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Look up the registered Ed25519 identity public key for a user.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the user does not exist or has no key on record
|
||||||
|
/// (the latter shouldn't happen after registration, but is handled defensively).
|
||||||
|
pub async fn get_identity_key(pool: &SqlitePool, user_id: Uuid) -> Result<Option<String>> {
|
||||||
|
let row: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT identity_pubkey FROM users WHERE id = ?")
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(k,)| k))
|
||||||
|
}
|
||||||
210
backchannel-server/src/db/messages.rs
Normal file
210
backchannel-server/src/db/messages.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use backchannel_common::protocol::server::{HistoricChannelMessage, HistoricDm};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ChannelMessageRow {
|
||||||
|
pub id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub author_id: String,
|
||||||
|
pub author_username: String,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct DmRow {
|
||||||
|
pub id: String,
|
||||||
|
pub sender_id: String,
|
||||||
|
pub ciphertext: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_channel_message(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
channel_id: Uuid,
|
||||||
|
author_id: Uuid,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(Uuid, DateTime<Utc>)> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_str = now.to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO channel_messages (id, channel_id, author_id, content, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(channel_id.to_string())
|
||||||
|
.bind(author_id.to_string())
|
||||||
|
.bind(content)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((id, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_channel_history(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
channel_id: Uuid,
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<HistoricChannelMessage>> {
|
||||||
|
let limit = limit.min(100) as i64;
|
||||||
|
|
||||||
|
let rows: Vec<ChannelMessageRow> = if let Some(before_id) = before_message_id {
|
||||||
|
// Fetch messages older than `before_id` by joining to get its created_at
|
||||||
|
sqlx::query_as::<_, ChannelMessageRow>(
|
||||||
|
"SELECT m.id, m.channel_id, m.author_id, u.username AS author_username,
|
||||||
|
m.content, m.created_at
|
||||||
|
FROM channel_messages m
|
||||||
|
JOIN users u ON u.id = m.author_id
|
||||||
|
WHERE m.channel_id = ?
|
||||||
|
AND m.created_at < (SELECT created_at FROM channel_messages WHERE id = ?)
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(channel_id.to_string())
|
||||||
|
.bind(before_id.to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, ChannelMessageRow>(
|
||||||
|
"SELECT m.id, m.channel_id, m.author_id, u.username AS author_username,
|
||||||
|
m.content, m.created_at
|
||||||
|
FROM channel_messages m
|
||||||
|
JOIN users u ON u.id = m.author_id
|
||||||
|
WHERE m.channel_id = ?
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(channel_id.to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut messages: Vec<HistoricChannelMessage> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let ts = r
|
||||||
|
.created_at
|
||||||
|
.parse::<DateTime<Utc>>()
|
||||||
|
.unwrap_or_else(|_| Utc::now());
|
||||||
|
Ok(HistoricChannelMessage {
|
||||||
|
message_id: Uuid::parse_str(&r.id)
|
||||||
|
.map_err(|e| ServerError::Internal(e.to_string()))?,
|
||||||
|
author_id: Uuid::parse_str(&r.author_id)
|
||||||
|
.map_err(|e| ServerError::Internal(e.to_string()))?,
|
||||||
|
author_username: r.author_username,
|
||||||
|
content: r.content,
|
||||||
|
timestamp: ts,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
// Return in chronological order (oldest first).
|
||||||
|
messages.reverse();
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_dm(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
sender_id: Uuid,
|
||||||
|
recipient_id: Uuid,
|
||||||
|
ciphertext: &str,
|
||||||
|
nonce: &str,
|
||||||
|
) -> Result<(Uuid, DateTime<Utc>)> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now();
|
||||||
|
let now_str = now.to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO direct_messages (id, sender_id, recipient_id, ciphertext, nonce, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(sender_id.to_string())
|
||||||
|
.bind(recipient_id.to_string())
|
||||||
|
.bind(ciphertext)
|
||||||
|
.bind(nonce)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((id, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_dm_history(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_a: Uuid,
|
||||||
|
user_b: Uuid,
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<HistoricDm>> {
|
||||||
|
let limit = limit.min(100) as i64;
|
||||||
|
|
||||||
|
let rows: Vec<DmRow> = if let Some(before_id) = before_message_id {
|
||||||
|
sqlx::query_as::<_, DmRow>(
|
||||||
|
"SELECT id, sender_id, ciphertext, nonce, created_at
|
||||||
|
FROM direct_messages
|
||||||
|
WHERE ((sender_id = ? AND recipient_id = ?)
|
||||||
|
OR (sender_id = ? AND recipient_id = ?))
|
||||||
|
AND created_at < (SELECT created_at FROM direct_messages WHERE id = ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(user_a.to_string())
|
||||||
|
.bind(user_b.to_string())
|
||||||
|
.bind(user_b.to_string())
|
||||||
|
.bind(user_a.to_string())
|
||||||
|
.bind(before_id.to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, DmRow>(
|
||||||
|
"SELECT id, sender_id, ciphertext, nonce, created_at
|
||||||
|
FROM direct_messages
|
||||||
|
WHERE (sender_id = ? AND recipient_id = ?)
|
||||||
|
OR (sender_id = ? AND recipient_id = ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(user_a.to_string())
|
||||||
|
.bind(user_b.to_string())
|
||||||
|
.bind(user_b.to_string())
|
||||||
|
.bind(user_a.to_string())
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut messages: Vec<HistoricDm> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let ts = r
|
||||||
|
.created_at
|
||||||
|
.parse::<DateTime<Utc>>()
|
||||||
|
.unwrap_or_else(|_| Utc::now());
|
||||||
|
Ok(HistoricDm {
|
||||||
|
message_id: Uuid::parse_str(&r.id)
|
||||||
|
.map_err(|e| ServerError::Internal(e.to_string()))?,
|
||||||
|
sender_id: Uuid::parse_str(&r.sender_id)
|
||||||
|
.map_err(|e| ServerError::Internal(e.to_string()))?,
|
||||||
|
ciphertext: r.ciphertext,
|
||||||
|
nonce: r.nonce,
|
||||||
|
timestamp: ts,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
messages.reverse();
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
85
backchannel-server/src/db/migrations/001_initial.sql
Normal file
85
backchannel-server/src/db/migrations/001_initial.sql
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
-- ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
identity_pubkey TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Roles ─────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
permissions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed built-in roles.
|
||||||
|
-- admin: all bits set (i64::MAX in SQLite integer storage).
|
||||||
|
-- member: READ_MESSAGES (bit 0) | SEND_MESSAGES (bit 1) = 3
|
||||||
|
INSERT OR IGNORE INTO roles (id, name, permissions) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'admin', 9223372036854775807),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'member', 3);
|
||||||
|
|
||||||
|
-- ── User ↔ Role membership ─────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Channels ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
topic TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed a default channel owned by a system placeholder user.
|
||||||
|
-- The created_by value references no real user; foreign key is intentionally
|
||||||
|
-- left loose here since the system user is never inserted.
|
||||||
|
INSERT OR IGNORE INTO channels (id, name, created_by) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000010', 'general', '00000000-0000-0000-0000-000000000000');
|
||||||
|
|
||||||
|
-- ── Channel messages ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_messages (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_messages_channel_created
|
||||||
|
ON channel_messages (channel_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ── Direct messages ────────────────────────────────────────────────────────────
|
||||||
|
-- Ciphertext is stored opaquely. The server cannot decrypt DM content.
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
sender_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
recipient_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
ciphertext TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_pair_created
|
||||||
|
ON direct_messages (sender_id, recipient_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ── Sessions ───────────────────────────────────────────────────────────────────
|
||||||
|
-- JWT IDs stored here enable soft revocation (logout / forced expiry).
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
jti TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
|
||||||
5
backchannel-server/src/db/mod.rs
Normal file
5
backchannel-server/src/db/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod channels;
|
||||||
|
pub mod keys;
|
||||||
|
pub mod messages;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod users;
|
||||||
86
backchannel-server/src/db/roles.rs
Normal file
86
backchannel-server/src/db/roles.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct RoleRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub permissions: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoleRow {
|
||||||
|
pub fn uuid(&self) -> Result<Uuid> {
|
||||||
|
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the permissions bitmask as `u64` (SQLite stores it as `i64`).
|
||||||
|
pub fn permissions_u64(&self) -> u64 {
|
||||||
|
self.permissions as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(pool: &SqlitePool, name: &str, permissions: u64) -> Result<Uuid> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(name)
|
||||||
|
.bind(permissions as i64)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<RoleRow>> {
|
||||||
|
let row = sqlx::query_as::<_, RoleRow>(
|
||||||
|
"SELECT id, name, permissions, created_at FROM roles WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assign(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(role_id.to_string())
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind(role_id.to_string())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the effective permission bitmask for a user by OR-ing all their role permissions.
|
||||||
|
pub async fn get_user_permissions(pool: &SqlitePool, user_id: Uuid) -> Result<u64> {
|
||||||
|
let rows: Vec<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT r.permissions FROM roles r
|
||||||
|
JOIN user_roles ur ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let perms = rows.into_iter().fold(0u64, |acc, (p,)| acc | p as u64);
|
||||||
|
Ok(perms)
|
||||||
|
}
|
||||||
115
backchannel-server/src/db/users.rs
Normal file
115
backchannel-server/src/db/users.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
|
||||||
|
/// A row from the `users` table.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
pub struct UserRow {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub identity_pubkey: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRow {
|
||||||
|
pub fn uuid(&self) -> Result<Uuid> {
|
||||||
|
Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new user. Returns the new row's UUID.
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
identity_pubkey: &str,
|
||||||
|
) -> Result<Uuid> {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, password_hash, identity_pubkey, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(identity_pubkey)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user by username.
|
||||||
|
pub async fn find_by_username(pool: &SqlitePool, username: &str) -> Result<Option<UserRow>> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, username, password_hash, identity_pubkey, created_at, updated_at
|
||||||
|
FROM users WHERE username = ?",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user by UUID.
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<UserRow>> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, username, password_hash, identity_pubkey, created_at, updated_at
|
||||||
|
FROM users WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id.to_string())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update (or set for the first time) a user's Ed25519 identity public key.
|
||||||
|
/// Called on every login to allow transparent key rotation.
|
||||||
|
pub async fn update_identity_key(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: Uuid,
|
||||||
|
identity_pubkey: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE users SET identity_pubkey = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(identity_pubkey)
|
||||||
|
.bind(now)
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a username is already taken.
|
||||||
|
pub async fn username_exists(pool: &SqlitePool, username: &str) -> Result<bool> {
|
||||||
|
let count: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM users WHERE username = ?")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0 > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign the built-in `member` role to a newly registered user.
|
||||||
|
pub async fn assign_member_role(pool: &SqlitePool, user_id: Uuid) -> Result<()> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(user_id.to_string())
|
||||||
|
.bind("00000000-0000-0000-0000-000000000002") // member role UUID
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
59
backchannel-server/src/error.rs
Normal file
59
backchannel-server/src/error.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, ServerError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ServerError {
|
||||||
|
#[error("unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("connection closed")]
|
||||||
|
ConnectionClosed,
|
||||||
|
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("JWT error: {0}")]
|
||||||
|
Jwt(#[from] jsonwebtoken::errors::Error),
|
||||||
|
|
||||||
|
#[error("password hash error: {0}")]
|
||||||
|
PasswordHash(String),
|
||||||
|
|
||||||
|
#[error("common error: {0}")]
|
||||||
|
Common(#[from] backchannel_common::BackchannelError),
|
||||||
|
|
||||||
|
#[error("internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<argon2::password_hash::Error> for ServerError {
|
||||||
|
fn from(e: argon2::password_hash::Error) -> Self {
|
||||||
|
ServerError::PasswordHash(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP-style error code for `ServerMessage::Error`.
|
||||||
|
impl ServerError {
|
||||||
|
pub fn code(&self) -> u16 {
|
||||||
|
match self {
|
||||||
|
ServerError::Unauthorized => 401,
|
||||||
|
ServerError::Forbidden => 403,
|
||||||
|
ServerError::NotFound(_) => 404,
|
||||||
|
ServerError::BadRequest(_) => 400,
|
||||||
|
ServerError::ConnectionClosed => 503,
|
||||||
|
_ => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
backchannel-server/src/handlers/auth.rs
Normal file
124
backchannel-server/src/handlers/auth.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
|
|
||||||
|
use crate::auth::{password, token};
|
||||||
|
use crate::db::users;
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
use crate::state::{AppState, ChannelBroadcast};
|
||||||
|
use crate::ws::session::Session;
|
||||||
|
|
||||||
|
pub async fn handle_login(
|
||||||
|
session: &mut Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
username: String,
|
||||||
|
password_plain: String,
|
||||||
|
identity_pubkey: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user = users::find_by_username(&state.db, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(ServerError::Unauthorized)?;
|
||||||
|
|
||||||
|
password::verify(&password_plain, &user.password_hash)?;
|
||||||
|
let user_id = user.uuid()?;
|
||||||
|
|
||||||
|
// Update identity key on every login to allow transparent key rotation.
|
||||||
|
users::update_identity_key(&state.db, user_id, &identity_pubkey).await?;
|
||||||
|
|
||||||
|
let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?;
|
||||||
|
let jti = extract_jti(&jwt, &state.jwt_secret)?;
|
||||||
|
|
||||||
|
session.user_id = Some(user_id);
|
||||||
|
session.username = Some(username.clone());
|
||||||
|
session.jti = Some(jti);
|
||||||
|
|
||||||
|
state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::UserOnline { user_id, username: username.clone() },
|
||||||
|
});
|
||||||
|
|
||||||
|
session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_register(
|
||||||
|
session: &mut Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
username: String,
|
||||||
|
password_plain: String,
|
||||||
|
identity_pubkey: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
if username.len() < 2 || username.len() > 32 {
|
||||||
|
return Err(ServerError::BadRequest("Username must be 2–32 characters".into()));
|
||||||
|
}
|
||||||
|
if password_plain.len() < 8 {
|
||||||
|
return Err(ServerError::BadRequest("Password must be at least 8 characters".into()));
|
||||||
|
}
|
||||||
|
if users::username_exists(&state.db, &username).await? {
|
||||||
|
return Err(ServerError::BadRequest("Username already taken".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = password::hash(&password_plain)?;
|
||||||
|
let user_id = users::create(&state.db, &username, &hash, &identity_pubkey).await?;
|
||||||
|
users::assign_member_role(&state.db, user_id).await?;
|
||||||
|
|
||||||
|
let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?;
|
||||||
|
let jti = extract_jti(&jwt, &state.jwt_secret)?;
|
||||||
|
|
||||||
|
session.user_id = Some(user_id);
|
||||||
|
session.username = Some(username.clone());
|
||||||
|
session.jti = Some(jti);
|
||||||
|
|
||||||
|
state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::UserOnline { user_id, username: username.clone() },
|
||||||
|
});
|
||||||
|
|
||||||
|
session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_resume(
|
||||||
|
session: &mut Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
token_str: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let claims = token::validate(&state.db, &token_str, &state.jwt_secret).await?;
|
||||||
|
let user_id = claims.user_id()?;
|
||||||
|
|
||||||
|
session.user_id = Some(user_id);
|
||||||
|
session.username = Some(claims.username.clone());
|
||||||
|
session.jti = Some(claims.jti.clone());
|
||||||
|
|
||||||
|
state.register_session(session.conn_id, user_id, claims.username.clone(), session.tx.clone()).await;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::UserOnline { user_id, username: claims.username.clone() },
|
||||||
|
});
|
||||||
|
|
||||||
|
session.send(ServerMessage::AuthSuccess {
|
||||||
|
user_id,
|
||||||
|
username: claims.username,
|
||||||
|
session_token: token_str,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_logout(session: &mut Session, state: &Arc<AppState>) -> Result<()> {
|
||||||
|
session.require_auth()?;
|
||||||
|
if let Some(jti) = session.jti.take() {
|
||||||
|
token::revoke(&state.db, &jti).await?;
|
||||||
|
}
|
||||||
|
session.user_id = None;
|
||||||
|
session.username = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the JTI claim from a JWT without re-running expiry validation.
|
||||||
|
/// Used immediately after `token::issue` to store the JTI in the session.
|
||||||
|
fn extract_jti(jwt: &str, secret: &[u8]) -> Result<String> {
|
||||||
|
let mut v = Validation::new(Algorithm::HS256);
|
||||||
|
v.validate_exp = false;
|
||||||
|
let data = decode::<token::Claims>(jwt, &DecodingKey::from_secret(secret), &v)?;
|
||||||
|
Ok(data.claims.jti)
|
||||||
|
}
|
||||||
107
backchannel-server/src/handlers/channels.rs
Normal file
107
backchannel-server/src/handlers/channels.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use backchannel_common::types::{has_permission, PermissionFlags};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::{channels, messages, roles};
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
use crate::state::{AppState, ChannelBroadcast};
|
||||||
|
use crate::ws::session::Session;
|
||||||
|
|
||||||
|
pub async fn handle_send(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
content: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
let username = session.require_username()?.to_string();
|
||||||
|
|
||||||
|
channels::find_by_id(&state.db, channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ServerError::NotFound("Channel not found".into()))?;
|
||||||
|
|
||||||
|
let perms = roles::get_user_permissions(&state.db, user_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::SEND_MESSAGES) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (message_id, timestamp) =
|
||||||
|
messages::insert_channel_message(&state.db, channel_id, user_id, &content).await?;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::ChannelMessage {
|
||||||
|
message_id,
|
||||||
|
channel_id,
|
||||||
|
author_id: user_id,
|
||||||
|
author_username: username,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_history(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, user_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::READ_MESSAGES) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = messages::fetch_channel_history(&state.db, channel_id, before_message_id, limit).await?;
|
||||||
|
session.send(ServerMessage::ChannelHistory { channel_id, messages: msgs })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_create(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
name: String,
|
||||||
|
topic: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, user_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
if channels::name_exists(&state.db, &name).await? {
|
||||||
|
return Err(ServerError::BadRequest("Channel name already exists".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_id = channels::create(&state.db, &name, topic.as_deref(), user_id).await?;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::ChannelCreated { channel_id, name, topic },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_delete(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, user_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
if !channels::delete(&state.db, channel_id).await? {
|
||||||
|
return Err(ServerError::NotFound("Channel not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::ChannelDeleted { channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
115
backchannel-server/src/handlers/dms.rs
Normal file
115
backchannel-server/src/handlers/dms.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::messages;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::ws::session::Session;
|
||||||
|
|
||||||
|
pub async fn handle_init_key_exchange(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
recipient_id: Uuid,
|
||||||
|
sender_ephemeral_pubkey: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let initiator_id = session.require_auth()?;
|
||||||
|
let initiator_username = session.require_username()?.to_string();
|
||||||
|
|
||||||
|
// Store pending exchange in AppState (cleared once recipient accepts).
|
||||||
|
state
|
||||||
|
.pending_key_exchanges
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((initiator_id, recipient_id), sender_ephemeral_pubkey.clone());
|
||||||
|
|
||||||
|
// Relay to recipient if they're currently connected.
|
||||||
|
state
|
||||||
|
.send_to_user(
|
||||||
|
recipient_id,
|
||||||
|
&ServerMessage::DmKeyExchangeRequest {
|
||||||
|
initiator_id,
|
||||||
|
initiator_username,
|
||||||
|
sender_ephemeral_pubkey,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_accept_key_exchange(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
initiator_id: Uuid,
|
||||||
|
recipient_ephemeral_pubkey: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let recipient_id = session.require_auth()?;
|
||||||
|
let recipient_username = session.require_username()?.to_string();
|
||||||
|
|
||||||
|
// Clear the pending exchange.
|
||||||
|
state
|
||||||
|
.pending_key_exchanges
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&(initiator_id, recipient_id));
|
||||||
|
|
||||||
|
// Relay response back to initiator.
|
||||||
|
state
|
||||||
|
.send_to_user(
|
||||||
|
initiator_id,
|
||||||
|
&ServerMessage::DmKeyExchangeResponse {
|
||||||
|
recipient_id,
|
||||||
|
recipient_username,
|
||||||
|
recipient_ephemeral_pubkey,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_send_dm(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
recipient_id: Uuid,
|
||||||
|
ciphertext: String,
|
||||||
|
nonce: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sender_id = session.require_auth()?;
|
||||||
|
let sender_username = session.require_username()?.to_string();
|
||||||
|
|
||||||
|
let (message_id, timestamp) =
|
||||||
|
messages::insert_dm(&state.db, sender_id, recipient_id, &ciphertext, &nonce).await?;
|
||||||
|
|
||||||
|
let msg = ServerMessage::DirectMessage {
|
||||||
|
message_id,
|
||||||
|
sender_id,
|
||||||
|
sender_username,
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliver to recipient and to sender's other sessions (multi-client sync).
|
||||||
|
state.send_to_user(recipient_id, &msg).await;
|
||||||
|
state.send_to_user(sender_id, &msg).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_dm_history(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
peer_id: Uuid,
|
||||||
|
before_message_id: Option<Uuid>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
|
||||||
|
let history =
|
||||||
|
messages::fetch_dm_history(&state.db, user_id, peer_id, before_message_id, limit).await?;
|
||||||
|
|
||||||
|
session.send(ServerMessage::DmHistory { peer_id, messages: history })
|
||||||
|
}
|
||||||
4
backchannel-server/src/handlers/mod.rs
Normal file
4
backchannel-server/src/handlers/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod channels;
|
||||||
|
pub mod dms;
|
||||||
|
pub mod roles;
|
||||||
87
backchannel-server/src/handlers/roles.rs
Normal file
87
backchannel-server/src/handlers/roles.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use backchannel_common::types::{has_permission, PermissionFlags};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::{keys, roles};
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
use crate::state::{AppState, ChannelBroadcast};
|
||||||
|
use crate::ws::session::Session;
|
||||||
|
|
||||||
|
pub async fn handle_create(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
name: String,
|
||||||
|
permissions: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let user_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, user_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_id = roles::create(&state.db, &name, permissions).await?;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::RoleCreated { role_id, name, permissions },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_assign(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
let caller_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, caller_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
roles::find_by_id(&state.db, role_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ServerError::NotFound("Role not found".into()))?;
|
||||||
|
|
||||||
|
roles::assign(&state.db, user_id, role_id).await?;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::RoleAssigned { user_id, role_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_revoke(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
let caller_id = session.require_auth()?;
|
||||||
|
let perms = roles::get_user_permissions(&state.db, caller_id).await?;
|
||||||
|
if !has_permission(perms, PermissionFlags::MANAGE_ROLES) {
|
||||||
|
return Err(ServerError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
roles::revoke(&state.db, user_id, role_id).await?;
|
||||||
|
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::RoleRevoked { user_id, role_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_query_key(
|
||||||
|
session: &Session,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
session.require_auth()?;
|
||||||
|
let pubkey = keys::get_identity_key(&state.db, user_id).await?;
|
||||||
|
session.send(ServerMessage::IdentityKeyResponse { user_id, identity_pubkey: pubkey })
|
||||||
|
}
|
||||||
3
backchannel-server/src/http/mod.rs
Normal file
3
backchannel-server/src/http/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mod server;
|
||||||
|
|
||||||
|
pub use server::serve;
|
||||||
65
backchannel-server/src/http/server.rs
Normal file
65
backchannel-server/src/http/server.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use mime_guess::from_path;
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "../backchannel-web/dist"]
|
||||||
|
struct WebAssets;
|
||||||
|
|
||||||
|
pub async fn serve(bind_addr: &str) -> Result<()> {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/*path", get(asset_or_index));
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to bind HTTP server to {bind_addr}"))?;
|
||||||
|
|
||||||
|
tracing::info!("BackChannel web UI listening on http://{bind_addr}");
|
||||||
|
axum::serve(listener, app).await.context("HTTP server failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> Response {
|
||||||
|
render_asset("index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn asset_or_index(Path(path): Path<String>) -> Response {
|
||||||
|
if path.is_empty() {
|
||||||
|
return render_asset("index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
render_asset(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_asset(path: &str) -> Response {
|
||||||
|
let canonical = path.trim_start_matches('/');
|
||||||
|
|
||||||
|
if let Some(content) = WebAssets::get(canonical) {
|
||||||
|
return build_response(canonical, content.data.as_ref(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(index) = WebAssets::get("index.html") {
|
||||||
|
return build_response("index.html", index.data.as_ref(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::NOT_FOUND, "Web UI asset not found").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_response(path: &str, body: &[u8], status: StatusCode) -> Response {
|
||||||
|
let mime = from_path(path).first_or_octet_stream();
|
||||||
|
|
||||||
|
let mut response = Response::new(Body::from(body.to_vec()));
|
||||||
|
*response.status_mut() = status;
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static("application/octet-stream")),
|
||||||
|
);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
62
backchannel-server/src/main.rs
Normal file
62
backchannel-server/src/main.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod handlers;
|
||||||
|
mod http;
|
||||||
|
mod state;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tokio::sync::{broadcast, RwLock, Mutex};
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "backchannel_server=debug,info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = config::ServerConfig::from_env()?;
|
||||||
|
|
||||||
|
let opts = SqliteConnectOptions::from_str(&config.db_url)?.create_if_missing(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::migrate!("./src/db/migrations").run(&pool).await?;
|
||||||
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
|
let (bc_tx, _) = broadcast::channel(1024);
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
sessions: RwLock::new(std::collections::HashMap::new()),
|
||||||
|
user_sessions: RwLock::new(std::collections::HashMap::new()),
|
||||||
|
pending_key_exchanges: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
channel_broadcast: bc_tx,
|
||||||
|
db: pool,
|
||||||
|
jwt_secret: config.jwt_secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::try_join!(
|
||||||
|
ws::listen(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&config.bind_addr,
|
||||||
|
config.tls_enabled,
|
||||||
|
config.tls_cert_path,
|
||||||
|
config.tls_key_path,
|
||||||
|
),
|
||||||
|
http::serve(&config.http_bind_addr)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
123
backchannel-server/src/state.rs
Normal file
123
backchannel-server/src/state.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A handle to send WebSocket frames directly to one connection.
|
||||||
|
pub type WsSender = mpsc::UnboundedSender<WsMessage>;
|
||||||
|
|
||||||
|
/// State for a single authenticated WebSocket connection.
|
||||||
|
pub struct ConnectedSession {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
/// Sending on this channel pushes a frame to the client's write task.
|
||||||
|
pub tx: WsSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel event broadcast to all connected clients.
|
||||||
|
///
|
||||||
|
/// Channel messages are fanned out via a `tokio::sync::broadcast` channel
|
||||||
|
/// so that the handler never has to hold a lock while iterating sessions.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ChannelBroadcast {
|
||||||
|
pub message: ServerMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared application state. Wrapped in `Arc` and passed to every task.
|
||||||
|
pub struct AppState {
|
||||||
|
/// All live WebSocket sessions, keyed by a per-connection UUID.
|
||||||
|
/// One user may hold multiple connections.
|
||||||
|
pub sessions: RwLock<HashMap<Uuid, ConnectedSession>>,
|
||||||
|
|
||||||
|
/// Reverse index: user_id → set of connection UUIDs.
|
||||||
|
/// Allows sending to all clients of a specific user (e.g. for DMs).
|
||||||
|
pub user_sessions: RwLock<HashMap<Uuid, Vec<Uuid>>>,
|
||||||
|
|
||||||
|
/// In-flight DM key-exchange requests.
|
||||||
|
/// Key: (initiator_user_id, recipient_user_id)
|
||||||
|
/// Value: initiator's base64 X25519 ephemeral public key.
|
||||||
|
/// Cleared once the recipient accepts the exchange.
|
||||||
|
pub pending_key_exchanges: Mutex<HashMap<(Uuid, Uuid), String>>,
|
||||||
|
|
||||||
|
/// Server-wide broadcast for channel events. Each connection handler
|
||||||
|
/// calls `.subscribe()` to get its own receiver.
|
||||||
|
pub channel_broadcast: broadcast::Sender<ChannelBroadcast>,
|
||||||
|
|
||||||
|
/// SQLite connection pool.
|
||||||
|
pub db: sqlx::SqlitePool,
|
||||||
|
|
||||||
|
/// Bytes used as the HMAC secret for JWT signing.
|
||||||
|
pub jwt_secret: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Look up all active sender handles for a given user ID.
|
||||||
|
/// Used when routing DMs or presence events to a specific user.
|
||||||
|
pub async fn senders_for_user(&self, user_id: Uuid) -> Vec<WsSender> {
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
let user_sessions = self.user_sessions.read().await;
|
||||||
|
|
||||||
|
user_sessions
|
||||||
|
.get(&user_id)
|
||||||
|
.map(|conn_ids| {
|
||||||
|
conn_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| sessions.get(id).map(|s| s.tx.clone()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a `ServerMessage` to every connection belonging to `user_id`.
|
||||||
|
pub async fn send_to_user(&self, user_id: Uuid, msg: &ServerMessage) {
|
||||||
|
if let Ok(json) = serde_json::to_string(msg) {
|
||||||
|
for tx in self.senders_for_user(user_id).await {
|
||||||
|
let _ = tx.send(WsMessage::Text(json.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new connection in the session maps.
|
||||||
|
pub async fn register_session(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
conn_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
username: String,
|
||||||
|
tx: WsSender,
|
||||||
|
) {
|
||||||
|
let session = ConnectedSession { user_id, username, tx };
|
||||||
|
self.sessions.write().await.insert(conn_id, session);
|
||||||
|
self.user_sessions
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.entry(user_id)
|
||||||
|
.or_default()
|
||||||
|
.push(conn_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a connection from the session maps.
|
||||||
|
pub async fn remove_session(self: &Arc<Self>, conn_id: Uuid) -> Option<ConnectedSession> {
|
||||||
|
let session = self.sessions.write().await.remove(&conn_id)?;
|
||||||
|
let mut user_sessions = self.user_sessions.write().await;
|
||||||
|
if let Some(ids) = user_sessions.get_mut(&session.user_id) {
|
||||||
|
ids.retain(|id| id != &conn_id);
|
||||||
|
if ids.is_empty() {
|
||||||
|
user_sessions.remove(&session.user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the given user has at least one active connection.
|
||||||
|
pub async fn is_user_online(&self, user_id: Uuid) -> bool {
|
||||||
|
self.user_sessions
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(&user_id)
|
||||||
|
.map(|v| !v.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
backchannel-server/src/ws/handler.rs
Normal file
179
backchannel-server/src/ws/handler.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use std::io::BufReader;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use tokio_tungstenite::{accept_async, tungstenite::Message as WsMessage, WebSocketStream};
|
||||||
|
|
||||||
|
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
|
use crate::state::{AppState, ChannelBroadcast};
|
||||||
|
use crate::ws::{router, session::Session};
|
||||||
|
|
||||||
|
/// Bind the TCP listener and accept connections in a loop.
|
||||||
|
pub async fn run(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
bind_addr: &str,
|
||||||
|
tls_enabled: bool,
|
||||||
|
tls_cert_path: Option<String>,
|
||||||
|
tls_key_path: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let listener = TcpListener::bind(bind_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to bind to {}", bind_addr))?;
|
||||||
|
|
||||||
|
tracing::info!("BackChannel server listening on ws{}://{}", if tls_enabled { "s" } else { "" }, bind_addr);
|
||||||
|
|
||||||
|
if tls_enabled {
|
||||||
|
let cert_path = tls_cert_path.expect("tls_cert_path required when TLS enabled");
|
||||||
|
let key_path = tls_key_path.expect("tls_key_path required when TLS enabled");
|
||||||
|
let acceptor = build_tls_acceptor(&cert_path, &key_path)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, addr) = listener.accept().await?;
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match acceptor.accept(stream).await {
|
||||||
|
Ok(tls_stream) => match accept_async(tls_stream).await {
|
||||||
|
Ok(ws) => handle_ws(ws, state, addr).await,
|
||||||
|
Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e),
|
||||||
|
},
|
||||||
|
Err(e) => tracing::debug!("TLS handshake failed from {}: {}", addr, e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loop {
|
||||||
|
let (stream, addr) = listener.accept().await?;
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match accept_async(stream).await {
|
||||||
|
Ok(ws) => handle_ws(ws, state, addr).await,
|
||||||
|
Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-connection WebSocket handler — generic over the underlying stream type
|
||||||
|
/// so it works with both plain TCP and TLS.
|
||||||
|
async fn handle_ws<S>(ws_stream: WebSocketStream<S>, state: Arc<AppState>, addr: SocketAddr)
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
tracing::debug!("Connection established from {}", addr);
|
||||||
|
let (mut ws_sink, mut ws_source) = ws_stream.split();
|
||||||
|
|
||||||
|
// Per-connection mpsc: any task can push to this sender; the write task
|
||||||
|
// drains it to the WebSocket sink.
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<WsMessage>();
|
||||||
|
|
||||||
|
let mut session = Session::new(tx);
|
||||||
|
let conn_id = session.conn_id;
|
||||||
|
|
||||||
|
// Subscribe to the server-wide channel broadcast before the loop starts.
|
||||||
|
let mut bc_rx = state.channel_broadcast.subscribe();
|
||||||
|
|
||||||
|
// Write task: merges direct messages (mpsc) + broadcast events → WS sink.
|
||||||
|
let write_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = rx.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(m) => { if ws_sink.send(m).await.is_err() { break; } }
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = bc_rx.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(ChannelBroadcast { message }) => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&message) {
|
||||||
|
if ws_sink.send(WsMessage::Text(json)).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!("conn {} lagged by {} broadcast messages", conn_id, n);
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read loop: receive, deserialize, dispatch.
|
||||||
|
while let Some(msg_result) = ws_source.next().await {
|
||||||
|
match msg_result {
|
||||||
|
Ok(WsMessage::Text(text)) => {
|
||||||
|
match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
|
Ok(client_msg) => {
|
||||||
|
if let Err(e) = router::route(client_msg, &mut session, &state).await {
|
||||||
|
let _ = session.send(ServerMessage::Error {
|
||||||
|
code: e.code(),
|
||||||
|
message: e.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = session.send(ServerMessage::Error {
|
||||||
|
code: 400,
|
||||||
|
message: "Invalid message format".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(WsMessage::Close(_)) | Err(_) => break,
|
||||||
|
Ok(_) => {} // Ignore Ping/Pong/Binary frames.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("Connection closed from {}", addr);
|
||||||
|
|
||||||
|
// Clean up session maps and broadcast offline status.
|
||||||
|
if let Some(removed) = state.remove_session(conn_id).await {
|
||||||
|
if !state.is_user_online(removed.user_id).await {
|
||||||
|
let _ = state.channel_broadcast.send(ChannelBroadcast {
|
||||||
|
message: ServerMessage::UserOffline {
|
||||||
|
user_id: removed.user_id,
|
||||||
|
username: removed.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `tokio_rustls::TlsAcceptor` from PEM cert and key files.
|
||||||
|
fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<tokio_rustls::TlsAcceptor> {
|
||||||
|
use rustls::ServerConfig;
|
||||||
|
use rustls_pemfile::{certs, private_key};
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
let cert_file = File::open(cert_path)
|
||||||
|
.with_context(|| format!("Cannot open TLS cert: {}", cert_path))?;
|
||||||
|
let key_file = File::open(key_path)
|
||||||
|
.with_context(|| format!("Cannot open TLS key: {}", key_path))?;
|
||||||
|
|
||||||
|
let certs: Vec<_> = certs(&mut BufReader::new(cert_file))
|
||||||
|
.collect::<std::result::Result<_, _>>()
|
||||||
|
.context("Failed to parse TLS certificates")?;
|
||||||
|
|
||||||
|
let key = private_key(&mut BufReader::new(key_file))
|
||||||
|
.context("Failed to parse TLS private key")?
|
||||||
|
.context("No private key found in key file")?;
|
||||||
|
|
||||||
|
let config = ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)
|
||||||
|
.context("Invalid TLS certificate/key combination")?;
|
||||||
|
|
||||||
|
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
|
||||||
|
}
|
||||||
18
backchannel-server/src/ws/mod.rs
Normal file
18
backchannel-server/src/ws/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pub mod handler;
|
||||||
|
pub mod router;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Bind the TCP listener and accept WebSocket connections.
|
||||||
|
pub async fn listen(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
bind_addr: &str,
|
||||||
|
tls_enabled: bool,
|
||||||
|
tls_cert_path: Option<String>,
|
||||||
|
tls_key_path: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
handler::run(state, bind_addr, tls_enabled, tls_cert_path, tls_key_path).await
|
||||||
|
}
|
||||||
64
backchannel-server/src/ws/router.rs
Normal file
64
backchannel-server/src/ws/router.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use backchannel_common::protocol::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::handlers;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::ws::session::Session;
|
||||||
|
|
||||||
|
/// Dispatch a `ClientMessage` to the appropriate handler.
|
||||||
|
pub async fn route(msg: ClientMessage, session: &mut Session, state: &Arc<AppState>) -> Result<()> {
|
||||||
|
use ClientMessage::*;
|
||||||
|
match msg {
|
||||||
|
Login { username, password, identity_pubkey } => {
|
||||||
|
handlers::auth::handle_login(session, state, username, password, identity_pubkey).await
|
||||||
|
}
|
||||||
|
Register { username, password, identity_pubkey } => {
|
||||||
|
handlers::auth::handle_register(session, state, username, password, identity_pubkey).await
|
||||||
|
}
|
||||||
|
ResumeSession { token } => {
|
||||||
|
handlers::auth::handle_resume(session, state, token).await
|
||||||
|
}
|
||||||
|
Logout => {
|
||||||
|
handlers::auth::handle_logout(session, state).await
|
||||||
|
}
|
||||||
|
SendChannelMessage { channel_id, content } => {
|
||||||
|
handlers::channels::handle_send(session, state, channel_id, content).await
|
||||||
|
}
|
||||||
|
FetchChannelHistory { channel_id, before_message_id, limit } => {
|
||||||
|
handlers::channels::handle_history(session, state, channel_id, before_message_id, limit).await
|
||||||
|
}
|
||||||
|
CreateChannel { name, topic } => {
|
||||||
|
handlers::channels::handle_create(session, state, name, topic).await
|
||||||
|
}
|
||||||
|
DeleteChannel { channel_id } => {
|
||||||
|
handlers::channels::handle_delete(session, state, channel_id).await
|
||||||
|
}
|
||||||
|
InitDmKeyExchange { recipient_id, sender_ephemeral_pubkey } => {
|
||||||
|
handlers::dms::handle_init_key_exchange(session, state, recipient_id, sender_ephemeral_pubkey).await
|
||||||
|
}
|
||||||
|
AcceptDmKeyExchange { initiator_id, recipient_ephemeral_pubkey } => {
|
||||||
|
handlers::dms::handle_accept_key_exchange(session, state, initiator_id, recipient_ephemeral_pubkey).await
|
||||||
|
}
|
||||||
|
SendDm { recipient_id, ciphertext, nonce } => {
|
||||||
|
handlers::dms::handle_send_dm(session, state, recipient_id, ciphertext, nonce).await
|
||||||
|
}
|
||||||
|
FetchDmHistory { peer_id, before_message_id, limit } => {
|
||||||
|
handlers::dms::handle_dm_history(session, state, peer_id, before_message_id, limit).await
|
||||||
|
}
|
||||||
|
CreateRole { name, permissions } => {
|
||||||
|
handlers::roles::handle_create(session, state, name, permissions).await
|
||||||
|
}
|
||||||
|
AssignRole { user_id, role_id } => {
|
||||||
|
handlers::roles::handle_assign(session, state, user_id, role_id).await
|
||||||
|
}
|
||||||
|
RevokeRole { user_id, role_id } => {
|
||||||
|
handlers::roles::handle_revoke(session, state, user_id, role_id).await
|
||||||
|
}
|
||||||
|
QueryIdentityKey { user_id } => {
|
||||||
|
handlers::roles::handle_query_key(session, state, user_id).await
|
||||||
|
}
|
||||||
|
Ping => session.send(ServerMessage::Pong),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backchannel-server/src/ws/session.rs
Normal file
45
backchannel-server/src/ws/session.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use backchannel_common::protocol::ServerMessage;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Result, ServerError};
|
||||||
|
use crate::state::WsSender;
|
||||||
|
|
||||||
|
/// Per-connection mutable state, owned exclusively by the connection's read task.
|
||||||
|
pub struct Session {
|
||||||
|
pub conn_id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
/// JWT ID stored for revocation on logout.
|
||||||
|
pub jti: Option<String>,
|
||||||
|
/// Sender to the write task for this connection.
|
||||||
|
pub tx: WsSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(tx: WsSender) -> Self {
|
||||||
|
Self {
|
||||||
|
conn_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
username: None,
|
||||||
|
jti: None,
|
||||||
|
tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize and send a `ServerMessage` to this connection's write task.
|
||||||
|
pub fn send(&self, msg: ServerMessage) -> Result<()> {
|
||||||
|
let json = serde_json::to_string(&msg)?;
|
||||||
|
self.tx
|
||||||
|
.send(WsMessage::Text(json))
|
||||||
|
.map_err(|_| ServerError::ConnectionClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_auth(&self) -> Result<Uuid> {
|
||||||
|
self.user_id.ok_or(ServerError::Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_username(&self) -> Result<&str> {
|
||||||
|
self.username.as_deref().ok_or(ServerError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backchannel-web/README.md
Normal file
24
backchannel-web/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# BackChannel Web
|
||||||
|
|
||||||
|
React + TypeScript browser client for BackChannel.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backchannel-web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default WebSocket target in the app is `ws://<host>:7777` and can be changed from the UI.
|
||||||
|
|
||||||
|
## Build for Embedded Server
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backchannel-web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rust server embeds files from `backchannel-web/dist` at compile time.
|
||||||
|
Rebuild the Rust server after producing a new web build.
|
||||||
1
backchannel-web/dist/assets/index-C3Y0u3Sh.css
vendored
Normal file
1
backchannel-web/dist/assets/index-C3Y0u3Sh.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
:root{color-scheme:light;--bg-0: #0d1b2a;--panel: #fffaf0;--text: #1d2d44;--accent: #e76f51;--accent-2: #2a9d8f;--line: #f4a261}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#415a77,var(--bg-0) 55%);font-family:Space Grotesk,Trebuchet MS,sans-serif;color:var(--text)}.layout{width:min(1400px,100vw);margin:0 auto;padding:20px;display:grid;gap:14px}.card{background:linear-gradient(145deg,#fffaf0,#fdf0d5);border:2px solid #e9c46a;border-radius:16px;box-shadow:0 8px 24px #0003}.topbar{padding:16px;display:flex;flex-wrap:wrap;justify-content:space-between;align-items:end;gap:12px}h1{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.06em;font-size:clamp(2rem,4vw,2.8rem)}h2{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.05em}p{margin:6px 0 0}.connection{display:flex;gap:8px;min-width:min(540px,100%)}.status{padding:10px 14px;font-weight:700;border-color:var(--accent-2)}.chat-shell{position:relative;padding-left:350px}.panel{padding:14px;display:grid;gap:10px}.chat-main{min-height:calc(100vh - 220px)}.auth-sidebar{position:fixed;left:20px;bottom:20px;width:320px;z-index:10}.switch{display:flex;gap:8px}.switch .active{background:var(--accent-2);color:#fff}input{width:100%;border:2px solid #264653;border-radius:10px;padding:8px 10px;font:inherit;background:#fff}form{display:grid;gap:8px}button{border:0;background:var(--accent);color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:700}.chat-main form{grid-template-columns:1fr auto;align-items:end}.send-button{padding:6px 10px;min-width:68px}button:disabled{opacity:.5;cursor:not-allowed}.meta{display:grid;gap:6px;font-size:.9rem}.log{background:#fff;border:1px solid var(--line);border-radius:10px;min-height:200px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;justify-content:flex-end;gap:6px}.chat-log{max-height:calc(100vh - 360px)}.line{display:grid;gap:2px;border-bottom:1px dashed #c8d4dd;padding-bottom:6px}time{font-size:.75rem;color:#526072}@media (max-width: 900px){.chat-shell{padding-left:0}.auth-sidebar{position:static;width:100%;margin-top:14px}.chat-main{min-height:auto}.chat-log{max-height:50vh}.connection{min-width:0;width:100%;flex-direction:column}}
|
||||||
40
backchannel-web/dist/assets/index-DEokFOhy.js
vendored
Normal file
40
backchannel-web/dist/assets/index-DEokFOhy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
backchannel-web/dist/index.html
vendored
Normal file
13
backchannel-web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BackChannel</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DEokFOhy.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-C3Y0u3Sh.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
backchannel-web/index.html
Normal file
12
backchannel-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BackChannel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1768
backchannel-web/package-lock.json
generated
Normal file
1768
backchannel-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backchannel-web/package.json
Normal file
25
backchannel-web/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "backchannel-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@noble/curves": "^1.8.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
236
backchannel-web/src/App.tsx
Normal file
236
backchannel-web/src/App.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { getOrCreateIdentityKey } from "./crypto";
|
||||||
|
import { ClientMessage, GENERAL_CHANNEL_ID, ServerMessage } from "./protocol";
|
||||||
|
|
||||||
|
type ChatLine = { id: string; by: string; text: string; at: string };
|
||||||
|
|
||||||
|
function parseServerMessage(raw: unknown): ServerMessage | null {
|
||||||
|
if (!raw || typeof raw !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = raw as { type?: unknown };
|
||||||
|
if (typeof candidate.type !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw as ServerMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWsUrl(): string {
|
||||||
|
const host = window.location.hostname || "127.0.0.1";
|
||||||
|
return `ws://${host}:7777`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [wsUrl, setWsUrl] = useState(() => localStorage.getItem("bc.ws_url") ?? defaultWsUrl());
|
||||||
|
const [socketState, setSocketState] = useState<"offline" | "connecting" | "online">("offline");
|
||||||
|
const [authMode, setAuthMode] = useState<"login" | "register">("login");
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const [me, setMe] = useState<{ id: string; username: string } | null>(null);
|
||||||
|
const [channelInput, setChannelInput] = useState("");
|
||||||
|
const [channelLines, setChannelLines] = useState<ChatLine[]>([]);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState("Not connected");
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const chatLogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const ready = useMemo(() => socketState === "online", [socketState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatLogRef.current) {
|
||||||
|
chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [channelLines]);
|
||||||
|
|
||||||
|
function formatTime(timestamp: string): string {
|
||||||
|
return new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message: ClientMessage) {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
setStatus("WebSocket is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocketState("connecting");
|
||||||
|
setStatus("Connecting...");
|
||||||
|
localStorage.setItem("bc.ws_url", wsUrl);
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setSocketState("online");
|
||||||
|
setStatus("Connected");
|
||||||
|
|
||||||
|
const token = localStorage.getItem("bc.session_token");
|
||||||
|
if (token) {
|
||||||
|
send({ type: "resume_session", token });
|
||||||
|
} else {
|
||||||
|
setStatus("Connected. Please login or register.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setSocketState("offline");
|
||||||
|
setStatus("Disconnected");
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setStatus("WebSocket error");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = parseServerMessage(JSON.parse(event.data));
|
||||||
|
if (!msg) {
|
||||||
|
setStatus("Invalid server message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleServer(msg);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServer(msg: ServerMessage) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "auth_success": {
|
||||||
|
localStorage.setItem("bc.session_token", msg.session_token);
|
||||||
|
setMe({ id: msg.user_id, username: msg.username });
|
||||||
|
setStatus(`Authenticated as ${msg.username}`);
|
||||||
|
send({ type: "fetch_channel_history", channel_id: GENERAL_CHANNEL_ID, before_message_id: null, limit: 100 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "auth_error": {
|
||||||
|
setStatus(`Auth error: ${msg.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "channel_history": {
|
||||||
|
setChannelLines(
|
||||||
|
[...msg.messages]
|
||||||
|
.reverse()
|
||||||
|
.map((m) => ({ id: m.message_id, by: m.author_username, text: m.content, at: m.timestamp })),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "channel_message": {
|
||||||
|
if (msg.channel_id === GENERAL_CHANNEL_ID) {
|
||||||
|
setChannelLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: msg.message_id, by: msg.author_username, text: msg.content, at: msg.timestamp },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
setStatus(`Error ${msg.code}: ${msg.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAuth(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const identity = getOrCreateIdentityKey();
|
||||||
|
|
||||||
|
if (authMode === "login") {
|
||||||
|
send({ type: "login", username, password, identity_pubkey: identity });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: "register", username, password, identity_pubkey: identity });
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitChannel(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!channelInput.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send({ type: "send_channel_message", channel_id: GENERAL_CHANNEL_ID, content: channelInput.trim() });
|
||||||
|
setChannelInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
send({ type: "logout" });
|
||||||
|
localStorage.removeItem("bc.session_token");
|
||||||
|
setMe(null);
|
||||||
|
setStatus("Logged out");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="layout">
|
||||||
|
<section className="card topbar">
|
||||||
|
<div>
|
||||||
|
<h1>BackChannel</h1>
|
||||||
|
<p>General chat room.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="connection">
|
||||||
|
<input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://127.0.0.1:7777" />
|
||||||
|
<button onClick={connect} disabled={ready}>Connect</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card status">{status}</section>
|
||||||
|
|
||||||
|
<section className="chat-shell">
|
||||||
|
<article className="card panel chat-main">
|
||||||
|
<h2>#general</h2>
|
||||||
|
<form onSubmit={submitChannel}>
|
||||||
|
<input value={channelInput} onChange={(e) => setChannelInput(e.target.value)} placeholder="message #general" />
|
||||||
|
<button className="send-button" type="submit" disabled={!ready}>Send</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div ref={chatLogRef} className="log chat-log">
|
||||||
|
{channelLines.map((line) => (
|
||||||
|
<div key={line.id} className="line">
|
||||||
|
<b>{line.by}</b>
|
||||||
|
<span>{line.text}</span>
|
||||||
|
<time>{formatTime(line.at)}</time>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="card panel auth-sidebar">
|
||||||
|
<h2>Auth</h2>
|
||||||
|
<div className="switch">
|
||||||
|
<button className={authMode === "login" ? "active" : ""} onClick={() => setAuthMode("login")}>Login</button>
|
||||||
|
<button className={authMode === "register" ? "active" : ""} onClick={() => setAuthMode("register")}>Register</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submitAuth}>
|
||||||
|
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
|
||||||
|
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="password" type="password" />
|
||||||
|
<button type="submit" disabled={!ready}>{authMode}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="meta">
|
||||||
|
<div>User: {me?.username ?? "(not authenticated)"}</div>
|
||||||
|
<div>ID: {me?.id ?? "-"}</div>
|
||||||
|
<button onClick={logout} disabled={!ready}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
backchannel-web/src/crypto.ts
Normal file
69
backchannel-web/src/crypto.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { chacha20poly1305 } from "@noble/ciphers/chacha";
|
||||||
|
import { x25519 } from "@noble/curves/ed25519";
|
||||||
|
import { hkdf } from "@noble/hashes/hkdf";
|
||||||
|
import { sha256 } from "@noble/hashes/sha256";
|
||||||
|
import { randomBytes } from "@noble/hashes/utils";
|
||||||
|
|
||||||
|
const HKDF_SALT = new TextEncoder().encode("backchannel-dm-salt-v1");
|
||||||
|
const HKDF_INFO = new TextEncoder().encode("backchannel-dm-key-v1");
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
export function bytesToBase64(input: Uint8Array): string {
|
||||||
|
let bin = "";
|
||||||
|
for (const b of input) {
|
||||||
|
bin += String.fromCharCode(b);
|
||||||
|
}
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBytes(input: string): Uint8Array {
|
||||||
|
const bin = atob(input);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i += 1) {
|
||||||
|
bytes[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrCreateIdentityKey(): string {
|
||||||
|
const current = localStorage.getItem("bc.identity_key");
|
||||||
|
if (current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = bytesToBase64(randomBytes(32));
|
||||||
|
localStorage.setItem("bc.identity_key", key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEphemeralKeypair(): { privateKey: Uint8Array; publicKeyB64: string } {
|
||||||
|
const privateKey = x25519.utils.randomPrivateKey();
|
||||||
|
const publicKey = x25519.getPublicKey(privateKey);
|
||||||
|
return { privateKey, publicKeyB64: bytesToBase64(publicKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDmKey(privateKey: Uint8Array, theirPublicKeyB64: string): Uint8Array {
|
||||||
|
const theirPublicKey = base64ToBytes(theirPublicKeyB64);
|
||||||
|
const sharedSecret = x25519.getSharedSecret(privateKey, theirPublicKey);
|
||||||
|
return hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptDm(key: Uint8Array, plaintext: string): { nonce: string; ciphertext: string } {
|
||||||
|
const nonceBytes = randomBytes(12);
|
||||||
|
const cipher = chacha20poly1305(key, nonceBytes);
|
||||||
|
const ciphertext = cipher.encrypt(encoder.encode(plaintext));
|
||||||
|
return {
|
||||||
|
nonce: bytesToBase64(nonceBytes),
|
||||||
|
ciphertext: bytesToBase64(ciphertext),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptDm(key: Uint8Array, nonceB64: string, ciphertextB64: string): string {
|
||||||
|
const nonceBytes = base64ToBytes(nonceB64);
|
||||||
|
const ciphertext = base64ToBytes(ciphertextB64);
|
||||||
|
const cipher = chacha20poly1305(key, nonceBytes);
|
||||||
|
const plaintext = cipher.decrypt(ciphertext);
|
||||||
|
return decoder.decode(plaintext);
|
||||||
|
}
|
||||||
11
backchannel-web/src/main.tsx
Normal file
11
backchannel-web/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
72
backchannel-web/src/protocol.ts
Normal file
72
backchannel-web/src/protocol.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export const GENERAL_CHANNEL_ID = "00000000-0000-0000-0000-000000000010";
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: "login"; username: string; password: string; identity_pubkey: string }
|
||||||
|
| { type: "register"; username: string; password: string; identity_pubkey: string }
|
||||||
|
| { type: "resume_session"; token: string }
|
||||||
|
| { type: "logout" }
|
||||||
|
| { type: "send_channel_message"; channel_id: string; content: string }
|
||||||
|
| { type: "fetch_channel_history"; channel_id: string; before_message_id: string | null; limit: number }
|
||||||
|
| { type: "init_dm_key_exchange"; recipient_id: string; sender_ephemeral_pubkey: string }
|
||||||
|
| { type: "accept_dm_key_exchange"; initiator_id: string; recipient_ephemeral_pubkey: string }
|
||||||
|
| { type: "send_dm"; recipient_id: string; ciphertext: string; nonce: string }
|
||||||
|
| { type: "fetch_dm_history"; peer_id: string; before_message_id: string | null; limit: number }
|
||||||
|
| { type: "ping" };
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: "auth_success"; user_id: string; username: string; session_token: string }
|
||||||
|
| { type: "auth_error"; reason: string }
|
||||||
|
| {
|
||||||
|
type: "channel_message";
|
||||||
|
message_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
author_id: string;
|
||||||
|
author_username: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "channel_history";
|
||||||
|
channel_id: string;
|
||||||
|
messages: Array<{
|
||||||
|
message_id: string;
|
||||||
|
author_id: string;
|
||||||
|
author_username: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "dm_key_exchange_request";
|
||||||
|
initiator_id: string;
|
||||||
|
initiator_username: string;
|
||||||
|
sender_ephemeral_pubkey: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "dm_key_exchange_response";
|
||||||
|
recipient_id: string;
|
||||||
|
recipient_username: string;
|
||||||
|
recipient_ephemeral_pubkey: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "direct_message";
|
||||||
|
message_id: string;
|
||||||
|
sender_id: string;
|
||||||
|
sender_username: string;
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "dm_history";
|
||||||
|
peer_id: string;
|
||||||
|
messages: Array<{
|
||||||
|
message_id: string;
|
||||||
|
sender_id: string;
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| { type: "error"; code: number; message: string }
|
||||||
|
| { type: "pong" };
|
||||||
207
backchannel-web/src/styles.css
Normal file
207
backchannel-web/src/styles.css
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg-0: #0d1b2a;
|
||||||
|
--panel: #fffaf0;
|
||||||
|
--text: #1d2d44;
|
||||||
|
--accent: #e76f51;
|
||||||
|
--accent-2: #2a9d8f;
|
||||||
|
--line: #f4a261;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: radial-gradient(circle at top left, #415a77, var(--bg-0) 55%);
|
||||||
|
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
width: min(1400px, 100vw);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(145deg, #fffaf0, #fdf0d5);
|
||||||
|
border: 2px solid #e9c46a;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: min(540px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
min-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 320px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch .active {
|
||||||
|
background: var(--accent-2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid #264653;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main form {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-log {
|
||||||
|
max-height: calc(100vh - 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px dashed #c8d4dd;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #526072;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.chat-shell {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-sidebar {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-log {
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
backchannel-web/tsconfig.json
Normal file
20
backchannel-web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
backchannel-web/tsconfig.tsbuildinfo
Normal file
1
backchannel-web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/crypto.ts","./src/main.tsx","./src/protocol.ts","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||||
6
backchannel-web/vite.config.ts
Normal file
6
backchannel-web/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user