Renaming project to json-api for clarity sake

This commit is contained in:
shockrah
2021-01-24 13:34:17 -08:00
parent 84c865e194
commit b67bb6105f
52 changed files with 83 additions and 2 deletions

14
json-api/.env Normal file
View File

@@ -0,0 +1,14 @@
DATABASE_URL=mysql://freechat_dev:password@localhost:3306/freechat
DATABASE_NAME=freechat
DATABASE_PASS=password
DATABASE_USER=freechat_dev
DATABASE_HOST=localhost
DATABASE_PORT=3306
REDIS_URL=redis://127.0.0.1:6379
# Server meta things
SERVER_NAME="Freechat Dev Server"
SERVER_DESCRIPTION="Server for sick development things"
SERVER_URL=localhost
SERVER_PORT=8888

12
json-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
target
**/*.rs.bk
static/css/
dev-sql/
diesel.toml
*.log
# Client pycache
client-tests/__pycache__/
client-tests/web/__pycache__/

25
json-api/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Client Tests",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/client-tests/client.py",
"console": "integratedTerminal"
},
{
"type": "lldb",
"request": "launch",
"name": "(Linux) Debug",
"program": "${workspaceFolder}/target/debug/freechat-server",
"args": [
"-s"
],
"cwd": "${workspaceFolder}"
}
]
}

3
json-api/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.pythonPath": "/home/shockrah/GitRepos/freechat/server-api/client-tests/bin/python3.9"
}

2218
json-api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
json-api/Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "json-api"
version = "0.2.0"
authors = ["shockrah <alejandros714@protonmail.com>"]
edition = "2018"
[workspace]
[dependencies]
tokio = { version = "0.2", features=["full"] }
hyper = "0.13"
futures = "0.3"
url = "2.1.1"
mysql_async = "0.23.1"
dotenv = "0.9.0"
chrono = "0.4.0"
time = "0.2"
getrandom = "0.1"
bcrypt = "0.8"
base64 = "0.12.1"
rand = "0.7.3"
jsonwebtoken = "7.2.0"
clap = "2.32.2"
serde_json = "1.0"
serde = { version = "1.0.114", features = [ "derive" ] }
lazy_static = "1.4.0"
# Database library/interface
db = { path = "db" }
[dev-dependencies]
tokio-test = "0.2.1"

45
json-api/build.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
_help() {
cat <<EOF
Usage: ./build.sh [-h|-t|-b|-r]
h Help : shows this command
f Full : Runs all available tests including the cargo ones
t Test : Runs All tests from cargo tests to client tests
T Test Realse : Runs tests against release build
b Build : Builds dev build with 'cargo build'
r Release : Builds release build with --release flag
EOF
}
[[ -z $1 ]] && _help && exit 0
diesel database reset
export CARGO_BIN=$HOME/.cargo/bin/cargo
full=
testing() {
release_opt=$1
cargo build $release_opt
cargo run $release_opt -- -s &
server=$!
echo Waiting on server to spin up && sleep 2
python3 client-tests/client.py
kill -9 $server 2> /dev/null
if [ ! -z $full ];then
cargo test $release_opt
fi
}
while getopts ":fhtTbr" arg; do
case ${arg} in
f) full=true;;
h) echo help command;;
t) testing;;
T) testing --release;;
b) cargo build;;
r) cargo build --release;;
*) _help;;
esac
done

2
json-api/client-tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/
lib/

View File

@@ -0,0 +1 @@
from . import web

View File

@@ -0,0 +1,220 @@
import time
import subprocess, os, sys
import json, requests
from web import http
class Worker:
def __init__(self, domain: str, create_admin: bool):
'''
@opt:base = base url string
@opt:create_admin = creates admin account directly off cargo -c <NAME>
@opt:admin = optionally pass in dictionary with some admin credentials
potentially from another instance to run multiple tests at once
'''
self.domain = domain
self.requests = {}
self.responses = {}
self.body_logs = {}
self.jwt = None # never gets assigned until /login is hit
self.basic_creds = self.__create_admin()
self.id = self.basic_creds['id']
self.secret = self.basic_creds['secret']
def __create_admin(self):
# /home/$user/.cargo/bin/cargo <- normally
# NOTE: the above path is prolly some awful shit on ci pipeline
CARGO_BIN = os.getenv('CARGO_BIN')
proc = subprocess.run(f'cargo run --release -- -c dev-test'.split(), text=True, capture_output=True)
try:
return json.loads(proc.stdout)
except:
import sys
print('TESTBOT UNABLE TO LOAD JSON DATA FROM -c flag', file=sys.stderr)
exit(1)
def auth(self, auth_opt: str):
if auth_opt == 'basic':
return self.basic_creds
else:
return {'id': self.id, 'jwt': auth_opt}
def _append_auth(self, opts: dict, auth: str):
'''
Default auth fallback type is jwt because that's they only other type of auth
FC cares about really
@param opts: Dictionary of parameters to pass to the endpoint
@auth: Denotes if we use basic auth or jwt if its not 'basic'
'''
if type(auth) == str:
opts['id'] = self.id
if auth == 'basic':
opts['secret'] = self.secret
else:
opts['jwt'] = auth
return opts
else:
# if its not a string we don't add anything in
return opts
def logs(self):
ids = sorted(self.requests.keys()) # shared keys in requests/responses
for key in ids:
self.responses[key].log()
# Logg the provided data to ensure that _it_ wasn't the cause for error
resp = self.responses[key]
if resp.code != resp.expected:
opts = self.requests[key].params
print(f'\tParams: {opts}')
if self.body_logs[key] is True:
print(f'\tBody: {self.responses[key].body}')
def request(self, method: str, path: str, auth: str, opts: dict, expectation: int, show_body=False):
assert(path[0] == '/')
# First make sure we add in the correct auth params that are requested
opts = self._append_auth(opts, auth)
# Build the request and store it in our structure
url = self.domain + path
req = http.Request(method, url, opts)
r_id = time.time()
resp = req.make(expectation)
# update log trackers
self.requests[r_id] = req
self.responses[r_id] = resp
self.body_logs[r_id] = show_body
return r_id
def run(worker: Worker):
VOICE_CHAN = 1
TEXT_CHAN = 2
# Basically every test requires a jwt to be passed in so we grab that here
# Should this fail so should nearly every other test from this point
req_login = worker.request('post', '/login', 'basic',{}, 200)
jwt = worker.responses[req_login].json()['jwt']
new_channel_name = time.time()
channel_tests = [
# sanity check
{'init': ['get', '/channels/list', {}], 'auth': None, 'hope': 401},
{'init': ['post', '/channels/list', {}], 'auth': jwt, 'hope': 404},
{'init': ['post', '/channels/create', {'name': str(new_channel_name), 'kind': TEXT_CHAN, 'description': 'asdf'}], 'auth': jwt, 'hope': 200},
# Just a regular test no saving for this one
{'init': ['post', '/channels/create', {'name': str(new_channel_name+1), 'kind': TEXT_CHAN,}], 'auth': jwt, 'hope': 200},
{'init': ['post', '/channels/create', {}], 'auth': jwt, 'hope': 400},
{'init': ['post', '/channels/create', {'name': 123, 'kind': 'adsf'}], 'auth': jwt, 'hope': 400},
# save this and compare its results to the previous
{'init': ['get', '/channels/list', {}], 'auth': jwt, 'hope': 200},
{'init': ['get', '/channels/list', {'random-param': 123}], 'auth': jwt, 'hope': 200},
]
for test in channel_tests:
method, path, opts = test['init']
auth = test['auth']
hope = test['hope']
worker.request(method, path, auth, opts, hope)
msg_chan_name = time.time()
_id = worker.request('post', '/channels/create', jwt, {
'name': str(msg_chan_name),
'kind': TEXT_CHAN,
}, 200)
chan_d = worker.responses[_id].json()
message_tests = [
# bs message spam
{'init': ['post', '/message/send', {'channel': chan_d['id'], 'content': 'bs content'}], 'auth': jwt, 'hope': 200},
{'init': ['post', '/message/send', {'channel': chan_d['id'], 'content': 'bs content'}], 'auth': jwt, 'hope': 200},
{'init': ['post', '/message/send', {'channel': chan_d['id'], 'content': 'bs content'}], 'auth': jwt, 'hope': 200},
{'init': ['post', '/message/send', {'channel': chan_d['id'], 'content': 'bs content'}], 'auth': jwt, 'hope': 200},
# can we get them back tho?
{
'init': [
'get', '/message/get_range', {'channel': chan_d['id'], 'start-time': int(msg_chan_name-10), 'end-time': int(msg_chan_name + 10)}
],
'auth': jwt, 'hope': 200
},
{
'init': [
'get', '/message/get_range', {'channel': chan_d['id'], 'end-time': int(msg_chan_name)}
],
'auth': jwt, 'hope': 400
},
{
'init': [
'get', '/message/get_range', {'channel': chan_d['id'], 'start-time': int(msg_chan_name), 'end-time': int(msg_chan_name)}
],
'auth': jwt, 'hope': 400
},
{
'init': [
'get', '/message/get_range', {'channel': chan_d['id'], 'end-time': int(msg_chan_name), 'start-time': int(msg_chan_name)}
],
'auth': jwt, 'hope': 400
},
# two tests that follow the rules
{
'init': [
'get', '/message/from_id', {'start': 1, 'channel': 3}
],
'auth': jwt, 'hope': 200, 'body': True
},
{
'init': [
'get', '/message/from_id', {'start':1, 'channel':3, 'limit':2}
],
'auth': jwt, 'hope': 200, 'body': True
},
# tests that don't follow the api's rules
{
# channel doesn't exist so a 404 seems to be inorder
'init': [
'get', '/message/from_id', {'start': 1, 'channel':9}
],
'auth': jwt, 'hope': 404
},
{
'init': [
# good channel but id is tooo high
'get', '/message/from_id', {'start': 5, 'channel':3}
],
'auth': jwt, 'hope': 404
},
]
for test in message_tests:
method, path, opts = test['init']
auth = test['auth']
hope = test['hope']
if 'body' in test:
worker.request(method, path, auth, opts, hope, show_body=test['body'])
else:
worker.request(method, path, auth, opts, hope)
worker.logs()
if __name__ == '__main__':
worker = Worker('http://localhost:8888', create_admin=True)
run(worker)

View File

@@ -0,0 +1,8 @@
home = /usr
implementation = CPython
version_info = 3.9.1.final.0
virtualenv = 20.2.2
include-system-site-packages = false
base-prefix = /usr
base-exec-prefix = /usr
base-executable = /usr/bin/python3

View File

@@ -0,0 +1,5 @@
certifi==2020.12.5
chardet==4.0.0
idna==2.10
requests==2.25.1
urllib3==1.26.2

View File

@@ -0,0 +1 @@
from . import http

View File

@@ -0,0 +1,126 @@
import sys
import requests
import time
import json
class RequestError(Exception):
pass
class Response:
'''
Response is wrapper for reading + extracting information we care about
Primarily created by Requests that get `make`'d.
'''
def __init__(self, method: str, url: str, body: str, code: int, expected: int, out=sys.stdout, color=True,
truncate_long_body=True):
self.method = method
self.url = url
self.body = body #typically a string before parsing anything
self.code = code #u16
self.expected = expected #u16
self.out = out # file handle to write to normally sys.stdout
self.color = color # bool telling if log should color anything (on by default)
self.truncate_long_body = truncate_long_body
def _color(self, cc, string):
nc = '\033[0m'
return f'{cc}{string}{nc}'
def _color_failing(self, string):
red = '\033[1;31m'
return self._color(red, string)
def _color_passing(self, string):
green = '\033[1;32m'
return self._color(green, string)
def __write_msg(self, s):
# mega dumb wrapper to reduce visual noise i think
if (len(s) == 1 or len(s) == 0) is False: print(s, file=self.out)
def _log_body(self):
if self.truncate_long_body:
if len(self.body) > 80:
msg = self.body
while len(msg) > 80:
msg = msg[:len(msg)//2] + msg[len(msg)//2+1:]
msg = '.....'.join([msg[:40], msg[44]])
self.__write_msg(msg)
else:
self.__write_msg(f'\t{self.body}')
else:
self.__write_msg(f'\t{self.body}')
def log(self):
if self.code != self.expected:
msg = f'Failed {self.method.upper()} {self.url}\t{self.code} expected {self.expected}'
if self.color:
msg = self._color_failing(msg)
self.__write_msg(msg)
self._log_body()
else:
msg = f'Passing: {self.method} {self.url}'
if self.color:
msg = self._color_passing(msg)
self.__write_msg(msg)
def json(self):
'''
Force an interpretation of json from the body
NOTE: this method is rather obtuse and a massive afterthough so its usage
should be limited as much as possible
'''
try:
return json.loads(self.body)
except:
return {}
def __str__(self):
'''
Returns: str(Response) -> `code => response.bdoy`
'''
return f'{self.code} => {self.body}'
class Request:
def __init__(self, method: str, url: str, params: dict):
assert(method in ['get', 'post', 'delete'])
self.method = method
self.url = url
self.params = params
def _make_request(self, method: str, hope: int):
# Lower driver for actuall making the request we are looking for
method = method.lower()
params = json.dumps(self.params)
if method == 'get':
resp = requests.get(self.url, data=params)
return Response('get', self.url, resp.text, resp.status_code, hope)
elif method == 'post':
resp = requests.post(self.url, data=params)
return Response('post', self.url, resp.text, resp.status_code, hope)
elif method == 'delete':
resp = requests.delete(self.url, data=params)
return Response('delete', self.url, resp.text, resp.status_code, hope)
else:
raise RequestError('Invalid method passed')
return resp
def make(self, hope: int) -> Response:
'''
@param hope: int -> status code we hope to get back
@return Response -> Wrapper around server http response
'''
return self._make_request(self.method, hope)

1576
json-api/db/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
json-api/db/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "db"
version = "0.1.0"
authors = ["shockrah <alejandros714@protonmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
mysql_async = "0.23.1"
redis = { version = "0.18.0", features = ["tokio-comp"] }
async-trait = "0.1.40"
serde_json = "1.0"
serde = { version = "1.0.117", features = [ "derive" ] }
chrono = "0.4.0"

35
json-api/db/src/auth.rs Normal file
View File

@@ -0,0 +1,35 @@
use mysql_async::{params, Pool};
use mysql_async::prelude::Queryable;
use mysql_async::error::Error;
use crate::UBigInt;
async fn update_jwt(p: &Pool, id: UBigInt, token: &str) -> Result<(), Error> {
let conn = p.get_conn().await?;
let _ = conn.drop_exec("UPDATE jwt SET token = :tk WHERE id = :id",
params!{"tk" => token, "id" => id}).await?;
Ok(())
}
pub async fn add_jwt(p: &Pool, id: UBigInt, token: &str) -> Result<(), Error> {
let conn = p.get_conn().await?;
let q = "INSERT INTO jwt (id, token) VALUES (:id, :tk)";
return match conn.prep_exec(q, params!{"tk" => token, "id" => id}).await {
Ok(_) => Ok(()),
Err(_) => update_jwt(p, id, token).await // attempt to refresh token
};
}
pub async fn listed_jwt(p: &Pool, id: UBigInt, token_given: &str) -> Result<bool, Error> {
// only checks if the given token is listed somewhere in the db
let conn = p.get_conn().await?;
let q = "SELECT token FROM jwt WHERE id = :id";
// if id.token == return true
let (_, db_t): (_, Option<String>) =
conn.first_exec(q, params!{"id" => id}).await?;
return match db_t {
Some(token_db) => Ok(token_db == token_given), // probably pointless check but its not that expensive so its stays as a sanity check
None => Ok(false)
};
}

198
json-api/db/src/channels.rs Normal file
View File

@@ -0,0 +1,198 @@
use mysql_async::{params, Pool, Conn};
use mysql_async::prelude::Queryable;
use mysql_async::error::Error as SqlError;
use async_trait::async_trait;
use crate::{VarChar, UBigInt, Integer};
use crate::common::FromDB;
use crate::{sql_err, no_conn, Response};
use serde::Serialize;
#[derive(Serialize)]
pub struct Channel {
pub id: UBigInt,
pub name: VarChar,
pub description: Option<VarChar>,
pub kind: Integer
}
pub const VOICE_CHANNEL: Integer = 1;
pub const TEXT_CHANNEL: Integer = 2;
#[async_trait]
impl FromDB<Channel, Integer> for Channel {
// id name desc kind
type Row = Option<(UBigInt, VarChar, Option<VarChar>, Integer)>;
async fn get(p: &Pool, id: UBigInt) -> Response<Channel> {
//! Retrieves a Full single Channel row from the DB or fails in a
//! fashion described by crate::Response<Channel>
//! @param p -> SqlPool
//! @param id -> UBigInt
//! @return on_success -> Response::Row(Channel)
//! @return on_fail -> Response::{Empty, Other<String>}
if let Ok(conn) = p.get_conn().await {
let q = "SELECT id, name, description, kind FROM channels WHERE id = :id";
let result: Result<(Conn, Self::Row), SqlError> =
conn.first_exec(q, params!{"id" => id}).await;
if let Ok((_, row)) = result {
return match row {
Some(row) => Response::Row(Channel {
id: id,
name: row.1,
description: row.2,
kind: row.3
}),
None => Response::Empty
}
}
return Response::Other(no_conn!("Invite::FromDB::get fetch failed"));
}
return Response::Other(no_conn!("Invite::FromDB::get"));
}
async fn update(p: &Pool, row: Self) -> Response<Channel> {
//! Updates a whole single based on a given Row Of Channel Type
//! @param p -> SqlPool
//! @param row -> Channel
//! @return on_success -> Response::Success
//! @return on_failure -> Response::Other
if let Ok(conn) = p.get_conn().await {
let q = "UPDATE channels
SET name = :name, description = :desc, kind = :kind
WHERE id = :id";
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{
"id" => row.id,
"name" => row.name,
"desc" => row.description,
"kind" => row.kind
}).await;
return match result {
Ok(_) => Response::Success,
Err(_) => Response::Other(sql_err!("Invite::FromDB::update Update failed"))
}
}
return Response::Other(no_conn!("Invite::FromDB::get connection failed"));
}
async fn delete(p: &Pool, id: UBigInt) -> Response<Channel> {
//! Deletes channel given UBigInt as the row key
//! @param p -> SqlPool
//! @param id -> UBigInt
//! @return on_success -> Response::Success
//! @return on_failure -> Response::Other
if let Ok(conn) = p.get_conn().await {
let q = "DELETE FROM channels WHERE id = :id";
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{"id" => id}).await;
return match result {
Ok(_) => Response::Success,
Err(sql) => Response::Other(sql_err!(sql))
}
}
else {
return Response::Other(no_conn!("Member::FromDB::delete"))
}
}
async fn filter(p: &Pool, kind: Integer) -> Response<Channel> {
//! @returns -> on success : Response::Set(Vec<Channel>)
//! @returns -> on empty set : Response::Set(EmptyVector)
//! @params -> on fail : Response::Other
// NOTE: used for mapping datasets to vectors
let map_rows = |row| {
let (id, name, desc, k): (UBigInt, VarChar, Option<VarChar>, Integer) =
mysql_async::from_row(row);
Channel {
id: id,
name: name,
description: desc,
kind: k
}
};
return match (p.get_conn().await, kind) {
// Filter for text/voice channels specifically
(Ok(conn), VOICE_CHANNEL..=TEXT_CHANNEL) => { // @NOTE: voice channel and text_channel are literally 1 & 2 respectively
let q = "SELECT id, name, description, kind FROM channels WHERE kind = :kind";
let q_result = conn.prep_exec(q, params!{"kind" => kind}).await;
if let Ok(res) = q_result {
let mapping_result = res.map_and_drop(map_rows).await;
return match mapping_result {
Ok((_, channels)) => Response::Set(channels),
Err(_) => Response::Other(sql_err!("db::Channels::filter @with params"))
};
}
else {
Response::Other(sql_err!(""))
}
},
/*
* Here we are attempting to get all the channels with no filters applied
* This should fetch everything basically in our channels registry
*/
(Ok(conn), _) => {
let q = "SELECT id, name, description, kind FROM channels";
if let Ok(query) = conn.prep_exec(q, ()).await {
let mapping_r = query.map_and_drop(map_rows).await;
return match mapping_r {
Ok((_, channels)) => Response::Set(channels),
Err(_) => Response::Other(sql_err!("db::Channels::filter @no params"))
};
}
else {
Response::Other(sql_err!("db::Channels::filter @no params @no initial query"))
}
},
(Err(_), _) => {Response::Other(no_conn!("Channel::FromDB::filter"))}
}
}
}
impl Channel {
pub async fn add(p: &Pool, name: &str, desc: &str, kind: Integer) -> Response<Self> {
//! @returns on success -> Response::Row<Channel>
//! @returns on partial success -> Response::Empty
//! @returns on failure -> Response::Other
if let Ok(conn) = p.get_conn().await {
let q = "INSERT INTO channels (name, description, kind) VALUES (:n, :d, :k)";
let insert_result = conn.drop_exec(q, params!{
"n" => name,
"d" => desc,
"k" => kind
}).await;
if let Ok(conn) = insert_result {
// This is only kosher because names are enforced as unique by sql
let q = "SELECT id FROM channels WHERE name = :name";
let fetch_result : Result<(Conn, Option<UBigInt>), SqlError> =
conn.first_exec(q, params!{"name" => name}).await;
return match fetch_result {
Ok((_, id_opt)) => {
if let Some(id) = id_opt {
Response::Row(Channel {
id: id,
name: name.into(),
description: Some(desc.into()),
kind: kind
})
}
else { Response::Empty }
},
Err(_) => Response::Empty
};
}
else {
return Response::RestrictedInput(
"Could not add channel, make sure the name is unique and not longer than 256 bytes".into());
}
}
return Response::Other(no_conn!("db::channels::add"))
}
}

