From 3f62287bc7052d81778a6c8b3a0b5682c18c4f62 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Fri, 8 Sep 2023 00:24:58 +0200 Subject: room watches --- client-web/source/helper.ts | 7 ++ client-web/source/index.ts | 6 +- client-web/source/menu.ts | 2 +- client-web/source/preferences/decl.ts | 3 +- client-web/source/protocol/mod.ts | 32 +++++---- client-web/source/room.ts | 4 +- client-web/source/room_watches.ts | 58 ++++++++++++++++- client-web/style/master.sass | 1 + client-web/style/watches.sass | 26 ++++++++ server/src/config.rs | 1 + server/src/logic.rs | 118 ++++++++++++++++++++++++++++++---- server/src/main.rs | 16 +++-- 12 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 client-web/style/watches.sass diff --git a/client-web/source/helper.ts b/client-web/source/helper.ts index ecfdf95..49c36fa 100644 --- a/client-web/source/helper.ts +++ b/client-web/source/helper.ts @@ -77,3 +77,10 @@ export function display_filesize(n: number): string { if (n > 1000) return (n / 1000).toFixed(1) + "kB" return n.toString() + "B" } + +export class EventEmitter { + private handlers: Set<(e: E) => unknown> = new Set() + public dispatch(e: E) { this.handlers.forEach(h => h(e)) } + public add_listener(listener: (e: E) => unknown) { this.handlers.add(listener) } + public remove_listener(listener: (e: E) => unknown) { this.handlers.delete(listener) } +} diff --git a/client-web/source/index.ts b/client-web/source/index.ts index f5f6b2d..3dbac6d 100644 --- a/client-web/source/index.ts +++ b/client-web/source/index.ts @@ -69,7 +69,7 @@ export async function main() { if (room_secret.length == 0) return window.location.href = "/" // send them back to the start page if (PREFS.warn_redirect) log({ scope: "crypto", warn: true }, "You were redirected from the old URL format. The server knows the room secret now - e2ee is insecure!") - const conn = await (new SignalingConnection().connect(room_secret)) + const conn = await (new SignalingConnection().connect()) const rtc_config: RTCConfiguration = { iceCandidatePoolSize: 10, iceServers: [{ @@ -81,9 +81,6 @@ export async function main() { r = new Room(conn, rtc_config) - conn.control_handler = (a) => r.control_handler(a) - conn.relay_handler = (a, b) => r.relay_handler(a, b) - setup_keybinds(r) r.on_ready = () => { const sud = e("div", { class: "side-ui" }) @@ -92,4 +89,5 @@ export async function main() { } if (globalThis.navigator.serviceWorker) init_serviceworker() + await conn.join(room_secret) } diff --git a/client-web/source/menu.ts b/client-web/source/menu.ts index 583f28b..3ad9c99 100644 --- a/client-web/source/menu.ts +++ b/client-web/source/menu.ts @@ -40,7 +40,7 @@ export function control_bar(room: Room, side_ui_container: HTMLElement): HTMLEle const leave = e("button", { class: "leave", onclick() { window.location.href = "/" } }, "Leave") const chat = side_ui(side_ui_container, room.chat.element, "Chat") const prefs = side_ui(side_ui_container, ui_preferences(), "Settings") - const rwatches = side_ui(side_ui_container, ui_room_watches(), "Known Rooms") + const rwatches = side_ui(side_ui_container, ui_room_watches(room.signaling), "Known Rooms") const local_controls = [ //ediv({ class: "local-controls", aria_label: "local resources" }, e("button", { onclick: () => room.local_user.await_add_resource(create_mic_res()) }, "Microphone"), e("button", { onclick: () => room.local_user.await_add_resource(create_camera_res()) }, "Camera"), diff --git a/client-web/source/preferences/decl.ts b/client-web/source/preferences/decl.ts index 40dfcd6..5f2d8a2 100644 --- a/client-web/source/preferences/decl.ts +++ b/client-web/source/preferences/decl.ts @@ -42,5 +42,6 @@ export const PREF_DECLS = { notify_join: { type: bool, default: true, description: "Send notifications when users join" }, notify_leave: { type: bool, default: true, description: "Send notifications when users leave" }, - enable_onbeforeunload: { type: bool, default: true, description: "Prompt for confirmation when leaving the site while local resources are active" } + enable_onbeforeunload: { type: bool, default: true, description: "Prompt for confirmation when leaving the site while local resources are active" }, + room_watches: { type: string, default: "Public=public", description: "Known rooms (as semicolon seperated list of name=secret pairs)" } } diff --git a/client-web/source/protocol/mod.ts b/client-web/source/protocol/mod.ts index 83ee8cb..0db1162 100644 --- a/client-web/source/protocol/mod.ts +++ b/client-web/source/protocol/mod.ts @@ -4,23 +4,22 @@ Copyright (C) 2023 metamuffin */ import { ClientboundPacket, RelayMessage, RelayMessageWrapper, ServerboundPacket } from "../../../common/packets.d.ts" +import { EventEmitter } from "../helper.ts"; import { log } from "../logger.ts" import { crypto_encrypt, crypto_seeded_key, crypt_decrypt, crypto_hash } from "./crypto.ts" export class SignalingConnection { - room!: string websocket!: WebSocket - room_hash!: string - key!: CryptoKey + room?: string + room_hash?: string + key?: CryptoKey my_id?: number // needed for outgoing relay messages - control_handler: (_packet: ClientboundPacket) => void = () => { } - relay_handler: (_sender: number, _message: RelayMessage) => void = () => { } + control_handler = new EventEmitter() + relay_handler = new EventEmitter<[number, RelayMessage]>() constructor() { } - async connect(room: string): Promise { - this.key = await crypto_seeded_key(room) - this.room_hash = await crypto_hash(room) + async connect(): Promise { log("ws", "connecting…") const ws_url = new URL(`${window.location.protocol.endsWith("s:") ? "wss" : "ws"}://${window.location.host}/signaling`) this.websocket = new WebSocket(ws_url) @@ -44,21 +43,28 @@ export class SignalingConnection { } on_open() { log("ws", "websocket opened"); - this.send_control({ join: { hash: this.room_hash } }) setInterval(() => this.send_control({ ping: null }), 30000) // stupid workaround for nginx disconnecting inactive connections } + + async join(room: string) { + this.room = room; + this.key = await crypto_seeded_key(room) + this.room_hash = await crypto_hash(room) + this.send_control({ join: { hash: this.room_hash } }) + } + on_error() { log({ scope: "ws", error: true }, "websocket error occurred!") } async on_message(data: string) { const packet: ClientboundPacket = JSON.parse(data) // TODO dont crash if invalid - this.control_handler(packet) + this.control_handler.dispatch(packet) if (packet.init) this.my_id = packet.init.your_id; if (packet.message) { - const plain_json = await crypt_decrypt(this.key, packet.message.message) + const plain_json = await crypt_decrypt(this.key!, packet.message.message) const plain: RelayMessageWrapper = JSON.parse(plain_json) // TODO make sure that protocol spec is met if (plain.sender == packet.message.sender) - this.relay_handler(packet.message.sender, plain.inner) + this.relay_handler.dispatch([packet.message.sender, plain.inner]) else { log({ scope: "crypto", warn: true }, `message dropped: sender inconsistent (${plain.sender} != ${packet.message.sender})`) } @@ -71,7 +77,7 @@ export class SignalingConnection { async send_relay(data: RelayMessage, recipient?: number | null) { recipient ??= undefined // null -> undefined const packet: RelayMessageWrapper = { inner: data, sender: this.my_id! } - const message = await crypto_encrypt(this.key, JSON.stringify(packet)) + const message = await crypto_encrypt(this.key!, JSON.stringify(packet)) this.send_control({ relay: { recipient, message } }) } } diff --git a/client-web/source/room.ts b/client-web/source/room.ts index ba18162..8fd165d 100644 --- a/client-web/source/room.ts +++ b/client-web/source/room.ts @@ -24,6 +24,8 @@ export class Room { constructor(public signaling: SignalingConnection, public rtc_config: RTCConfiguration) { this.element = e("div", { class: "room", aria_label: "user list" }) + signaling.control_handler.add_listener(p => this.control_handler(p)) + signaling.relay_handler.add_listener(([a, b]) => this.relay_handler(a, b)) } control_handler(packet: ClientboundPacket) { @@ -58,4 +60,4 @@ export class Room { log("ws", `<- [relay from ${sender.display_name}]: `, message); sender.on_relay(message) } -} \ No newline at end of file +} diff --git a/client-web/source/room_watches.ts b/client-web/source/room_watches.ts index 331022d..d91972d 100644 --- a/client-web/source/room_watches.ts +++ b/client-web/source/room_watches.ts @@ -1,7 +1,61 @@ import { e } from "./helper.ts"; +import { PREFS } from "./preferences/mod.ts"; +import { crypto_hash } from "./protocol/crypto.ts"; +import { SignalingConnection } from "./protocol/mod.ts"; -export function ui_room_watches(): HTMLElement { - const listing = e("div", {}) +interface Watch { + secret: string, + hash: string, + name: string, + user_count: number, +} + +export function ui_room_watches(conn: SignalingConnection): HTMLElement { + const listing = e("div", { class: "room-watches-listing" }) + + const watches: Watch[] = [] + const update_watches = () => (conn.send_control({ watch_rooms: watches.map(w => w.hash) }), update_listing()); + + (async () => { + for (const e of PREFS.room_watches.split(";")) { + const [name, secret] = e.split("="); + watches.push({ + name, + secret, + hash: await crypto_hash(secret), + user_count: 0 + }) + } + update_watches() + })() + + conn.control_handler.add_listener(packet => { + if (packet.room_info) { + const w = watches.find(w => w.hash == packet.room_info!.hash) + w!.user_count = packet.room_info.user_count + update_listing() + } + }) + + const update_listing = () => { + listing.innerHTML = "" + for (const w of watches) { + const ucont = [] + if (w.user_count > 0) ucont.push(e("div", {})) + if (w.user_count > 1) ucont.push(e("div", {})) + if (w.user_count > 2) ucont.push(e("div", {})) + if (w.user_count > 3) ucont.push(e("span", {}, `+${w.user_count - 3}`)) + listing.append(e("li", {}, + e("a", { + href: "#" + encodeURIComponent(w.secret), + class: w.secret == conn.room ? "current-room" : [] + }, + w.name, + e("div", { class: "users" }, ...ucont) + ) + )) + } + } return e("div", { class: "room-watches" }, e("h2", {}, "Known Rooms"), diff --git a/client-web/style/master.sass b/client-web/style/master.sass index 6a26b67..0863129 100644 --- a/client-web/style/master.sass +++ b/client-web/style/master.sass @@ -9,6 +9,7 @@ @use 'side' @use 'menu' @use 'start' +@use 'watches' @import url("/assets/font/include.css") @import url("/overrides.css") diff --git a/client-web/style/watches.sass b/client-web/style/watches.sass new file mode 100644 index 0000000..ee45f5d --- /dev/null +++ b/client-web/style/watches.sass @@ -0,0 +1,26 @@ +.room-watches-listing + list-style: none + a + display: inline-block + width: 100% + margin: 0.5em + border-radius: 4px + padding: 0.5em + background-color: var(--bg-light) + + .current-room + background-color: var(--ac-dark) + + .users + float: right + display: inline-block + div + margin: 0.25em + display: inline-block + width: 1em + height: 1em + border-radius: 3px + background-color: rgb(146, 243, 73) + span + display: inline-block + color: #bbbbbb diff --git a/server/src/config.rs b/server/src/config.rs index 5ef6c69..124e160 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -28,4 +28,5 @@ pub struct AppearanceConfig { pub accent_dark: String, pub background: String, pub background_dark: String, + pub background_light: String, } diff --git a/server/src/logic.rs b/server/src/logic.rs index a69ca79..d474b79 100644 --- a/server/src/logic.rs +++ b/server/src/logic.rs @@ -28,16 +28,19 @@ pub struct Client(u64); pub struct State { idgen: IdGenerator, rooms: RwLock>>, + watches: RwLock>>, } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Room { + pub hash: String, pub users: RwLock>, } #[derive(Debug, Default)] pub struct ClientState { current_room: Option>, + watches: Vec, } impl State { @@ -79,9 +82,22 @@ impl State { } if let Some(room) = cstate.current_room { - room.leave(client).await; + room.leave(self, client).await; // TODO dont leak room } + { + let mut w = self.watches.write().await; + for e in cstate.watches { + let mut remove = false; + if let Some(e) = w.get_mut(&e) { + e.remove(&client); + remove = e.is_empty() + } + if remove { + w.remove(&e); + } + } + } } async fn on_recv(&self, client: Client, cstate: &mut ClientState, packet: ServerboundPacket) { @@ -89,15 +105,21 @@ impl State { ServerboundPacket::Ping => (), ServerboundPacket::Join { hash } => { if let Some(room) = &cstate.current_room { - room.leave(client).await; + room.leave(self, client).await; // TODO dont leak room // if room.should_remove().await { // self.rooms.write().await.remove(üw); // } } if let Some(hash) = hash { - let room = self.rooms.write().await.entry(hash).or_default().clone(); - room.join(client).await; + let room = self + .rooms + .write() + .await + .entry(hash.clone()) + .or_insert_with(|| Room::new(&hash).into()) + .clone(); + room.join(self, client).await; cstate.current_room = Some(room.clone()) } else { cstate.current_room = None @@ -116,7 +138,33 @@ impl State { } } } - ServerboundPacket::WatchRooms(_) => todo!(), + ServerboundPacket::WatchRooms(mut list) => { + let mut w = self.watches.write().await; + let r = self.rooms.read().await; + + for e in list.to_owned() { + w.entry(e.to_string()).or_default().insert(client); + if let Some(r) = r.get(&e) { + client + .send(ClientboundPacket::RoomInfo { + hash: e, + user_count: r.users.read().await.len(), + }) + .await; + } + } + std::mem::swap(&mut cstate.watches, &mut list); + for e in list { + let mut remove = false; + if let Some(e) = w.get_mut(&e) { + e.remove(&client); + remove = e.is_empty() + } + if remove { + w.remove(&e); + } + } + } } } } @@ -132,21 +180,47 @@ impl Client { } impl Room { - pub async fn join(&self, client: Client) { + pub fn new(hash: &String) -> Self { + Self { + hash: hash.to_owned(), + users: Default::default(), + } + } + pub async fn join(&self, state: &State, client: Client) { debug!("client join {client:?}"); - self.users.write().await.insert(client); + let user_count = { + let mut g = self.users.write().await; + g.insert(client); + g.len() + }; + for w in state + .watches + .read() + .await + .get(&self.hash) + .into_iter() + .flatten() + { + w.send(ClientboundPacket::RoomInfo { + hash: self.hash.to_owned(), + user_count, + }) + .await; + } // send join of this client to all clients - self.broadcast(Some(client), ClientboundPacket::ClientJoin { id: client }) + self.broadcast(None, ClientboundPacket::ClientJoin { id: client }) .await; // send join of all other clients to this one for rc in self.users.read().await.iter() { - self.send_to_client(client, ClientboundPacket::ClientJoin { id: *rc }) - .await; + if *rc != client { + self.send_to_client(client, ClientboundPacket::ClientJoin { id: *rc }) + .await; + } } } - pub async fn leave(&self, client: Client) { + pub async fn leave(&self, state: &State, client: Client) { debug!("client leave {client:?}"); for c in self.users.read().await.iter() { if *c != client { @@ -154,7 +228,25 @@ impl Room { .await; } } - self.users.write().await.remove(&client); + let user_count = { + let mut g = self.users.write().await; + g.remove(&client); + g.len() + }; + for w in state + .watches + .read() + .await + .get(&self.hash) + .into_iter() + .flatten() + { + w.send(ClientboundPacket::RoomInfo { + hash: self.hash.to_owned(), + user_count, + }) + .await; + } self.broadcast(Some(client), ClientboundPacket::ClientLeave { id: client }) .await; } diff --git a/server/src/main.rs b/server/src/main.rs index 9ea0f94..f251e4e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,10 +6,11 @@ #![feature(lazy_cell)] pub mod assets; pub mod config; +pub mod idgen; pub mod logic; pub mod protocol; -pub mod idgen; +use crate::protocol::ClientboundPacket; use assets::css; use config::{AppearanceConfig, Config}; use futures_util::{SinkExt, StreamExt, TryFutureExt}; @@ -22,11 +23,12 @@ use std::net::SocketAddr; use std::str::FromStr; use std::sync::Arc; use tokio::sync::mpsc; -use warp::hyper::Server; -use warp::ws::WebSocket; -use warp::{reply, ws::Message, Filter, Rejection, Reply}; - -use crate::protocol::ClientboundPacket; +use warp::{ + hyper::Server, + reply, + ws::{Message, WebSocket}, + Filter, Rejection, Reply, +}; fn main() { tokio::runtime::Builder::new_multi_thread() @@ -164,12 +166,14 @@ fn css_overrides( accent_dark, background, background_dark, + background_light, }: &AppearanceConfig, ) -> String { format!( r#":root {{ --bg: {background}; --bg-dark: {background_dark}; +--bg-light: {background_light}; --ac: {accent}; --ac-dark: {accent_dark}; --ac-dark-transparent: {accent_dark}c9; -- cgit v1.2.3-70-g09d2