diff options
Diffstat (limited to 'server/src')
| -rw-r--r-- | server/src/api_model.rs | 48 | ||||
| -rw-r--r-- | server/src/auth.rs | 33 | ||||
| -rw-r--r-- | server/src/db_utils.rs | 138 | ||||
| -rw-r--r-- | server/src/main.rs | 448 |
4 files changed, 585 insertions, 82 deletions
diff --git a/server/src/api_model.rs b/server/src/api_model.rs index a7c8e88..f3bb5cf 100644 --- a/server/src/api_model.rs +++ b/server/src/api_model.rs @@ -1,7 +1,7 @@ -use rocket::serde::Serialize; +use rocket::serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Serialize, Copy, Clone, ToSchema)] +#[derive(Deserialize, Serialize, Copy, Clone, ToSchema)] pub enum ReviewState { Draft, Open, @@ -9,6 +9,13 @@ pub enum ReviewState { Closed, } +#[derive(Deserialize, Serialize, Copy, Clone, ToSchema)] +pub enum UserReviewRole { + Reviewer, + Watcher, + None, +} + #[derive(Serialize, ToSchema)] pub struct User { #[schema(example = 1337u64)] @@ -22,6 +29,13 @@ pub struct User { } #[derive(Serialize, ToSchema)] +pub struct ReviewUserEntry { + pub user: User, + #[schema(example = UserReviewRole::Reviewer)] + pub role: UserReviewRole, +} + +#[derive(Serialize, ToSchema)] pub struct Review { #[schema(example = 1000u64)] pub id: u64, @@ -30,8 +44,7 @@ pub struct Review { #[schema(example = "We're adding more features because features are what we want.")] pub description: String, pub owner: User, - pub reviewers: Vec<User>, - pub watchers: Vec<User>, + pub users: Vec<ReviewUserEntry>, #[schema(example = ReviewState::Open)] pub state: ReviewState, #[schema(example = 37.5)] @@ -65,6 +78,23 @@ pub struct Reviews { } #[derive(Serialize, ToSchema)] +pub struct ProjectUserEntry { + pub user: User, + #[schema(example = UserReviewRole::Reviewer)] + pub default_role: UserReviewRole, + #[schema(example = false)] + pub maintainer: bool, +} + +#[derive(Deserialize, ToSchema)] +pub struct ProjectUserEntryData { + #[schema(example = UserReviewRole::Reviewer)] + pub default_role: Option<UserReviewRole>, + #[schema(example = false)] + pub maintainer: Option<bool>, +} + +#[derive(Serialize, ToSchema)] pub struct Project { #[schema(example = 1u64)] pub id: u64, @@ -72,7 +102,15 @@ pub struct Project { pub title: String, #[schema(example = "Example project")] pub description: String, - pub members: Vec<User>, + pub users: Vec<ProjectUserEntry>, +} + +#[derive(Deserialize, ToSchema)] +pub struct ProjectData<'r> { + #[schema(example = "FAKE: Features All Kids Erase")] + pub title: Option<&'r str>, + #[schema(example = "Example project")] + pub description: Option<&'r str>, } #[derive(Serialize, ToSchema)] diff --git a/server/src/auth.rs b/server/src/auth.rs index c827126..f1b8f70 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,5 +1,6 @@ use core::net::IpAddr; -use rocket::fairing::AdHoc; +use futures::future::TryFutureExt; +use rocket::fairing::{self, AdHoc}; use rocket::form::Form; use rocket::http::{Cookie, CookieJar, Status}; use rocket::outcome::{try_outcome, IntoOutcome}; @@ -7,7 +8,8 @@ use rocket::request::{FromRequest, Outcome, Request}; use rocket::response::status::Unauthorized; use rocket::serde::json::{self, Json}; use rocket::serde::{Deserialize, Serialize}; -use rocket::State; +use rocket::{Build, Rocket, State}; +use rocket_db_pools::{sqlx, Connection, Database}; use std::collections::BTreeMap; use std::sync::Mutex; use std::time::Instant; @@ -16,6 +18,7 @@ use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; use utoipa::{Modify, OpenApi, ToSchema}; use crate::api_model; +use crate::Db; #[derive(OpenApi)] #[openapi( @@ -164,16 +167,24 @@ fn new_session( ), )] #[post("/login", data = "<login>")] -fn login( +async fn login( auth_config: &State<AuthConfig>, sessions: &State<Sessions>, ipaddr: IpAddr, cookies: &CookieJar<'_>, + mut db: Connection<Db>, login: Form<Login<'_>>, ) -> Result<Json<api_model::StatusResponse>, Unauthorized<&'static str>> { if login.username == "user" && login.password == "password" { + let user_id = sqlx::query!("SELECT id FROM users WHERE username=?", login.username) + .fetch_one(&mut **db) + .map_ok(|r| r.id) + .map_err(|_| Unauthorized("Unknown username or password")) + .await + .unwrap(); + let max_age = Duration::days(i64::from(auth_config.session_max_age_days)); - let session = new_session(sessions, 1u64, ipaddr.to_string(), max_age); + let session = new_session(sessions, user_id, ipaddr.to_string(), max_age); let cookie = Cookie::build((SESSION_COOKIE, json::to_string(&session).unwrap())) .path("/api") @@ -237,6 +248,19 @@ fn unauthorized() -> Json<api_model::StatusResponse> { Json(STATUS_UNAUTHORIZED) } +async fn run_import(rocket: Rocket<Build>) -> fairing::Result { + match Db::fetch(&rocket) { + Some(db) => match sqlx::query!("INSERT IGNORE INTO users (username) VALUES (?)", "user") + .execute(&**db) + .await + { + Ok(_) => Ok(rocket), + Err(_) => Err(rocket), + }, + None => Err(rocket), + } +} + pub fn stage(basepath: &str) -> AdHoc { let l_basepath = basepath.to_string(); AdHoc::on_ignite("Auth Stage", |rocket| async { @@ -248,6 +272,7 @@ pub fn stage(basepath: &str) -> AdHoc { }), }) .attach(AdHoc::config::<AuthConfig>()) + .attach(AdHoc::try_on_ignite("Auth Import", run_import)) .mount(l_basepath.clone(), routes![login, logout, status]) .register(l_basepath, catchers![unauthorized]) }) diff --git a/server/src/db_utils.rs b/server/src/db_utils.rs new file mode 100644 index 0000000..e74fd68 --- /dev/null +++ b/server/src/db_utils.rs @@ -0,0 +1,138 @@ +#![allow(dead_code)] + +use sqlx::database::HasArguments; +use sqlx::encode::Encode; +use sqlx::types::Type; +use sqlx::{Arguments, Database}; +use std::fmt::Display; + +pub struct UpdateBuilder<'args, DB> +where + DB: Database, +{ + table: Option<String>, + names: Option<Vec<(String, String)>>, + where_: Option<Vec<(String, String, String)>>, + values: Option<<DB as HasArguments<'args>>::Arguments>, +} + +impl<'args, DB: Database> UpdateBuilder<'args, DB> +where + DB: Database, +{ + pub fn new() -> Self + where + <DB as HasArguments<'args>>::Arguments: Default, + { + UpdateBuilder { + table: None, + names: Some(Vec::new()), + where_: Some(Vec::new()), + values: Some(Default::default()), + } + } + + #[inline] + fn sanity_check(&self) { + assert!( + self.values.is_some(), + "UpdateBuilder must be reset before reuse after `.build()`" + ); + } + + pub fn table(&mut self, table: impl Display) -> &mut Self { + self.sanity_check(); + + self.table = Some(format!("{table}")); + + self + } + + pub fn and_where<T>(&mut self, name: impl Display, op: impl Display, value: T) -> &mut Self + where + T: 'args + Encode<'args, DB> + Send + Type<DB>, + { + self.sanity_check(); + + let values = self.values.as_mut().expect("BUG: Values taken already"); + values.add(value); + + let mut placeholder = String::new(); + values + .format_placeholder(&mut placeholder) + .expect("error in format_placeholder"); + + let where_ = self.where_.as_mut().expect("BUG: Where taken already"); + where_.push((format!("{name}"), format!("{op}"), placeholder)); + + self + } + + pub fn set<T>(&mut self, name: impl Display, value: T) -> &mut Self + where + T: 'args + Encode<'args, DB> + Send + Type<DB>, + { + self.sanity_check(); + + assert!( + self.where_.as_ref().unwrap().is_empty(), + "set must not be called after add_where" + ); + + let names = self.names.as_mut().expect("BUG: Names taken already"); + let values = self.values.as_mut().expect("BUG: Values taken already"); + values.add(value); + + let mut placeholder = String::new(); + values + .format_placeholder(&mut placeholder) + .expect("error in format_placeholder"); + names.push((format!("{name}"), placeholder)); + + self + } + + pub fn build(&mut self) -> (String, <DB as HasArguments<'args>>::Arguments) { + self.sanity_check(); + + let table = self.table.take().unwrap(); + let mut query = format!("UPDATE {table} SET"); + let mut first = true; + for (name, placeholder) in self.names.take().unwrap() { + if first { + first = false; + } else { + query.push(','); + } + query.push_str(&format!(" {name}={placeholder}")); + } + + let where_ = self.where_.take().unwrap(); + if !where_.is_empty() { + query.push_str(" WHERE"); + + first = true; + for (name, op, placeholder) in where_ { + if first { + first = false; + } else { + query.push_str(" AND"); + } + query.push_str(&format!(" {name}{op}{placeholder}")); + } + } + + // TODO: This method should return a Query, constructed by QueryBuilder, but I can't + // figure out how to create QueryBuilder::with_arguments in this generic method, + // where DB isn't "known". + (query, self.values.take().unwrap()) + } + + pub fn reset(&mut self) -> &mut Self { + self.names = Some(Vec::new()); + self.where_ = Some(Vec::new()); + self.values = Some(Default::default()); + + self + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 342ed53..be5bb77 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,17 +2,19 @@ extern crate rocket; use futures::{future::TryFutureExt, stream::TryStreamExt}; - use rocket::fairing::{self, AdHoc}; -use rocket::response::status::NotFound; +use rocket::http::Status; +use rocket::response::status::{Custom, NotFound}; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket}; use rocket_db_pools::{sqlx, Connection, Database}; +use sqlx::Acquire; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; mod api_model; mod auth; +mod db_utils; use auth::AuthApiAddon; @@ -22,33 +24,44 @@ struct Db(sqlx::MySqlPool); #[derive(OpenApi)] #[openapi( - paths(projects, project, reviews, review,), + paths( + projects, + project, + project_new, + project_update, + project_user_add, + project_user_update, + project_user_del, + reviews, + review, + ), modifiers(&AuthApiAddon), )] pub struct MainApi; -enum Role { - Reviewer, - Watcher, -} - -struct UserRole { - user: api_model::User, - role: Role, -} - -impl TryFrom<u8> for Role { +impl TryFrom<u8> for api_model::UserReviewRole { type Error = &'static str; fn try_from(value: u8) -> Result<Self, Self::Error> { match value { - 0 => Ok(Role::Reviewer), - 1 => Ok(Role::Watcher), + 0 => Ok(api_model::UserReviewRole::None), + 1 => Ok(api_model::UserReviewRole::Reviewer), + 2 => Ok(api_model::UserReviewRole::Watcher), _ => Err("Invalid role"), } } } +impl From<api_model::UserReviewRole> for u8 { + fn from(value: api_model::UserReviewRole) -> u8 { + match value { + api_model::UserReviewRole::None => 0, + api_model::UserReviewRole::Reviewer => 1, + api_model::UserReviewRole::Watcher => 2, + } + } +} + impl TryFrom<u8> for api_model::ReviewState { type Error = &'static str; @@ -112,30 +125,23 @@ async fn projects( }) } -#[utoipa::path( - responses( - (status = 200, description = "Get project", body = api_model::Project), - (status = 404, description = "No such project"), - ), - security( - ("session" = []), - ), -)] -#[get("/project/<projectid>")] -async fn project( - mut db: Connection<Db>, - _session: auth::Session, +async fn get_project( + db: &mut Connection<Db>, projectid: u64, ) -> Result<Json<api_model::Project>, NotFound<&'static str>> { - let members = sqlx::query!( - "SELECT id, username, name, active FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", + let users = sqlx::query!( + "SELECT id, username, name, active, default_role, maintainer FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", projectid) - .fetch(&mut **db) - .map_ok(|r| api_model::User { - id: r.id, - username: r.username, - name: r.name, - active: r.active != 0, + .fetch(&mut ***db) + .map_ok(|r| api_model::ProjectUserEntry { + user: api_model::User { + id: r.id, + username: r.username, + name: r.name, + active: r.active != 0, + }, + default_role: api_model::UserReviewRole::try_from(r.default_role).unwrap(), + maintainer: r.maintainer != 0, }) .try_collect::<Vec<_>>() .await @@ -145,12 +151,12 @@ async fn project( "SELECT id,title,description FROM projects WHERE id=?", projectid ) - .fetch_one(&mut **db) + .fetch_one(&mut ***db) .map_ok(|r| api_model::Project { id: r.id, title: r.title, description: r.description, - members, + users, }) .map_err(|_| NotFound("No such project")) .await?; @@ -160,6 +166,266 @@ async fn project( #[utoipa::path( responses( + (status = 200, description = "Get project", body = api_model::Project), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] +#[get("/project/<projectid>")] +async fn project( + mut db: Connection<Db>, + _session: auth::Session, + projectid: u64, +) -> Result<Json<api_model::Project>, NotFound<&'static str>> { + get_project(&mut db, projectid).await +} + +#[utoipa::path( + responses( + (status = 200, description = "Project updated", body = api_model::Project), + ), + security( + ("session" = []), + ), +)] +#[post("/project/new", data = "<data>")] +async fn project_new( + mut db: Connection<Db>, + session: auth::Session, + data: Json<api_model::ProjectData<'_>>, +) -> Json<api_model::Project> { + let projectid: u64; + + { + let mut tx = db.begin().await.unwrap(); + + projectid = sqlx::query!( + "INSERT INTO projects (title, description) VALUES (?, ?)", + data.title.unwrap_or("Unnamed"), + data.description.unwrap_or("") + ) + .execute(&mut *tx) + .map_ok(|r| r.last_insert_id()) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)", + projectid, session.user_id, u8::from(api_model::UserReviewRole::Reviewer), true) + .execute(&mut *tx) + .await + .unwrap(); + + tx.commit().await.unwrap(); + } + + get_project(&mut db, projectid).await.unwrap() +} + +async fn project_check_maintainer( + db: &mut Connection<Db>, + session: auth::Session, + projectid: u64, +) -> Result<&'static str, Custom<&'static str>> { + let is_maintainer = sqlx::query!( + "SELECT COUNT(user) AS count FROM project_users WHERE project=? AND user=? AND maintainer=TRUE", + projectid, session.user_id) + .fetch_one(&mut ***db) + .map_ok(|r| r.count) + .await + .unwrap(); + + if is_maintainer == 0 { + // Check if it is because user is not maintainer or project just doesn't exist + sqlx::query!("SELECT id AS count FROM projects WHERE id=?", projectid) + .fetch_one(&mut ***db) + .map_err(|_| Custom(Status::NotFound, "No such project")) + .await?; + + Err(Custom(Status::Unauthorized, "Not maintainer of project")) + } else { + Ok("") + } +} + +#[utoipa::path( + responses( + (status = 200, description = "Project updated"), + (status = 401, description = "Not maintainer of project"), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] +#[post("/project/<projectid>", data = "<data>")] +async fn project_update( + mut db: Connection<Db>, + session: auth::Session, + projectid: u64, + data: Json<api_model::ProjectData<'_>>, +) -> Result<&'static str, Custom<&'static str>> { + project_check_maintainer(&mut db, session, projectid) + .await + .unwrap(); + + if data.title.is_none() && data.description.is_none() { + // Nothing to update. Treat as "success". + } else { + let mut update_builder: db_utils::UpdateBuilder<sqlx::MySql> = + db_utils::UpdateBuilder::new(); + update_builder.table("projects"); + + if let Some(title) = &data.title { + update_builder.set("title", title); + } + if let Some(description) = &data.description { + update_builder.set("description", description); + } + update_builder.and_where("id", "=", projectid); + + let (query, args) = update_builder.build(); + + let mut query_builder: sqlx::QueryBuilder<sqlx::MySql> = + sqlx::QueryBuilder::with_arguments(query, args); + query_builder.build().execute(&mut **db).await.unwrap(); + } + + Ok("") +} + +#[utoipa::path( + responses( + (status = 200, description = "User added to project"), + (status = 401, description = "Not maintainer of project"), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] +#[post("/project/<projectid>/user/new?<userid>", data = "<data>")] +async fn project_user_add( + mut db: Connection<Db>, + session: auth::Session, + projectid: u64, + userid: u64, + data: Json<api_model::ProjectUserEntryData>, +) -> Result<&'static str, Custom<&'static str>> { + project_check_maintainer(&mut db, session, projectid) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)", + projectid, + userid, + u8::from( + data.default_role + .unwrap_or(api_model::UserReviewRole::Reviewer) + ), + data.maintainer.unwrap_or(false) + ) + .execute(&mut **db) + .await + .unwrap(); + + Ok("") +} + +#[utoipa::path( + responses( + (status = 200, description = "User updated in project"), + (status = 401, description = "Not maintainer of project"), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] +#[post("/project/<projectid>/user/<userid>", data = "<data>")] +async fn project_user_update( + mut db: Connection<Db>, + session: auth::Session, + projectid: u64, + userid: u64, + data: Json<api_model::ProjectUserEntryData>, +) -> Result<&'static str, Custom<&'static str>> { + let need_maintainer = data.maintainer.is_some() || userid != session.user_id; + + if need_maintainer { + project_check_maintainer(&mut db, session, projectid) + .await + .unwrap(); + } + + if data.default_role.is_none() && data.maintainer.is_none() { + // Nothing to update. Treat as "success". + } else { + let mut update_builder: db_utils::UpdateBuilder<sqlx::MySql> = + db_utils::UpdateBuilder::new(); + update_builder.table("project_users"); + + if let Some(default_role) = &data.default_role { + update_builder.set("default_role", u8::from(*default_role)); + } + if let Some(maintainer) = &data.maintainer { + update_builder.set("maintainer", maintainer); + } + update_builder.and_where("project", "=", projectid); + update_builder.and_where("user", "=", userid); + + let (query, args) = update_builder.build(); + + let mut query_builder: sqlx::QueryBuilder<sqlx::MySql> = + sqlx::QueryBuilder::with_arguments(query, args); + query_builder.build().execute(&mut **db).await.unwrap(); + } + + Ok("") +} + +#[utoipa::path( + responses( + (status = 200, description = "User removed from project"), + (status = 401, description = "Not maintainer of project"), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] +#[delete("/project/<projectid>/user/<userid>")] +async fn project_user_del( + mut db: Connection<Db>, + session: auth::Session, + projectid: u64, + userid: u64, +) -> Result<&'static str, Custom<&'static str>> { + let need_maintainer = userid != session.user_id; + + if need_maintainer { + project_check_maintainer(&mut db, session, projectid) + .await + .unwrap(); + } + + sqlx::query!( + "DELETE FROM project_users WHERE project=? AND user=?", + projectid, + userid + ) + .execute(&mut **db) + .await + .unwrap(); + + Ok("") +} + +#[utoipa::path( + responses( (status = 200, description = "Get all reviews for project", body = api_model::Reviews), ), security( @@ -231,57 +497,79 @@ async fn review( _session: auth::Session, reviewid: u64, ) -> Result<Json<api_model::Review>, NotFound<&'static str>> { - let mut users = sqlx::query!( - "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id", + let mut projectid = 0; + + let mut review = sqlx::query!( + "SELECT reviews.id AS id,project,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?", reviewid) + .fetch_one(&mut **db) + .map_ok(|r| { + projectid = r.project; + + api_model::Review { + id: r.id, + title: r.title, + description: r.description, + owner: api_model::User { + id: r.user_id, + username: r.username, + name: r.name, + active: r.user_active != 0, + }, + users: Vec::new(), + state: api_model::ReviewState::try_from(r.state).unwrap(), + progress: r.progress, + } + }) + .map_err(|_| NotFound("No such review")) + .await?; + + let mut users = sqlx::query!( + "SELECT id,username,name,active,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,username,id", + projectid) .fetch(&mut **db) - .map_ok(|r| UserRole { + .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, username: r.username, name: r.name, active: r.active != 0, }, - role: Role::try_from(r.role).unwrap(), + role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::<Vec<_>>() .await .unwrap(); - let first_reviewer = users - .iter() - .position(|u| matches!(u.role, Role::Reviewer)) - .unwrap_or(users.len()); - let mut reviewers: Vec<api_model::User> = Vec::with_capacity(first_reviewer); - for user_role in users.drain(0..first_reviewer) { - reviewers.push(user_role.user); - } - let mut watchers: Vec<api_model::User> = Vec::with_capacity(users.len()); - for user_role in users.drain(0..) { - watchers.push(user_role.user); - } - - let review = sqlx::query!( - "SELECT reviews.id AS id,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?", + let override_users = sqlx::query!( + "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id", reviewid) - .fetch_one(&mut **db) - .map_ok(|r| api_model::Review { - id: r.id, - title: r.title, - description: r.description, - owner: api_model::User { - id: r.user_id, + .fetch(&mut **db) + .map_ok(|r| api_model::ReviewUserEntry { + user: api_model::User { + id: r.id, username: r.username, name: r.name, - active: r.user_active != 0, + active: r.active != 0, }, - reviewers, - watchers, - state: api_model::ReviewState::try_from(r.state).unwrap(), - progress: r.progress, + role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) - .map_err(|_| NotFound("No such review")) - .await?; + .try_collect::<Vec<_>>() + .await + .unwrap(); + + for override_user in override_users { + if let Some(user) = users + .iter_mut() + .find(|ue| ue.user.id == override_user.user.id) + { + user.role = override_user.role; + } else { + users.push(override_user); + } + } + + review.users = users; Ok(Json(review)) } @@ -312,7 +600,21 @@ async fn main() -> Result<(), rocket::Error> { let _rocket = rocket::build() .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) - .mount(basepath, routes![projects, project, reviews, review]) + .mount( + basepath, + // Remember to update openapi paths when you add something here. + routes![ + projects, + project, + project_new, + project_update, + project_user_add, + project_user_update, + project_user_del, + reviews, + review + ], + ) .mount( "/", SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api), |