53
json-api/db/src/common.rs Normal file
View File

@@ -0,0 +1,53 @@
use mysql_async::Pool;
use async_trait::async_trait;
use crate::Response;
use crate::UBigInt;
#[macro_export]
macro_rules! no_conn {
($spec:literal) => {
format!("[ CON Error ] : {}", $spec)
}
}
#[macro_export]
macro_rules! sql_err {
($spec:literal) => {
format!("[ SQL Error ] : {}", $spec)
};
// Using this mostly to pull in sql err types from lib to outside world for logging
($exp:expr) => {
format!("[ SQL Error ] : {}", $exp)
}
}
#[macro_export]
macro_rules! sql_err_log {
($spec:expr) => {
println!($spec);
}
}
/*
* NOTE: pay attention to work on async in traits for rust
* Each of one these funcs will implicitly do a single heap allocation which
* for our case is fine though ideally we don't
*
* As soon as we finda way around that we should depecrate `#[async_trait`
* for the performance
* */
#[async_trait]
pub trait FromDB<T, FilterType> {
type Row;
async fn get(p: &Pool, id: UBigInt) -> Response<T>;
async fn update(p: &Pool, row: T) -> Response<T>;
async fn delete(p: &Pool, id: UBigInt) -> Response<T>;
async fn filter(p: &Pool, filter_val: FilterType) -> Response<T>;
}

