diff options
Diffstat (limited to 'server/src/main.rs')
| -rw-r--r-- | server/src/main.rs | 448 |
1 files changed, 375 insertions, 73 deletions
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), |
