blatherskite

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

commit a40113661478296e0ae8e0d8dfde4aab2c2bf00b
parent 1fd6eefe7973a1295de01c4aa3500dd97b8494c8
Author: quantumish <freifeld.david@gmail.com>
Date:   Thu,  8 Dec 2022 22:29:59 -0800

Ported.

Diffstat:
MREADME.md | 37+++++++++++++++++++++++++++----------
Mscuttlebutt/Cargo.toml | 5++++-
Ascuttlebutt/diesel.toml | 8++++++++
Ascuttlebutt/migrations/2022-11-29-172636_test/down.sql | 6++++++
Ascuttlebutt/migrations/2022-11-29-172636_test/up.sql | 42++++++++++++++++++++++++++++++++++++++++++
Dscuttlebutt/src/db.rs | 562-------------------------------------------------------------------------------
Ascuttlebutt/src/error.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscuttlebutt/src/main.rs | 1456+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Ascuttlebutt/src/models.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscuttlebutt/src/responses.rs | 25+++++++++++++++++++++----
Ascuttlebutt/src/schema.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1205 insertions(+), 1248 deletions(-)

diff --git a/README.md b/README.md @@ -15,7 +15,12 @@ brew install cassandra-cpp-driver ``` > **Warning** -> This is a little wonky on M1 Macs: you'll need to follow the advice of [this page](https://stackoverflow.com/questions/69486339/nativelibrarydarwin-java64-failed-to-link-the-c-library-against-jna-native-m) when you face the inevitable JNA link error. Download for more recent JNA version is [here](https://search.maven.org/artifact/net.java.dev.jna/jna/5.8.0/jar). I personally ran `sudo mv jna-5.8.0.jar /opt/homebrew/Cellar/cassandra/4.0.6/libexec/jna-5.6.0.jar` (I think...) once I downloaded the new version, and that fixed it. +> This is a little wonky on M1 Macs: you'll need to follow the advice of +> [this page](https://stackoverflow.com/questions/69486339/nativelibrarydarwin-java64-failed-to-link-the-c-library-against-jna-native-m) +> when you face the inevitable JNA link error. Download for more recent JNA version is +> [here](https://search.maven.org/artifact/net.java.dev.jna/jna/5.8.0/jar). I personally ran +> `sudo mv jna-5.8.0.jar /opt/homebrew/Cellar/cassandra/4.0.6/libexec/jna-5.6.0.jar` (I +> think...) once I downloaded the new version, and that fixed it. You also need to install the Rust language: @@ -25,40 +30,52 @@ 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. + +Then, launch `chatterbox` - the service for sending/getting messages - and `scuttlebutt` - +the service for everything else. + ``` cargo run -p chatterbox & cargo run -p scuttlebutt & ``` ## Features -Beyond basic text messaging, `blatherskite` has support for: +Beyond basic text messaging, `blatherskite` has support for: - Discord-esque servers - Threads - Direct messages - Basic permissioning (owner/admin/none) ### Terminology -Here's a quick guide to to the terms used by the service (that you might see in the `scuttlebutt` documentation): +Here's a quick guide to to the terms used by the service (that you might see in the +`scuttlebutt` documentation): - Users can create or be invited to *groups* which contain *channels*. - Groups have: - *members*, the users who are part of the group - an *owner*, who made the group and is permitted to do specific actions (like deleting it) - *admin*, users who have elevated permissions for a group (like adding/removing channels) -- *DMs* are a special kind of group that are made between users directly and limit certain functionality. DMs only have one channel and have no admin. -- Channels also have *members* (which can be a subset of the group!). Channels by default are *public*, which means when a user is invited to a group they will be added to the channel. You can set them to *private* with another API call. +- *DMs* are a special kind of group that are made between users directly and limit certain + functionality. DMs only have one channel and have no admin. +- Channels also have *members* (which can be a subset of the group!). Channels by default + are *public*, which means when a user is invited to a group they will be added to the + channel. You can set them to *private* with another API call. ## Scuttlebutt -Scuttlebutt is an HTTP service that handles the creation, deletion, and updating of groups/channels/users as well as misc other actions. +Scuttlebutt is an HTTP service that handles the creation, deletion, and updating of +groups/channels/users as well as misc other actions. -The various methods and objects are documented at `localhost:3000`, and the basic usage flow is something like: +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. +- `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: diff --git a/scuttlebutt/Cargo.toml b/scuttlebutt/Cargo.toml @@ -11,7 +11,7 @@ base64 = "0.13.0" cassandra-cpp = "1.1.0" chrono = { version = "0.4.22", features = ["serde"] } ctor = "0.1.23" -error-stack = "0.2.3" +# error-stack = "0.1" hex = "0.4.3" hmac = "0.12.1" jwt = "0.16.0" @@ -28,3 +28,6 @@ sha2 = "0.10.6" thiserror = "1.0.37" tokio = { version = "1", features = ["full"] } tracing-subscriber = "0.3.15" +diesel = { version = "2.0.0", features = ["postgres"] } +dotenvy = "0.15.6" +html-escape = "0.2.12" diff --git a/scuttlebutt/diesel.toml b/scuttlebutt/diesel.toml @@ -0,0 +1,8 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" + +[migrations_directory] +dir = "migrations" diff --git a/scuttlebutt/migrations/2022-11-29-172636_test/down.sql b/scuttlebutt/migrations/2022-11-29-172636_test/down.sql @@ -0,0 +1,6 @@ +DROP TABLE users; +DROP TABLE groups; +DROP TABLE channels; +DROP TABLE user_groups; +DROP TABLE user_dms; +DROP TABLE messages; diff --git a/scuttlebutt/migrations/2022-11-29-172636_test/up.sql b/scuttlebutt/migrations/2022-11-29-172636_test/up.sql @@ -0,0 +1,42 @@ +CREATE TABLE users ( + id BIGINT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + hash TEXT NOT NULL +); + +CREATE TABLE groups ( + id BIGINT PRIMARY KEY, + name TEXT NOT NULL, + members BIGINT[] NOT NULL, + is_dm BOOLEAN NOT NULL, + channels BIGINT[] NOT NULL, + admin BIGINT[] NOT NULL, + owner BIGINT NOT NULL +); + +CREATE TABLE channels ( + id BIGINT PRIMARY KEY, + src_group BIGINT NOT NULL, + name TEXT NOT NULL, + members BIGINT[] NOT NULL, + private BOOLEAN NOT NULL +); + +CREATE TABLE user_groups ( + id BIGINT PRIMARY KEY, + groups BIGINT[] NOT NULL +); + +CREATE TABLE user_dms ( + id BIGINT PRIMARY KEY, + dms BIGINT[] NOT NULL +); + +CREATE TABLE messages ( + channel BIGINT PRIMARY KEY, + id BIGINT NOT NULL, + author BIGINT NOT NULL, + content TEXT + thread BIGINT +); diff --git a/scuttlebutt/src/db.rs b/scuttlebutt/src/db.rs @@ -1,562 +0,0 @@ -use cassandra_cpp::{Value, SetIterator, Session, AsRustType, BindRustType, Result, Cluster, stmt}; -use crate::responses::*; - -#[derive(Debug)] -pub enum IdType { - User, - Group, - Channel, - Message -} - -/// Trait for the back-end database that contains all CRUD database operations. -/// -/// **Every method (outside of `valid_id`) assumes that the IDs passed are valid.** -/// -/// Meant to enable switching backends, but right now the Result type is hardcoded -/// to a `cassandra_cpp::Result` due to other concerns (see [Issue #2](https://github.com/quantumish/blatherskite/issues/2) discussion on repo). -pub trait Database: Sync + Send { - fn valid_id(&self, kind: IdType, id: i64) -> Result<bool>; - - fn create_user(&self, id: i64, name: String, email: String, hash: String) -> Result<()>; - fn update_user(&self, id: i64, name: String, email: String) -> Result<()>; - fn get_user(&self, id: i64) -> Result<User>; - fn get_user_hash(&self, id: i64) -> Result<String>; - fn delete_user(&self, id: i64) -> Result<()>; - - fn create_group(&self, gid: i64, uid: i64, name: String, dm: bool) -> Result<()>; - fn get_group(&self, id: i64) -> Result<Group>; - fn update_group(&self, id: i64, name: String) -> Result<()>; - fn delete_group(&self, id: i64) -> Result<()>; - - fn get_group_members(&self, gid: i64) -> Result<Vec<i64>>; - fn add_group_member(&self, gid: i64, uid: i64) -> Result<()>; - fn remove_group_member(&self, gid: i64, uid: i64) -> Result<()>; - - fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>>; - fn add_group_channel(&self, gid: i64, uid: i64) -> Result<()>; - fn remove_group_channel(&self, gid: i64, uid: i64) -> Result<()>; - - fn get_group_admin(&self, gid: i64) -> Result<Vec<i64>>; - fn add_group_admin(&self, gid: i64, uid: i64) -> Result<()>; - fn remove_group_admin(&self, gid: i64, uid: i64) -> Result<()>; - - fn get_group_owner(&self, gid: i64) -> Result<i64>; - - fn is_group_dm(&self, gid: i64) -> Result<bool>; - - fn create_channel(&self, cid: i64, gid: i64, uid: i64, name: String) -> Result<()>; - fn get_channel(&self, id: i64) -> Result<Channel>; - fn update_channel(&self, id: i64, name: String) -> Result<()>; - fn delete_channel(&self, id: i64) -> Result<()>; - - fn get_channel_members(&self, gid: i64) -> Result<Vec<i64>>; - fn add_channel_member(&self,cid: i64, id: i64) -> Result<()>; - fn remove_channel_member(&self, cid: i64, id: i64) -> Result<()>; - - fn is_channel_private(&self, id: i64) -> Result<bool>; - fn set_channel_private(&self, id: i64, value: bool) -> Result<bool>; - - fn create_user_groups(&self, id: i64) -> Result<()>; - fn get_user_groups(&self, id: i64) -> Result<Vec<i64>>; - fn delete_user_groups(&self, id: i64) -> Result<()>; - fn add_user_group(&self, uid: i64, gid: i64) -> Result<()>; - fn remove_user_group(&self, uid: i64, gid: i64) -> Result<()>; - - fn create_user_dms(&self, id: i64) -> Result<()>; - fn get_user_dms(&self, id: i64) -> Result<Vec<i64>>; - fn delete_user_dms(&self, id: i64) -> Result<()>; - fn add_user_dm(&self, uid: i64, gid: i64) -> Result<()>; - - fn get_message(&self, id: i64) -> Result<Message>; - fn get_messages(&self, cid: i64, num: u64) -> Result<Vec<Message>>; - fn delete_message(&self, id: i64) -> Result<()>; - fn set_thread(&self, id: i64, cid: i64) -> Result<()>; -} - -/// Cassandra backend struct -pub struct Cassandra { - kspc: String, // keyspace - sess: Session -} - -impl Cassandra { - /// Initialize the database session and creates tables - /// - /// Arguments: - /// - `keyspc`: the keyspace to use for all database queries. - pub fn new(keyspc: &str) -> Self { - let contact_points = "127.0.0.1"; // NOTE: generalize me - let mut cluster = Cluster::default(); - cluster.set_contact_points(contact_points).unwrap(); - cluster.set_load_balance_round_robin(); - let session = cluster.connect().unwrap(); - - session.execute(&stmt!(&format!( - "CREATE KEYSPACE IF NOT EXISTS {keyspc} \ - WITH replication = {{'class':'SimpleStrategy', 'replication_factor': 1}}" - ))).wait().unwrap(); - - session.execute(&stmt!(&format!( - "CREATE TABLE IF NOT EXISTS {keyspc}.users \ - (id bigint PRIMARY KEY, name text, email text, hash text);" - ))).wait().unwrap(); - - session.execute(&stmt!(&format!( - "CREATE TABLE IF NOT EXISTS {keyspc}.groups \ - (id bigint PRIMARY KEY, name text, members set<bigint>, is_dm boolean, \ - channels set<bigint>, admin set<bigint>, owner bigint);" - ))).wait().unwrap(); - - session.execute(&stmt!(&format!( - "CREATE TABLE IF NOT EXISTS {keyspc}.channels \ - (id bigint PRIMARY KEY, group bigint, name text, \ - members set<bigint>, private boolean);" - ))).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}.user_dms \ - (id bigint PRIMARY KEY, dms set<bigint>);" - ))).wait().unwrap(); - - session.execute(&stmt!(&format!( - "CREATE TABLE IF NOT EXISTS {keyspc}.messages \ - (channel bigint, id bigint, author bigint, \ - content text, group bigint, thread bigint, \ - PRIMARY KEY (channel, id)) \ - WITH CLUSTERING ORDER BY (id DESC);" - ))).wait().unwrap(); - - Self { - kspc: keyspc.to_string(), - sess: session - } - } - - /// Delete a row from the database. - /// - /// Arguments: - /// - `table`: the table to delete the row from - /// - `id`: the id of the row to delete - fn delete_row(&self, table: &str, id: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "DELETE FROM {}.{table} WHERE id={id};", self.kspc - ))).wait().unwrap(); - Ok(()) - } - - /// Extract a set from a database row - /// - /// Arguments: - /// - `table`: the table with the desired row - /// - `set`: the name of the column with the set in it - /// - `id`: the id of the row to get the set from - fn get_set(&self, table: &str, set: &str, id: i64) -> Result<Vec<i64>> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT {set} FROM {}.{table} WHERE id = {id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - let set: Value = row.get_column(0)?; - Ok(match set.is_null() { - true => Vec::new(), - false => set.get_set()?.map(|i| i.get_i64().unwrap()).collect() - }) - } - - /// Remove an element from a set in a database row - /// - /// Arguments: - /// - `table`: the table with the desired row - /// - `set`: the name of the column with the set in it - /// - `id`: the id of the row to get the set from - /// - `elem`: the element to remove from the set - fn pop_set(&self, table: &str, set: &str, id: i64, elem: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "UPDATE {}.{table} SET {set} = {set} - {{{elem}}} WHERE ID={id};", self.kspc - ))).wait()?; - Ok(()) - } - - /// Add an element to a set in a database row - /// - /// Arguments: - /// - `table`: the table with the desired row - /// - `set`: the name of the column with the set in it - /// - `id`: the id of the row to get the set from - /// - `elem`: the element to add to the set - fn push_set(&self, table: &str, set: &str, id: i64, elem: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "UPDATE {}.{table} SET {set} = {set} + {{{elem}}} WHERE ID={id};", self.kspc - ))).wait()?; - Ok(()) - } -} - -impl Database for Cassandra { - fn valid_id(&self, kind: IdType, id: i64) -> Result<bool> { - let table = match kind { - IdType::User => "users", - IdType::Group => "groups", - IdType::Channel => "channels", - IdType::Message => "messages", - }; - let res = self.sess.execute(&stmt!(&format!( - "SELECT * FROM {}.{table} WHERE ID={id};", self.kspc - ))).wait()?; - if let Some(_row) = res.first_row() { - return Ok(true) - } else { return Ok(false) }; - } - - fn create_user(&self, id: i64, name: String, email: String, hash: String) -> Result<()> { - let mut stmt = stmt!(&format!( - "INSERT INTO {}.users (id, name, email, hash) VALUES ({id}, ?, ?, ?);", self.kspc - )); - stmt.bind(0, name.as_str())?; - stmt.bind(1, email.as_str())?; - stmt.bind(2, hash.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn get_user(&self, id: i64) -> Result<User> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT name, email FROM {}.users WHERE ID={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(User { - id, - username: row.get(0)?, - email: row.get(1)? - }) - } - - fn get_user_hash(&self, id: i64) -> Result<String> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT hash FROM {}.users WHERE ID={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(row.get(0)?) - } - - fn update_user(&self, id: i64, name: String, email: String) -> Result<()> { - let mut stmt = stmt!(&format!( - "UPDATE {}.users SET name=?, email=? WHERE ID={id};", self.kspc - )); - stmt.bind(0, name.as_str())?; - stmt.bind(1, email.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn delete_user(&self, id: i64) -> Result<()> { - self.delete_row("users", id) - } - - fn get_group(&self, id: i64) -> Result<Group> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT name, members, channels, owner, is_dm FROM {}.groups WHERE ID={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - let members: SetIterator = row.get(1)?; - let channels: SetIterator = row.get(2)?; - Ok(Group { - id, - name: row.get(0)?, - members: members.map(|i| i.get_i64().unwrap()).collect(), - channels: channels.map(|i| i.get_i64().unwrap()).collect(), - admin: self.get_set("groups", "admin", id)?, // HACK - owner: row.get(3)?, - is_dm: row.get(4)?, - }) - } - - fn create_group(&self, gid: i64, uid: i64, name: String, dm: bool) -> Result<()> { - let mut stmt = stmt!(&format!( - "INSERT INTO {}.groups (id, name, channels, \ - members, is_dm, owner) VALUES ({gid}, ?, {{}}, {{{uid}}}, {dm}, {uid});", self.kspc - )); - stmt.bind(0, name.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn delete_group(&self, id: i64) -> Result<()> { - self.delete_row("groups", id) - } - - fn update_group(&self, id: i64, name: String) -> Result<()> { - let mut stmt = stmt!(&format!( - "UPDATE {}.groups SET name = ? WHERE id = {id};", self.kspc - )); - stmt.bind(0, name.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn get_group_members(&self, gid: i64) -> Result<Vec<i64>> { - self.get_set("groups", "members", gid) - } - - fn add_group_member(&self, gid: i64, uid: i64) -> Result<()> { - self.push_set("groups", "members", gid, uid) - } - - fn remove_group_member(&self, gid: i64, uid: i64) -> Result<()> { - self.pop_set("groups", "members", gid, uid) - } - - fn get_group_channels(&self, gid: i64) -> Result<Vec<i64>> { - self.get_set("groups", "channels", gid) - } - - fn add_group_channel(&self, gid: i64, cid: i64) -> Result<()> { - self.push_set("groups", "channels", gid, cid) - } - - fn remove_group_channel(&self, gid: i64, cid: i64) -> Result<()> { - self.pop_set("groups", "channels", gid, cid) - } - - fn get_group_admin(&self, gid: i64) -> Result<Vec<i64>> { - self.get_set("groups", "admin", gid) - } - - fn add_group_admin(&self, gid: i64, uid: i64) -> Result<()> { - self.push_set("groups", "admin", gid, uid) - } - - - fn remove_group_admin(&self, gid: i64, uid: i64) -> Result<()> { - self.pop_set("groups", "admin", gid, uid) - } - - fn get_group_owner(&self, gid: i64) -> Result<i64> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT owner FROM {}.groups WHERE id={gid};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(row.get(0)?) - } - - fn is_group_dm(&self, gid: i64) -> Result<bool> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT is_dm FROM {}.groups WHERE id={gid};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(row.get(0)?) - } - - fn get_channel(&self, id: i64) -> Result<Channel> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT group, name, members, private FROM {}.channels WHERE ID={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - let members: SetIterator = row.get(2)?; - Ok(Channel { - id, - group: row.get(0)?, - name: row.get(1)?, - members: members.map(|i| i.get_i64().unwrap()).collect(), - private: row.get(3)? - }) - } - - fn create_channel(&self, cid: i64, gid: i64, uid: i64, name: String) -> Result<()> { - let mut stmt = stmt!(&format!( - "INSERT INTO {}.channels (id, group, name, members, private) VALUES ({cid}, {gid}, ?, {{{uid}}}, false);", self.kspc - )); - stmt.bind(0, name.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn delete_channel(&self, id: i64) -> Result<()> { - self.delete_row("channels", id) - } - - fn update_channel(&self, id: i64, name: String) -> Result<()> { - let mut stmt = stmt!(&format!( - "UPDATE {}.channels SET name = ? WHERE id = {id};", self.kspc - )); - stmt.bind(0, name.as_str())?; - self.sess.execute(&stmt).wait()?; - Ok(()) - } - - fn get_channel_members(&self, cid: i64) -> Result<Vec<i64>> { - self.get_set("channels", "members", cid) - } - - fn add_channel_member(&self, gid: i64, uid: i64) -> Result<()> { - self.push_set("channels", "members", gid, uid) - } - - fn remove_channel_member(&self, gid: i64, uid: i64) -> Result<()> { - self.pop_set("channels", "members", gid, uid) - } - - fn is_channel_private(&self, id: i64) -> Result<bool> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT private FROM {}.channels WHERE id={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(row.get(0)?) - } - - fn set_channel_private(&self, id: i64, value: bool) -> Result<bool> { - let res = self.sess.execute(&stmt!(&format!( - "UPDATE {}.channels SET private = {value} WHERE id={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - Ok(row.get(0)?) - } - - fn create_user_dms(&self, id: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "INSERT INTO {}.user_dms (id, dms) VALUES ({id}, {{}});", self.kspc - ))).wait()?; - Ok(()) - } - - fn get_user_dms(&self, id: i64) -> Result<Vec<i64>> { - self.get_set("user_dms", "dms", id) - } - - fn add_user_dm(&self, uid: i64, gid: i64) -> Result<()> { - self.push_set("user_dms", "dms", uid, gid) - } - - fn delete_user_dms(&self, id: i64) -> Result<()> { - self.delete_row("user_dms", id) - } - - fn create_user_groups(&self, id: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "INSERT INTO {}.user_groups (id, groups) VALUES ({id}, {{}});", self.kspc - ))).wait()?; - Ok(()) - } - - fn get_user_groups(&self, id: i64) -> Result<Vec<i64>> { - self.get_set("user_groups", "groups", id) - } - - fn add_user_group(&self, uid: i64, gid: i64) -> Result<()> { - self.push_set("user_groups", "groups", uid, gid) - } - - fn remove_user_group(&self, uid: i64, gid: i64) -> Result<()> { - self.pop_set("user_groups", "groups", uid, gid) - } - - fn delete_user_groups(&self, id: i64) -> Result<()> { - self.delete_row("user_groups", id) - } - - // TODO the unwraps here are not great - fn get_messages(&self, cid: i64, num: u64) -> Result<Vec<Message>> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT * FROM {}.messages WHERE channel={cid} LIMIT {num};", self.kspc - ))).wait()?; - Ok(res.iter().map(|row| { - let maybe_thread: Value = row.get_column(5).unwrap(); - Message { - id: row.get(0).unwrap(), - author: row.get(2).unwrap(), - channel: row.get(1).unwrap(), - content: row.get(4).unwrap(), - thread: match maybe_thread.is_null() { - true => None, - false => Some(maybe_thread.get_i64().unwrap()) - } - } - }).collect::<Vec<Message>>()) - } - - fn get_message(&self, id: i64) -> Result<Message> { - let res = self.sess.execute(&stmt!(&format!( - "SELECT channel, author, content, thread FROM {}.messages WHERE ID={id};", self.kspc - ))).wait()?; - let row = res.first_row().unwrap(); - let thread: Value = row.get_column(3)?; - Ok(Message { - id, - channel: row.get(0)?, - author: row.get(1)?, - content: row.get(2)?, - thread: match thread.is_null() { - true => None, - false => Some(thread.get_i64().unwrap()) - } - }) - } - - fn delete_message(&self, id: i64) -> Result<()> { - self.delete_row("messages", id) - } - - fn set_thread(&self, id: i64, cid: i64) -> Result<()> { - self.sess.execute(&stmt!(&format!( - "UPDATE {}.messages SET thread = {cid} WHERE id = {id};", self.kspc - ))).wait()?; - Ok(()) - } -} - -#[cfg(test)] -pub mod tests { - use pretty_assertions::assert_eq; - - use super::*; - - #[test] - fn test_delete_row() { - let db = Cassandra::new("test"); - db.sess.execute(&stmt!( - "INSERT INTO test.users (id, name, email, hash) VALUES (11, 'fred', '', '');" - )).wait().unwrap(); - db.delete_row("users", 11).unwrap(); - let res = db.sess.execute(&stmt!( - "SELECT * FROM test.users WHERE id=11;" - )).wait().unwrap(); - assert_eq!(res.row_count(), 0); - } - - #[test] - fn test_get_set() { - let db = Cassandra::new("test"); - db.sess.execute(&stmt!( - "INSERT INTO test.user_groups (id, groups) VALUES (12, {1,2,3});" - )).wait().unwrap(); - assert_eq!(db.get_set("user_groups", "groups", 12).unwrap(), vec![1,2,3]); - db.delete_row("user_groups", 12).unwrap(); - } - - #[test] - fn test_push_set() { - let db = Cassandra::new("test"); - db.sess.execute(&stmt!( - "INSERT INTO test.user_groups (id, groups) VALUES (13, {1,2,3});" - )).wait().unwrap(); - db.push_set("user_groups", "groups", 13, 4).unwrap(); - assert_eq!(db.get_set("user_groups", "groups", 13).unwrap(), vec![1,2,3,4]); - db.delete_row("user_groups", 13).unwrap(); - } - - #[test] - fn test_pop_set() { - let db = Cassandra::new("test"); - db.sess.execute(&stmt!( - "INSERT INTO test.user_groups (id, groups) VALUES (14, {1,2,3});" - )).wait().unwrap(); - db.pop_set("user_groups", "groups", 14, 3).unwrap(); - assert_eq!(db.get_set("user_groups", "groups", 14).unwrap(), vec![1,2]); - db.delete_row("user_groups", 14).unwrap(); - } -} diff --git a/scuttlebutt/src/error.rs b/scuttlebutt/src/error.rs @@ -0,0 +1,186 @@ +use poem::http::StatusCode; +use diesel::result::{QueryResult, Error}; + +#[derive(Debug, Clone)] +pub struct UserFacingPropagatedError { + code: StatusCode, + backtrace: Option<String>, + error_kind: String, + error: String, + context: Option<String>, +} + +impl std::fmt::Display for UserFacingPropagatedError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + writeln!(f, "<h1>{}</h1>", self.code)?; + writeln!(f, "Received <code>{}: {}</code>", self.error_kind, self.error)?; + if let Some(ctx) = &self.context { + writeln!(f, " while {}.", ctx)?; + } + if let Some(trace) = &self.backtrace { + writeln!(f, "<pre>{}</pre>", html_escape::encode_text(&trace))?; + } + Ok(()) + } +} + +impl std::error::Error for UserFacingPropagatedError {} + +#[derive(Debug, Clone)] +pub struct UserFacingError { + code: StatusCode, + reason: Option<String>, +} + +impl UserFacingError { + pub fn new(code: StatusCode, reason: &str) -> Self { + Self { + code, + reason: Some(String::from(reason)) + } + } + pub fn terse(code: StatusCode) -> Self { + Self { + code, + reason: None, + } + } +} + +impl Into<poem::Error> for UserFacingError { + fn into(self) -> poem::Error { + let code = self.code; + poem::Error::new(self, code) + } +} + +impl std::fmt::Display for UserFacingError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "<h1>{}</h1>", self.code)?; + if let Some(reason) = &self.reason { + write!(f, "{}", reason)?; + } + writeln!(f, "") + } +} + +impl std::error::Error for UserFacingError {} + +#[derive(Debug)] +pub struct WithBacktrace<E> +where E: std::error::Error +{ + backtrace: std::backtrace::Backtrace, + pub err: E, + pub context: Option<String>, +} + +impl<E> std::fmt::Display for WithBacktrace<E> + where E: std::error::Error +{ + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.err) + } +} + +impl<E> std::error::Error for WithBacktrace<E> +where E: std::error::Error {} + +pub trait WithBacktraceExt<E> where E: std::error::Error { + type Success; + fn with_backtrace(self) -> Result<Self::Success, WithBacktrace<E>>; +} + +impl<T,E> WithBacktraceExt<E> for Result<T, E> +where E: std::error::Error +{ + type Success = T; + fn with_backtrace(self) -> Result<Self::Success, WithBacktrace<E>> { + self.map_err(|err| WithBacktrace { + err, + backtrace: std::backtrace::Backtrace::capture(), + context: None + }) + } +} + +pub trait InternalResultExt { + type Success; + fn poemify(self, ctx: &str) -> poem::Result<Self::Success>; +} + + +#[derive(Debug)] +pub struct StdAnyhowError(pub anyhow::Error); + +impl std::fmt::Display for StdAnyhowError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for StdAnyhowError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + +impl From<anyhow::Error> for StdAnyhowError { + fn from(err: anyhow::Error) -> Self { + Self(err) + } +} + +impl<T> InternalResultExt for QueryResult<T> { + type Success = T; + + fn poemify(self, ctx: &str) -> poem::Result<Self::Success> { + self.map_err(|err| { + log::error!("{:#?}", err); + let code = match err { + diesel::result::Error::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + poem::error::Error::new(UserFacingPropagatedError { + code, + backtrace: None, + context: Some(String::from(ctx)), + error_kind: format!("{:?}", err), + error: err.to_string(), + }, code) + }) + } +} + + +impl<T> InternalResultExt for anyhow::Result<T> { + type Success = T; + + fn poemify(self, ctx: &str) -> poem::Result<Self::Success> { + self.map_err(|err| { + log::error!("{:#?}", err); + poem::error::InternalServerError(StdAnyhowError::from(err)) + }) + } + +} + +impl<T, E> InternalResultExt for std::result::Result<T, WithBacktrace<E>> + where E: std::error::Error +{ + type Success = T; + + fn poemify(self, ctx: &str) -> poem::Result<Self::Success> { + self.map_err(|err| { + log::error!("{:#?}", err.err); + poem::error::InternalServerError( UserFacingPropagatedError { + code: StatusCode::INTERNAL_SERVER_ERROR, + backtrace: Some(err.backtrace.to_string()), + context: Some(String::from(ctx)), + error_kind: String::from(std::any::type_name::<E>()), + error: err.to_string(), + }) + }) + } + +} diff --git a/scuttlebutt/src/main.rs b/scuttlebutt/src/main.rs @@ -1,15 +1,17 @@ +#![allow(warnings, unused)] + use chrono::{DateTime, Duration, Local, Utc}; use hmac::Hmac; use jwt::{SignWithKey, VerifyWithKey}; use poem::{ - listener::TcpListener, web::Data, EndpointExt, Request, Result, - Route, Server, + listener::TcpListener, web::Data, EndpointExt, Request, Result, + Route, Server, http::StatusCode, }; use poem_openapi::{ - auth::ApiKey, - param::Query, - payload::{Json, PlainText}, - *, + auth::ApiKey, + param::Query, + payload::{Json, PlainText}, + *, }; use std::sync::Mutex; use rand::{distributions::Alphanumeric, Rng}; @@ -17,11 +19,17 @@ use rustflake::Snowflake; use serde::{Deserialize, Serialize}; use sha2::Sha256; -pub mod responses; -pub use responses::*; +use diesel::pg::PgConnection; +use diesel::prelude::*; +use dotenvy::dotenv; +use std::env; -pub mod db; -pub use db::*; +pub mod models; +use models::*; +pub mod schema; +use schema::*; +pub mod error; +use error::*; type ServerKey = Hmac<Sha256>; @@ -29,703 +37,809 @@ type ServerKey = Hmac<Sha256>; /// The serialized form of this struct forms the content portion of the JWT returned by /login #[derive(Serialize, Deserialize)] struct Claims { - id: i64, - exp: DateTime<Local>, + id: i64, + exp: DateTime<Local>, } /// API key authorization scheme #[derive(SecurityScheme)] #[oai( - type = "api_key", - key_name = "Authorization", // header to look for API key in - in = "header", - checker = "api_checker" // hook to run when checking authorization + type = "api_key", + key_name = "Authorization", // header to look for API key in + in = "header", + checker = "api_checker" // hook to run when checking authorization )] struct Authorization(Claims); +fn append_array(conn: &mut PgConnection, table: &str, field: &str, id: i64, val: i64) -> Result<usize, WithBacktrace<diesel::result::Error>> { + diesel::sql_query(format!( + "UPDATE {table} SET {field} = array_append({field},$1) WHERE id = $2;" + )) + .bind::<diesel::sql_types::BigInt, _>(val) + .bind::<diesel::sql_types::BigInt, _>(id) + .execute(conn).with_backtrace() +} + + +fn remove_array(conn: &mut PgConnection, table: &str, field: &str, id: i64, val: i64) -> Result<usize, WithBacktrace<diesel::result::Error>> { + diesel::sql_query(format!( + "UPDATE {table} SET {field} = array_remove({field},$1) WHERE id = $2;" + )) + .bind::<diesel::sql_types::BigInt, _>(val) + .bind::<diesel::sql_types::BigInt, _>(id) + .execute(conn).with_backtrace() +} + + /// Check if a user has supplied a valid authorization token. /// /// Returns None if the token was invalid or if it fails to parse the given token /// (which will then be handled by Poem to throw a 401), otherwise returns the /// Claims struct. async fn api_checker(req: &Request, api_key: ApiKey) -> Option<Claims> { - let encoded_claims_str = match api_key.key.split(".").nth(1) { - None => return None, - Some(s) => s, - }; - let claims_str = match base64::decode(encoded_claims_str) { - Err(_) => return None, - Ok(s) => s, - }; - let claims: Claims = match serde_json::from_str(&String::from_utf8(claims_str).unwrap()) { - Err(_) => return None, - Ok(c) => c - }; - if claims.exp < Local::now() { - return None; - } - let server_key = req.data::<ServerKey>().unwrap(); // get server secret - VerifyWithKey::<Claims>::verify_with_key(api_key.key.as_str(), server_key).ok() + let encoded_claims_str = match api_key.key.split(".").nth(1) { + None => return None, + Some(s) => s, + }; + let claims_str = match base64::decode(encoded_claims_str) { + Err(_) => return None, + Ok(s) => s, + }; + let claims: Claims = match serde_json::from_str(&String::from_utf8(claims_str).unwrap()) { + Err(_) => return None, + Ok(c) => c + }; + if claims.exp < Local::now() { + return None; + } + let server_key = req.data::<ServerKey>().unwrap(); // get server secret + VerifyWithKey::<Claims>::verify_with_key(api_key.key.as_str(), server_key).ok() } /// Wrapper struct for the API functions -struct Api { - // The backend. - db: Box<dyn Database>, -} +struct Api {} /// Generates a unique i64 for ID generation // FIXME: Very bad performance - acts as a chokehold for parallelism since // every request that sends a message / makes a channel / etc. has to contest // a global mutex. pub fn gen_id() -> i64 { - static STATE: Mutex<Option<Snowflake>> = Mutex::new(None); + static STATE: Mutex<Option<Snowflake>> = Mutex::new(None); - STATE - .lock() - .unwrap() - .get_or_insert_with(|| Snowflake::default()) - .generate() + STATE + .lock() + .unwrap() + .get_or_insert_with(|| Snowflake::default()) + .generate() } pub fn check_name(name: String) -> String{ - let name_chars = name.chars(); - let fixed_name_chars = name_chars.filter(|i| !i.is_whitespace()); - let mut fixed_name: String = "".to_string(); - for i in fixed_name_chars{ - fixed_name.push(i); - }; - assert!(fixed_name != "".to_string(), "name is empty or contains only illegal characters"); - return fixed_name; + let name_chars = name.chars(); + let fixed_name_chars = name_chars.filter(|i| !i.is_whitespace()); + let mut fixed_name: String = "".to_string(); + for i in fixed_name_chars{ + fixed_name.push(i); + }; + // assert!(fixed_name != "".to_string(), "name is empty or contains only illegal characters"); + return fixed_name; +} + +pub fn open_db_conn() -> PgConnection { + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + PgConnection::establish(&database_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) } #[OpenApi] #[allow(unused_variables)] impl Api { - fn new(db: Box<dyn Database>) -> Api { - Api { db } - } - - fn __remove_group_member(&self, gid: i64, uid: i64) { - self.db.remove_group_member(gid, uid).unwrap(); - let channels = self.db.get_group_channels(gid).unwrap(); - for channel in channels { - self.db.remove_channel_member(channel, uid).unwrap(); - } - self.db.remove_user_group(uid, gid).unwrap(); - } - - #[oai(path = "/login", method = "post")] - /// Log in as a user. Returns an authentication token given id and hash. - /// - /// Expects hash of user's password to be given in the request body. - /// Checks validity of hash, then signs JWT with a server secret key. - async fn login(&self, key: Data<&ServerKey>, id: Query<i64>, hash: PlainText<String>) -> LoginResponse { - use LoginResponse::*; - if hash.0.len() != 64 { - return BadRequest; - } else if !self.db.valid_id(IdType::User, id.0).unwrap() { - return NotFound; - } - let db_hash = self.db.get_user_hash(id.0).unwrap(); - if hex::decode(db_hash.clone()).unwrap() != hex::decode(hash.0.clone()).unwrap() { - - Unauthorized - } else { - let token = Claims { - id: id.0, - exp: Local::now() + Duration::days(1), - } - .sign_with_key(key.0); - Success(PlainText(token.unwrap())) - } - } - - #[oai(path = "/user", method = "get")] - /// Get the user with the given ID - /// - /// Does not require any authorization. - async fn get_user(&self, id: Query<i64>) -> UserResponse { - use UserResponse::*; - if !self.db.valid_id(IdType::User, id.0).unwrap() { return NotFound; } - match self.db.get_user(id.0) { - Ok(user) => Success(Json(user)), - Err(e) => InternalError(PlainText(e.to_string())) - } - } - - #[oai(path = "/user", method = "post")] - /// Create a new user. - /// - /// Expects hash of user's password to be given in the request body. - /// Does not require any authorization. - async fn make_user(&self, name: Query<String>, email: Query<String>, hash: PlainText<String>) -> CreateUserResponse { - use CreateUserResponse::*; - if hash.0.len() != 64 { - return BadRequest(PlainText("Invalid hash provided.".to_string())); - } - let checked_name = check_name(name.0.clone()); - let id = gen_id(); - self.db.create_user(id, checked_name.clone(), email.0.clone(), hash.0).unwrap(); - self.db.create_user_groups(id).unwrap(); - self.db.create_user_dms(id).unwrap(); - Success(Json(User { - id, - username: name.0, - email: email.0, - })) - } - - #[oai(path = "/user", method = "put")] - /// Update your name and email. - async fn update_user(&self, auth: Authorization, name: Query<String>, email: Query<String>) -> GenericResponse { - use GenericResponse::*; - let checked_name = check_name(name.0.clone()); - self.db.update_user(auth.0.id, checked_name, email.0).unwrap(); - Success - } - - #[oai(path = "/user", method = "delete")] - /// Delete your user. - /// - /// Has the side effects of removing your user from every group, channel, or DM - /// it is a member of. - async fn delete_user(&self, auth: Authorization) -> DeleteResponse { - use DeleteResponse::*; - self.db.delete_user(auth.0.id).unwrap(); - for group in self.db.get_user_groups(auth.0.id).unwrap() { - self.__remove_group_member(group, auth.0.id); - } - for dm in self.db.get_user_dms(auth.0.id).unwrap() { - self.__remove_group_member(dm, auth.0.id); - } - self.db.delete_user_groups(auth.0.id).unwrap(); - Success - } - - #[oai(path = "/user/groups", method = "get")] - /// Get all groups accessible to you. - async fn get_groups(&self, auth: Authorization) -> GroupsResponse { - use GroupsResponse::*; - let groups = self.db.get_user_groups(auth.0.id).unwrap(); - let group_vec = groups.iter().map(|i| { - self.db.get_group(*i).unwrap() - }).collect(); - Success(Json(group_vec)) - } - - #[oai(path = "/user/dms", method = "get")] - /// Get all DMs accessible to you. - async fn get_dms(&self, auth: Authorization) -> GroupsResponse { - use GroupsResponse::*; - let groups = self.db.get_user_dms(auth.0.id).unwrap(); - let group_vec = groups.iter().map(|i| { - self.db.get_group(*i).unwrap() - }).collect(); - Success(Json(group_vec)) - } - - - #[oai(path = "/user/groups", method = "delete")] - /// Leave a group accessible to you - async fn leave_group(&self, auth: Authorization, gid: Query<i64>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())); - } - self.__remove_group_member(gid.0, auth.0.id); - Success - } - - #[oai(path = "/group", method = "get")] - /// Gets the group with the given ID - async fn get_group(&self, auth: Authorization, id: Query<i64>) -> GroupResponse { - use GroupResponse::*; - if !self.db.valid_id(IdType::Group, id.0).unwrap() || - !self.db.get_group_members(id.0).unwrap().contains(&auth.0.id) - { - return NotFound; - } - Success(Json(self.db.get_group(id.0).unwrap())) - } - - #[oai(path = "/group", method = "post")] - /// Create a new group. - /// - /// The group created... - /// - will have a default public "main" channel - /// - will have your user as the owner - /// - will have your user as an admin - async fn make_group(&self, auth: Authorization, name: Query<String>) -> CreateGroupResponse { - use CreateGroupResponse::*; - let gid = gen_id(); - if name.0 == "" { - return BadRequest(PlainText("Empty string not allowed for name".to_string())) - } - let fixed_name = check_name(name.0.clone()); - self.db.create_group(gid, auth.0.id, fixed_name.clone(), false).unwrap(); - self.db.add_user_group(auth.0.id, gid).unwrap(); - self.db.add_group_admin(gid, auth.0.id).unwrap(); - let cid = gen_id(); - self.db.create_channel(cid, gid, auth.0.id, String::from("main")).unwrap(); - self.db.add_group_channel(gid, cid).unwrap(); - Success(Json(Group { - id: gid, - name: name.0, - members: vec![auth.0.id], - channels: vec![cid], - admin: vec![auth.0.id], - owner: auth.0.id, - is_dm: false - })) - } - - #[oai(path = "/dm", method = "post")] - /// Create a new DM with a user `uid`. - /// - /// The group created... - /// - will have the `is_dm` attribute set to true. - /// - will have only one channel "main" with you and `uid` - /// - will have no owner or admins - async fn make_dm(&self, auth: Authorization, uid: Query<i64>) -> CreateGroupResponse { - use CreateGroupResponse::*; - if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound; - } - let gid = gen_id(); - self.db.create_group(gid, auth.0.id, String::from(""), true).unwrap(); - self.db.add_group_member(gid, uid.0).unwrap(); - self.db.add_user_dm(auth.0.id, gid).unwrap(); - self.db.add_user_dm(uid.0, gid).unwrap(); - let cid = gen_id(); - self.db.create_channel(cid, gid, auth.0.id, String::from("main")).unwrap(); - self.db.add_group_channel(gid, cid).unwrap(); - self.db.add_channel_member(cid, uid.0).unwrap(); - Success(Json(Group { - id: gid, - name: String::from(""), - members: vec![auth.0.id, uid.0], - channels: vec![cid], - admin: vec![], - owner: auth.0.id, - is_dm: true - })) - } - - #[oai(path = "/group", method = "put")] - /// Update the name of an existing group. - /// - /// Only authorized for the owner of a group. - async fn update_group(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> GenericResponse { - use GenericResponse::*; - if name.0 == "" { - return BadRequest(PlainText("Empty string not allowed for name".to_string())) - } else if !self.db.valid_id(IdType::Group, id.0).unwrap() { - return NotFound(PlainText("Didn't find group or experienced database error.".to_string())); - } else if self.db.get_group_owner(id.0).unwrap() != auth.0.id { - return Unauthorized; - } - let fixed_name = check_name(name.0.clone()); - self.db.update_group(id.0, fixed_name).unwrap(); - Success - } - - #[oai(path = "/group", method = "delete")] - /// Delete a group. - /// - /// Only authorized for the owner of a group. - async fn delete_group(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Group, id.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())); - } else if self.db.get_group_owner(id.0).unwrap() != auth.0.id { - return Unauthorized; - } - let group = self.db.get_group(id.0).unwrap(); - for member in group.members { - self.db.remove_user_group(member, id.0).unwrap(); - } - for channel in group.channels { - self.db.delete_channel(channel).unwrap(); - } - self.db.delete_group(id.0).unwrap(); - Success - } - - #[oai(path = "/group/members", method = "get")] - /// Get the members of the specified group. - /// - /// No specific order for the list is guaranteed. - async fn get_group_members(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { - use MembersResponse::*; - if !self.db.valid_id(IdType::Group, id.0).unwrap() { - return NotFound; - } - let members = self.db.get_group_members(id.0).unwrap(); - Success(Json(members.iter().map(|m| { - self.db.get_user(*m).unwrap() - }).collect::<Vec<User>>())) - } - - #[oai(path = "/group/members", method = "put")] - /// Add a member to an existing group - /// - /// Only authorized for group admins. - /// Has the side effect of adding that member to all public channels. - async fn add_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())); - } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) && - self.db.get_group_owner(gid.0).unwrap() != auth.0.id - { - return Unauthorized; - } - self.db.add_group_member(gid.0, uid.0).unwrap(); - let channels = self.db.get_group_channels(gid.0).unwrap(); - for channel in channels { - if self.db.is_channel_private(channel).unwrap() { continue; } - self.db.add_channel_member(channel, uid.0).unwrap(); - } - if !self.db.is_group_dm(gid.0).unwrap() { - self.db.add_user_group(uid.0, gid.0).unwrap(); - } else { - self.db.add_user_dm(uid.0, gid.0).unwrap(); - } - Success - } - - #[oai(path = "/group/members", method = "delete")] - /// Remove a member from an existing group - /// - /// Only authorized for group admin. - /// Attempting to remove the owner from their group will always be unauthorized. - /// - /// Has the side effect of removing the member from all channels. - async fn remove_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())) - } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound(PlainText("User not found".to_string())) - } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) - || self.db.get_group_owner(gid.0).unwrap() == uid.0 - { - return Unauthorized; - } - self.__remove_group_member(gid.0, uid.0); - Success - } - - #[oai(path = "/group/admin", method = "get")] - /// Get the admins of the specified group. - /// - /// No specific order for the list is guaranteed. - async fn get_group_admin(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { - use MembersResponse::*; - if !self.db.valid_id(IdType::Group, id.0).unwrap() { - return NotFound; - } - let members = self.db.get_group_admin(id.0).unwrap(); - Success(Json(members.iter().map(|m| { - self.db.get_user(*m).unwrap() - }).collect::<Vec<User>>())) - } - - #[oai(path = "/group/admin", method = "put")] - /// Add an admin to an existing group - /// - /// Only authorized for the owner of a group. - async fn add_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())); - } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound(PlainText("User not found".to_string())) - } else if self.db.get_group_owner(gid.0).unwrap() != auth.0.id { - return Unauthorized; - } - self.db.add_group_admin(gid.0, uid.0).unwrap(); - Success - } - - #[oai(path = "/group/admin", method = "delete")] - /// Remove an admin from an existing group - /// - /// Only authorized for the owner of a group. - async fn remove_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())) - } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound(PlainText("User not found".to_string())) - } else if self.db.get_group_owner(gid.0).unwrap() != auth.0.id { - return Unauthorized; - } - self.db.remove_group_admin(gid.0, uid.0).unwrap(); - Success - } - - #[oai(path = "/group/channels", method = "get")] - /// Gets all channels in a group that are accessible to you - async fn get_channels(&self, auth: Authorization, gid: Query<i64>) -> ChannelsResponse { - use ChannelsResponse::*; - if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound; - } - let channels = self.db.get_group_channels(gid.0).unwrap(); - Success(Json(channels.iter().map(|c| { - self.db.get_channel(*c).unwrap() - }).filter(|c| c.members.contains(&auth.0.id)).collect::<Vec<Channel>>())) - } - - #[oai(path = "/group/channels", method = "post")] - /// Create a channel in a group. - /// - /// Only authorized for a group admin. - /// Defaults to a public channel with no members but yourself. - // TODO add some mechanism for auto-inviting current members - async fn make_channel(&self, auth: Authorization, gid: Query<i64>, name: Query<String>) -> CreateChannelResponse { - use CreateChannelResponse::*; - if name.0 == "" { - return BadRequest(PlainText("Empty string not allowed for name".to_string())) - } else if !self.db.valid_id(IdType::Group, gid.0).unwrap() { - return NotFound(PlainText("Group not found".to_string())); - } else if !self.db.get_group_admin(gid.0).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - let cid = gen_id(); - let fixed_name = check_name(name.0.clone()); - self.db.create_channel(cid, gid.0, auth.0.id, fixed_name.clone()).unwrap(); - self.db.add_group_channel(gid.0, cid).unwrap(); - Success(Json(Channel { - id: cid, - group: gid.0, - name: name.0, - members: vec![auth.0.id], - private: false - })) - } - - #[oai(path = "/channel", method = "put")] - /// Update the name of a channel. - /// - /// Only authorized for group admins. - async fn update_channel(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())); - } - let channel = self.db.get_channel(id.0).unwrap(); - if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - let fixed_name = check_name(name.0.clone()); - self.db.update_channel(id.0, fixed_name).unwrap(); - Success - } - - #[oai(path = "/channel/private", method = "put")] - /// Make a channel private. - /// - /// Only authorized for group admins. - async fn make_channel_private(&self, auth: Authorization, id: Query<i64>, val: Query<bool>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())); - } - let channel = self.db.get_channel(id.0).unwrap(); - if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - self.db.set_channel_private(id.0, val.0).unwrap(); - Success - } - - #[oai(path = "/channel", method = "get")] - /// Get a channel. - async fn get_channel(&self, auth: Authorization, id: Query<i64>) -> ChannelResponse { - use ChannelResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() || - !self.db.get_channel_members(id.0).unwrap().contains(&auth.0.id) - { - return NotFound; - } - Success(Json(self.db.get_channel(id.0).unwrap())) - } - - #[oai(path = "/channel", method = "delete")] - /// Delete a channel. - /// - /// Only authorized for group admins. - async fn delete_channel(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Channel, id.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())); - } - let channel = self.db.get_channel(id.0).unwrap(); - if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - self.db.remove_group_channel(channel.group, id.0).unwrap(); - self.db.delete_channel(id.0).unwrap(); - Success - } - - #[oai(path = "/channel/members", method = "get")] - /// Get the members that can access a channel. - /// - /// No specific order for the list is guaranteed. - async fn get_channel_members(&self, auth: Authorization, id: Query<i64>) -> MembersResponse { - use MembersResponse::*; - let members = self.db.get_channel_members(id.0).unwrap(); - Success(Json(members.iter().map(|m| { - self.db.get_user(*m).unwrap() - }).collect::<Vec<User>>())) - } - - #[oai(path = "/channel/members", method = "put")] - /// Add a member to a channel - /// - /// Only authorized for group admins. - async fn add_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> GenericResponse { - use GenericResponse::*; - if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())) - } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound(PlainText("User not found".to_string())) - } - let channel = self.db.get_channel(cid.0).unwrap(); - if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - self.db.add_channel_member(cid.0, uid.0).unwrap(); - Success - } - - #[oai(path = "/channel/members", method = "delete")] - /// Remove a member from a channel. - /// - /// Only authorized for group admins. - async fn remove_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())) - } else if !self.db.valid_id(IdType::User, uid.0).unwrap() { - return NotFound(PlainText("User not found".to_string())) - } - let channel = self.db.get_channel(cid.0).unwrap(); - if !self.db.get_group_admin(channel.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - self.db.remove_channel_member(cid.0, uid.0).unwrap(); - Success - } - - #[oai(path = "/channel/term", method = "get")] - /// Get a batch of messages in channel containing `term` in the last 100 messages - /// - /// Will not search for `term` in any messages older than the last 100. - async fn search_channel(&self, auth: Authorization, cid: Query<i64>, term: Query<String>, off: Query<u64>) -> MessagesResponse { - use MessagesResponse::*; - if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())) - } - let mut messages = self.db.get_messages(cid.0, 100).unwrap(); - messages.retain(|msg| msg.content.contains(&term.0)); - Success(Json(messages)) - } - - #[oai(path = "/channel/messages", method = "get")] - /// Returns batch of messages in channel. Do not use for small batches. - /// - /// For small batches, use `chatterbox`, the websocket service for messaging, instead. - async fn get_channel_messages(&self, auth: Authorization, cid: Query<i64>, num_msgs: Query<u64>) -> MessagesResponse { - use MessagesResponse::*; - if !self.db.valid_id(IdType::Channel, cid.0).unwrap() { - return NotFound(PlainText("Channel not found".to_string())) - } - Success(Json(self.db.get_messages(cid.0, num_msgs.0).unwrap())) - } - - #[oai(path = "/message/thread", method = "put")] - /// Make a thread for a given message. - /// - /// Thread will be private with you as its sole member - async fn make_thread(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> CreateChannelResponse { - use CreateChannelResponse::*; - if name.0 == "" { - return BadRequest(PlainText("Empty string not allowed for name".to_string())) - } else if !self.db.valid_id(IdType::Message, id.0).unwrap() { - return NotFound(PlainText("Message not found".to_string())) - } - let fixed_name = check_name(name.0.clone()); - let tid = gen_id(); - let msg = self.db.get_message(id.0).unwrap(); - let chan = self.db.get_channel(msg.channel).unwrap(); - self.db.create_channel(tid, chan.group, auth.0.id, fixed_name.clone()).unwrap(); - self.db.set_channel_private(tid, true).unwrap(); - self.db.set_thread(id.0, tid).unwrap(); - Success(Json(Channel { - id: tid, - group: chan.group, - members: vec![auth.0.id], - name: name.0, - private: true - })) - } - - #[oai(path = "/message", method = "delete")] - /// Delete a message - /// - /// Only authorized for the message author or a group admin. - async fn delete_message(&self, auth: Authorization, id: Query<i64>) -> DeleteResponse { - use DeleteResponse::*; - if !self.db.valid_id(IdType::Message, id.0).unwrap() { - return NotFound(PlainText("Message not found".to_string())) - } - let msg = self.db.get_message(id.0).unwrap(); - let chan = self.db.get_channel(msg.channel).unwrap(); - if msg.author != auth.0.id && !self.db.get_group_admin(chan.group).unwrap().contains(&auth.0.id) { - return Unauthorized; - } - self.db.delete_message(id.0).unwrap(); - Success - } + fn __remove_group_member(&self, conn: &mut PgConnection, gid: i64, uid: i64) -> Result<usize, WithBacktrace<diesel::result::Error>> { + remove_array(conn, "groups", "members", gid, uid)?; + + let channels = groups::table.select(groups::dsl::channels) + .filter(groups::dsl::id.eq(gid)).first::<Vec<i64>>(conn) + .with_backtrace()?; + + for channel in channels { + remove_array(conn, "channels", "members", channel, uid)?; + } + remove_array(conn, "user_groups", "groups", uid, gid) + } + + #[oai(path = "/login", method = "post")] + /// Log in as a user. Returns an authentication token given id and hash. + /// + /// Expects hash of user's password to be given in the request body. + /// Checks validity of hash, then signs JWT with a server secret key. + // good + async fn login(&self, key: Data<&ServerKey>, id: Query<i64>, hash: PlainText<String>) -> Result<PlainText<String>> { + if hash.0.len() != 64 { + return Err(UserFacingError::new(StatusCode::BAD_REQUEST, "Hash is not of length 24!").into()) + } + let conn = &mut open_db_conn(); + let user: User = users::table.find(id.0).first(conn) + .poemify("retrieving specified user")?; + if hex::decode(&user.hash).unwrap() != hex::decode(&hash.0).unwrap() { + return Err(UserFacingError::new(StatusCode::UNAUTHORIZED, "Incorrect hash provided.").into()) + } else { + let token = Claims { + id: id.0, + exp: Local::now() + Duration::days(1), + } + .sign_with_key(key.0); + Ok(PlainText(token.unwrap())) + } + } + + #[oai(path = "/user", method = "get")] + /// Get the user with the given ID + /// + /// Does not require any authorization. + // good + async fn get_user(&self, id: Query<i64>) -> Result<Json<User>> { + let conn = &mut open_db_conn(); + let user: User = users::table.find(id.0).first(conn) + .poemify("retrieving specified user")?; + Ok(Json(user)) + } + + #[oai(path = "/user", method = "post")] + /// Create a new user. + /// + /// Expects hash of user's password to be given in the request body. + /// Does not require any authorization. + // good + async fn make_user(&self, name: Query<String>, email: Query<String>, hash: PlainText<String>) -> Result<Json<User>> { + let conn = &mut open_db_conn(); + + if hash.0.len() != 64 { + return Err(UserFacingError::new(StatusCode::BAD_REQUEST, "Hash is not of length 24!").into()) + } + + let user = User { + id: gen_id(), + name: check_name(name.0), + email: email.0, + hash: hash.0, + }; + + diesel::insert_into(users::table).values(&user) + .execute(conn).poemify("adding user to database")?; + // diesel::update(users::table).set(users::dsl::hash.eq(hash.0)) + // .execute(conn).poemify("setting user hash"); + diesel::insert_into(user_groups::table) + .values((user_groups::dsl::id.eq(user.id), user_groups::dsl::groups.eq(Vec::<i64>::new()))) + .execute(conn).poemify("initializing associated database entries")?; + diesel::insert_into(user_dms::table) + .values((user_dms::dsl::id.eq(user.id), user_dms::dsl::dms.eq(Vec::<i64>::new()))) + .execute(conn).poemify("initializing associated database entries")?; + + Ok(Json(user)) + } + + #[oai(path = "/user", method = "put")] + /// Update your name and email. + // good + async fn update_user(&self, auth: Authorization, name: Query<String>, email: Query<String>) -> Result<()> { + let conn = &mut open_db_conn(); + let checked_name = check_name(name.0.clone()); + diesel::update(users::table.find(auth.0.id)) + .set((users::dsl::name.eq(checked_name), users::dsl::email.eq(email.0))) + .execute(conn).poemify("updating database")?; + Ok(()) + } + + #[oai(path = "/user", method = "delete")] + /// Delete your user. + /// + /// Has the side effects of removing your user from every group, channel, or DM + /// it is a member of. + // good + async fn delete_user(&self, auth: Authorization) -> Result<()> { + let conn = &mut open_db_conn(); + diesel::delete(users::table.find(auth.0.id)).execute(conn).poemify("deleting your user")?; + let your_groups: Vec<i64> = user_groups::table + .select(user_groups::dsl::groups).find(auth.0.id) + .first(conn).poemify("getting your groups")?; + let your_dms: Vec<i64> = user_dms::table + .select(user_dms::dsl::dms).find(auth.0.id) + .first(conn).poemify("getting your DMs")?; + for group in your_groups { + self.__remove_group_member(conn, group, auth.0.id); + } + for dm in your_dms { + self.__remove_group_member(conn, dm, auth.0.id); + } + diesel::delete(user_dms::table.find(auth.0.id)).execute(conn).poemify("deleting your groups list")?; + diesel::delete(user_groups::table.find(auth.0.id)).execute(conn).poemify("deleting your DM list")?; + Ok(()) + } + + #[oai(path = "/user/groups", method = "get")] + /// Get all groups accessible to you. + // good + async fn get_groups(&self, auth: Authorization) -> Result<Json<Vec<Group>>> { + let conn = &mut open_db_conn(); + let res: UserGroup = user_groups::table.find(auth.0.id) + .first(conn).poemify("retrieving your groups")?; + Ok(Json(res.groups.iter().map(|i| { + groups::table.find(*i).first(conn).unwrap() + }).collect())) + } + + #[oai(path = "/user/dms", method = "get")] + /// Get all DMs accessible to you. + // good + async fn get_dms(&self, auth: Authorization) -> Result<Json<Vec<Group>>> { + let conn = &mut open_db_conn(); + let res: UserDm = user_dms::table.find(auth.0.id) + .first(conn).poemify("retrieving your dms")?; + Ok(Json(res.dms.iter().map(|i| { + groups::table.find(*i).first(conn).unwrap() + }).collect())) + } + + #[oai(path = "/user/groups", method = "delete")] + /// Leave a group accessible to you + // good + async fn leave_group(&self, auth: Authorization, gid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + self.__remove_group_member(conn, gid.0, auth.0.id).poemify("removing you from group")?; + Ok(()) + } + + + #[oai(path = "/group", method = "get")] + /// Gets the group with the given ID + // good + async fn get_group(&self, auth: Authorization, id: Query<i64>) -> Result<Json<Group>> { + let conn = &mut open_db_conn(); + let group: Group = groups::table.find(id.0).first(conn) + .poemify("retrieving specified group")?; + if !group.members.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()) + } + Ok(Json(group)) + } + + + #[oai(path = "/group", method = "post")] + /// Create a new group. + /// + /// The group created... + /// - will have a default public "main" channel + /// - will have your user as the owner + /// - will have your user as an admin + // good + async fn make_group(&self, auth: Authorization, name: Query<String>) -> Result<Json<Group>> { + let conn = &mut open_db_conn(); + let gid = gen_id(); + let channel = Channel { + id: gen_id(), + src_group: gid, + name: String::from("main"), + members: vec![auth.0.id], + private: false + }; + diesel::insert_into(channels::table).values(&channel) + .execute(conn).poemify("adding 'main' channel to database")?; + let group = Group { + id: gid, + name: check_name(name.0), + members: vec![auth.0.id], + admin: vec![auth.0.id], + owner: auth.0.id, + is_dm: false, + channels: vec![channel.id] + }; + diesel::insert_into(groups::table).values(&group) + .execute(conn).poemify("adding group to database")?; + append_array(conn, "user_groups", "groups", auth.0.id, gid) + .poemify("adding group to your groups")?; + Ok(Json(group)) + } + + #[oai(path = "/dm", method = "post")] + /// Create a new DM with a user `uid`. + /// + /// The group created... + /// - will have the `is_dm` attribute set to true. + /// - will have only one channel "main" with you and `uid` + /// - will have no owner or admins + // good + async fn make_dm(&self, auth: Authorization, uid: Query<i64>) -> Result<Json<Group>> { + let conn = &mut open_db_conn(); + let user: User = users::table.find(uid.0).first(conn) + .poemify("retreiving specified user")?; + let gid = gen_id(); + let channel = Channel { + id: gen_id(), + src_group: gid, + name: String::from("main"), + members: vec![auth.0.id, uid.0], + private: false + }; + diesel::insert_into(channels::table).values(&channel) + .execute(conn).poemify("adding 'main' channel to database")?; + let group = Group { + id: gid, + name: String::from(""), + members: vec![auth.0.id, uid.0], + admin: vec![], + owner: auth.0.id, + is_dm: false, + channels: vec![channel.id] + }; + diesel::insert_into(groups::table).values(&group) + .execute(conn).poemify("adding group to database")?; + append_array(conn, "user_dms", "groups", auth.0.id, gid) + .poemify("adding DM to your DMs")?; + append_array(conn, "user_dms", "groups", uid.0, gid) + .poemify("adding DM to their DMs")?; + Ok(Json(group)) + } + + #[oai(path = "/group", method = "put")] + /// Update the name of an existing group. + /// + /// Only authorized for the owner of a group. + // good + async fn update_group(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut group: Group = groups::table.find(id.0).first(conn) + .poemify("retreiving specified group")?; + if group.owner != auth.0.id { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + group.name = check_name(name.0); + diesel::update(groups::table.find(id.0)).set(&group) + .execute(conn).poemify("updating group in database"); + Ok(()) + } + + #[oai(path = "/group", method = "delete")] + /// Delete a group. + /// + /// Only authorized for the owner of a group. + // good + async fn delete_group(&self, auth: Authorization, id: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let group: Group = groups::table.find(id.0).first(conn) + .poemify("retreiving specified group")?; + if group.owner != auth.0.id { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + for member in group.members { + remove_array(conn, "user_groups", "groups", member, id.0) + .poemify("removing member from group")?; + } + for channel in group.channels { + diesel::delete(channels::table.find(channel)).execute(conn) + .poemify("deleting channel from database")?; + } + diesel::delete(groups::table.find(id.0)).execute(conn) + .poemify("deleting group from database")?; + Ok(()) + } + + #[oai(path = "/group/members", method = "get")] + /// Get the members of the specified group. + /// + /// No specific order for the list is guaranteed. + // good + async fn get_group_members(&self, auth: Authorization, id: Query<i64>) -> Result<Json<Vec<User>>> { + let conn = &mut open_db_conn(); + let group: Group = groups::table.find(id.0).first(conn) + .poemify("retreiving specified group")?; + Ok(Json(group.members.iter().map(|m| { + users::table.find(*m).first(conn).unwrap() + }).collect())) + } + + #[oai(path = "/group/members", method = "put")] + /// Add a member to an existing group + /// + /// Only authorized for group admins. + /// Has the side effect of adding that member to all public channels. + // good + async fn add_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut group: Group = groups::table.find(gid.0).first(conn) + .poemify("retreiving specified group")?; + if !group.admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + group.members.push(uid.0); + for chan_id in group.channels { + let mut channel: Channel = channels::table.find(chan_id).first(conn) + .poemify(&format!("retreiving channel (id {}) of group", chan_id))?; + if channel.private { continue; } + diesel::update(channels::table.find(chan_id)).set(&channel) + .execute(conn).poemify("adding member to channel"); + } + if !group.is_dm { + append_array(conn, "user_groups", "groups", auth.0.id, gid.0) + .poemify("adding group to your groups")?; + } else { + append_array(conn, "user_dms", "dms", auth.0.id, gid.0) + .poemify("adding DM to your DMs")?; + } + Ok(()) + } + + #[oai(path = "/group/members", method = "delete")] + /// Remove a member from an existing group + /// + /// Only authorized for group admin. + /// Attempting to remove the owner from their group will always be unauthorized. + /// + /// Has the side effect of removing the member from all channels. + // good + async fn remove_group_member(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let admin: Vec<i64> = groups::table.select(groups::dsl::admin) + .find(gid.0).first(conn).poemify("getting group admin")?; + if !admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + self.__remove_group_member(conn, gid.0, uid.0).poemify("removing group member")?; + Ok(()) + } + + #[oai(path = "/group/admin", method = "get")] + /// Get the admins of the specified group. + /// + /// No specific order for the list is guaranteed. + // good + async fn get_group_admin(&self, auth: Authorization, id: Query<i64>) -> Result<Json<Vec<User>>> { + let conn = &mut open_db_conn(); + Ok(Json( + groups::table + .select(groups::dsl::admin) + .filter(groups::dsl::id.eq(id.0)) + .first::<Vec<i64>>(conn) + .poemify("retreiving admin of group")? + .iter().map(|a| { + users::table.find(*a).first(conn).unwrap() + }).collect() + )) + } + + #[oai(path = "/group/admin", method = "put")] + /// Add an admin to an existing group + /// + /// Only authorized for the owner of a group. + // good + async fn add_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut group: Group = groups::table.find(gid.0).first(conn) + .poemify("retreiving specified group")?; + let _user: User = users::table.find(uid.0).first(conn) + .poemify("retreiving specified user")?; // prevent adding invalid UID + if auth.0.id != group.owner { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + group.admin.push(uid.0); + diesel::update(groups::table.find(gid.0)).set(&group) + .execute(conn).poemify("updating group in database"); + Ok(()) + } + + #[oai(path = "/group/admin", method = "delete")] + /// Remove an admin from an existing group + /// + /// Only authorized for the owner of a group. + // good + async fn remove_group_admin(&self, auth: Authorization, gid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut group: Group = groups::table.find(gid.0).first(conn) + .poemify("retreiving specified group")?; + let _user: User = users::table.find(uid.0).first(conn) + .poemify("retreiving specified user")?; // prevent adding invalid UID + if auth.0.id != group.owner { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + group.admin.retain(|x| *x != uid.0); + diesel::update(groups::table.find(gid.0)).set(&group) + .execute(conn).poemify("updating group in database"); + Ok(()) + } + + #[oai(path = "/group/channels", method = "get")] + /// Gets all channels in a group that are accessible to you + // good + async fn get_channels(&self, auth: Authorization, gid: Query<i64>) -> Result<Json<Vec<Channel>>> { + let conn = &mut open_db_conn(); + Ok(Json( + groups::table + .select(groups::dsl::channels) + .filter(groups::dsl::id.eq(gid.0)) + .first::<Vec<i64>>(conn) + .poemify("retreiving group channels")? + .iter() + .map(|c| channels::table.find(*c).first(conn).unwrap()) + .filter(|c: &Channel| c.members.contains(&auth.0.id)) + .collect() + )) + } + + #[oai(path = "/group/channels", method = "post")] + // good + async fn make_channel(&self, auth: Authorization, gid: Query<i64>, name: Query<String>) -> Result<Json<Channel>> { + let conn = &mut open_db_conn(); + let out: Group = groups::table.find(gid.0).first(conn) + .with_backtrace().poemify("retrieving specified group")?; + + if !out.admin.contains(&auth.0.id) { + return Err(UserFacingError::new(StatusCode::FORBIDDEN, + "You do not have permission to perform the requested action." + ).into()) + } + + let chan = Channel { + id: gen_id(), + src_group: gid.0, + name: check_name(name.0.clone()), + members: vec![auth.0.id], + private: false + }; + + diesel::insert_into(channels::table).values(&chan) + .execute(conn).poemify("adding channel to database")?; + + append_array(conn, "groups", "channels", gid.0, chan.id) + .poemify("adding channel to group")?; + + Ok(Json(chan)) + } + + #[oai(path = "/channel", method = "put")] + /// Update the name of a channel. + /// + /// Only authorized for group admins. + // good + async fn update_channel(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> Result<()> { + let conn = &mut open_db_conn(); + diesel::update(channels::table.find(auth.0.id)) + .set(channels::dsl::name.eq(check_name(name.0))) + .execute(conn).poemify("updating database"); + Ok(()) + } + + #[oai(path = "/channel/private", method = "put")] + /// Make a channel private. + /// + /// Only authorized for group admins. + // good + async fn make_channel_private(&self, auth: Authorization, id: Query<i64>, val: Query<bool>) -> Result<()> { + let conn = &mut open_db_conn(); + let group: i64 = channels::table.select(channels::dsl::src_group).find(id.0) + .first(conn).poemify("getting channel group")?; + let admin: Vec<i64> = groups::table.select(groups::dsl::admin) + .find(group).first(conn).poemify("getting group admin")?; + if !admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()) + } + diesel::update(channels::table.find(id.0)) + .set(channels::dsl::private.eq(val.0)) + .execute(conn).poemify("setting channel privacy in database"); + Ok(()) + } + + #[oai(path = "/channel", method = "get")] + /// Get a channel. + // good + async fn get_channel(&self, auth: Authorization, id: Query<i64>) -> Result<Json<Channel>> { + let conn = &mut open_db_conn(); + Ok(Json(channels::table.find(id.0).first(conn).poemify("retrieving specified channel")?)) + } + + #[oai(path = "/channel", method = "delete")] + /// Delete a channel. + /// + /// Only authorized for group admins. + // good + async fn delete_channel(&self, auth: Authorization, id: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let group: i64 = channels::table.select(channels::dsl::src_group).find(id.0) + .first(conn).poemify("getting channel group")?; + let admin: Vec<i64> = groups::table.select(groups::dsl::admin) + .find(group).first(conn).poemify("getting group admin")?; + if !admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()) + } + diesel::delete(channels::table.find(id.0)).execute(conn) + .poemify("deleting channel from database")?; + remove_array(conn, "groups", "channels", group, id.0) + .poemify("removing channel from group")?; + Ok(()) + } + + #[oai(path = "/channel/members", method = "get")] + /// Get the members that can access a channel. + /// + /// No specific order for the list is guaranteed. + // good + async fn get_channel_members(&self, auth: Authorization, id: Query<i64>) -> Result<Json<Vec<User>>> { + let conn = &mut open_db_conn(); + Ok(Json( + channels::table + .select(channels::dsl::members) + .filter(channels::dsl::id.eq(id.0)) + .first::<Vec<i64>>(conn) + .poemify("retreiving channel members")? + .iter().map(|u| { + users::table.find(*u).first(conn).unwrap() + }).collect() + )) + } + + #[oai(path = "/channel/members", method = "put")] + /// Add a member to a channel + /// + /// Only authorized for group admins. + // good + async fn add_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut chan: Channel = channels::table.find(cid.0).first(conn) + .poemify("retreiving specified channel")?; + let group: Group = groups::table.find(chan.src_group).first(conn) + .poemify("retreiving channel's group")?; + let _user: User = users::table.find(uid.0).first(conn) + .poemify("retreiving specified user")?; // prevent adding invalid UID + if !group.admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + chan.members.push(uid.0); + diesel::update(channels::table.find(cid.0)).set(&chan) + .execute(conn).poemify("updating channel in database"); + Ok(()) + } + + #[oai(path = "/channel/members", method = "delete")] + /// Remove a member from a channel. + /// + /// Only authorized for group admins. + // good + async fn remove_channel_member(&self, auth: Authorization, cid: Query<i64>, uid: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let mut chan: Channel = channels::table.find(cid.0).first(conn) + .poemify("retreiving specified channel")?; + let group: Group = groups::table.find(chan.src_group).first(conn) + .poemify("retreiving channel's group")?; + let _user: User = users::table.find(uid.0).first(conn) + .poemify("retreiving specified user")?; // prevent adding invalid UID + if !group.admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into()); + } + chan.members.retain(|x| *x != uid.0); + diesel::update(channels::table.find(cid.0)).set(&chan) + .execute(conn).poemify("updating channel in database"); + Ok(()) + } + + #[oai(path = "/channel/term", method = "get")] + /// Get a batch of messages in channel containing `term` in the last 100 messages + /// + /// Will not search for `term` in any messages older than the last 100. + // good + async fn search_channel(&self, auth: Authorization, cid: Query<i64>, term: Query<String>) -> Result<Json<Vec<Message>>> { + let conn = &mut open_db_conn(); + let mut messages = messages::table.limit(100).load::<Message>(conn) + .poemify("retrieving past 100 messages")?; + messages.retain(|msg| { + if let Some(content) = &msg.content { + return content.contains(&term.0) + } else { return false } + }); + Ok(Json(messages)) + } + + #[oai(path = "/channel/messages", method = "get")] + /// Returns batch of messages in channel. Do not use for small batches. + /// + /// For small batches, use `chatterbox`, the websocket service for messaging, instead. + // good + async fn get_channel_messages(&self, auth: Authorization, cid: Query<i64>, num_msgs: Query<i64>) -> Result<Json<Vec<Message>>> { + let conn = &mut open_db_conn(); + Ok(Json( + messages::table.limit(num_msgs.0).load::<Message>(conn) + .poemify("retrieving past messages")? + )) + } + + #[oai(path = "/message/thread", method = "put")] + /// Make a thread for a given message. + /// + /// Thread will be private with you as its sole member + async fn make_thread(&self, auth: Authorization, id: Query<i64>, name: Query<String>) -> Result<Json<Channel>> { + let conn = &mut open_db_conn(); + let mut msg: Message = messages::table.find(id.0).first(conn) + .poemify("retreiving specified message")?; + let group: i64 = channels::table.select(channels::dsl::src_group).find(msg.channel) + .first(conn).poemify("getting channel group")?; + let thread = Channel { + id: gen_id(), + name: check_name(name.0), + src_group: group, + members: vec![auth.0.id], + private: true, + }; + msg.thread = Some(thread.id); + diesel::insert_into(channels::table).values(&thread) + .execute(conn).poemify("adding thread to database")?; + diesel::update(messages::table.find(id.0)).set(&msg) + .execute(conn).poemify("updating message in database")?; + Ok(Json(thread)) + } + + #[oai(path = "/message", method = "delete")] + /// Delete a message + /// + /// Only authorized for the message author or a group admin. + async fn delete_message(&self, auth: Authorization, id: Query<i64>) -> Result<()> { + let conn = &mut open_db_conn(); + let msg: Message = messages::table.find(id.0).first(conn) + .poemify("retreiving specified message")?; + let group: i64 = channels::table.select(channels::dsl::src_group).find(msg.channel) + .first(conn).poemify("getting channel group")?; + let admin: Vec<i64> = groups::table.select(groups::dsl::admin) + .find(group).first(conn).poemify("getting group admin")?; + if msg.author != auth.0.id && !admin.contains(&auth.0.id) { + return Err(UserFacingError::terse(StatusCode::FORBIDDEN).into());; + } + diesel::delete(messages::table.find(id.0)).execute(conn) + .poemify("deleting message")?; + Ok(()) + } + + } #[tokio::main] async fn main() -> Result<(), std::io::Error> { - use hmac::Mac; - if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "poem=debug"); - } - tracing_subscriber::fmt::init(); - - let db = Box::new(Cassandra::new("bsk")); - let api_service = OpenApiService::new(Api::new(db), "Scuttlebutt", "1.0") - .description( - "Scuttlebutt is the REST API for managing everything but sending/receiving messages \ - - which means creating/updating/deleting all of your users/groups/channels.", - ) - .server("http://localhost:3000/api"); - - // API documentation - let ui = api_service.swagger_ui(); - - // Generate server-side secret key used for signing the JWTs - let key: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(7) - .map(char::from) - .collect(); - - let app = Route::new() - .nest("/api", api_service) - .nest("/", ui) - .data(ServerKey::new_from_slice(&key.as_bytes()).unwrap()); - - Server::new(TcpListener::bind("127.0.0.1:3000")).run(app).await + use hmac::Mac; + + dotenv().ok(); + + if std::env::var_os("RUST_LOG").is_none() { + std::env::set_var("RUST_LOG", "poem=debug"); + } + tracing_subscriber::fmt::init(); + + let api_service = OpenApiService::new(Api {}, "Scuttlebutt", "1.0") + .description( + "Scuttlebutt is the REST API for managing everything but sending/receiving messages \ + - which means creating/updating/deleting all of your users/groups/channels.", + ) + .server("http://localhost:3000/api"); + + // API documentation + // let ui = api_service.swagger_ui(); + + // Generate server-side secret key used for signing the JWTs + let key: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(7) + .map(char::from) + .collect(); + + let app = Route::new() + .nest("/api", api_service) + // .nest("/", ui) + .data(ServerKey::new_from_slice(&key.as_bytes()).unwrap()) + .catch_error(|_: poem::error::NotFoundError| async move { + poem::Response::builder() + .status(StatusCode::NOT_FOUND) + .body("<h1>404 Not Found</h1>Path not found.") + }) + .catch_error(|err: poem_openapi::error::ParseParamError| async move { + poem::Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(format!("<h1>400 Bad Request</h1><pre>{}.</pre>", err)) + }); + + Server::new(TcpListener::bind("127.0.0.1:3000")).run(app).await } #[cfg(test)] diff --git a/scuttlebutt/src/models.rs b/scuttlebutt/src/models.rs @@ -0,0 +1,61 @@ +use diesel::prelude::*; +use crate::schema::*; +use poem_openapi::{ + payload::{Json, PlainText}, + ApiResponse, Object, +}; +use serde::{Deserialize, Serialize}; + + +#[derive(Object, Serialize, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct Channel { + pub id: i64, + pub src_group: i64, + pub name: String, + pub members: Vec<i64>, + pub private: bool, +} + +#[derive(Object, Serialize, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct Group { + pub id: i64, + pub name: String, + pub members: Vec<i64>, + pub is_dm: bool, + pub channels: Vec<i64>, + pub admin: Vec<i64>, + pub owner: i64 +} + +#[derive(Object, Serialize, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct User { + pub id: i64, + pub name: String, + pub email: String, + pub hash: String, +} + + +#[derive(Serialize, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct UserGroup { + pub id: i64, + pub groups: Vec<i64> +} + + +#[derive(Serialize, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct UserDm { + pub id: i64, + pub dms: Vec<i64> +} + + +#[derive(Object, Deserialize, Queryable, Identifiable, Insertable, AsChangeset, Debug)] +pub struct Message { + pub channel: i64, + pub id: i64, + pub author: i64, + pub content: Option<String>, + pub thread: Option<i64> +} + diff --git a/scuttlebutt/src/responses.rs b/scuttlebutt/src/responses.rs @@ -4,6 +4,8 @@ use poem_openapi::{ }; use serde::{Deserialize, Serialize}; +use crate::models; + #[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] /// Object representing a user pub struct User { @@ -33,7 +35,7 @@ pub struct Group { #[derive(Object, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] /// Object representing a group's channel. /// No guarantees are made for the order of any vector in this struct. -pub struct Channel { +pub struct ChannelOrig { pub id: i64, pub name: String, // ID of the group the channel is in @@ -154,7 +156,21 @@ pub enum CreateGroupResponse { pub enum ChannelResponse { /// Returns the channel requested #[oai(status = 200)] - Success(Json<Channel>), + Success(Json<ChannelOrig>), + /// Invalid ID or user is not a member of specified channel. + #[oai(status = 404)] + NotFound, + /// Internal server error: likely due to a database operation failing + #[oai(status = 500)] + InternalError(PlainText<String>), +} + + +#[derive(ApiResponse)] +pub enum ChannelResponse2 { + /// Returns the channel requested + #[oai(status = 200)] + Success(Json<models::Channel>), /// Invalid ID or user is not a member of specified channel. #[oai(status = 404)] NotFound, @@ -163,11 +179,12 @@ pub enum ChannelResponse { InternalError(PlainText<String>), } + #[derive(ApiResponse)] pub enum CreateChannelResponse { /// Returns the channel requested #[oai(status = 200)] - Success(Json<Channel>), + Success(Json<ChannelOrig>), /// You are not authorized to perform the action #[oai(status = 401)] Unauthorized, @@ -240,7 +257,7 @@ pub enum GroupsResponse { pub enum ChannelsResponse { /// Returns the channels in a group #[oai(status = 200)] - Success(Json<Vec<Channel>>), + Success(Json<Vec<ChannelOrig>>), /// Invalid group ID #[oai(status = 404)] NotFound, diff --git a/scuttlebutt/src/schema.rs b/scuttlebutt/src/schema.rs @@ -0,0 +1,65 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + channels (id) { + id -> Int8, + src_group -> Int8, + name -> Text, + members -> Array<Int8>, + private -> Bool, + } +} + +diesel::table! { + groups (id) { + id -> Int8, + name -> Text, + members -> Array<Int8>, + is_dm -> Bool, + channels -> Array<Int8>, + admin -> Array<Int8>, + owner -> Int8, + } +} + +diesel::table! { + messages (channel) { + channel -> Int8, + id -> Int8, + author -> Int8, + content -> Nullable<Text>, + thread -> Nullable<Int8>, + } +} + +diesel::table! { + user_dms (id) { + id -> Int8, + dms -> Array<Int8>, + } +} + +diesel::table! { + user_groups (id) { + id -> Int8, + groups -> Array<Int8>, + } +} + +diesel::table! { + users (id) { + id -> Int8, + name -> Text, + email -> Text, + hash -> Text, + } +} + +diesel::allow_tables_to_appear_in_same_query!( + channels, + groups, + messages, + user_dms, + user_groups, + users, +);