116
json-api/db/src/invites.rs Normal file
View File

@@ -0,0 +1,116 @@
use mysql_async::{params, Pool, Conn};
use mysql_async::prelude::Queryable;
use mysql_async::error::Error as SqlError;
use async_trait::async_trait;
use crate::{UBigInt, BigInt};
use crate::common::FromDB;
use crate::{Response, no_conn};
#[allow(dead_code)]
pub struct Invite {
pub id: BigInt,
pub uses: Option<BigInt>,
pub expires: bool
}
#[async_trait]
impl FromDB<Invite, bool> for Invite {
type Row = Option<(BigInt, Option<BigInt>, bool)>;
async fn get(p: &Pool, id: UBigInt) -> Response<Self> {
// NOTE: cast is required for this as `id` is used as unix timestamp
let id: BigInt = id as BigInt;
if id <= 0 {
return Response::Empty;
}
if let Ok(conn) = p.get_conn().await {
let q = "SELECT id, uses, expires FROM invites WHERE id = :id ";
let result: Result<(Conn, Self::Row), SqlError> =
conn.first_exec(q, params!{"id" => id}).await;
if let Ok((_, row)) = result {
return match row {
Some(row) => Response::Row(Self {
id: id as BigInt,
uses: row.1,
expires: row.2
}),
None => Response::Empty
}
}
}
return Response::Empty;
}
async fn update(p: &Pool, row: Self) -> Response<Self> {
let q = r#"UPDATE invites
SET uses = :uses, expires: :exp
WHERE id = :id
"#;
// forcibly udpate even if theres nothing there
// this way we avoid doing an extra network hit
if row.id <= 0 {
return Response::Empty;
}
if let Ok(conn) = p.get_conn().await {
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{
"id" => row.id,
"uses" => row.uses,
"exp" => row.expires
}).await;
return match result {
Ok(_) => Response::Success,
Err(_) => Response::Other(format!("Could not update entry {}", row.id))
}
}
return Response::Empty;
}
async fn delete(p: &Pool, id: UBigInt) -> Response<Self> {
if id <= 0 { // really lame "assertion" that each method has to use for invites since they all internally use
return Response::Empty;
}
if let Ok(conn) = p.get_conn().await {
let q = "DELETE FROM invites WHERE id = :id";
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{"id" => id as BigInt}).await;
return match result {
Ok(_) => Response::Success,
Err(_) => Response::Other(format!("Could not delete {}", id))
}
}
return Response::Success;
}
async fn filter(p: &Pool, expirey_flag: bool) -> Response<Self> {
if let Ok(conn) = p.get_conn().await {
let q = "SELECT id, uses, expires FROM invites WHERE expires = :exp";
if let Ok(query) = conn.prep_exec(q, params!{"exp" => expirey_flag}).await {
let mapping_r = query.map_and_drop(|row| {
let (id, uses): (BigInt, Option<BigInt>) = mysql_async::from_row(row);
Invite {
id: id,
uses: uses,
expires: expirey_flag
}
}).await;
return match mapping_r {
Ok((_, invites)) => Response::Set(invites),
Err(_) => Response::Empty
}
}
else {
return Response::Other(no_conn!("db::Invite::filter"));
}
}
else {
return Response::Other(no_conn!("db::Invites::filter"));
}
}
}

45
json-api/db/src/lib.rs Normal file
View File

@@ -0,0 +1,45 @@
extern crate serde;
pub mod member;
pub mod common;
pub mod invites;
pub mod channels;
pub mod messages;
pub mod auth;
use std::vec::Vec;
pub type BigInt = i64;
pub type UBigInt = u64;
pub type Integer = i32;
pub type UInteger = u32;
pub type VarChar = String;
pub enum Response<T> {
// A set of rows collected
Set(Vec<T>),
// Single row collected
Row(T),
// Nothing was found -> for select/update/delete's
Empty,
// Generic success for things like update/delete's
Success,
// Mask 500's with probable user generated errors like duplicate keys being added
// or data that doesn't fit in column or something
RestrictedInput(String),
// Not sure what this will be used for but maybe its useful at some point
Other(String)
}
#[macro_export]
macro_rules! fetch_row {
($table:literal, $col:literal, $key:expr, $rtype:ty,$conn:expr) => {
let lib_str = format!("SELECT * FROM {} WHERE {} = :{}", $table, $col, $col);
let (_, $rtype) = $conn.first_exec!(lib_str, sql_params{
$col => $key,
});
}
}

211
json-api/db/src/member.rs Normal file
View File

@@ -0,0 +1,211 @@
use mysql_async::{params, Pool, Conn};
use mysql_async::prelude::Queryable;
use mysql_async::error::Error as SqlError;
use async_trait::async_trait;
use serde::Serialize;
use crate::{Response, no_conn, sql_err};
use crate::{UBigInt, BigInt, Integer, VarChar};
use crate::common::{FromDB};
#[derive(Debug, Serialize)]
pub struct Member {
pub id: UBigInt,
pub secret: VarChar,
pub name: VarChar,
pub joindate: BigInt,
pub status: Integer,
pub permissions: UBigInt,
}
pub const STATUS_ONLINE: Integer = 0;
pub const STATUS_OFFLINE: Integer = 1;
pub const STATUS_AWAY: Integer = 2;
pub const STATUS_DO_NOT_DISTURB: Integer = 3;
/*
*
* conn = getconn
* result = conn.query
* return response based on result
*
*/
#[async_trait]
impl FromDB<Member, Integer> for Member {
type Row = Option<(VarChar, VarChar, BigInt, Integer, UBigInt)>;
async fn get(p: &Pool, id: UBigInt) -> Response<Self> {
if let Ok(conn) = p.get_conn().await {
let q = "SELECT secret, name, joindate, status, permissions FROM members WHERE id = :id";
let db_res : Result<(Conn, Self::Row), SqlError>
= conn.first_exec(q, params!{"id" => id}).await;
if let Ok((_, row)) = db_res {
return match row {
Some(row) => Response::Row(Self {
id: id,
secret: row.0,
name: row.1,
joindate: row.2,
status: row.3,
permissions: row.4
}),
None => Response::Empty
}
}
return Response::Other(sql_err!("Fetch failed"));
}
return Response::Other(no_conn!("Member::FromDB::get"));
}
async fn update(p: &Pool, row: Member) -> Response<Self> {
let q = r#"UPDATE members
SET status = :status, permissions = :perms, name = :name
WHERE id = :id"#;
if let Ok(conn) = p.get_conn().await {
let query = conn.drop_exec(q, params!{
"id" => row.id,
"status" => row.status,
"name" => row.name,
"perms" => row.permissions
}).await;
return match query {
Ok(_) => Response::Success,
Err(err) => Response::Other(sql_err!(err))
}
}
return Response::Other(no_conn!("db::Member::update"));
}
async fn delete(p: &Pool, id: UBigInt) -> Response<Self> {
if let Ok(conn) = p.get_conn().await {
let q = "DELETE from members WHERE id = :id";
let db_result: Result<Conn, SqlError>
= conn.drop_exec(q, params!{"id" => id}).await;
match Member::get(p, id).await {
Response::Row(_) => {
if let Ok(conn) = db_result {
return match conn.prep_exec("", params!{"id" => id}).await {
Ok(_) => Response::Success,
Err(_) => Response::Other(sql_err!("Member::FromDB::delete"))
}
}
return Response::Success
},
Response::Empty => return Response::Empty,
_ => return Response::Other(sql_err!("Member::FromDB::delete | another stupid get happened delid this"))
}
}
return Response::Empty;
}
async fn filter(p: &Pool, status: Integer) -> Response<Self> {
//! @params status
return match (p.get_conn().await, status) {
(Ok(conn), STATUS_ONLINE) | (Ok(conn), STATUS_OFFLINE) |
(Ok(conn), STATUS_AWAY) | (Ok(conn), STATUS_DO_NOT_DISTURB) => {
// TODO: Allow people to query somewhere in the middle of this set
// i.e. we should be able to get user 100 -> 150 instead of just the
// first 1000 people
let q =
"SELECT id, name, status, permissions FROM memebrs
WHERE status = :stat LIMIT 1000"; // high limit for now
if let Ok(query) = conn.prep_exec(q, params!{"stat" => status}).await {
let mapping_r = query.map_and_drop(|row| {
let (id, name, status, permissions): (UBigInt, VarChar, Integer, UBigInt) =
mysql_async::from_row(row);
Member {
id: id,
secret: "".into(), // no show for obv reasons
name: name,
joindate: 0, // doesn't matter
status: status,
permissions: permissions
}
}).await;
match mapping_r {
Ok((_, members)) => Response::Set(members),
Err(_) => Response::Other(sql_err!("db::Members::filter"))
}
}
else {
Response::Other(sql_err!("db::Members::filter"))
}
}
_ => Response::Other(sql_err!("err"))
}
}
}
impl Member {
pub async fn add(p: &Pool, name: &str, secret: &str, perms: u64) -> Result<Response<Self>, SqlError> {
//! @param {pool} p
//! @param {&str} name of new user
//! @param {&str} encrypted secret : userland auth module should have a function for this
//! @param {u64} permissions mask
//! @returns : on_succes => Ok(Response<Member>)
//! @returns : on_partial_succes => Ok(Response<Member>)
//! @returns : on_failure => Err(SomeBS)
use chrono::Utc;
let conn = p.get_conn().await?;
//name
//perms
//secret
let now: BigInt = Utc::now().timestamp();
let conn = conn.drop_exec(
"INSERT INTO members(secret, name, joindate, status, permissions)
VALUES(:secret, :name, :joindate, :status, :permissions)",
mysql_async::params!{
"secret" => secret.clone(),
"name" => name.clone(),
"joindate" => now,
"status" => STATUS_ONLINE,
"permissions" => perms
}).await?;
let (_, opt_id): (Conn, Option<UBigInt>) = conn.first_exec(
"SELECT id FROM members WHERE secret = :secret",
params!{
"secret" => secret.clone()
}).await?;
if let Some(id) = opt_id {
return Ok(Response::Row(Self {
id: id,
secret: secret.into(),
name: name.into(),
joindate: now,
status: STATUS_ONLINE,
permissions: perms
}))
}
return Ok(Response::Empty);
}
pub async fn update_perms(p: &Pool, uid: UBigInt, permissions: UBigInt) -> Result<UBigInt, SqlError> {
//! @return on_sucess Ok(NewPermisionsMask)
//!
let conn = p.get_conn().await?;
conn.drop_exec(
"UPDATE members SET permissions = :perms WHERE id = :id",
params!{
"id" => uid,
"perms" => permissions
}).await?;
Ok(permissions)
}
}

