aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIlion Beyst <ilion.beyst@gmail.com>2021-12-29 19:56:31 +0100
committerIlion Beyst <ilion.beyst@gmail.com>2021-12-29 19:56:31 +0100
commit3eeaab6cec70e7a06a99a1ac2662974f71064bee (patch)
tree9d5a2665ed32df41b2be131d5e27e8b321ce78a8
parentee5af8d07625bfc7ad11b842b3941bb095aa6a6e (diff)
parent1fb4a5151bd8cfe6de4d8c19e2066a9281a0b61a (diff)
downloadplanetwars.dev-3eeaab6cec70e7a06a99a1ac2662974f71064bee.tar.xz
planetwars.dev-3eeaab6cec70e7a06a99a1ac2662974f71064bee.zip
Merge branch 'backend-server'
-rw-r--r--.gitignore2
-rw-r--r--Cargo.toml1
-rw-r--r--backend/Cargo.toml26
-rw-r--r--backend/Rocket.toml2
-rw-r--r--backend/diesel.toml5
-rw-r--r--backend/migrations/.gitkeep0
-rw-r--r--backend/migrations/00000000000000_diesel_initial_setup/down.sql6
-rw-r--r--backend/migrations/00000000000000_diesel_initial_setup/up.sql36
-rw-r--r--backend/migrations/2021-12-13-145111_users/down.sql2
-rw-r--r--backend/migrations/2021-12-13-145111_users/up.sql8
-rw-r--r--backend/migrations/2021-12-13-151129_sessions/down.sql1
-rw-r--r--backend/migrations/2021-12-13-151129_sessions/up.sql5
-rw-r--r--backend/migrations/2021-12-18-130837_bots/down.sql3
-rw-r--r--backend/migrations/2021-12-18-130837_bots/up.sql14
-rw-r--r--backend/src/db/bots.rs53
-rw-r--r--backend/src/db/mod.rs3
-rw-r--r--backend/src/db/sessions.rs46
-rw-r--r--backend/src/db/users.rs108
-rw-r--r--backend/src/lib.rs85
-rw-r--r--backend/src/main.rs16
-rw-r--r--backend/src/routes/bots.rs75
-rw-r--r--backend/src/routes/mod.rs2
-rw-r--r--backend/src/routes/users.rs94
-rw-r--r--backend/src/schema.rs39
-rw-r--r--backend/tests/bots.rs98
-rw-r--r--backend/tests/login.rs61
-rw-r--r--backend/tests/util/mod.rs59
27 files changed, 850 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a9d37c5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
index 8af2433..1f820d6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,4 +3,5 @@
members = [
"planetwars-rules",
"planetwars-cli",
+ "backend",
]
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
new file mode 100644
index 0000000..de98df7
--- /dev/null
+++ b/backend/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "mozaic4-backend"
+version = "0.0.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+tokio = { version = "1.15", features = ["full"] }
+hyper = "0.14"
+axum = { version = "0.4", features = ["json", "headers"] }
+diesel = { version = "1.4.4", features = ["postgres", "chrono"] }
+bb8 = "0.7"
+bb8-diesel = "0.2"
+dotenv = "0.15.0"
+rust-argon2 = "0.8"
+rand = "0.8.4"
+serde = { version = "1.0", features = ["derive"] }
+serde_bytes = "0.11"
+chrono = { version = "0.4", features = ["serde"] }
+serde_json = "1.0"
+base64 = "0.13.0"
+zip = "0.5"
+
+[dev-dependencies]
+parking_lot = "0.11" \ No newline at end of file
diff --git a/backend/Rocket.toml b/backend/Rocket.toml
new file mode 100644
index 0000000..40635de
--- /dev/null
+++ b/backend/Rocket.toml
@@ -0,0 +1,2 @@
+[debug.databases.postgresql_database]
+url = "postgresql://planetwars:planetwars@localhost/planetwars" \ No newline at end of file
diff --git a/backend/diesel.toml b/backend/diesel.toml
new file mode 100644
index 0000000..92267c8
--- /dev/null
+++ b/backend/diesel.toml
@@ -0,0 +1,5 @@
+# For documentation on how to configure this file,
+# see diesel.rs/guides/configuring-diesel-cli
+
+[print_schema]
+file = "src/schema.rs"
diff --git a/backend/migrations/.gitkeep b/backend/migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/backend/migrations/.gitkeep
diff --git a/backend/migrations/00000000000000_diesel_initial_setup/down.sql b/backend/migrations/00000000000000_diesel_initial_setup/down.sql
new file mode 100644
index 0000000..a9f5260
--- /dev/null
+++ b/backend/migrations/00000000000000_diesel_initial_setup/down.sql
@@ -0,0 +1,6 @@
+-- This file was automatically created by Diesel to setup helper functions
+-- and other internal bookkeeping. This file is safe to edit, any future
+-- changes will be added to existing projects as new migrations.
+
+DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
+DROP FUNCTION IF EXISTS diesel_set_updated_at();
diff --git a/backend/migrations/00000000000000_diesel_initial_setup/up.sql b/backend/migrations/00000000000000_diesel_initial_setup/up.sql
new file mode 100644
index 0000000..d68895b
--- /dev/null
+++ b/backend/migrations/00000000000000_diesel_initial_setup/up.sql
@@ -0,0 +1,36 @@
+-- This file was automatically created by Diesel to setup helper functions
+-- and other internal bookkeeping. This file is safe to edit, any future
+-- changes will be added to existing projects as new migrations.
+
+
+
+
+-- Sets up a trigger for the given table to automatically set a column called
+-- `updated_at` whenever the row is modified (unless `updated_at` was included
+-- in the modified columns)
+--
+-- # Example
+--
+-- ```sql
+-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
+--
+-- SELECT diesel_manage_updated_at('users');
+-- ```
+CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
+BEGIN
+ EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
+ FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
+BEGIN
+ IF (
+ NEW IS DISTINCT FROM OLD AND
+ NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
+ ) THEN
+ NEW.updated_at := current_timestamp;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/backend/migrations/2021-12-13-145111_users/down.sql b/backend/migrations/2021-12-13-145111_users/down.sql
new file mode 100644
index 0000000..49285a1
--- /dev/null
+++ b/backend/migrations/2021-12-13-145111_users/down.sql
@@ -0,0 +1,2 @@
+DROP INDEX users_username_index
+DROP TABLE users; \ No newline at end of file
diff --git a/backend/migrations/2021-12-13-145111_users/up.sql b/backend/migrations/2021-12-13-145111_users/up.sql
new file mode 100644
index 0000000..f35e718
--- /dev/null
+++ b/backend/migrations/2021-12-13-145111_users/up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE users(
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(52) NOT NULL,
+ password_salt BYTEA NOT NULL,
+ password_hash BYTEA NOT NULL
+);
+
+CREATE UNIQUE INDEX users_username_index ON users(username); \ No newline at end of file
diff --git a/backend/migrations/2021-12-13-151129_sessions/down.sql b/backend/migrations/2021-12-13-151129_sessions/down.sql
new file mode 100644
index 0000000..54d1e93
--- /dev/null
+++ b/backend/migrations/2021-12-13-151129_sessions/down.sql
@@ -0,0 +1 @@
+DROP TABLE sessions; \ No newline at end of file
diff --git a/backend/migrations/2021-12-13-151129_sessions/up.sql b/backend/migrations/2021-12-13-151129_sessions/up.sql
new file mode 100644
index 0000000..f8ec21b
--- /dev/null
+++ b/backend/migrations/2021-12-13-151129_sessions/up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE sessions (
+ id serial PRIMARY KEY,
+ user_id integer REFERENCES users(id) NOT NULL,
+ token VARCHAR(255) NOT NULL UNIQUE
+) \ No newline at end of file
diff --git a/backend/migrations/2021-12-18-130837_bots/down.sql b/backend/migrations/2021-12-18-130837_bots/down.sql
new file mode 100644
index 0000000..3d14604
--- /dev/null
+++ b/backend/migrations/2021-12-18-130837_bots/down.sql
@@ -0,0 +1,3 @@
+DROP TABLE code_bundles;
+DROP INDEX bots_index;
+DROP TABLE bots; \ No newline at end of file
diff --git a/backend/migrations/2021-12-18-130837_bots/up.sql b/backend/migrations/2021-12-18-130837_bots/up.sql
new file mode 100644
index 0000000..27f3582
--- /dev/null
+++ b/backend/migrations/2021-12-18-130837_bots/up.sql
@@ -0,0 +1,14 @@
+CREATE TABLE bots (
+ id serial PRIMARY KEY,
+ owner_id integer REFERENCES users(id) NOT NULL,
+ name text NOT NULL
+);
+
+CREATE UNIQUE INDEX bots_index ON bots(owner_id, name);
+
+CREATE TABLE code_bundles (
+ id serial PRIMARY KEY,
+ bot_id integer REFERENCES bots(id) NOT NULL,
+ path text NOT NULL,
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
+); \ No newline at end of file
diff --git a/backend/src/db/bots.rs b/backend/src/db/bots.rs
new file mode 100644
index 0000000..bc9cb11
--- /dev/null
+++ b/backend/src/db/bots.rs
@@ -0,0 +1,53 @@
+use diesel::prelude::*;
+use serde::{Deserialize, Serialize};
+
+use crate::schema::{bots, code_bundles};
+use chrono;
+
+#[derive(Insertable)]
+#[table_name = "bots"]
+pub struct NewBot<'a> {
+ pub owner_id: i32,
+ pub name: &'a str,
+}
+
+#[derive(Queryable, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Bot {
+ pub id: i32,
+ pub owner_id: i32,
+ pub name: String,
+}
+
+pub fn create_bot(new_bot: &NewBot, conn: &PgConnection) -> QueryResult<Bot> {
+ diesel::insert_into(bots::table)
+ .values(new_bot)
+ .get_result(conn)
+}
+
+pub fn find_bot(id: i32, conn: &PgConnection) -> QueryResult<Bot> {
+ bots::table.find(id).first(conn)
+}
+
+#[derive(Insertable)]
+#[table_name = "code_bundles"]
+pub struct NewCodeBundle<'a> {
+ pub bot_id: i32,
+ pub path: &'a str,
+}
+
+#[derive(Queryable, Serialize, Deserialize, Debug)]
+pub struct CodeBundle {
+ pub id: i32,
+ pub bot_id: i32,
+ pub path: String,
+ pub created_at: chrono::NaiveDateTime,
+}
+
+pub fn create_code_bundle(
+ new_code_bundle: &NewCodeBundle,
+ conn: &PgConnection,
+) -> QueryResult<CodeBundle> {
+ diesel::insert_into(code_bundles::table)
+ .values(new_code_bundle)
+ .get_result(conn)
+}
diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs
new file mode 100644
index 0000000..947b789
--- /dev/null
+++ b/backend/src/db/mod.rs
@@ -0,0 +1,3 @@
+pub mod bots;
+pub mod sessions;
+pub mod users;
diff --git a/backend/src/db/sessions.rs b/backend/src/db/sessions.rs
new file mode 100644
index 0000000..96f3926
--- /dev/null
+++ b/backend/src/db/sessions.rs
@@ -0,0 +1,46 @@
+use super::users::User;
+use crate::schema::{sessions, users};
+use base64;
+use diesel::PgConnection;
+use diesel::{insert_into, prelude::*, Insertable, RunQueryDsl};
+use rand::{self, Rng};
+
+#[derive(Insertable)]
+#[table_name = "sessions"]
+struct NewSession {
+ token: String,
+ user_id: i32,
+}
+
+#[derive(Queryable, Debug, PartialEq)]
+pub struct Session {
+ pub id: i32,
+ pub user_id: i32,
+ pub token: String,
+}
+
+pub fn create_session(user: &User, conn: &PgConnection) -> Session {
+ let new_session = NewSession {
+ token: gen_session_token(),
+ user_id: user.id,
+ };
+ let session = insert_into(sessions::table)
+ .values(&new_session)
+ .get_result::<Session>(conn)
+ .unwrap();
+
+ return session;
+}
+
+pub fn find_user_by_session(token: &str, conn: &PgConnection) -> QueryResult<(Session, User)> {
+ sessions::table
+ .inner_join(users::table)
+ .filter(sessions::token.eq(&token))
+ .first::<(Session, User)>(conn)
+}
+
+pub fn gen_session_token() -> String {
+ let mut rng = rand::thread_rng();
+ let token: [u8; 32] = rng.gen();
+ return base64::encode(&token);
+}
diff --git a/backend/src/db/users.rs b/backend/src/db/users.rs
new file mode 100644
index 0000000..663f173
--- /dev/null
+++ b/backend/src/db/users.rs
@@ -0,0 +1,108 @@
+use crate::schema::users;
+use argon2;
+use diesel::{prelude::*, PgConnection};
+use rand::Rng;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Deserialize)]
+pub struct Credentials<'a> {
+ pub username: &'a str,
+ pub password: &'a str,
+}
+
+#[derive(Insertable)]
+#[table_name = "users"]
+pub struct NewUser<'a> {
+ pub username: &'a str,
+ pub password_hash: &'a [u8],
+ pub password_salt: &'a [u8],
+}
+
+#[derive(Queryable, Debug)]
+pub struct User {
+ pub id: i32,
+ pub username: String,
+ pub password_salt: Vec<u8>,
+ pub password_hash: Vec<u8>,
+}
+
+// TODO: make this configurable somewhere
+fn argon2_config() -> argon2::Config<'static> {
+ argon2::Config {
+ variant: argon2::Variant::Argon2i,
+ version: argon2::Version::Version13,
+ mem_cost: 4096,
+ time_cost: 3,
+ lanes: 1,
+ thread_mode: argon2::ThreadMode::Sequential,
+ // TODO: set a secret
+ secret: &[],
+ ad: &[],
+ hash_length: 32,
+ }
+}
+
+pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
+ let argon_config = argon2_config();
+
+ let salt: [u8; 32] = rand::thread_rng().gen();
+ let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
+ let new_user = NewUser {
+ username: &credentials.username,
+ password_salt: &salt,
+ password_hash: &hash,
+ };
+ diesel::insert_into(users::table)
+ .values(&new_user)
+ .get_result::<User>(conn)
+}
+
+pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> {
+ users::table
+ .filter(users::username.eq(&credentials.username))
+ .first::<User>(db_conn)
+ .optional()
+ .unwrap()
+ .and_then(|user| {
+ let password_matches = argon2::verify_raw(
+ credentials.password.as_bytes(),
+ &user.password_salt,
+ &user.password_hash,
+ &argon2_config(),
+ )
+ .unwrap();
+
+ if password_matches {
+ return Some(user);
+ } else {
+ return None;
+ }
+ })
+}
+
+#[test]
+fn test_argon() {
+ let credentials = Credentials {
+ username: "piepkonijn",
+ password: "geheim123",
+ };
+ let argon_config = argon2_config();
+
+ let salt: [u8; 32] = rand::thread_rng().gen();
+ let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
+ let new_user = NewUser {
+ username: &credentials.username,
+ password_hash: &hash,
+ password_salt: &salt,
+ };
+
+ let password_matches = argon2::verify_raw(
+ credentials.password.as_bytes(),
+ &new_user.password_salt,
+ &new_user.password_hash,
+ &argon2_config(),
+ )
+ .unwrap();
+
+ assert!(password_matches);
+}
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
new file mode 100644
index 0000000..665523f
--- /dev/null
+++ b/backend/src/lib.rs
@@ -0,0 +1,85 @@
+#![feature(proc_macro_hygiene, decl_macro)]
+
+#[macro_use]
+extern crate diesel;
+
+pub mod db;
+pub mod routes;
+pub mod schema;
+
+use std::ops::Deref;
+
+use axum;
+use bb8::PooledConnection;
+use bb8_diesel::{self, DieselConnectionManager};
+use diesel::PgConnection;
+
+use axum::{
+ async_trait,
+ extract::{Extension, FromRequest, RequestParts},
+ http::StatusCode,
+ routing::{get, post},
+ AddExtensionLayer, Router,
+};
+
+async fn index_handler() -> &'static str {
+ "Hello, world!"
+}
+
+type ConnectionPool = bb8::Pool<DieselConnectionManager<PgConnection>>;
+
+pub async fn app() -> Router {
+ let database_url = "postgresql://planetwars:planetwars@localhost/planetwars";
+ let manager = DieselConnectionManager::<PgConnection>::new(database_url);
+ let pool = bb8::Pool::builder().build(manager).await.unwrap();
+
+ let app = Router::new()
+ .route("/", get(index_handler))
+ .route("/users/register", post(routes::users::register))
+ .route("/users/login", post(routes::users::login))
+ .route("/users/me", get(routes::users::current_user))
+ .route("/bots", post(routes::bots::create_bot))
+ .route("/bots/:bot_id", get(routes::bots::get_bot))
+ .route("/bots/:bot_id/upload", post(routes::bots::upload_bot_code))
+ .layer(AddExtensionLayer::new(pool));
+ app
+}
+
+// we can also write a custom extractor that grabs a connection from the pool
+// which setup is appropriate depends on your application
+pub struct DatabaseConnection(PooledConnection<'static, DieselConnectionManager<PgConnection>>);
+
+impl Deref for DatabaseConnection {
+ type Target = PooledConnection<'static, DieselConnectionManager<PgConnection>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+#[async_trait]
+impl<B> FromRequest<B> for DatabaseConnection
+where
+ B: Send,
+{
+ type Rejection = (StatusCode, String);
+
+ async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
+ let Extension(pool) = Extension::<ConnectionPool>::from_request(req)
+ .await
+ .map_err(internal_error)?;
+
+ let conn = pool.get_owned().await.map_err(internal_error)?;
+
+ Ok(Self(conn))
+ }
+}
+
+/// Utility function for mapping any error into a `500 Internal Server Error`
+/// response.
+fn internal_error<E>(err: E) -> (StatusCode, String)
+where
+ E: std::error::Error,
+{
+ (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
+}
diff --git a/backend/src/main.rs b/backend/src/main.rs
new file mode 100644
index 0000000..c75aaf6
--- /dev/null
+++ b/backend/src/main.rs
@@ -0,0 +1,16 @@
+use std::net::SocketAddr;
+
+extern crate mozaic4_backend;
+extern crate tokio;
+
+#[tokio::main]
+async fn main() {
+ let app = mozaic4_backend::app().await;
+
+ let addr = SocketAddr::from(([127, 0, 0, 1], 9000));
+
+ axum::Server::bind(&addr)
+ .serve(app.into_make_service())
+ .await
+ .unwrap();
+}
diff --git a/backend/src/routes/bots.rs b/backend/src/routes/bots.rs
new file mode 100644
index 0000000..da09669
--- /dev/null
+++ b/backend/src/routes/bots.rs
@@ -0,0 +1,75 @@
+use axum::extract::{Path, RawBody};
+use axum::http::StatusCode;
+use axum::Json;
+use rand::Rng;
+use serde::{Deserialize, Serialize};
+use std::io::Cursor;
+use std::path;
+
+use crate::db::bots::{self, CodeBundle};
+use crate::db::users::User;
+use crate::DatabaseConnection;
+use bots::Bot;
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct BotParams {
+ name: String,
+}
+
+pub async fn create_bot(
+ conn: DatabaseConnection,
+ user: User,
+ params: Json<BotParams>,
+) -> (StatusCode, Json<Bot>) {
+ let bot_params = bots::NewBot {
+ owner_id: user.id,
+ name: &params.name,
+ };
+ let bot = bots::create_bot(&bot_params, &conn).unwrap();
+ (StatusCode::CREATED, Json(bot))
+}
+
+// TODO: handle errors
+pub async fn get_bot(conn: DatabaseConnection, Path(bot_id): Path<i32>) -> Json<Bot> {
+ let bot = bots::find_bot(bot_id, &conn).unwrap();
+ Json(bot)
+}
+
+// TODO: proper error handling
+pub async fn upload_bot_code(
+ conn: DatabaseConnection,
+ user: User,
+ Path(bot_id): Path<i32>,
+ RawBody(body): RawBody,
+) -> (StatusCode, Json<CodeBundle>) {
+ // TODO: put in config somewhere
+ let data_path = "./data/bots";
+
+ let bot = bots::find_bot(bot_id, &conn).expect("Bot not found");
+
+ assert_eq!(user.id, bot.owner_id);
+
+ // generate a random filename
+ let token: [u8; 16] = rand::thread_rng().gen();
+ let name = base64::encode(&token);
+
+ let path = path::Path::new(data_path).join(name);
+ // let capped_buf = data.open(10usize.megabytes()).into_bytes().await.unwrap();
+ // assert!(capped_buf.is_complete());
+ // let buf = capped_buf.into_inner();
+ let buf = hyper::body::to_bytes(body).await.unwrap();
+
+ zip::ZipArchive::new(Cursor::new(buf))
+ .unwrap()
+ .extract(&path)
+ .unwrap();
+
+ let bundle = bots::NewCodeBundle {
+ bot_id: bot.id,
+ path: path.to_str().unwrap(),
+ };
+ let code_bundle =
+ bots::create_code_bundle(&bundle, &conn).expect("Failed to create code bundle");
+
+ (StatusCode::CREATED, Json(code_bundle))
+}
diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs
new file mode 100644
index 0000000..718d7ef
--- /dev/null
+++ b/backend/src/routes/mod.rs
@@ -0,0 +1,2 @@
+pub mod bots;
+pub mod users;
diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs
new file mode 100644
index 0000000..fc77d7b
--- /dev/null
+++ b/backend/src/routes/users.rs
@@ -0,0 +1,94 @@
+use crate::db::users::{Credentials, User};
+use crate::db::{sessions, users};
+use crate::DatabaseConnection;
+use axum::extract::{FromRequest, RequestParts, TypedHeader};
+use axum::headers::authorization::Bearer;
+use axum::headers::Authorization;
+use axum::http::StatusCode;
+use axum::{async_trait, Json};
+use serde::{Deserialize, Serialize};
+
+type AuthorizationHeader = TypedHeader<Authorization<Bearer>>;
+
+#[async_trait]
+impl<B> FromRequest<B> for User
+where
+ B: Send,
+{
+ type Rejection = (StatusCode, String);
+
+ async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
+ let conn = DatabaseConnection::from_request(req).await?;
+ let TypedHeader(Authorization(bearer)) = AuthorizationHeader::from_request(req)
+ .await
+ .map_err(|_| (StatusCode::UNAUTHORIZED, "".to_string()))?;
+
+ let (_session, user) = sessions::find_user_by_session(bearer.token(), &conn)
+ .map_err(|_| (StatusCode::UNAUTHORIZED, "".to_string()))?;
+
+ Ok(user)
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct UserData {
+ pub user_id: i32,
+ pub username: String,
+}
+
+impl From<User> for UserData {
+ fn from(user: User) -> Self {
+ UserData {
+ user_id: user.id,
+ username: user.username,
+ }
+ }
+}
+
+#[derive(Deserialize)]
+pub struct RegistrationParams {
+ pub username: String,
+ pub password: String,
+}
+
+pub async fn register(
+ conn: DatabaseConnection,
+ params: Json<RegistrationParams>,
+) -> Json<UserData> {
+ let credentials = Credentials {
+ username: &params.username,
+ password: &params.password,
+ };
+ let user = users::create_user(&credentials, &conn).unwrap();
+ Json(user.into())
+}
+
+#[derive(Deserialize)]
+pub struct LoginParams {
+ pub username: String,
+ pub password: String,
+}
+
+pub async fn login(
+ conn: DatabaseConnection,
+ params: Json<LoginParams>,
+) -> Result<String, StatusCode> {
+ let credentials = Credentials {
+ username: &params.username,
+ password: &params.password,
+ };
+ // TODO: handle failures
+ let authenticated = users::authenticate_user(&credentials, &conn);
+
+ match authenticated {
+ None => Err(StatusCode::FORBIDDEN),
+ Some(user) => {
+ let session = sessions::create_session(&user, &conn);
+ Ok(session.token)
+ }
+ }
+}
+
+pub async fn current_user(user: User) -> Json<UserData> {
+ Json(user.into())
+}
diff --git a/backend/src/schema.rs b/backend/src/schema.rs
new file mode 100644
index 0000000..bf58434
--- /dev/null
+++ b/backend/src/schema.rs
@@ -0,0 +1,39 @@
+table! {
+ bots (id) {
+ id -> Int4,
+ owner_id -> Int4,
+ name -> Text,
+ }
+}
+
+table! {
+ code_bundles (id) {
+ id -> Int4,
+ bot_id -> Int4,
+ path -> Text,
+ created_at -> Timestamp,
+ }
+}
+
+table! {
+ sessions (id) {
+ id -> Int4,
+ user_id -> Int4,
+ token -> Varchar,
+ }
+}
+
+table! {
+ users (id) {
+ id -> Int4,
+ username -> Varchar,
+ password_salt -> Bytea,
+ password_hash -> Bytea,
+ }
+}
+
+joinable!(bots -> users (owner_id));
+joinable!(code_bundles -> bots (bot_id));
+joinable!(sessions -> users (user_id));
+
+allow_tables_to_appear_in_same_query!(bots, code_bundles, sessions, users,);
diff --git a/backend/tests/bots.rs b/backend/tests/bots.rs
new file mode 100644
index 0000000..fe81712
--- /dev/null
+++ b/backend/tests/bots.rs
@@ -0,0 +1,98 @@
+#![feature(async_closure)]
+extern crate mozaic4_backend;
+extern crate zip;
+
+use rocket::http::{ContentType, Status};
+
+mod util;
+use mozaic4_backend::db::{bots, sessions, users};
+use mozaic4_backend::DbConn;
+use sessions::Session;
+use users::{Credentials, User};
+use util::{run_test, BearerAuth};
+
+async fn user_with_session(conn: &DbConn) -> (User, Session) {
+ conn.run(|conn| {
+ let credentials = Credentials {
+ username: "piepkonijn",
+ password: "geheim123",
+ };
+ let user = users::create_user(&credentials, conn).unwrap();
+ let session = sessions::create_session(&user, conn);
+ (user, session)
+ })
+ .await
+}
+
+#[rocket::async_test]
+async fn test_bot_create() {
+ run_test(async move |client, conn| {
+ let (user, session) = user_with_session(&conn).await;
+
+ let response = client
+ .post("/bots")
+ .header(BearerAuth::new(session.token.clone()))
+ .header(ContentType::JSON)
+ .body(
+ r#"{
+ "name": "testbot"
+ }"#,
+ )
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Created);
+ assert_eq!(response.content_type(), Some(ContentType::JSON));
+
+ let resp_text = response.into_string().await.unwrap();
+ let json: serde_json::Value = serde_json::from_str(&resp_text).unwrap();
+ assert_eq!(json["name"], "testbot");
+ assert_eq!(json["owner_id"], user.id);
+ })
+ .await
+}
+
+// create an example zipfile for bot upload
+fn create_zip() -> std::io::Result<Vec<u8>> {
+ use std::io::Write;
+ use zip::write::FileOptions;
+
+ let cursor = std::io::Cursor::new(Vec::new());
+ let mut zip = zip::ZipWriter::new(cursor);
+
+ zip.start_file("test.txt", FileOptions::default())?;
+ zip.write_all(b"sup brudi")?;
+ let buf = zip.finish()?;
+ Ok(buf.into_inner())
+}
+
+#[rocket::async_test]
+async fn test_bot_upload() {
+ run_test(async move |client, conn| {
+ let (user, session) = user_with_session(&conn).await;
+
+ let owner_id = user.id;
+ let bot = conn
+ .run(move |conn| {
+ let new_bot = bots::NewBot {
+ name: "testbot",
+ owner_id: owner_id,
+ };
+ bots::create_bot(&new_bot, conn).unwrap()
+ })
+ .await;
+
+ let zip_file = create_zip().unwrap();
+
+ let response = client
+ .post(format!("/bots/{}/upload", bot.id))
+ .header(BearerAuth::new(session.token.clone()))
+ .header(ContentType::JSON)
+ .body(zip_file)
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Created);
+ })
+ .await
+}
diff --git a/backend/tests/login.rs b/backend/tests/login.rs
new file mode 100644
index 0000000..60c5d6f
--- /dev/null
+++ b/backend/tests/login.rs
@@ -0,0 +1,61 @@
+#![feature(async_closure)]
+extern crate mozaic4_backend;
+
+use rocket::http::{ContentType, Status};
+
+mod util;
+use util::run_test;
+
+#[rocket::async_test]
+async fn test_registration() {
+ run_test(async move |client, _conn| {
+ let response = client
+ .post("/register")
+ .header(ContentType::JSON)
+ .body(r#"{"username": "piepkonijn", "password": "geheim123"}"#)
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Ok);
+ assert_eq!(response.content_type(), Some(ContentType::JSON));
+
+ let response = client
+ .post("/login")
+ .header(ContentType::JSON)
+ .body(r#"{"username": "piepkonijn", "password": "geheim123"}"#)
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Ok);
+ let token = response.into_string().await.unwrap();
+
+ let response = client
+ .get("/users/me")
+ .header(util::BearerAuth::new(token))
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Ok);
+ assert_eq!(response.content_type(), Some(ContentType::JSON));
+ let resp = response.into_string().await.unwrap();
+ let json: serde_json::Value = serde_json::from_str(&resp).unwrap();
+ assert_eq!(json["username"], "piepkonijn");
+ })
+ .await
+}
+
+#[rocket::async_test]
+async fn test_reject_invalid_credentials() {
+ run_test(async move |client, _conn| {
+ let response = client
+ .post("/login")
+ .header(ContentType::JSON)
+ .body(r#"{"username": "piepkonijn", "password": "letmeinplease"}"#)
+ .dispatch()
+ .await;
+
+ assert_eq!(response.status(), Status::Forbidden);
+ // assert_eq!(response.content_type(), Some(ContentType::JSON));
+ })
+ .await
+}
diff --git a/backend/tests/util/mod.rs b/backend/tests/util/mod.rs
new file mode 100644
index 0000000..f34e9f3
--- /dev/null
+++ b/backend/tests/util/mod.rs
@@ -0,0 +1,59 @@
+use std::future::Future;
+
+use diesel::RunQueryDsl;
+use mozaic4_backend::DbConn;
+use rocket::{http::Header, local::asynchronous::Client};
+
+// We use a lock to synchronize between tests so DB operations don't collide.
+// For now. In the future, we'll have a nice way to run each test in a DB
+// transaction so we can regain concurrency.
+static DB_LOCK: parking_lot::Mutex<()> = parking_lot::const_mutex(());
+
+async fn reset_db(db: &DbConn) {
+ db.run(|conn| {
+ diesel::sql_query(
+ r#"
+ TRUNCATE TABLE users, sessions,
+ bots, code_bundles"#,
+ )
+ .execute(conn)
+ .expect("drop all tables");
+ })
+ .await
+}
+
+pub async fn run_test<F, R>(test_closure: F)
+where
+ F: FnOnce(Client, DbConn) -> R,
+ R: Future<Output = ()>,
+{
+ let _lock = DB_LOCK.lock();
+
+ let client = Client::untracked(mozaic4_backend::rocket())
+ .await
+ .expect("failed to create test client");
+ let db = mozaic4_backend::DbConn::get_one(client.rocket())
+ .await
+ .expect("failed to get db connection");
+
+ // make sure we start with a clean DB
+ reset_db(&db).await;
+
+ test_closure(client, db).await;
+}
+
+pub struct BearerAuth {
+ token: String,
+}
+
+impl BearerAuth {
+ pub fn new(token: String) -> Self {
+ Self { token }
+ }
+}
+
+impl<'a> Into<Header<'a>> for BearerAuth {
+ fn into(self) -> Header<'a> {
+ Header::new("Authorization", format!("Bearer {}", self.token))
+ }
+}