diff options
Diffstat (limited to 'webd/src/session.rs')
| -rw-r--r-- | webd/src/session.rs | 623 |
1 files changed, 623 insertions, 0 deletions
diff --git a/webd/src/session.rs b/webd/src/session.rs new file mode 100644 index 0000000..8a20721 --- /dev/null +++ b/webd/src/session.rs | |||
| @@ -0,0 +1,623 @@ | |||
| 1 | //! One gateway session: a browser socket, one upstream TLS connection and one | ||
| 2 | //! `ClientCore`. | ||
| 3 | //! | ||
| 4 | //! The browser sends `Command` JSON and renders `View` JSON. All game state, | ||
| 5 | //! protocol framing and balance arithmetic stay in the Rust core. | ||
| 6 | |||
| 7 | use std::collections::HashMap; | ||
| 8 | use std::net::TcpStream; | ||
| 9 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 10 | use std::sync::{Arc, Mutex, mpsc}; | ||
| 11 | use std::thread; | ||
| 12 | use std::time::{Duration, Instant}; | ||
| 13 | |||
| 14 | use rusqlite::Connection; | ||
| 15 | use serde::{Deserialize, Serialize}; | ||
| 16 | use tungstenite::error::Error as WsError; | ||
| 17 | use tungstenite::http::HeaderValue; | ||
| 18 | use tungstenite::http::header::ORIGIN; | ||
| 19 | use tungstenite::protocol::{WebSocket, WebSocketConfig}; | ||
| 20 | use tungstenite::{Message, accept_hdr_with_config}; | ||
| 21 | |||
| 22 | use zagan_bet::client::core::{ClientCore, Effect}; | ||
| 23 | use zagan_bet::client::database; | ||
| 24 | use zagan_bet::client::transport::{self, TlsStream}; | ||
| 25 | use zagan_bet::client::view::{Command, View}; | ||
| 26 | use zagan_bet::protocol::Packet; | ||
| 27 | use zagan_bet::version::VERSION; | ||
| 28 | |||
| 29 | use crate::config::Config; | ||
| 30 | |||
| 31 | const COOKIE_NAME: &str = "zagan_session"; | ||
| 32 | /// On HTTPS, use the `__Host-` prefix so a sibling origin cannot set a | ||
| 33 | /// domain-scoped cookie and fixate the session. | ||
| 34 | const HOST_COOKIE_NAME: &str = "__Host-zagan_session"; | ||
| 35 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); | ||
| 36 | const POLL: Duration = Duration::from_millis(10); | ||
| 37 | |||
| 38 | pub enum SocketEvent { | ||
| 39 | Attach(WebSocket<TcpStream>), | ||
| 40 | } | ||
| 41 | |||
| 42 | /// Session id → generation and the thread that owns it. | ||
| 43 | /// | ||
| 44 | /// The generation lets an ending thread remove its own entry without deleting | ||
| 45 | /// a replacement session that reused the same cookie. | ||
| 46 | pub type Registry = Arc<Mutex<HashMap<String, (u64, mpsc::Sender<SocketEvent>)>>>; | ||
| 47 | |||
| 48 | static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); | ||
| 49 | |||
| 50 | pub fn new_registry() -> Registry { | ||
| 51 | Arc::new(Mutex::new(HashMap::new())) | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Complete the WebSocket handshake, then attach this socket to an existing | ||
| 55 | /// session or start one. | ||
| 56 | pub fn accept(stream: TcpStream, config: Config, registry: Registry) -> Result<(), String> { | ||
| 57 | let mut session_id = None; | ||
| 58 | let origin = config.origin.clone(); | ||
| 59 | let secure = config.cookie_secure; | ||
| 60 | |||
| 61 | let ws_config = WebSocketConfig { | ||
| 62 | // A command or view is a few KiB at most; keep a compromised page from | ||
| 63 | // making the gateway buffer tens of megabytes. | ||
| 64 | max_message_size: Some(64 * 1024), | ||
| 65 | max_frame_size: Some(64 * 1024), | ||
| 66 | ..WebSocketConfig::default() | ||
| 67 | }; | ||
| 68 | let mut ws = accept_hdr_with_config( | ||
| 69 | stream, | ||
| 70 | |request: &tungstenite::handshake::server::Request, | ||
| 71 | mut response: tungstenite::handshake::server::Response| { | ||
| 72 | if let Some(header) = request.headers().get(ORIGIN) { | ||
| 73 | if header.to_str().ok() != Some(origin.as_str()) { | ||
| 74 | return Err(tungstenite::http::Response::builder() | ||
| 75 | .status(403) | ||
| 76 | .body(Some("origin not allowed".to_string())) | ||
| 77 | .expect("static response")); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | let found = request | ||
| 82 | .headers() | ||
| 83 | .get("cookie") | ||
| 84 | .and_then(|value| value.to_str().ok()) | ||
| 85 | .and_then(cookie_id); | ||
| 86 | let (id, fresh) = match found { | ||
| 87 | Some(id) => (id, false), | ||
| 88 | None => (random_id(), true), | ||
| 89 | }; | ||
| 90 | if fresh { | ||
| 91 | let mut cookie = format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict"); | ||
| 92 | if secure { | ||
| 93 | cookie.push_str("; Secure"); | ||
| 94 | } | ||
| 95 | if let Ok(value) = HeaderValue::from_str(&cookie) { | ||
| 96 | response.headers_mut().append("set-cookie", value); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | session_id = Some(id); | ||
| 100 | Ok(response) | ||
| 101 | }, | ||
| 102 | Some(ws_config), | ||
| 103 | ) | ||
| 104 | .map_err(|error| format!("WebSocket handshake failed: {error}"))?; | ||
| 105 | |||
| 106 | let id = session_id.ok_or_else(|| "handshake produced no session id".to_string())?; | ||
| 107 | ws.get_ref() | ||
| 108 | .set_nonblocking(true) | ||
| 109 | .map_err(|error| format!("could not make the browser socket nonblocking: {error}"))?; | ||
| 110 | |||
| 111 | let (sender, receiver) = mpsc::channel(); | ||
| 112 | let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); | ||
| 113 | { | ||
| 114 | let mut sessions = registry.lock().map_err(|_| "session registry poisoned")?; | ||
| 115 | if let Some((_, existing)) = sessions.get(&id).cloned() { | ||
| 116 | match existing.send(SocketEvent::Attach(ws)) { | ||
| 117 | Ok(()) => return Ok(()), | ||
| 118 | Err(mpsc::SendError(SocketEvent::Attach(returned))) => { | ||
| 119 | sessions.remove(&id); | ||
| 120 | ws = returned; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
| 124 | sessions.insert(id.clone(), (generation, sender.clone())); | ||
| 125 | } | ||
| 126 | |||
| 127 | thread::spawn(move || { | ||
| 128 | let session = match Session::new(id.clone(), config) { | ||
| 129 | Ok(session) => Some(session), | ||
| 130 | Err(error) => { | ||
| 131 | eprintln!("[webd {id}] could not start: {error}"); | ||
| 132 | None | ||
| 133 | } | ||
| 134 | }; | ||
| 135 | if let Some(mut session) = session { | ||
| 136 | session.attach(ws); | ||
| 137 | session.poll_loop(&receiver); | ||
| 138 | } | ||
| 139 | if let Ok(mut sessions) = registry.lock() { | ||
| 140 | let own = sessions | ||
| 141 | .get(&id) | ||
| 142 | .map(|(generation_now, _)| *generation_now == generation) | ||
| 143 | .unwrap_or(false); | ||
| 144 | if own { | ||
| 145 | sessions.remove(&id); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | eprintln!("[webd {id}] session ended"); | ||
| 149 | }); | ||
| 150 | |||
| 151 | Ok(()) | ||
| 152 | } | ||
| 153 | |||
| 154 | fn cookie_id(raw: &str) -> Option<String> { | ||
| 155 | for pair in raw.split(';') { | ||
| 156 | let (name, value) = pair.split_once('=')?; | ||
| 157 | if matches!(name.trim(), COOKIE_NAME | HOST_COOKIE_NAME) { | ||
| 158 | let value = value.trim(); | ||
| 159 | if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { | ||
| 160 | return Some(value.to_ascii_lowercase()); | ||
| 161 | } | ||
| 162 | return None; | ||
| 163 | } | ||
| 164 | } | ||
| 165 | None | ||
| 166 | } | ||
| 167 | |||
| 168 | fn random_id() -> String { | ||
| 169 | use rand::RngCore; | ||
| 170 | let mut bytes = [0u8; 32]; | ||
| 171 | rand::thread_rng().fill_bytes(&mut bytes); | ||
| 172 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | ||
| 173 | } | ||
| 174 | |||
| 175 | #[derive(Deserialize)] | ||
| 176 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 177 | enum ClientMessage { | ||
| 178 | Command { command: Command }, | ||
| 179 | Ping, | ||
| 180 | } | ||
| 181 | |||
| 182 | #[derive(Serialize)] | ||
| 183 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 184 | enum ServerMessage<'a> { | ||
| 185 | Hello { | ||
| 186 | version: &'a str, | ||
| 187 | grace_seconds: u64, | ||
| 188 | /// Signed-in user, so a refresh during a game can skip the login card. | ||
| 189 | user: Option<&'a str>, | ||
| 190 | /// Estimated game-server Unix time, milliseconds. | ||
| 191 | time: u64, | ||
| 192 | }, | ||
| 193 | View { | ||
| 194 | view: &'a View, | ||
| 195 | }, | ||
| 196 | Notice { | ||
| 197 | text: &'a str, | ||
| 198 | }, | ||
| 199 | Error { | ||
| 200 | text: &'a str, | ||
| 201 | }, | ||
| 202 | Replaced, | ||
| 203 | Pong, | ||
| 204 | } | ||
| 205 | |||
| 206 | struct Session { | ||
| 207 | id: String, | ||
| 208 | config: Config, | ||
| 209 | core: ClientCore, | ||
| 210 | /// In-memory trust store seeded with the pinned fingerprint. No player | ||
| 211 | /// credentials are ever stored here. | ||
| 212 | trust: Connection, | ||
| 213 | upstream: Option<TlsStream>, | ||
| 214 | ws: Option<WebSocket<TcpStream>>, | ||
| 215 | pending_id: Option<u64>, | ||
| 216 | pending_since: Option<Instant>, | ||
| 217 | grace_until: Option<Instant>, | ||
| 218 | drop_upstream: bool, | ||
| 219 | } | ||
| 220 | |||
| 221 | impl Session { | ||
| 222 | fn new(id: String, config: Config) -> Result<Self, String> { | ||
| 223 | let trust = Connection::open_in_memory() | ||
| 224 | .map_err(|error| format!("could not open the trust store: {error}"))?; | ||
| 225 | database::init_db(&trust); | ||
| 226 | database::save_fingerprint(&trust, &config.upstream, &config.fingerprint) | ||
| 227 | .map_err(|error| format!("could not pin the upstream key: {error}"))?; | ||
| 228 | |||
| 229 | Ok(Self { | ||
| 230 | id, | ||
| 231 | config, | ||
| 232 | core: ClientCore::new(), | ||
| 233 | trust, | ||
| 234 | upstream: None, | ||
| 235 | ws: None, | ||
| 236 | pending_id: None, | ||
| 237 | pending_since: None, | ||
| 238 | grace_until: None, | ||
| 239 | drop_upstream: false, | ||
| 240 | }) | ||
| 241 | } | ||
| 242 | |||
| 243 | fn attach(&mut self, ws: WebSocket<TcpStream>) { | ||
| 244 | if let Some(mut old) = self.ws.take() { | ||
| 245 | send_json(&mut old, &ServerMessage::Replaced); | ||
| 246 | close_socket(&mut old); | ||
| 247 | } | ||
| 248 | eprintln!("[webd {}] browser attached", self.id); | ||
| 249 | self.ws = Some(ws); | ||
| 250 | self.grace_until = None; | ||
| 251 | let mut ws = self.ws.take().expect("just attached"); | ||
| 252 | if let Err(text) = self.ensure_upstream() { | ||
| 253 | send_json(&mut ws, &ServerMessage::Error { text: &text }); | ||
| 254 | } | ||
| 255 | send_json( | ||
| 256 | &mut ws, | ||
| 257 | &ServerMessage::Hello { | ||
| 258 | version: VERSION, | ||
| 259 | grace_seconds: self.config.grace.as_secs(), | ||
| 260 | user: self.core.user(), | ||
| 261 | time: self.core.server_now_ms(), | ||
| 262 | }, | ||
| 263 | ); | ||
| 264 | self.send_view(&mut ws); | ||
| 265 | self.ws = Some(ws); | ||
| 266 | } | ||
| 267 | |||
| 268 | fn poll_loop(&mut self, receiver: &mpsc::Receiver<SocketEvent>) { | ||
| 269 | loop { | ||
| 270 | match receiver.recv_timeout(POLL) { | ||
| 271 | Ok(SocketEvent::Attach(ws)) => self.attach(ws), | ||
| 272 | Err(mpsc::RecvTimeoutError::Timeout) => {} | ||
| 273 | Err(mpsc::RecvTimeoutError::Disconnected) => break, | ||
| 274 | } | ||
| 275 | |||
| 276 | self.pump_browser(); | ||
| 277 | self.pump_upstream(); | ||
| 278 | self.check_request_timeout(); | ||
| 279 | |||
| 280 | if self.ws.is_some() { | ||
| 281 | self.grace_until = None; | ||
| 282 | } else if let Some(deadline) = self.grace_until { | ||
| 283 | if Instant::now() >= deadline { | ||
| 284 | break; | ||
| 285 | } | ||
| 286 | } else { | ||
| 287 | self.grace_until = Some(Instant::now() + self.config.grace); | ||
| 288 | } | ||
| 289 | } | ||
| 290 | |||
| 291 | // Dropping the TLS stream closes upstream; the server forfeits any | ||
| 292 | // live hand, exactly like a desktop client disconnecting. | ||
| 293 | self.upstream = None; | ||
| 294 | eprintln!("[webd {}] grace expired", self.id); | ||
| 295 | } | ||
| 296 | |||
| 297 | fn pump_browser(&mut self) { | ||
| 298 | let Some(mut ws) = self.ws.take() else { | ||
| 299 | return; | ||
| 300 | }; | ||
| 301 | let mut keep = true; | ||
| 302 | |||
| 303 | loop { | ||
| 304 | match ws.read() { | ||
| 305 | Ok(Message::Text(text)) => { | ||
| 306 | if !self.handle_browser_text(&text, &mut ws) { | ||
| 307 | keep = false; | ||
| 308 | break; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | Ok(Message::Close(_)) => { | ||
| 312 | keep = false; | ||
| 313 | break; | ||
| 314 | } | ||
| 315 | Ok(Message::Ping(payload)) => { | ||
| 316 | let _ = send_message(&mut ws, Message::Pong(payload)); | ||
| 317 | } | ||
| 318 | Ok(Message::Binary(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => {} | ||
| 319 | Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => break, | ||
| 320 | Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => { | ||
| 321 | keep = false; | ||
| 322 | break; | ||
| 323 | } | ||
| 324 | Err(_) => { | ||
| 325 | keep = false; | ||
| 326 | break; | ||
| 327 | } | ||
| 328 | } | ||
| 329 | } | ||
| 330 | |||
| 331 | if keep { | ||
| 332 | self.ws = Some(ws); | ||
| 333 | } else { | ||
| 334 | self.grace_until = Some(Instant::now() + self.config.grace); | ||
| 335 | self.pending_id = None; | ||
| 336 | self.pending_since = None; | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | fn handle_browser_text(&mut self, text: &str, ws: &mut WebSocket<TcpStream>) -> bool { | ||
| 341 | let message = match serde_json::from_str::<ClientMessage>(text) { | ||
| 342 | Ok(message) => message, | ||
| 343 | Err(error) => { | ||
| 344 | send_json( | ||
| 345 | ws, | ||
| 346 | &ServerMessage::Error { | ||
| 347 | text: &format!("Malformed message: {error}"), | ||
| 348 | }, | ||
| 349 | ); | ||
| 350 | return true; | ||
| 351 | } | ||
| 352 | }; | ||
| 353 | |||
| 354 | match message { | ||
| 355 | ClientMessage::Ping => send_json(ws, &ServerMessage::Pong), | ||
| 356 | ClientMessage::Command { command } => { | ||
| 357 | if let Err(text) = self.ensure_upstream() { | ||
| 358 | send_json(ws, &ServerMessage::Error { text: &text }); | ||
| 359 | self.send_view(ws); | ||
| 360 | return true; | ||
| 361 | } | ||
| 362 | let effects = self.core.command(command); | ||
| 363 | self.apply_effects(effects, Some(ws)); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | true | ||
| 367 | } | ||
| 368 | |||
| 369 | fn ensure_upstream(&mut self) -> Result<(), String> { | ||
| 370 | if self.upstream.is_some() { | ||
| 371 | return Ok(()); | ||
| 372 | } | ||
| 373 | |||
| 374 | let mut trust = |_prompt: &transport::TrustPrompt| false; | ||
| 375 | match transport::connect(&self.config.upstream, &self.trust, false, &mut trust) { | ||
| 376 | Ok(connected) => { | ||
| 377 | self.core.set_connected(true); | ||
| 378 | self.core.set_server_time(connected.server_time); | ||
| 379 | self.core.set_host_info( | ||
| 380 | Some(self.config.upstream.clone()), | ||
| 381 | Some(self.config.fingerprint.clone()), | ||
| 382 | ); | ||
| 383 | self.upstream = Some(connected.stream); | ||
| 384 | eprintln!( | ||
| 385 | "[webd {}] upstream connected: {} ({}), ping {:.1}ms", | ||
| 386 | self.id, | ||
| 387 | self.config.upstream, | ||
| 388 | connected.server_version, | ||
| 389 | connected.elapsed.as_secs_f64() * 1000.0 | ||
| 390 | ); | ||
| 391 | Ok(()) | ||
| 392 | } | ||
| 393 | Err(error) => { | ||
| 394 | self.core.reset_after_disconnect(); | ||
| 395 | Err(format!("Upstream connection failed: {error}")) | ||
| 396 | } | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | fn pump_upstream(&mut self) { | ||
| 401 | let Some(mut upstream) = self.upstream.take() else { | ||
| 402 | return; | ||
| 403 | }; | ||
| 404 | let mut ws = self.ws.take(); | ||
| 405 | let mut disconnected = None; | ||
| 406 | |||
| 407 | loop { | ||
| 408 | match upstream.read_available_packet() { | ||
| 409 | Ok(Some(Packet::Event { body, .. })) => { | ||
| 410 | if let Some(effects) = self.core.handle_async_line(&body) { | ||
| 411 | self.apply_effects(effects, ws.as_mut()); | ||
| 412 | } | ||
| 413 | } | ||
| 414 | Ok(Some(Packet::Reply { id, body })) => { | ||
| 415 | if self.pending_id == Some(id) { | ||
| 416 | self.pending_id = None; | ||
| 417 | self.pending_since = None; | ||
| 418 | } | ||
| 419 | let effects = self.core.handle_reply_id(id, &body); | ||
| 420 | self.apply_effects(effects, ws.as_mut()); | ||
| 421 | } | ||
| 422 | Ok(None) => break, | ||
| 423 | Err(error) => { | ||
| 424 | disconnected = Some(error.to_string()); | ||
| 425 | break; | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | if self.drop_upstream { | ||
| 431 | self.drop_upstream = false; | ||
| 432 | drop(upstream); | ||
| 433 | } else if disconnected.is_none() { | ||
| 434 | self.upstream = Some(upstream); | ||
| 435 | } | ||
| 436 | |||
| 437 | if let Some(message) = disconnected { | ||
| 438 | eprintln!("[webd {}] upstream lost: {message}", self.id); | ||
| 439 | self.core.reset_after_disconnect(); | ||
| 440 | self.pending_id = None; | ||
| 441 | self.pending_since = None; | ||
| 442 | if let Some(ws) = ws.as_mut() { | ||
| 443 | send_json( | ||
| 444 | ws, | ||
| 445 | &ServerMessage::Error { | ||
| 446 | text: "Connection to the game server was lost", | ||
| 447 | }, | ||
| 448 | ); | ||
| 449 | self.send_view(ws); | ||
| 450 | } | ||
| 451 | } | ||
| 452 | |||
| 453 | self.ws = ws; | ||
| 454 | } | ||
| 455 | |||
| 456 | fn check_request_timeout(&mut self) { | ||
| 457 | let Some(sent) = self.pending_since else { | ||
| 458 | return; | ||
| 459 | }; | ||
| 460 | if sent.elapsed() < REQUEST_TIMEOUT { | ||
| 461 | return; | ||
| 462 | } | ||
| 463 | |||
| 464 | let id = self.pending_id.take(); | ||
| 465 | self.pending_since = None; | ||
| 466 | self.upstream = None; | ||
| 467 | let mut ws = self.ws.take(); | ||
| 468 | if let Some(id) = id { | ||
| 469 | let effects = self.core.handle_reply_id(id, "ERROR Request timed out"); | ||
| 470 | self.apply_effects(effects, ws.as_mut()); | ||
| 471 | if let Some(ws) = ws.as_mut() { | ||
| 472 | send_json( | ||
| 473 | ws, | ||
| 474 | &ServerMessage::Notice { | ||
| 475 | text: "Request timed out. Disconnected.", | ||
| 476 | }, | ||
| 477 | ); | ||
| 478 | } | ||
| 479 | } | ||
| 480 | self.core.reset_after_disconnect(); | ||
| 481 | if let Some(ws) = ws.as_mut() { | ||
| 482 | self.send_view(ws); | ||
| 483 | } | ||
| 484 | self.ws = ws; | ||
| 485 | } | ||
| 486 | |||
| 487 | fn apply_effects(&mut self, effects: Vec<Effect>, mut ws: Option<&mut WebSocket<TcpStream>>) { | ||
| 488 | for effect in effects { | ||
| 489 | match effect { | ||
| 490 | Effect::Print(lines) => { | ||
| 491 | // A multi-line print is a terminal table; the structured | ||
| 492 | // `View` carries that data to the browser. | ||
| 493 | if lines.len() == 1 { | ||
| 494 | if let (Some(socket), Some(line)) = (ws.as_deref_mut(), lines.first()) { | ||
| 495 | send_json(socket, &ServerMessage::Notice { text: line }); | ||
| 496 | } | ||
| 497 | } | ||
| 498 | } | ||
| 499 | Effect::SendRequest { id, payload } => { | ||
| 500 | self.pending_id = Some(id); | ||
| 501 | self.pending_since = Some(Instant::now()); | ||
| 502 | match self.ensure_upstream() { | ||
| 503 | Ok(()) => { | ||
| 504 | if let Some(upstream) = self.upstream.as_mut() { | ||
| 505 | if let Err(error) = upstream.write_request(id, None, &payload) { | ||
| 506 | let text = format!("Could not send request: {error}"); | ||
| 507 | if let Some(socket) = ws.as_deref_mut() { | ||
| 508 | send_json(socket, &ServerMessage::Error { text: &text }); | ||
| 509 | } | ||
| 510 | self.drop_upstream = true; | ||
| 511 | self.upstream = None; | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | Err(text) => { | ||
| 516 | self.pending_id = None; | ||
| 517 | self.pending_since = None; | ||
| 518 | if let Some(socket) = ws.as_deref_mut() { | ||
| 519 | send_json(socket, &ServerMessage::Error { text: &text }); | ||
| 520 | } | ||
| 521 | } | ||
| 522 | } | ||
| 523 | } | ||
| 524 | Effect::Disconnect => { | ||
| 525 | self.upstream = None; | ||
| 526 | self.drop_upstream = true; | ||
| 527 | self.core.reset_after_disconnect(); | ||
| 528 | } | ||
| 529 | Effect::Render(_) | Effect::ClearRegion | Effect::Quit => {} | ||
| 530 | } | ||
| 531 | } | ||
| 532 | |||
| 533 | if let Some(socket) = ws { | ||
| 534 | self.send_view(socket); | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | fn send_view(&self, ws: &mut WebSocket<TcpStream>) { | ||
| 539 | let Some(view) = self.core.view() else { | ||
| 540 | return; | ||
| 541 | }; | ||
| 542 | send_json(ws, &ServerMessage::View { view: &view }); | ||
| 543 | } | ||
| 544 | } | ||
| 545 | |||
| 546 | fn send_message(ws: &mut WebSocket<TcpStream>, message: Message) -> Result<(), WsError> { | ||
| 547 | // The read side is nonblocking; writes are small and should not fail | ||
| 548 | // half-way because a browser is briefly not draining its socket. | ||
| 549 | let _ = ws.get_ref().set_nonblocking(false); | ||
| 550 | let result = ws.send(message); | ||
| 551 | let _ = ws.get_ref().set_nonblocking(true); | ||
| 552 | result | ||
| 553 | } | ||
| 554 | |||
| 555 | fn send_json(ws: &mut WebSocket<TcpStream>, message: &ServerMessage<'_>) { | ||
| 556 | let Ok(text) = serde_json::to_string(message) else { | ||
| 557 | return; | ||
| 558 | }; | ||
| 559 | let _ = send_message(ws, Message::Text(text)); | ||
| 560 | } | ||
| 561 | |||
| 562 | fn close_socket(ws: &mut WebSocket<TcpStream>) { | ||
| 563 | let _ = ws.get_ref().set_nonblocking(false); | ||
| 564 | let _ = ws.close(None); | ||
| 565 | let _ = ws.get_ref().set_nonblocking(true); | ||
| 566 | } | ||
| 567 | |||
| 568 | #[cfg(test)] | ||
| 569 | mod tests { | ||
| 570 | use super::*; | ||
| 571 | use zagan_bet::client::view::{LobbyView, View}; | ||
| 572 | |||
| 573 | #[test] | ||
| 574 | fn reads_only_well_formed_session_cookies() { | ||
| 575 | let id = "a".repeat(64); | ||
| 576 | assert_eq!( | ||
| 577 | cookie_id(&format!("other=1; {COOKIE_NAME}={id}; theme=dark")), | ||
| 578 | Some(id) | ||
| 579 | ); | ||
| 580 | assert_eq!(cookie_id(&format!("{COOKIE_NAME}=short")), None); | ||
| 581 | assert_eq!(cookie_id("other=1"), None); | ||
| 582 | assert_eq!(cookie_id(""), None); | ||
| 583 | } | ||
| 584 | |||
| 585 | #[test] | ||
| 586 | fn parses_a_browser_command_and_rejects_nonsense() { | ||
| 587 | let message: ClientMessage = serde_json::from_str( | ||
| 588 | r#"{"type":"command","command":{"Login":{"user":"alice","credential":"abc"}}}"#, | ||
| 589 | ) | ||
| 590 | .expect("login command"); | ||
| 591 | match message { | ||
| 592 | ClientMessage::Command { command } => { | ||
| 593 | assert_eq!( | ||
| 594 | command, | ||
| 595 | Command::Login { | ||
| 596 | user: "alice".to_string(), | ||
| 597 | credential: "abc".to_string() | ||
| 598 | } | ||
| 599 | ); | ||
| 600 | } | ||
| 601 | _ => panic!("expected a command"), | ||
| 602 | } | ||
| 603 | |||
| 604 | assert!(serde_json::from_str::<ClientMessage>("{}").is_err()); | ||
| 605 | assert!(serde_json::from_str::<ClientMessage>(r#"{"type":"nope"}"#).is_err()); | ||
| 606 | } | ||
| 607 | |||
| 608 | #[test] | ||
| 609 | fn serializes_a_view_for_the_browser() { | ||
| 610 | let view = View::Lobby(LobbyView { | ||
| 611 | connected: true, | ||
| 612 | user: Some("alice".to_string()), | ||
| 613 | account: Some("1".to_string()), | ||
| 614 | balance: Some(1000), | ||
| 615 | games: vec![("slots".to_string(), "Slots".to_string())], | ||
| 616 | login_error: None, | ||
| 617 | }); | ||
| 618 | let json = serde_json::to_string(&ServerMessage::View { view: &view }).unwrap(); | ||
| 619 | assert!(json.contains(r#""type":"view""#)); | ||
| 620 | assert!(json.contains(r#""screen":"lobby""#)); | ||
| 621 | assert!(json.contains(r#""user":"alice""#)); | ||
| 622 | } | ||
| 623 | } | ||