245
json-api/db/src/messages.rs Normal file
View File

@@ -0,0 +1,245 @@
use mysql_async::{params, Pool, Conn};
use mysql_async::prelude::Queryable;
use mysql_async::error::Error as SqlError;
use async_trait::async_trait;
use serde::Serialize;
use crate::{Response, no_conn, sql_err};
use crate::{UBigInt, BigInt, VarChar};
use crate::common::FromDB;
#[allow(dead_code)]
#[derive(Serialize)]
pub struct Message {
pub id: UBigInt,
pub time: BigInt,
pub content: VarChar,
pub author_id: UBigInt,
pub channel_id: UBigInt
}
const MAX_MESSAGES: u64 = 1000;
#[async_trait]
impl FromDB<Message, (BigInt, UBigInt)> for Message {
type Row = Option<(UBigInt, BigInt, VarChar, UBigInt, UBigInt)>;
async fn get(p: &Pool, id: UBigInt) -> Response<Self> {
//! Typically used as the backend to the .update(...) method to
//! pick out a message to later edit
if let Ok(conn) = p.get_conn().await {
let q = "SELECT id, time, content, author_id, channel_id WHERE id = :id";
let result: Result<(Conn, Self::Row), SqlError> =
conn.first_exec(q, params!{"id" => id}).await;
if let Ok((_, row)) = result {
return match row {
Some(row) => Response::Row(Self {
id,
time: row.1,
content: row.2,
author_id: row.3,
channel_id: row.4
}),
None => Response::Empty
}
}
return Response::Other(sql_err!("Message::FromDB::get"));
}
return Response::Other(no_conn!("Message"));
}
async fn update(p: &Pool, row: Self) -> Response<Self> {
//! Primarily used by users to edit previous comments
// NOTE: we only allow the changing of content in this since
// no other column has good reason to be modified
if let Ok(conn) = p.get_conn().await {
let q = "UPDATE messages
SET content = :content
WHERE id = :id";
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{"id" => row.id, "content" => row.content}).await;
return match result {
Ok(_) => Response::Success,
Err(_) => Response::Other(sql_err!("Message::FromDB::update"))
}
}
return Response::Other(no_conn!("Message::FromDB::update"))
}
async fn delete(p: &Pool, id: UBigInt) -> Response<Self> {
//! Deletes a single message
//! Typically used by normal users/bots to remove unwanted comments
if let Ok(conn) = p.get_conn().await {
let q = "DELETE FROM messages WHERE id = :id";
let result: Result<Conn, SqlError> =
conn.drop_exec(q, params!{"id" => id}).await;
return match result {
Ok(_) => Response::Success,
Err(_) => Response::Other(sql_err!("Message::FromDB::delete"))
}
}
return Response::Other(no_conn!("Message::FromDB::update"))
}
async fn filter(p: &Pool, (time, channel_id): (BigInt, UBigInt)) -> Response<Self> {
//! FIlter happens via unix_timestamp and channel_id respectively
if let Ok(conn) = p.get_conn().await {
let q = "SELECT id, time, content, author_id";
if let Ok(query)= conn.prep_exec(q, params!{"time" => time, "cid" => channel_id}).await {
let mapping_r = query.map_and_drop(|row| {
let (id, time, content, author_id): (UBigInt, BigInt, VarChar, UBigInt) =
mysql_async::from_row(row);
Message {
id,
time,
content,
author_id,
channel_id
}
}).await;
match mapping_r {
Ok((_, messages)) => Response::Set(messages),
Err(_) => Response::Other(sql_err!("db::Message::filter"))
}
}
else {
return Response::Empty;
}
}
else {
return Response::Other(no_conn!("db::Message::filter"));
}
}
}
impl Message {
pub async fn send(p: &Pool, content: &str, cid: UBigInt, uid: UBigInt) -> Result<Response<Self>, SqlError> {
//! @returns on_sucess -> empty
//! @returns on_failure Err(SqlErr)
use chrono::Utc;
let conn = p.get_conn().await?;
let q = "INSERT INTO messages
(time, content, author_id, channel_id)
VALUES (:time, :content, :author, :channel)";
let now = Utc::now().timestamp();
let res = conn.prep_exec(q, params!{
"time" => now,
"content" => content,
"author" => uid,
"channel" => cid
}).await;
if let Err(e) = res {
return match e {
SqlError::Server(err) => {
if err.code == 1452 {
return Ok(Response::RestrictedInput("Channel not found".into()))
}
else {
Ok(Response::Other(sql_err!("db::messages::send")))
}
},
_ => Ok(Response::Other(sql_err!("db::messages::send")))
}
}
// all good response
else {
return Ok(Response::Empty);
}
}
pub async fn get_time_range(p: &Pool, channel_id: UBigInt, start: BigInt, end: BigInt, limit: Option<u64>) -> Result<Response<Self>, SqlError> {
//! @returns on success : Set(Vec<Messages>)
//! @returns on userfail: RestrictedInput(message)
//! @returns on error : Err(SqlError)
if start >= end {
Ok(Response::RestrictedInput("Invalid start/end parameters".into()))
}
else {
let conn = p.get_conn().await?;
let limit = if let Some(limit) = limit {
match limit {
1 ..= MAX_MESSAGES => limit,
_ => MAX_MESSAGES
}
} else {
MAX_MESSAGES
};
let q = "SELECT id, time, content, author_id FROM messages
WHERE channel_id = :channel AND time >= :start AND time < :end
LIMIT :limit";
let select_result = conn.prep_exec(
q, params!{
"start" => start,
"end" => end,
"channel" => channel_id,
"limit" => limit
}).await?;
let(_conn, messages) = select_result.map_and_drop(|row| {
type Tuple = (UBigInt, BigInt, String, UBigInt);
let (id, time, content, author_id): Tuple = mysql_async::from_row(row);
Self {
id,
time,
content,
author_id,
channel_id
}
}).await?;
Ok(Response::Set(messages))
}
}
pub async fn get_from_id(p: &Pool, channel_id: UBigInt, start: UBigInt, limit: Option<UBigInt>) -> Result<Response<Self>, SqlError> {
//! @returns on success : Set(Vec<Messages>)
//! @returns on failure : Err(SqlError)
let conn = p.get_conn().await?;
let limit = if let Some(limit) = limit{
match limit {
1 ..= MAX_MESSAGES => limit,
_ => MAX_MESSAGES
}
} else {
MAX_MESSAGES // messages at a time
};
let q = "SELECT id, time, content, author_id FROM messages WHERE
channel_id = :channel AND id >= :start LIMIT :limit";
let params = params!{
"channel" => channel_id,
"start" => start,
"limit" => limit
};
let select_result = conn.prep_exec(q, params).await?;
let (_conn, messages) = select_result.map_and_drop(|row| {
type Tuple = (UBigInt, BigInt, String, UBigInt);
let (id, time, content, author_id): Tuple = mysql_async::from_row(row);
Self {
id,
time,
content,
author_id,
channel_id
}
}).await?;
Ok(Response::Set(messages))
}
}

49
json-api/endpoints.md Normal file
View File

