blatherskite

a toy discord-like chat app backend written for a swe class
Log | Files | Refs | README

commit 5a988fd14016aaa2db56cb6d00db7ca132d3c38b
parent 04811cc6530c75ef64f36f11ddf18453c5d95ad9
Author: quantumish <freifeld.david@gmail.com>
Date:   Thu,  6 Oct 2022 11:00:26 -0700

MVP

Diffstat:
AREADME.md | 39+++++++++++++++++++++++++++++++++++++++
Mchatterbox/Cargo.toml | 7+++++++
Mchatterbox/src/main.rs | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mscuttlebutt/src/main.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Atest.py | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 354 insertions(+), 61 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1,39 @@ +# blatherskite + +# Dependencies +You'll need to install CassandraDB for this: you can do that by running: +``` +brew install cassandra +``` + +You also need to install the Rust language: +``` +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +# Usage +Start by launching the database in the background with +``` +cassandra -f +``` +Then, launch `chatterbox` - the service for sending/getting messages - and `scuttlebutt` - the service for everything else. +``` +cargo run -p chatterbox & +cargo run -p scuttlebutt & +``` + +## Scuttlebutt +Scuttlebutt is an HTTP service that handles the creation, deletion, and updating of groups/channels/users as well as misc others. + +The various methods and objects are documented at `localhost:3000`, and the basic usage flow is something like: +- `POST /api/user` to make a user, which will return a User object (see Schemas on the docs) +- `GET /api/login` to login with said user. This will return a JWT that you'll use to authenticate future requests. This token will expire in a day! +- Whatever requests you'd like at that point! Authenticate by including a `ScuttleKey` header with the token you got. + +## Chatterbox +Chatterbox is a websocket service used for sending and receiving messages. To use: +- Connect to the websocket at `ws://localhost:3001/ws/whee` +- Send authentication in the form of `{"hash": "YOUR_PASSWORD_HASH", "id": "YOUR_ID"}` +- Then use the websocket as normal! + - Send message requests in the form of `{"content": "whee", "channel": "CHANNEL_ID"}` + - Recieve messages! diff --git a/chatterbox/Cargo.toml b/chatterbox/Cargo.toml @@ -6,7 +6,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.65" +cassandra-cpp = "1.1.0" +chrono = "0.4.22" futures-util = "0.3.24" +hex = "0.4.3" poem = { version = "1.3.43", features = ["websocket"] } +rustflake = "0.1.1" +serde = "1.0.145" +serde_json = "1.0.85" tokio = { version = "1.21.1", features = ["full"] } tracing-subscriber = "0.3.15" diff --git a/chatterbox/src/main.rs b/chatterbox/src/main.rs @@ -1,57 +1,132 @@ /// Currently a modified version of `poem`'s default websocket-chat example +use cassandra_cpp::*; use futures_util::{SinkExt, StreamExt}; use poem::{ - get, handler, - listener::TcpListener, - web::{ - websocket::{Message, WebSocket}, - Data, Path, - }, - EndpointExt, IntoResponse, Route, Server, + get, handler, + listener::TcpListener, + web::{ + websocket::{Message, WebSocket}, + Data, Path, + }, + EndpointExt, IntoResponse, Route, Server, }; +use rustflake::Snowflake; +use serde_json::Value; +use std::result::Result; +use serde::{Serialize, Deserialize}; +use chrono::{DateTime, Local}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct MessageObj { + pub id: i64, + pub channel: i64, + pub author: i64, + pub content: String, +} + +pub fn gen_id() -> i64 { + static STATE: std::sync::Mutex<Option<Snowflake>> = std::sync::Mutex::new(None); + + STATE + .lock() + .unwrap() + .get_or_insert_with(|| Snowflake::new(1_564_790_400_000, 2, 1)) + .generate() +} + +const KEYSPC: &'static str = "bsk"; + +fn setup_db() -> Session { + let contact_points = "127.0.0.1"; + let mut cluster = Cluster::default(); + cluster.set_contact_points(contact_points).unwrap(); + cluster.set_load_balance_round_robin(); + cluster.connect().unwrap() +} #[handler] fn ws( - Path(name): Path<String>, - ws: WebSocket, - sender: Data<&tokio::sync::broadcast::Sender<String>>, + Path(name): Path<String>, + ws: WebSocket, + sender: Data<&tokio::sync::broadcast::Sender<String>>, ) -> impl IntoResponse { - let sender = sender.clone(); - let mut receiver = sender.subscribe(); - ws.on_upgrade(move |socket| async move { - let (mut sink, mut stream) = socket.split(); - - tokio::spawn(async move { - while let Some(Ok(msg)) = stream.next().await { - if let Message::Text(text) = msg { - if sender.send(format!("{}: {}", name, text)).is_err() { - break; - } - } - } - }); - - tokio::spawn(async move { - while let Ok(msg) = receiver.recv().await { - if sink.send(Message::Text(msg)).await.is_err() { - break; - } - } - }); - }) + let sender = sender.clone(); + ws.on_upgrade(move |socket| async move { + let mut receiver = sender.subscribe(); + let (mut sink, mut stream) = socket.split(); + + tokio::spawn(async move { + let sess = setup_db(); + let mut user: Option<Value> = None; + while let Some(Ok(msg)) = stream.next().await { + if let Message::Text(auth) = msg { + let req: Value = serde_json::from_str(&auth).unwrap(); + let res = sess.execute(&stmt!(&format!( + "SELECT hash FROM {}.users WHERE id={};", + KEYSPC, req["id"].as_i64().unwrap(), + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let db_hash: String = row.get(0).unwrap(); + if hex::decode(db_hash).unwrap() != hex::decode(req["hash"].as_str().unwrap()).unwrap() { + return; + } + user = Some(req); + break + } + } + while let Some(Ok(mesg)) = stream.next().await { + if let Message::Text(text) = mesg { + let id = gen_id(); + let req: Value = serde_json::from_str(&text).unwrap(); + let msg = MessageObj { + id, + content: req["content"].as_str().unwrap().to_string(), + author: user.clone().unwrap()["id"].as_i64().unwrap(), + channel: req["channel"].as_i64().unwrap(), + }; + let now = Local::now(); + let res = sess.execute(&stmt!(&format!( + "INSERT INTO {}.messages (channel, id, author, time, content) VALUES ({},{},{},'{}','{}');", + KEYSPC, msg.channel, gen_id(), msg.author, now.to_rfc3339(), msg.content + ))).wait().unwrap(); + if sender.send(serde_json::to_string(&msg).unwrap()).is_err() { + break; + } + } + } + }); + + tokio::spawn(async move { + let sess = setup_db(); + while let Ok(msg) = receiver.recv().await { + let req: Value = serde_json::from_str(&msg).unwrap(); + let res = sess.execute(&stmt!(&format!( + "SELECT members FROM {}.channels WHERE id={};", KEYSPC, req["channel"].as_i64().unwrap(), + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let members: SetIterator = row.get(0).unwrap(); + if !members.map(|i| i.get_i64().unwrap()).collect::<Vec<i64>>().contains(&req["author"].as_i64().unwrap()) { + continue + } + if sink.send(Message::Text(msg)).await.is_err() { + break; + } + } + }); + }) } #[tokio::main] async fn main() -> Result<(), std::io::Error> { - if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "poem=debug"); - } - tracing_subscriber::fmt::init(); + if std::env::var_os("RUST_LOG").is_none() { + std::env::set_var("RUST_LOG", "poem=debug"); + } + tracing_subscriber::fmt::init(); - let app = Route::new().at( - "/ws/:name", - get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)), - ); + let app = Route::new().at( + "/ws/:name", + get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)), + ); - Server::new(TcpListener::bind("127.0.0.1:3001")).run(app).await + Server::new(TcpListener::bind("127.0.0.1:3001")).run(app).await } diff --git a/scuttlebutt/src/main.rs b/scuttlebutt/src/main.rs @@ -84,7 +84,7 @@ impl Api { session.execute(&stmt!(&format!("CREATE TABLE IF NOT EXISTS {keyspc}.groups (id bigint PRIMARY KEY, name text, members set<bigint>, channels set<bigint>);"))).wait().unwrap(); session.execute(&stmt!(&format!("CREATE TABLE IF NOT EXISTS {keyspc}.channels (id bigint PRIMARY KEY, name text, group bigint, members set<bigint>);"))).wait().unwrap(); session.execute(&stmt!(&format!("CREATE TABLE IF NOT EXISTS {keyspc}.user_groups (id bigint PRIMARY KEY, groups set<bigint>);"))).wait().unwrap(); - session.execute(&stmt!(&format!("CREATE TABLE IF NOT EXISTS {keyspc}.messages (group bigint, channel bigint, author bigint, time timestamp, content text, PRIMARY KEY ((group, channel)));"))).wait().unwrap(); + session.execute(&stmt!(&format!("CREATE TABLE IF NOT EXISTS {keyspc}.messages (channel bigint, id bigint, author bigint, time timestamp, content text, PRIMARY KEY (channel, id)) WITH CLUSTERING ORDER BY (id DESC);"))).wait().unwrap(); Api { sess: session, @@ -103,13 +103,13 @@ impl Api { } - async fn __remove_channel_member(&self, cid: i64, uid: i64) { + fn __remove_channel_member(&self, cid: i64, uid: i64) { self.sess.execute(&stmt!(&format!( "UPDATE {}.channels SET members = members - {{{}}} WHERE id={};", self.kspc, uid, cid ))).wait().unwrap(); } - async fn __remove_group_member(&self, gid: i64, uid: i64) { + fn __remove_group_member(&self, gid: i64, uid: i64) { self.sess.execute(&stmt!(&format!( "UPDATE {}.groups SET members = members - {{{}}} WHERE id={};", self.kspc, uid, gid ))).wait().unwrap(); @@ -119,7 +119,7 @@ impl Api { let row = res.first_row().unwrap(); let channels: SetIterator = row.get(0).unwrap(); for channel in channels { - self.__remove_channel_member(channel.get_i64().unwrap(), uid).await; + self.__remove_channel_member(channel.get_i64().unwrap(), uid); } self.sess.execute(&stmt!(&format!( "UPDATE {}.user_groups SET groups = groups - {{{}}} WHERE id={};", self.kspc, gid, uid @@ -257,7 +257,6 @@ impl Api { self.sess.execute(&stmt!(&format!( "DELETE FROM {}.users WHERE id={};", self.kspc, id ))).wait().unwrap(); - let res = self.sess.execute(&stmt!(&format!( "SELECT groups FROM {}.user_groups WHERE id={};", self.kspc, auth.0.id @@ -267,7 +266,7 @@ impl Api { _ => res.first_row().unwrap().get(0).unwrap(), }; for group in groups { - self.__remove_group_member(group.get_i64().unwrap(), id).await; + self.__remove_group_member(group.get_i64().unwrap(), id); } self.sess.execute(&stmt!(&format!( "DELETE FROM {}.user_groups WHERE id={};", self.kspc, id @@ -280,7 +279,7 @@ impl Api { async fn get_groups(&self, auth: Authorization) -> GroupsResponse { use GroupsResponse::*; let res = self.sess.execute(&stmt!(&format!( - "SELECT id, name, members, channels FROM {}.user_groups WHERE id={};", + "SELECT groups FROM {}.user_groups WHERE id={};", self.kspc, auth.0.id ))).wait().unwrap(); @@ -312,7 +311,7 @@ impl Api { if let Err(e) = self.validate_id("groups", gid.0) { return NotFound(PlainText("Didn't find group or experienced database error.".to_string())); } - self.__remove_group_member(gid.0, auth.0.id).await; + self.__remove_group_member(gid.0, auth.0.id); Success } @@ -469,6 +468,21 @@ impl Api { "UPDATE {}.groups SET members = members + {{{}}} WHERE id = {};", self.kspc, uid.0, gid.0 ))).wait().unwrap(); + let res = self.sess.execute(&stmt!(&format!( + "SELECT channels FROM {}.groups WHERE id = {};", + self.kspc, gid.0 + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let mut channels: SetIterator = row.get(0).unwrap(); + let cid: i64 = channels.next().unwrap().get_i64().unwrap(); + self.sess.execute(&stmt!(&format!( + "UPDATE {}.channels SET members = members + {{{}}} WHERE id = {};", + self.kspc, uid.0, cid + ))).wait().unwrap(); + self.sess.execute(&stmt!(&format!( + "UPDATE {}.user_groups SET groups = groups + {{{}}} WHERE id = {};", + self.kspc, gid.0, uid.0 + ))).wait().unwrap(); Success } @@ -486,7 +500,7 @@ impl Api { } else if let Err(_) = self.validate_id("users", uid.0) { return NotFound(PlainText("User not found".to_string())) } - self.__remove_group_member(gid.0, uid.0).await; + self.__remove_group_member(gid.0, uid.0); Success } @@ -584,7 +598,7 @@ impl Api { use ChannelResponse::*; let res = self.sess.execute(&stmt!(&format!( "SELECT name, group, members FROM {}.channels WHERE id={};", self.kspc, id.0 - ))).wait().unwrap(); + ))).wait().unwrap(); let (name, group, members): (String, i64, SetIterator) = match res.row_count() { 1 => { let row = res.first_row().unwrap(); @@ -593,7 +607,6 @@ impl Api { 0 => return NotFound, _ => return InternalError(PlainText(UNUSUAL_ROW_ERROR.to_string())) }; - Success(Json(Channel { id: id.0, name, @@ -605,13 +618,49 @@ impl Api { #[oai(path = "/channel", method = "delete")] /// Deletes a channel async fn delete_channel(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { - todo!() + use DeleteResponse::*; + if let Err(_) = self.validate_id("channels", id.0) { + NotFound(PlainText("Channel not found.".to_string())) + } else { + let res = self.sess.execute(&stmt!(&format!( + "SELECT group FROM {}.channels WHERE id={};", self.kspc, id.0 + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let group: i64 = row.get(0).unwrap(); + self.sess.execute(&stmt!(&format!( + "UPDATE {}.groups SET channels = channels - {{{}}} WHERE id = {};", + self.kspc, id.0, group + ))).wait().unwrap(); + self.sess.execute(&stmt!(&format!( + "DELETE FROM {}.channels WHERE id = {};", + self.kspc, id.0 + ))).wait().unwrap(); + Success + } } #[oai(path = "/channel/members", method = "get")] /// Gets the members that can access a channel async fn get_channel_members(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { - todo!() + use MembersResponse::*; + let res = self.sess.execute(&stmt!(&format!( + "SELECT members FROM {}.channels WHERE id={};", self.kspc, id.0 + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + let members: SetIterator = row.get(0).unwrap(); + let members_objs = members.map(|_| { + let res = self.sess.execute(&stmt!(&format!( + "SELECT id, name, email FROM {}.users WHERE id={};", + self.kspc, id.0 + ))).wait().unwrap(); + let row = res.first_row().unwrap(); + User { + id: id.0, + username: row.get(1).unwrap(), + email: row.get(2).unwrap(), + } + }).collect::<Vec<User>>(); + Success(Json(members_objs)) } #[oai(path = "/channel/members", method = "put")] @@ -622,7 +671,16 @@ impl Api { id: Query<i64>, uid: Query<i64>, ) -> GenericResponse { - todo!() + use GenericResponse::*; + if let Err(_) = self.validate_id("channels", id.0) { + return NotFound(PlainText("Channel not found".to_string())) + } else if let Err(_) = self.validate_id("users", uid.0) { + return NotFound(PlainText("User not found".to_string())) + } + self.sess.execute(&stmt!(&format!( + "UPDATE {}.channels SET members = members + {{{}}} WHERE id={};", self.kspc, uid.0, id.0 + ))).wait().unwrap(); + Success } #[oai(path = "/channel/members", method = "delete")] @@ -639,12 +697,12 @@ impl Api { } else if let Err(_) = self.validate_id("users", uid.0) { return NotFound(PlainText("User not found".to_string())) } - self.__remove_channel_member(cid.0, uid.0).await; + self.__remove_channel_member(cid.0, uid.0); Success } #[oai(path = "/channel/message", method = "get")] - /// Returns batch of messages in channel containing "term" starting at offset + /// Returns batch of messages in channel containing "term" in the last 100 messages async fn search_channel( &self, auth: Authorization, @@ -652,7 +710,26 @@ impl Api { term: Query<String>, off: Query<u64>, ) -> MessagesResponse { - todo!() + use MessagesResponse::*; + if let Err(_) = self.validate_id("channels", cid.0) { + return NotFound(PlainText("Channel not found".to_string())) + } + let res = self.sess.execute(&stmt!(&format!( + "SELECT * FROM {}.messages WHERE channel={} LIMIT 100;", + self.kspc, cid.0 + ))).wait().unwrap(); + let messages = res.iter().filter(|row| { + let content: String = row.get(4).unwrap(); + content.contains(&term.0) + }).map(|row| { + Message { + id: row.get(0).unwrap(), + author: row.get(2).unwrap(), + channel: row.get(1).unwrap(), + content: row.get(4).unwrap() + } + }).collect::<Vec<Message>>(); + Success(Json(messages)) } #[oai(path = "/channel/messages", method = "get")] @@ -665,7 +742,23 @@ impl Api { cid: Query<i64>, num_msgs: Query<u64>, ) -> MessagesResponse { - todo!() + use MessagesResponse::*; + if let Err(_) = self.validate_id("channels", cid.0) { + return NotFound(PlainText("Channel not found".to_string())) + } + let res = self.sess.execute(&stmt!(&format!( + "SELECT * FROM {}.messages WHERE channel={} LIMIT {};", + self.kspc, cid.0, num_msgs.0, + ))).wait().unwrap(); + let messages = res.iter().map(|row| { + Message { + id: row.get(0).unwrap(), + author: row.get(2).unwrap(), + channel: row.get(1).unwrap(), + content: row.get(4).unwrap() + } + }).collect::<Vec<Message>>(); + Success(Json(messages)) } } @@ -698,7 +791,7 @@ async fn main() -> Result<(), std::io::Error> { .nest("/", ui) .data(ServerKey::new_from_slice(&key.as_bytes()).unwrap()); // let cli = poem::test::TestClient::new(app); - // let resp = cli.post("/api/login?id=234").body("abc").send().await; + // let resp = cli.post("/api/login?id=234").body("abc").send(); // resp.assert_status_is_ok(); // Ok(()) Server::new(TcpListener::bind("127.0.0.1:3000")) diff --git a/test.py b/test.py @@ -0,0 +1,79 @@ +import sys +import requests +import asyncio +import websockets + +HASH = "6c6e2b0cfda80007e693d52b5956083ea68770e1310d0ed02d195cb14113b284" +if sys.argv[1] == "setup": + r = requests.post(f'http://localhost:3000/api/user?name=quantum&email=test@example.com&hash={HASH}') + quantum = r.json()["id"] + r = requests.post(f'http://localhost:3000/api/login?id={quantum}', data=HASH, headers={"Content-Type": "text/plain"}) + tok = r.text + + r = requests.post(f'http://localhost:3000/api/user?name=jemoka&email=test@example.com&hash={HASH}') + jemoka = r.json()["id"] + r = requests.post(f'http://localhost:3000/api/user?name=exr0n&email=test@example.com&hash={HASH}') + exr0n = r.json()["id"] + r = requests.post(f'http://localhost:3000/api/user?name=enquirer&email=test@example.com&hash={HASH}') + enquirer = r.json()["id"] + r = requests.post(f'http://localhost:3000/api/user?name=zbuster&email=test@example.com&hash={HASH}') + zbuster = r.json()["id"] + + print({"quantum": quantum, "exr0n": exr0n, "jemoka": jemoka, "enquirer": enquirer, "zbuster": zbuster}) + + r = requests.post(f'http://localhost:3000/api/group?name=testing', headers={"ScuttleKey": tok}) + print(r.text) + testing = r.json()["id"] + + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={zbuster}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={exr0n}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={testing}&uid={jemoka}', headers={"ScuttleKey": tok}) + r = requests.get(f'http://localhost:3000/api/group?id={testing}', headers={"ScuttleKey": tok}) + + r = requests.post(f'http://localhost:3000/api/group?name=whoo', headers={"ScuttleKey": tok}) + whoo = r.json()["id"] + r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={zbuster}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whoo}&uid={enquirer}', headers={"ScuttleKey": tok}) + + r = requests.post(f'http://localhost:3000/api/group?name=whee', headers={"ScuttleKey": tok}) + whee = r.json()["id"] + r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={enquirer}', headers={"ScuttleKey": tok}) + r = requests.put(f'http://localhost:3000/api/group/members?gid={whee}&uid={jemoka}', headers={"ScuttleKey": tok}) + + exit(0) + + +async def main(): + ids = {'quantum': 420625705584431104, 'exr0n': 420625705622179840, 'jemoka': 420625705609596928, 'enquirer': 420625705634762752, 'zbuster': 420625705643151360} + name = sys.argv[1] + my_id = ids[name] + r = requests.post(f'http://localhost:3000/api/login?id={my_id}', data=HASH, headers={"Content-Type": "text/plain"}) + tok = r.text + + while True: + cmd = input("$ ").split(" ") + if cmd[0] == "groups": + r = requests.get(f'http://localhost:3000/api/user/groups', headers={"ScuttleKey": tok}) + print(r.text, my_id) + for g in r.json(): + print(f"{g['name']} ({g['id']})") + elif cmd[0] == "group": + r = requests.get(f'http://localhost:3000/api/group/channels?gid={cmd[1]}', headers={"ScuttleKey": tok}) + for c in r.json(): + print(f"{c['name']} ({c['id']})") + elif cmd[0] == "channel": + async with websockets.connect("ws://localhost:3001/ws/whee") as websocket: + await websocket.send(f'{{"hash": "{HASH}", "id": {my_id}}}') + await websocket.send(f'{{"content": "{name} says whee", "channel": {cmd[1]}}}') + while True: + print(await websocket.recv()) + +loop = asyncio.get_event_loop() +loop.run_until_complete(main()) + + + + + + +