@@ -0,0 +1,49 @@
# Documenting the currently tested / working endpoints
Mostly a dev reference for now since it's a lot more terse.
## Parameters
All parameters are to be passed in via json, other parameters are ignored
## Auth
Basically every endpoint except for `/join` and `/meta` require the folowing auth
data at a bare minimum.
* id: u64 -> User id [ Public to the instance ]
* jwt: String
To get a JWT you must `POST /login` with the following:
* id: u64 -> User id [ Public to the instance ]
* secret: String -> Server generated password
## Chat api
`/channels/list` NoPermsRequired
No parameters required
`/channels/create` RequiredPerm[CREATE_CHANNEL=64]
* @Required name: `String`
* @Required kind: `Signed 32-bit Integer`
* @Optional description: `String`
For voice channel `1`. For text channel `2`. All other options result in a HTTP 400.
`/channels/delete` RequiredPerm[DELETE_CHANNEL=128]
* @Required channel_id: `Unsigned 64-bit Integer`
`/message/send` RequiredPerm[SEND_MESSAGES=2]
* @Required channel `Unsigned 64-bit Integer`
* @Required content `String`

2
json-api/hmac.secret Normal file
View File

@@ -0,0 +1,2 @@
bïò<EFBFBD>Cˆ'BIŸ¬%©b»jˆ]<>Ž¥µH>ŒŒ$ ° æúϦwÙY攘#ÂýÐ
4O¶>'ŸÉ“

View File

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE `invites`;

View File

@@ -0,0 +1,12 @@
-- @id : id of the invite which is also its kill date
-- @uses : can be null which means it doesn't have a use limit
-- @expires: boolean that tells wether the key expires or not
CREATE TABLE IF NOT EXISTS `invites` (
`id` BIGINT UNIQUE NOT NULL,
`uses` BIGINT,
`expires` BOOLEAN NOT NULL,
PRIMARY KEY( `id` )
);

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE `channels`;

View File

@@ -0,0 +1,8 @@
-- TODO: somehow make the name colum unique
CREATE TABLE IF NOT EXISTS `channels` (
`id` BIGINT UNSIGNED NOT NULL auto_increment,
`name` VARCHAR(255) NOT NULL,
`description` VARCHAR(1024),
`kind` INTEGER NOT NULL,
PRIMARY KEY(`id`), UNIQUE KEY(`name`)
);

View File

@@ -0,0 +1 @@
DROP TABLE `members`;

View File

@@ -0,0 +1,11 @@
-- TODO: add rate limiter in some form
-- PERMISSIONS start at 0 and full perms => all F's
CREATE TABLE IF NOT EXISTS `members`(
`id` BIGINT UNSIGNED NOT NULL auto_increment,
`secret` varchar(256) NOT NULL,
`name` varchar(256) NOT NULL,
`joindate` bigint NOT NULL,
`status` integer NOT NULL,
`permissions` bigint UNSIGNED NOT NULL,
PRIMARY KEY( `id` , `secret` )
);

View File

@@ -0,0 +1 @@
DROP TABLE `messages`;

View File

@@ -0,0 +1,11 @@
-- Time stamp is _not_ in ms
CREATE TABLE IF NOT EXISTS `messages`(
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`time` BIGINT NOT NULL,
`content` VARCHAR(2048) NOT NULL,
`author_id` BIGINT UNSIGNED NOT NULL,
`channel_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY (`author_id`) REFERENCES members(`id`),
FOREIGN KEY (`channel_id`) REFERENCES channels(`id`) ON DELETE CASCADE
);

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE `jwt`;

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS `jwt`(
`id` BIGINT UNSIGNED NOT NULL,
`token` VARCHAR(256) NOT NULL,
PRIMARY KEY (`id`)
);

View File

@@ -0,0 +1,3 @@
#!/bin/bash
cat /dev/urandom | fold -bw 16 | base64 | head -1

View File

@@ -0,0 +1,8 @@
#!/bin/sh
# Running the
mysql -u root -p \
"create user 'freechat_dev'@'localhost' identified by 'password';"
mysql -u root -p \
"GRANT ALL PRIVILEGES ON freechat.* TO 'freechat_dev'@'localhost';"

65
json-api/src/admin.rs Normal file
View File

@@ -0,0 +1,65 @@
// Module deals endpoints pertaining to admin-only actions
use hyper::{Response, Body};
use hyper::StatusCode;
use mysql_async::Pool;
use serde_json::Value;
use crate::perms::ADMIN_PERMS;
use db::{
self,
member::Member
};
macro_rules! get_target_id {
($obj:expr) => {
match $obj.get("target-id") {
Some(val) => val.as_u64(),
None => None
}
}
}
pub async fn new_admin(p: &Pool, response: &mut Response<Body>, params: Value) {
// @requires: owner level permission as regular admins can have conflict of interests
// @user-param: "target-id": Number
if let Some(uid) = get_target_id!(params) {
if let Err(e) = Member::update_perms(p, uid, ADMIN_PERMS).await {
eprintln!("{}", e);
}
}
else {
// this is likely the users fault providing shit ass json
*response.status_mut() = StatusCode::BAD_REQUEST;
*response.body_mut() = Body::from("Missing target user id");
}
}
pub async fn set_permissions(p: &Pool, response: &mut Response<Body>, params: Value) {
// @requiresL: admin level permissions, admins can't touch other admins
let tuid = get_target_id!(params);
let new_perms_opt = match params.get("permissions") {
Some(val) => val.as_u64(),
None => None
};
match (tuid, new_perms_opt) {
(Some(uid), Some(new_perms)) => {
// Returns Ok(Response::sucess) | Err(log)
if let Err(e) = Member::update_perms(p, uid, new_perms).await {
eprintln!("{}", e); // wil be some sql error
}
},
_ => {
*response.status_mut() = StatusCode::BAD_REQUEST;
*response.body_mut() = Body::from("Missing one or more parameters");
}
}
}

275
json-api/src/auth.rs Normal file
View File

@@ -0,0 +1,275 @@
use serde::{Serialize, Deserialize};
use bcrypt::{self, BcryptResult};
use mysql_async::Pool;
use chrono::{Utc, Duration};
use crate::routes;
use db::{member::Member, common::FromDB};
use db::Response;
use jsonwebtoken::EncodingKey;
lazy_static! {
static ref HMAC_SECRET: Vec<u8> = {
std::fs::read("hmac.secret").expect("Couldn't get HMAC secret")
};
static ref ENCODING_KEY: EncodingKey = {
EncodingKey::from_secret(&HMAC_SECRET)
};
}
#[derive(Debug, Serialize, Deserialize)]
struct Claim {
sub: db::UBigInt, // user id
exp: db::BigInt, // expiry date
cookie: String, // unique cookie value
}
impl Claim {
pub fn new(id: db::UBigInt) -> Claim {
Claim {
sub: id,
exp: Utc::now()
.checked_add_signed(Duration::weeks(1))
.expect("Couldn't generate an expirey date")
.timestamp(),
cookie: generate_cookie()
}
}
}
// used when we create a new users for the first time
#[derive(Debug)]
pub enum AuthReason {
Good, //passed regular check
OpenAuth, // route does not require auth
NoKey, // key missing
BadKey, // key is bad
LoginValid, // used only to access the login route which is also our refresh
ServerIssue(String) // for well 500's
}
fn valid_secret(given_pass: &str, hash: &str) -> bool {
let result = bcrypt::verify(given_pass, hash);
return match result {
Ok(result) => result,
Err(e) => {
eprintln!("{}", e);
return false;
}
}
}
fn valid_perms(member: Member, path: &str) -> bool {
use crate::perms;
// if there are perms on the current path make sure the user has them
if let Some(p) = perms::get_perm_mask(path) {
return (p & member.permissions) == p;
}
// if no perms then we don't care
else {
return true;
}
}
fn rng_secret(length: usize) -> String {
use getrandom::getrandom;
use base64::{encode_config, URL_SAFE};
let mut buf: Vec<u8> = vec![0;length];
getrandom(&mut buf).unwrap();
encode_config(buf,URL_SAFE)
}
pub fn generate_secret() -> String {
/*
* Generates a url-safe-plaintext secret for our db
* */
return rng_secret(64);
}
pub fn generate_cookie() -> String {
return rng_secret(32)
}
pub fn encrypt_secret(raw: &str) -> BcryptResult<String> {
const BCRYPT_COST: u32 = 14;
return bcrypt::hash(raw, BCRYPT_COST);
}
fn jwt_from_serde(params: &serde_json::Value) -> Option<&str> {
// gets the `token` from the parameters
// option<value> -> some(value) -> string
return params.get("jwt")?.as_str();
}
async fn valid_jwt(p: &Pool, token: &str) -> AuthReason {
use jsonwebtoken::{
decode, DecodingKey,
Validation, Algorithm
};
// crypto things that should prolly not fail assuming we're configured correctly
let algo = Algorithm::HS512;
let dk = DecodingKey::from_secret(&HMAC_SECRET);
if let Ok(decoded) = decode::<Claim>(token, &dk, &Validation::new(algo)) {
// subject used for querying speed NOT security
let listed = db::auth::listed_jwt(p, decoded.claims.sub, token).await.unwrap();
let active = Utc::now().timestamp() < decoded.claims.exp;
return match listed && active {
true => AuthReason::Good,
false => AuthReason::BadKey
};
}
else {
return AuthReason::BadKey;
}
}
fn login_params_from_serde(params: &serde_json::Value) -> Option<(db::UBigInt, &str)> {
let id_v = params.get("id");
let secret_v = params.get("secret");
return match (id_v, secret_v) {
(Some(id_v), Some(secret_v)) => {
match (id_v.as_u64(), secret_v.as_str()) {
(Some(id), Some(secret)) => Some((id, secret)),
_ => None
}
},
_ => None
}
}
pub async fn wall_entry<'path, 'pool, 'params>(
path: &'path str,
pool: &'pool Pool,
params: &'params serde_json::Value)
-> AuthReason {
// Dont need to auth if it's not required
let open_path = routes::is_open(path);
let jwt = jwt_from_serde(params);
if open_path { // ignore the parameters since they're irelevant
return AuthReason::OpenAuth;
}
if let Some(jwt) = jwt {
// get the headers here
return valid_jwt(pool, jwt).await;
}
if let Some((id, secret)) = login_params_from_serde(params) {
// Last chance we might be hitting the /login route so we have to do the heavy auth flow
if path != routes::AUTH_LOGIN {
return AuthReason::BadKey;
}
else {
return match Member::get(pool, id).await {
Response::Row(user) => {
if valid_secret(secret, &user.secret) && valid_perms(user, path){
AuthReason::LoginValid
}
else {
AuthReason::BadKey
}
},
Response::Empty => AuthReason::BadKey,
Response::Other(err) => AuthReason::ServerIssue(err),
_ => AuthReason::ServerIssue("db-lib returned garbage".into())
}
}
}
return AuthReason::NoKey;
}
pub async fn login_get_jwt(p: &Pool, response: &mut hyper::Response<hyper::Body>, params: serde_json::Value) {
// basically this route generates a jwt for the user and returns via the jwt key
// in the json response
use jsonwebtoken::{
Header, Algorithm,
encode
};
use hyper::header::HeaderValue;
let id = params.get("id").unwrap().as_u64().unwrap(); // only route where we have the "id is there guarantee"
let claim = Claim::new(id);
let header = Header::new(Algorithm::HS512);
let encoded = encode(
&header,
&claim,
&ENCODING_KEY).unwrap();
match db::auth::add_jwt(p, id, &encoded).await {
Ok(_) => {
response.headers_mut().insert("Content-Type",
HeaderValue::from_static("application/json"));
let payload = serde_json::json!({
"jwt": encoded
});
*response.body_mut() = hyper::Body::from(payload.to_string());
},
Err(e) => {
eprintln!("{}", e);
*response.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
}
};
}
#[cfg(test)]
mod auth_tests {
use jsonwebtoken::{
Header, encode, Algorithm
};
#[test]
fn validity_check() {
use bcrypt::{hash, DEFAULT_COST};
let plain = super::generate_secret();
match hash(&plain, DEFAULT_COST) {
Ok(hash) => assert_eq!(super::valid_secret(&plain, &hash), true),
Err(err) => panic!("{}", err)
}
}
#[test]
fn verify_jwt() {
let claim = super::Claim::new(123); // example claim that we send out
let header = Header::new(Algorithm::HS512); // header that basically all clients get
let encoded = encode(
&header,
&claim,
&super::ENCODING_KEY).unwrap();
use jsonwebtoken::{decode, DecodingKey, Validation};
let dc = decode::<super::Claim>(
&encoded,
&DecodingKey::from_secret(&super::HMAC_SECRET),
&Validation::new(Algorithm::HS512)
); // decoding works yet fails on the debugger
assert_eq!(dc.is_ok(), true);
println!("{:?}", dc);
println!("{}", encoded);
let mut parts:Vec<String> = Vec::new();
for i in encoded.split('.') {
parts.push(i.to_string());
}
let head = &parts[0];
let claim = &parts[1];
let sig = &parts[2];
let res = jsonwebtoken::crypto::verify(
sig,
&format!("{}.{}", head, claim),
&DecodingKey::from_secret(&super::HMAC_SECRET),
Algorithm::HS512);
println!("{:?}", res);
}
}

106
json-api/src/channels.rs Normal file
View File

@@ -0,0 +1,106 @@
use hyper::{StatusCode, Response, Body};
use mysql_async::Pool;
use serde_json::{json, Value};
use db::{
self,
common::FromDB,
channels::Channel
};
use crate::http::set_json_body;
pub async fn list_channels(pool: &Pool, response: &mut Response<Body>) {
/*
* @user-params -> for now none as i don't feel like dealing with it
* @TODO: add in a let var that actually
*/
return match db::channels::Channel::filter(pool, 0).await {
db::Response::Set(channels) => {
set_json_body(response, json!(channels));
},
db::Response::Other(_msg) => *response.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR,
_ => *response.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR,
};
}
pub async fn create_channel(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* Create a channel base on a few parameters that may or may not be there
* @responds with the data of the newly created channel
*/
// Theres an extra un-needed unwrap to be cut out from this proc
// specifically with the desc parameter
use std::convert::TryInto;
let req_params: (Option<&str>, Option<&str>, Option<i64>) =
match (params.get("name"), params.get("description"), params.get("kind")) {
(Some(name), Some(desc), Some(kind)) => (name.as_str(), desc.as_str(), kind.as_i64()),
(Some(name), None, Some(kind)) => (name.as_str(), Some("No Description"), kind.as_i64()),
_ => (None, None, None)
};
match req_params {
(Some(name), Some(desc), Some(kind)) => {
use db::channels::{TEXT_CHANNEL, VOICE_CHANNEL};
if kind < VOICE_CHANNEL as i64 || kind > TEXT_CHANNEL as i64 {
*response.status_mut() = StatusCode::BAD_REQUEST; // restriciting to 1|2 for valid channel kinds
}
else {
// Send the data up to the db, then return the new channel back to the user(?)
match db::channels::Channel::add(pool, name, desc, kind.try_into().unwrap()).await {
db::Response::Row(row) => {
set_json_body(response, json!(row));
},
// user error that the db doesn't deal with so we just blame the user
db::Response::RestrictedInput(msg) => {
*response.status_mut() = StatusCode::BAD_REQUEST;
set_json_body(response, json!({"error": msg}));
},
// inserted but could not fetch
db::Response::Empty => *response.status_mut() = StatusCode::NOT_FOUND,
//like legit issues past here
db::Response::Other(msg) => {
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; // conn issue probably
eprintln!("\t[ Channels ] {}", msg);
},
_ => *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR // ngmi
}
}
},
// basically one of the parameter gets failed so we bail on all of this
_ => *response.status_mut() = StatusCode::BAD_REQUEST
}
}
pub async fn delete_channel(pool: &Pool, response: &mut Response<Body>, params: Value) {
// make sure we have the right parameters provided
if let Some(name) = params.get("channel_id") {
if let Some(id) = name.as_u64() {
// TODO: something more intelligent with the logging im ngl
match Channel::delete(pool, id).await {
db::Response::Success => {},
db::Response::Other(data) => {
eprintln!("\t{}", data);
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
}
_ => {
eprintln!("\tBro like restart the server");
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
}
}
}
else {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
}
else {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
}

37
json-api/src/http.rs Normal file
View File

@@ -0,0 +1,37 @@
use serde_json::{self, Value};
use hyper::http::HeaderValue;
use hyper::Response;
use hyper::Body;
use hyper::body::to_bytes;
use std::u8;
const APP_JSON_HEADER: &'static str = "application/json";
const CONTENT_TYPE: &'static str = "Content-Type";
pub fn set_json_body(response: &mut Response<Body>, values: Value) {
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static(APP_JSON_HEADER));
*response.body_mut() = Body::from(values.to_string());
}
pub async fn parse_json_params(body_raw: &mut Body) -> Result<Value, serde_json::error::Error> {
let bytes: &[u8] = &*to_bytes(body_raw).await.unwrap(); // rarely fails
let values: Value;
if bytes.len() == 0 {
values = serde_json::from_str("{}")?;
}
else {
values = serde_json::from_slice(bytes)?;
}
Ok(values)
}
#[inline]
pub fn extract_uid(values: &Value) -> u64 {
// pulling 'id' from user params is safe because the
// auth modules guarantees this to be there already
values.get("id").unwrap().as_u64().unwrap()
}

View File

@@ -0,0 +1,2 @@

218
json-api/src/invites.rs Normal file
View File

@@ -0,0 +1,218 @@
use serde_json::Value;
use serde::Serialize;
use mysql_async;
use mysql_async::{Conn, Pool};
use mysql_async::error::Error;
use mysql_async::prelude::{params, Queryable};
use hyper::{Response, Body, StatusCode};
use chrono::Utc;
use db::{UBigInt, BigInt};
use db::common::FromDB;
use db::member::Member;
#[derive(Serialize)]
struct Invite {
id: BigInt, // doubles as the timestamp for when it dies
uses: Option<BigInt>, // optional because some links are permanent
expires: bool,
}
/*
* Error handling:
* All errors raisable from this module come from mysql_async and thus
* are of the enum mysql_async::error::Error
*/
async fn valid_invite(pool: &Pool, id: BigInt) -> bool {
/*
* Fetches an invite from the database to check for validity
*/
let query: Option<db::invites::Invite> = match db::invites::Invite::get(pool, id as u64).await {
db::Response::Row(invite) => { Some(invite) },
_ => { None }
};
if let Some(invite) = query {
// if expires at all
if invite.expires {
let now = Utc::now().timestamp();
// old?
let mut valid_status = now > invite.id;
// used?
if invite.uses.is_some() && valid_status == false {
valid_status = invite.uses.unwrap() <= 0; // safe unwrap since we know its Some(_)
}
return valid_status
}
// no expiry date? no problem
return true
}
// prolly not a real id
return false
}
async fn use_invite(pool: &Pool, code: Option<BigInt>) -> Option<Member>{
/*
* Attempts to change the state of the current invite being provided
*/
use crate::auth;
use crate::perms::GENERAL_NEW;
let id = match code {
Some(id) => id,
None => 0
};
// some random comment
if valid_invite(pool, id).await {
let raw_secret = auth::generate_secret();
if let Ok(secret) = auth::encrypt_secret(&raw_secret) {
return match db::member::Member::add(pool, "Anonymous".into(), &secret, GENERAL_NEW).await {
Ok(response) => {
match response {
db::Response::Row(member) => Some(member),
_ => None,
}
},
// TODO: logggin or something idk
Err(_) => return None
}
}
// Returning None because we couldn't actually create a proper secret to store
else {
return None;
}
}
// The invite itself was not valid
else {
return None;
}
}
pub async fn join(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* Main dispatcher for dealing with an attempted join via a given invide code
*/
let code = match params.get("invite-id") {
Some(val) => val.as_i64(),
None => None
};
match use_invite(&pool, code).await {
Some(new_account) => *response.body_mut() = Body::from(serde_json::to_string(&new_account).unwrap()),
None => {
}
}
}
async fn insert_new_invite(pool: &Pool, invite: &Invite) -> Result<(), Error>{
let conn = pool.get_conn().await?;
conn.prep_exec(
"INSERT INTO invites (id, uses, expires)
VALUES (:id, :uses, :expires)", params!{
"id" => invite.id,
"uses" => invite.uses,
"expires" => invite.expires
}).await?;
Ok(())
}
async fn process_expires_parameter(p: &Pool, exp: &Value, id: UBigInt) -> bool {
// TODO: fix this somewhat unsafe code
// NOTE: its unsafe because of these lazy as heck unwraps everywhere
use crate::perms::{CREATE_PERM_INVITES, CREATE_TMP_INVITES};
let conn = p.get_conn().await.unwrap();
let db_tup: (Conn, Option<UBigInt>) = conn.first_exec(
"SELECT permissions FROM members WHERE id = :id",
params!{"id" => id})
.await.unwrap();
// depending on what type of invite we requested we should make sure we have the
// right permissions to do so
let real_perms = db_tup.1.unwrap(); // safe via auth module
if let Some(exp) = exp.as_bool() {
// perma?
if exp {
return (real_perms & CREATE_PERM_INVITES) == CREATE_PERM_INVITES;
}
else {
return (real_perms & CREATE_TMP_INVITES) == CREATE_TMP_INVITES;
}
}
else {
return false;
}
}
pub async fn create(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* Creates a new invite
*/
// no user can actually have an id of 0 this won't find anyone on the backend
let id = match params.get("id") {
Some(val) => val.as_u64().unwrap_or(0),
None => 0
};
let use_count = match params.get("uses") {
Some(val) => val.as_i64(),
None => None
};
let expires = match params.get("expires") {
Some(exp_val) => process_expires_parameter(pool, exp_val, id).await,
None => true
};
let invite = Invite {
id: (Utc::now() + chrono::Duration::minutes(30)).timestamp(),
uses: use_count,
expires: expires
};
match insert_new_invite(&pool, &invite).await {
Ok(_) => {},
Err(mysqle) => {
println!("\tINVITES::CREATE::ERROR: {}", mysqle);
*response.status_mut() = StatusCode::BAD_REQUEST;
}
}
}
#[cfg(test)]
mod invites_test {
/*
* INVITE CREATION
* Good - Bad - Malicious
*/
use crate::testing::{get_pool, hyper_resp};
use hyper::StatusCode;
use serde_json::Value;
#[tokio::test]
async fn create_invite_good() {
// Generation of data
let p = get_pool();
let mut resp = hyper_resp();
// expected params
let params: Value = serde_json::from_str(r#"
{
"uses": 3,
"expire": null
}
"#).unwrap();
// Collection
super::join(&p, &mut resp, params).await;
let _ = p.disconnect().await;
assert_eq!(StatusCode::OK, resp.status());
}
}

243
json-api/src/main.rs Normal file
View File

@@ -0,0 +1,243 @@
extern crate db;
extern crate chrono;
extern crate clap;
extern crate dotenv;
extern crate getrandom;
extern crate bcrypt;
extern crate base64;
extern crate serde;
extern crate jsonwebtoken;
#[macro_use] extern crate lazy_static;
use std::net::SocketAddr;
use std::convert::Infallible; // our main dispatcher basically never fails hence why we use this
use std::env::{self, set_var};
use tokio;
use hyper::{
self,
Server,
Response, Request, Body,
Method, StatusCode,
service::{make_service_fn, service_fn}
};
use mysql_async::Pool;
use dotenv::dotenv;
use clap::{Arg, App};
use auth::AuthReason;
mod auth;
mod routes;
mod meta;
mod invites;
mod channels;
mod members;
mod perms;
mod messages;
mod admin;
mod http;
mod testing;
const NO_ERR: u16 = 0;
const CONFIG_ERR: u16 = 1;
const SHUTDOWN_ERR: u16 = 2;
async fn route_dispatcher(pool: &Pool, resp: &mut Response<Body>, meth: &Method, path: &str, params: serde_json::Value) {
// At some point we should have some way of hiding this obnoxious complexity
const GET: &Method = &Method::GET;
const POST: &Method = &Method::POST;
const DELETE: &Method = &Method::DELETE;
match (meth, path) {
/* INVITES */
(GET, routes::INVITE_CREATE) => invites::create(pool, resp, params).await,
(GET, routes::INVITE_JOIN) => invites::join(pool, resp, params).await,
/* CHANNELS */
(GET, routes::CHANNELS_LIST) => channels::list_channels(pool, resp).await,
(POST, routes::CHANNELS_CREATE) => channels::create_channel(pool, resp, params).await,
(DELETE, routes::CHANNELS_DELETE) => channels::delete_channel(pool, resp, params).await,
/* MESSAGING */
(POST, routes::MESSAGE_SEND) => messages::send_message(pool, resp, params).await,
(GET, routes::MESSAGE_TIME_RANGE) => messages::get_by_time(pool, resp, params).await,
(GET, routes::MESSAGE_FROM_ID) =>messages::from_id(pool, resp, params).await,
/* ADMIN */
(POST, routes::SET_PERMS_BY_ADMIN) => admin::set_permissions(pool, resp, params).await,
/* MEMBERS */
(GET, routes::GET_ONLINE_MEMBERS) => members::get_online_members(pool, resp).await,
/* OWNER */
(POST, routes::SET_NEW_ADMIN) => admin::new_admin(pool, resp, params).await,
/* META ROUTE */
(GET, routes::META) => meta::server_meta(resp).await,
_ => {
eprintln!("\tNOT FOUND: {}: {}", meth, path);
*resp.status_mut() = StatusCode::NOT_FOUND
}
}
}
async fn main_responder(request: Request<Body>) -> Result<Response<Body>, hyper::Error>{
use AuthReason::*;
let mut response = Response::new(Body::empty());
let (parts, mut body) = request.into_parts();
let method = parts.method;
let path = parts.uri.path();
let params_res = http::parse_json_params(&mut body).await;
if let Ok(params) = params_res {
let mysql_pool = Pool::new(&env::var("DATABASE_URL").unwrap());
match auth::wall_entry(path, &mysql_pool, &params).await {
OpenAuth | Good => route_dispatcher(&mysql_pool, &mut response, &method, path, params).await,
LoginValid => auth::login_get_jwt(&mysql_pool, &mut response, params).await,
NoKey | BadKey => *response.status_mut() = StatusCode::UNAUTHORIZED,
ServerIssue(msg) => {
println!("\tAUTH : 500 [{}]", msg);
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
}
}
}
else {
println!("\tPARSER: Parameter parsing failed");
*response.status_mut() = StatusCode::BAD_REQUEST;
}
Ok(response)
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Failed to capture ctrl-c signal");
}
async fn start_server(ecode: u16) -> u16 {
println!("Servering on localhost:8888");
let addr = SocketAddr::from(([127,0,0,1], 8888));
let service = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(main_responder))
});
let server = Server::bind(&addr).serve(service);
let graceful_shutdown = server.with_graceful_shutdown(shutdown_signal());
if let Err(e) = graceful_shutdown.await {
eprintln!("Server shutdown error: {}", e);
return ecode | SHUTDOWN_ERR;
}
else {
return ecode
}
}
async fn attempt_owner_creation(name: &str) {
/*
* Attempts to create an owner level account 'name' as the name
* Writes succesful output to stdout
* Writes error output to stderr
* NOTE: Messy because there's 0 other places where this kind of direct
* functionality is required. db-lib is basically built to talk to the api
* */
let p = Pool::new(&env::var("DATABASE_URL").unwrap());
let owner_secret = auth::generate_secret();
if let Ok(enc_secret) = auth::encrypt_secret(&owner_secret) {
if let Ok(response) = db::member::Member::add(&p, name, &enc_secret, perms::OWNER).await {
match response {
db::Response::Row(mut owner) => {
owner.secret = owner_secret; // giving the secret itself back to the user
println!("{}", serde_json::to_string(&owner).expect("SQL query passed but serde couldn't parse the data for some reason"))
},
db::Response::Empty => {
eprintln!("SQL server failed to return owner data, check configs and also the members table to make sure there's nothing there by accident");
},
_ => {}
};
}
else {
eprintln!("Could not communicate with the SQL server, check your configs!");
}
}
else {
eprintln!("Could not generate a proper secret");
}
p.disconnect();
}
#[tokio::main]
async fn main() -> Result<(), u16>{
let mut main_ret: u16 = 0;
let d_result = dotenv();
// check for a database_url before the override we get from the cmd line
if let Err(_d) = d_result {
if let Err(_e) = env::var("DATABASE_URL") {
main_ret |= CONFIG_ERR;
}
}
let args = App::new("Freechat Server")
.version("0.1")
.author("shockrah")
.about("Decentralized chat system")
.arg(Arg::with_name("db-url")
.short("d")
.long("db-url")
.value_name("DATABASE URL")
.help("Sets the DATABASE URL via an environment variable")
.takes_value(true))
.arg(Arg::with_name("create-owner")
.short("c")
.long("create-owner")
.value_name("Owner")
.help("Creates an account with full permissions in the SQL database."))
.arg(Arg::with_name("server")
.short("s")
.long("server")
.help("Starts the API server"))
.get_matches();
if args.args.len() == 0 {
println!("Freechat Server 0.1
shockrah
Decentralized chat system
USAGE:
freechat-server [FLAGS] [OPTIONS]
FLAGS:
-h, --help Prints help information
-s, --server Starts the API server
-V, --version Prints version information
OPTIONS:
-c, --create-owner <Owner> Creates an account with full permissions in the SQL database.
-d, --db-url <DATABASE URL> Sets the DATABASE URL via an environment variable");
}
if let Some(db_url) = args.value_of("db-url") {
set_var("DATABASE_URL", db_url);
}
if let Some(owner_name) = args.value_of("create-owner") {
attempt_owner_creation(owner_name).await;
}
if args.is_present("server") {
if main_ret == NO_ERR {
main_ret = start_server(main_ret).await;
}
}
if main_ret != 0 {
// dumb as heck loggin method here
if main_ret & CONFIG_ERR != 0 {println!("ERROR: Config was not setup properly => Missing {{DATABASE_URL}}");}
if main_ret & SHUTDOWN_ERR != 0 {println!("ERROR: Couldn't shutdown gracefully");}
Err(main_ret)
}
else {
Ok(())
}
}

21
json-api/src/members.rs Normal file
View File

@@ -0,0 +1,21 @@
use hyper::{Response, Body, StatusCode};
use mysql_async::Pool;
use serde_json::json;
use db::member::STATUS_ONLINE;
use db::common::FromDB;
use crate::http::set_json_body;
pub async fn get_online_members(p: &Pool, response: &mut Response<Body>) {
// TODO: at some point we should provide a way of not querying literally every user in
// existance
// TODO: loggin at some point or something idklol
return match db::channels::Channel::filter(p, STATUS_ONLINE).await {
db::Response::Set(users) => {
set_json_body(response, json!(users));
},
db::Response::Other(_msg) => *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR,
_ => *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR
}
}

194
json-api/src/messages.rs Normal file
View File

@@ -0,0 +1,194 @@
use mysql_async::Pool;
use hyper::{Response, Body, StatusCode};
use serde_json::Value;
use serde_json::json;
use crate::http::{self, set_json_body};
use db::messages::Message;
pub async fn get_by_time(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* Has a ton of required parameters just be warned
* @channel: channel id we're looking at
* @start-time: how long ago do we start searching
* @end-time: how long ago do we stop searching
* {
* "channel": 1,
* "start-time": unix_now - 24 hours
* "end-time": unix_now - 23 hours
* }
*
*/
let channel = match params.get("channel") {
Some(chan_v) => chan_v.as_u64(),
None => None
};
let start_time = match params.get("start-time") {
Some(val) => val.as_i64(),
None => None
};
let end_time = match params.get("end-time") {
Some(val) => val.as_i64(),
None => None
};
let limit = match params.get("limit") {
Some(val) => val.as_u64(),
None => None
};
// TODO: flatten me mommy
if let (Some(channel), Some(start), Some(end)) = (channel, start_time, end_time) {
match Message::get_time_range(pool, channel, start, end, limit).await {
Ok(db_response) => {
match db_response {
// this absolute lack of data streaming is prolly gonna suck like
// a whore in hell week for performance but lets pretend servers don't get massive
db::Response::Set(messages) => {
set_json_body(response, json!({"messages": messages}));
},
db::Response::RestrictedInput(_/*error message to log*/) => {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
_ => {
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
}
};
},
Err(e) => {
eprintln!("{}", e);
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
}
};
} else {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
}
pub async fn send_message(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* @content: expecting string type
* @channel: channel id that we're going to send a message to
*/
// NOTE: auth module guarantees this will be there in the correct form
let author = http::extract_uid(&params);
match (params.get("content") , params.get("channel")) {
(Some(content_v), Some(channel_id_v)) => {
let (content, channel) = (content_v.as_str(), channel_id_v.as_u64());
if let (Some(message), Some(cid)) = (content, channel) {
// call returns empty on sucess so we don't need to do anything
// TODO: loggin
let db_result = db::messages::Message::send(pool, message, cid, author).await;
if let Err(issue) = db_result {
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
eprintln!("\t{}", issue);
}
else {
match db_result.unwrap() {
db::Response::RestrictedInput(msg) => {
// user issue
*response.status_mut() = StatusCode::BAD_REQUEST;
set_json_body(response, json!({"msg": msg}))
},
db::Response::Empty => {}, // nothing to do hyper defaults to 200
db::Response::Other(msg) => {
eprintln!("{}", msg);
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
},
_ => *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR
};
}
}
else {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
},
_ => *response.status_mut() = StatusCode::BAD_REQUEST
}
}
pub async fn from_id(pool: &Pool, response: &mut Response<Body>, params: Value) {
/*
* @start-id: u64
* @limit: optional<u64>
* @channel: u64
* {
* "channel": 1,
* "start": 123,
* "limit": 100
* }
*/
let channel = match params.get("channel") {
Some(chan_v) => chan_v.as_u64(),
None => None
};
let start_id = match params.get("start") {
Some(val) => val.as_u64(),
None => None
};
let limit = match params.get("limit") {
Some(val) => val.as_u64(),
None => None
};
if let (Some(channel), Some(start_id)) = (channel, start_id) {
match Message::get_from_id(pool, channel, start_id, limit).await {
Ok(db_response) => {
match db_response {
db::Response::Set(messages) => {
// NOTE this check is here because the db's check doesn't
// correctly with async and caching and magic idfk its here
// it works its correct and the cost is the same as putting
// it in the db layer so whatever
if messages.len() == 0 {
*response.status_mut() = StatusCode::NOT_FOUND;
}
else {
set_json_body(response, json!({"messages": messages}));
}
},
_ => *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR
};
},
Err(err) => {
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
eprintln!("{}", err);
}
};
}
else {
*response.status_mut() = StatusCode::BAD_REQUEST;
}
}
#[cfg(test)]
mod messaging_tests {
use crate::testing::{get_pool, hyper_resp};
use serde_json::Value;
use hyper::StatusCode;
#[tokio::test]
async fn send_message_test_missing_channel() {
/*
* Attempt to send a message i na channel that does not exist
*/
let p = get_pool();
let mut resp = hyper_resp();
let params: Value = serde_json::from_str(r#"
{
"channel": "this does not exist",
"content": "bs message",
"id": 420
}
"#).unwrap();
super::send_message(&p, &mut resp, params).await;
assert_ne!(StatusCode::OK, resp.status());
}
}

25
json-api/src/meta.rs Normal file
View File

@@ -0,0 +1,25 @@
// Basic handler for getting meta data about the server
use std::env::var;
use hyper::{Response, Body};
use serde_json::to_string;
use serde::Serialize;
#[derive( Serialize)]
struct Config {
name: String,
description: String,
url: String,
port: u16
}
pub async fn server_meta(response: &mut Response<Body>) {
let payload = Config {
name: var("SERVER_NAME").unwrap_or("No name".into()),
description: var("SERVER_DESCRIPTION").unwrap_or("No description".into()),
url: var("SERVER_URL").expect("Couldn't get url from environment"),
port: var("SERVER_PORT").expect("Couldn't get port from environment").parse::<u16>().unwrap(),
};
*response.body_mut() = Body::from(to_string(&payload).unwrap());
}

36
json-api/src/perms.rs Normal file
View File

@@ -0,0 +1,36 @@
// GENERAL PERMISSIONS
pub const JOIN_VOICE:u64 = 1;
pub const SEND_MESSAGES:u64 = 2;
pub const CHANGE_NICK:u64 = 16;
pub const ALLOW_PFP:u64 = 32;
pub const CREATE_TMP_INVITES:u64 = 4;
pub const CREATE_PERM_INVITES:u64 = 8; // to make perma invites you need both flags
pub const _ADMIN: u64 = 1 << 62; // can make other admins but can't really touch the owner
// ADMIN PERMS
pub const CREATE_CHANNEL:u64 = 64;
pub const DELETE_CHANNEL:u64 = 128;
// BELOW ARE COLLECTIVE PERMISSION SETS
pub const OWNER: u64 = std::u64::MAX;
pub const GENERAL_NEW: u64 = JOIN_VOICE | SEND_MESSAGES | ALLOW_PFP | CHANGE_NICK;
pub const ADMIN_PERMS: u64 = !(std::u64::MAX & OWNER); // filter the only perm admins don't get
pub fn get_perm_mask(path: &str) -> Option<u64> {
use crate::routes::{
INVITE_CREATE,
CHANNELS_LIST, CHANNELS_CREATE, CHANNELS_DELETE,
MESSAGE_SEND,
};
match path {
INVITE_CREATE => Some(CREATE_TMP_INVITES),
CHANNELS_LIST => None,
CHANNELS_CREATE => Some(CREATE_CHANNEL),
CHANNELS_DELETE => Some(DELETE_CHANNEL),
MESSAGE_SEND => Some(SEND_MESSAGES),
_ => Some(0)
}
}

27
json-api/src/routes.rs Normal file
View File

@@ -0,0 +1,27 @@
type Rstr = &'static str;
pub const AUTH_LOGIN: Rstr = "/login"; // requires @id @secret
pub const META: Rstr = "/meta"; // @ perms none @ requires JWT however
pub const INVITE_CREATE: Rstr = "/invite/create"; // @ perms::CREATE_INVITE
pub const INVITE_JOIN: Rstr = "/join"; // @ none for new accounts
pub const CHANNELS_LIST: Rstr = "/channels/list"; // requires none
pub const CHANNELS_CREATE: Rstr = "/channels/create"; // requires @name @kind perms::CREATE_CHANNEl
pub const CHANNELS_DELETE: Rstr = "/channels/delete"; // requires @name perms::DELETE_CHANNEL
pub const MESSAGE_SEND: Rstr = "/message/send"; // requires @content perms::MESSAGE_SEND
pub const MESSAGE_TIME_RANGE: Rstr = "/message/get_range"; // requires @channel(id) @start-time @end-time
pub const MESSAGE_FROM_ID: Rstr = "/message/from_id"; // requires @channel(id) requires @start(id) @<optional>limit(1..1000)
pub const GET_ONLINE_MEMBERS: Rstr = "/members/get_online";
// ADMIN ROUTES
pub const SET_PERMS_BY_ADMIN: Rstr = "/admin/setpermisions"; // @requires perms::ADMIN
pub const SET_NEW_ADMIN: Rstr = "/owner/newadmin"; // @requiers: owner perms
pub fn is_open(path: &str) -> bool {
return path.starts_with("/join") || path.starts_with("/meta");
}

54
json-api/src/schema.rs Normal file
View File

@@ -0,0 +1,54 @@
table! {
channels (id) {
id -> Unsigned<Bigint>,
name -> Varchar,
description -> Nullable<Varchar>,
kind -> Integer,
}
}
table! {
invites (id) {
id -> Bigint,
uses -> Nullable<Bigint>,
expires -> Bool,
}
}
table! {
jwt (id) {
id -> Unsigned<Bigint>,
token -> Varchar,
}
}
table! {
members (id, secret) {
id -> Unsigned<Bigint>,
secret -> Varchar,
name -> Varchar,
joindate -> Bigint,
status -> Integer,
permissions -> Unsigned<Bigint>,
}
}
table! {
messages (id) {
id -> Unsigned<Bigint>,
time -> Bigint,
content -> Varchar,
author_id -> Unsigned<Bigint>,
channel_id -> Unsigned<Bigint>,
}
}
joinable!(messages -> channels (channel_id));
allow_tables_to_appear_in_same_query!(
channels,
invites,
jwt,
members,
messages,
);

View File

@@ -0,0 +1,20 @@
// Functions which are only really useful for the unit tests but which show up
// constantly in the tests themselves
#[cfg(test)]
pub fn get_pool() -> mysql_async::Pool {
use dotenv::dotenv;
use mysql_async::Pool;
dotenv().ok();
return Pool::new(&std::env::var("DATABASE_URL").unwrap())
}
#[cfg(test)]
pub fn hyper_resp() -> hyper::Response<hyper::Body> {
use hyper::{Response, Body};
Response::new(Body::empty())
}