#[macro_use] extern crate rocket; use futures::{future::TryFutureExt, stream::TryStreamExt}; use rocket::fairing::{self, AdHoc}; use rocket::figment::Figment; use rocket::http::Status; use rocket::response::status::{Custom, NotFound}; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket, State}; use rocket_db_pools::{sqlx, Connection, Database}; use sqlx::Acquire; use std::path::PathBuf; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use eyeballs_api::api_model; use eyeballs_common::fs_utils; use eyeballs_common::git; use eyeballs_common::git_socket; #[cfg(test)] mod tests; mod auth; mod authorized_keys; mod db_utils; mod git_root; mod trans; use auth::AuthApiAddon; #[derive(Database)] #[database("eyeballs")] struct Db(sqlx::MySqlPool); #[derive(OpenApi)] #[openapi( paths( healthcheck, projects, project, project_new, project_update, project_user_add, project_user_update, project_user_del, reviews, review, review_id, review_del, review_id_del, users, user_key_add, user_key_get, user_key_del, user_keys, translation_review_new, translation_review_strings, translation_reviews, ), modifiers(&AuthApiAddon), )] pub struct MainApi; #[utoipa::path( responses( (status = 200, description = "Get all projects", body = api_model::Projects), ), security( ("session" = []), ), )] #[get("/projects?&")] async fn projects( mut db: Connection, _session: auth::Session, limit: Option, offset: Option, ) -> Json { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT id,title FROM projects ORDER BY title,id LIMIT ? OFFSET ?", uw_limit, uw_offset ) .fetch(&mut **db) .map_ok(|r| api_model::ProjectEntry { id: r.id, title: r.title, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!("SELECT COUNT(id) AS count FROM projects") .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::Projects { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, projects: entries, }) } fn abbrivate_key(key: &str) -> String { let len = key.len(); if len <= 24 { // Realistically this will only happen for empty keys, // but make sure to NEVER send the whole key if it is really short. return String::new(); } let start = &key[0..8]; let end = &key[len - 8..len]; format!("{start}...{end}") } async fn get_project( mut db: Connection, projectid: &str, ) -> Result, NotFound<&'static str>> { let users = sqlx::query!( "SELECT id, name, dn, 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::ProjectUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, default_role: api_model::UserReviewRole::try_from(r.default_role).unwrap(), maintainer: r.maintainer != 0, }) .try_collect::>() .await .unwrap(); let project = sqlx::query!( "SELECT id,title,description,remote,remote_key,main_branch FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| api_model::Project { id: r.id, title: r.title, description: r.description, remote: r.remote, remote_key_abbrev: abbrivate_key(r.remote_key.as_str()), main_branch: r.main_branch, users, }) .map_err(|_| NotFound("No such project")) .await?; Ok(Json(project)) } #[utoipa::path( responses( (status = 200, description = "Get project", body = api_model::Project), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[get("/project/")] async fn project( db: Connection, _session: auth::Session, projectid: &str, ) -> Result, NotFound<&'static str>> { get_project(db, projectid).await } // Remove linebreaks and potential openssh wrapper fn cleanup_key(key: &str) -> String { let mut ret = String::with_capacity(key.len()); let mut lines = key.lines(); while let Some(line) = lines.next() { match line { "-----BEGIN OPENSSH PRIVATE KEY-----" | "-----END OPENSSH PRIVATE KEY-----" => {} _ => ret.push_str(line), } } ret } #[utoipa::path( responses( (status = 200, description = "Project created", body = api_model::Project), (status = 409, description = "Project with id already exists"), ), security( ("session" = []), ), )] #[post("/project//new", data = "")] async fn project_new( db: &Db, conn: Connection, git_roots_config: &State>, roots_state: &State, session: auth::Session, projectid: &str, data: Json>, ) -> Result, Custom> { let remote = data.remote.unwrap_or(""); let remote_key = match &data.remote_key { Some(data) => cleanup_key(data.as_str()), None => String::new(), }; let main_branch = data.main_branch.unwrap_or("main"); { let mut tx = db.begin().await.unwrap(); sqlx::query!( "INSERT INTO projects (id, title, description, remote, remote_key, main_branch) VALUES (?, ?, ?, ?, ?, ?)", projectid, data.title.unwrap_or("Unnamed"), data.description.unwrap_or(""), remote, remote_key, main_branch, ) .execute(&mut *tx) .map_err(|_| Custom(Status::Conflict, "Project with id already exists".to_string())) .await?; 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(); } roots_state .new_project( git_roots_config, db, projectid, remote, remote_key.as_str(), main_branch, ) .map_err(|e| Custom(Status::InternalServerError, format!("{e}"))) .await?; Ok(get_project(conn, projectid).await.unwrap()) } async fn project_check_maintainer( db: &mut Connection, session: auth::Session, projectid: &str, ) -> 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/", data = "")] async fn project_update( mut db: Connection, session: auth::Session, projectid: &str, data: Json>, ) -> Result<&'static str, Custom<&'static str>> { project_check_maintainer(&mut db, session, projectid).await?; let mut update_builder: db_utils::UpdateBuilder = 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); } if let Some(remote) = &data.remote { update_builder.set("remote", remote); } if let Some(remote_key) = &data.remote_key { update_builder.set("remote_key", cleanup_key(remote_key)); } if let Some(main_branch) = &data.main_branch { update_builder.set("main_branch", main_branch); } update_builder.and_where("id", "=", projectid); if update_builder.ok() { let (query, args) = update_builder.build(); let mut query_builder: sqlx::QueryBuilder = 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"), (status = 409, description = "User already in project"), ), security( ("session" = []), ), )] #[post("/project//user//new", data = "")] async fn project_user_add( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, data: Json, ) -> Result<&'static str, Custom<&'static str>> { project_check_maintainer(&mut db, session, projectid).await?; 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) .map_err(|_| Custom(Status::Conflict, "User already in project")) .await?; Ok("") } #[utoipa::path( responses( (status = 200, description = "User updated in project"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project, no such user or user not in project"), ), security( ("session" = []), ), )] #[post("/project//user/", data = "")] async fn project_user_update( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, data: Json, ) -> 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?; } let mut update_builder: db_utils::UpdateBuilder = 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); if update_builder.ok() { let (query, args) = update_builder.build(); let mut query_builder: sqlx::QueryBuilder = sqlx::QueryBuilder::with_arguments(query, args); let result = query_builder.build().execute(&mut **db).await.unwrap(); if result.rows_affected() == 0 { return Err(Custom(Status::NotFound, "No such project or no such user")); } } Ok("") } #[utoipa::path( responses( (status = 200, description = "User removed from project"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project, no such user or user not in project"), ), security( ("session" = []), ), )] #[delete("/project//user/")] async fn project_user_del( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, ) -> Result<&'static str, Custom<&'static str>> { let need_maintainer = userid != session.user_id; if need_maintainer { project_check_maintainer(&mut db, session, projectid).await?; } let result = sqlx::query!( "DELETE FROM project_users WHERE project=? AND user=?", projectid, userid ) .execute(&mut **db) .await .unwrap(); if result.rows_affected() == 0 { return Err(Custom(Status::NotFound, "No such project or no such user")); } Ok("") } #[utoipa::path( responses( (status = 200, description = "Get all reviews for project", body = api_model::Reviews), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[get("/project//reviews?&")] async fn reviews( mut db: Connection, _session: auth::Session, projectid: &str, limit: Option, offset: Option, ) -> Result, NotFound<&'static str>> { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT reviews.id AS id,title,state,progress,branch,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?", projectid, uw_limit, uw_offset) .fetch(&mut **db) .map_ok(|r| api_model::ReviewEntry { id: r.id, title: r.title, owner: api_model::User { id: r.user_id, name: r.name, active: r.user_dn.is_some(), }, state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, branch: r.branch, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!( "SELECT COUNT(id) AS count FROM reviews WHERE project=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if count == 0 { let projects = sqlx::query!( "SELECT COUNT(id) AS count FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if projects == 0 { return Err(NotFound("No such project")); } } let u32_count = u32::try_from(count).unwrap(); Ok(Json(api_model::Reviews { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, reviews: entries, })) } async fn get_project_default_review_users<'a>( db: &mut Connection, projectid: &str, ) -> Vec { sqlx::query!( "SELECT id,name,dn,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,id", projectid) .fetch(&mut ***db) .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::>() .await .unwrap() } async fn get_review_users( mut db: Connection, projectid: &str, reviewid: u64, ) -> Vec { let mut users = get_project_default_review_users(&mut db, projectid).await; let override_users = sqlx::query!( "SELECT id,name,dn,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,id", reviewid) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::>() .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); } } users } async fn get_review_from_branch( mut db: Connection, projectid: &str, branch: &str, ) -> Result, NotFound<&'static str>> { let mut review = sqlx::query!( "SELECT reviews.id AS id,title,description,state,progress,archived,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE project=? AND branch=?", projectid, branch) .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, name: r.name, active: r.user_dn.is_some(), }, users: Vec::new(), state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, branch: branch.to_string(), archived: r.archived != 0, } }) .map_err(|_| NotFound("No such review")) .await?; review.users = get_review_users(db, projectid, review.id).await; Ok(Json(review)) } async fn get_review_from_id( mut db: Connection, projectid: &str, reviewid: u64, ) -> Result, NotFound<&'static str>> { let mut review = sqlx::query!( "SELECT title,description,state,progress,branch,archived,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE project=? AND reviews.id=?", projectid, reviewid) .fetch_one(&mut **db) .map_ok(|r| { api_model::Review { id: reviewid, title: r.title, description: r.description, owner: api_model::User { id: r.user_id, name: r.name, active: r.user_dn.is_some(), }, users: Vec::new(), state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, branch: r.branch, archived: r.archived != 0, } }) .map_err(|_| NotFound("No such review")) .await?; review.users = get_review_users(db, projectid, reviewid).await; Ok(Json(review)) } #[allow(clippy::too_many_arguments)] async fn del_review( mut db: Connection, roots: &State, session: auth::Session, projectid: &str, reviewid: u64, state: u8, owner: &str, branch: &str, ) -> Result<&'static str, Custom<&'static str>> { let state = api_model::ReviewState::try_from(state).unwrap(); if owner != session.user_id && project_check_maintainer(&mut db, session, projectid) .await .is_err() { return Err(Custom( Status::Unauthorized, "Not owner of review or maintainer of project", )); } match state { api_model::ReviewState::Draft | api_model::ReviewState::Dropped => {} api_model::ReviewState::Open | api_model::ReviewState::Closed => { return Err(Custom(Status::BadRequest, "Review is open or closed")); } } roots .del_branch(projectid, branch) .map_err(|_| Custom(Status::InternalServerError, "git error")) .await?; sqlx::query!( "DELETE FROM reviews WHERE project=? AND id=?", projectid, reviewid ) .execute(&mut **db) .await .unwrap(); Ok("") } async fn del_review_from_branch( mut db: Connection, roots: &State, session: auth::Session, projectid: &str, branch: &str, ) -> Result<&'static str, Custom<&'static str>> { let (id, state, owner) = sqlx::query!( "SELECT id,state,owner FROM reviews WHERE project=? AND branch=?", projectid, branch ) .fetch_one(&mut **db) .map_ok(|r| (r.id, r.state, r.owner)) .map_err(|_| Custom(Status::NotFound, "No such review")) .await?; del_review( db, roots, session, projectid, id, state, owner.as_str(), branch, ) .await } async fn del_review_from_id( mut db: Connection, roots: &State, session: auth::Session, projectid: &str, reviewid: u64, ) -> Result<&'static str, Custom<&'static str>> { let (state, branch, owner) = sqlx::query!( "SELECT state,branch,owner FROM reviews WHERE project=? AND id=?", projectid, reviewid ) .fetch_one(&mut **db) .map_ok(|r| (r.state, r.branch, r.owner)) .map_err(|_| Custom(Status::NotFound, "No such review")) .await?; del_review( db, roots, session, projectid, reviewid, state, owner.as_str(), branch.as_str(), ) .await } #[utoipa::path( responses( (status = 200, description = "Get review", body = api_model::Review), (status = 404, description = "No such review"), ), security( ("session" = []), ), )] #[get("/review//")] async fn review( db: Connection, _session: auth::Session, projectid: &str, branch: PathBuf, ) -> Result, NotFound<&'static str>> { get_review_from_branch(db, projectid, branch.as_path().to_str().unwrap()).await } // Backup for the above. Matches if ends up encoded (with / as %2f) #[get("/review//", rank = 1)] async fn review_encoded_path( db: Connection, _session: auth::Session, projectid: &str, branch: &str, ) -> Result, NotFound<&'static str>> { get_review_from_branch(db, projectid, branch).await } #[utoipa::path( responses( (status = 200, description = "Review deleted"), (status = 400, description = "Review is open or closed"), (status = 401, description = "Not owner of review or maintainer of project"), (status = 404, description = "No such review"), ), security( ("session" = []), ), )] #[delete("/review//")] async fn review_del( db: Connection, roots: &State, session: auth::Session, projectid: &str, branch: PathBuf, ) -> Result<&'static str, Custom<&'static str>> { del_review_from_branch( db, roots, session, projectid, branch.as_path().to_str().unwrap(), ) .await } // Backup for the above. Matches if ends up encoded (with / as %2f) #[delete("/review//", rank = 1)] async fn review_encoded_path_del( db: Connection, roots: &State, session: auth::Session, projectid: &str, branch: &str, ) -> Result<&'static str, Custom<&'static str>> { del_review_from_branch(db, roots, session, projectid, branch).await } #[utoipa::path( responses( (status = 200, description = "Get review", body = api_model::Review), (status = 404, description = "No such review"), ), security( ("session" = []), ), )] #[get("/review/?")] async fn review_id( db: Connection, _session: auth::Session, projectid: &str, reviewid: u64, ) -> Result, NotFound<&'static str>> { get_review_from_id(db, projectid, reviewid).await } #[utoipa::path( responses( (status = 200, description = "Remove deleted"), (status = 400, description = "Review is open or closed"), (status = 401, description = "Not owner of review or maintainer of project"), (status = 404, description = "No such review"), ), security( ("session" = []), ), )] #[delete("/review/?")] async fn review_id_del( db: Connection, roots: &State, session: auth::Session, projectid: &str, reviewid: u64, ) -> Result<&'static str, Custom<&'static str>> { del_review_from_id(db, roots, session, projectid, reviewid).await } #[utoipa::path( responses( (status = 200, description = "Get all users", body = api_model::Users), ), security( ("session" = []), ), )] #[get("/users?&")] async fn users( mut db: Connection, _session: auth::Session, limit: Option, offset: Option, ) -> Json { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT id,name,dn FROM users ORDER BY id LIMIT ? OFFSET ?", uw_limit, uw_offset ) .fetch(&mut **db) .map_ok(|r| api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }) .try_collect::>() .await .unwrap(); let count = sqlx::query!("SELECT COUNT(id) AS count FROM users") .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::Users { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, users: entries, }) } #[utoipa::path( responses( (status = 200, description = "Key added to current user", body = api_model::UserKey), (status = 400, description = "Key too large or invalid"), ), security( ("session" = []), ), )] #[post("/user/keys/add", data = "")] async fn user_key_add( mut db: Connection, authorized_keys_config: &State>, authorized_keys_state: &State, session: auth::Session, data: Json>, ) -> Result, Custom<&'static str>> { if data.data.len() > 8192 { return Err(Custom(Status::BadRequest, "Key is too large")); } if data.kind.contains(' ') || data.data.contains(' ') { return Err(Custom(Status::BadRequest, "Invalid kind or data")); } let comment = data.comment.unwrap_or(""); let result = sqlx::query!( "INSERT INTO user_keys (user, kind, data, comment) VALUES (?, ?, ?, ?)", session.user_id, data.kind, data.data, comment, ) .execute(&mut **db) .await .unwrap(); let key = api_model::UserKey { id: result.last_insert_id(), kind: data.kind.to_string(), data: data.data.to_string(), comment: comment.to_string(), }; authorized_keys_state .new_user_key( authorized_keys_config, key.id, session.user_id.as_str(), key.kind.as_str(), key.data.as_str(), ) .await; Ok(Json(key)) } #[utoipa::path( responses( (status = 200, description = "User key", body = api_model::UserKey), (status = 404, description = "No such key"), ), security( ("session" = []), ), )] #[get("/user/keys/")] async fn user_key_get( mut db: Connection, session: auth::Session, id: u64, ) -> Result, NotFound<&'static str>> { let user_key = sqlx::query!( "SELECT id,kind,data,comment FROM user_keys WHERE id=? AND user=?", id, session.user_id, ) .fetch_one(&mut **db) .map_ok(|r| api_model::UserKey { id: r.id, kind: r.kind, data: r.data, comment: r.comment, }) .map_err(|_| NotFound("No such user key")) .await?; Ok(Json(user_key)) } #[utoipa::path( responses( (status = 200, description = "Key removed from current user"), (status = 404, description = "No such key for current user"), ), security( ("session" = []), ), )] #[delete("/user/keys/")] async fn user_key_del( mut db: Connection, authorized_keys_config: &State>, authorized_keys_state: &State, session: auth::Session, id: u64, ) -> Result<&'static str, Custom<&'static str>> { let result = sqlx::query!( "DELETE FROM user_keys WHERE id=? AND user=?", id, session.user_id, ) .execute(&mut **db) .await .unwrap(); if result.rows_affected() == 0 { return Err(Custom(Status::NotFound, "No such key for current user")); } authorized_keys_state .del_user_key(authorized_keys_config, id, session.user_id.as_str()) .await; Ok("") } #[utoipa::path( responses( (status = 200, description = "Get all keys for user", body = api_model::UserKeys), ), security( ("session" = []), ), )] #[get("/user/keys?&")] async fn user_keys( mut db: Connection, session: auth::Session, limit: Option, offset: Option, ) -> Json { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT id,kind,data,comment FROM user_keys WHERE user=? ORDER BY id LIMIT ? OFFSET ?", session.user_id, uw_limit, uw_offset ) .fetch(&mut **db) .map_ok(|r| api_model::UserKey { id: r.id, kind: r.kind, data: r.data, comment: r.comment, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!( "SELECT COUNT(id) AS count FROM user_keys WHERE user=?", session.user_id, ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::UserKeys { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, keys: entries, }) } #[utoipa::path( responses( (status = 200, description = "All good"), ), security( (), ), )] #[get("/healthcheck")] fn healthcheck() -> &'static str { "" } async fn get_translation_review( mut db: Connection, projectid: &str, translation_reviewid: u64, ) -> Result, NotFound<&'static str>> { let mut translation_review = sqlx::query!( "SELECT title,description,state,progress,archived,base,head,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM translation_reviews JOIN users ON users.id=owner WHERE project=? AND translation_reviews.id=?", projectid, translation_reviewid) .fetch_one(&mut **db) .map_ok(|r| { api_model::TranslationReview { id: translation_reviewid, title: r.title, description: r.description, owner: api_model::User { id: r.user_id, name: r.name, active: r.user_dn.is_some(), }, users: Vec::new(), state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, archived: r.archived != 0, base: r.base, head: r.head, } }) .map_err(|_| NotFound("No such review")) .await?; translation_review.users = get_translation_review_users(db, projectid, translation_reviewid).await; Ok(Json(translation_review)) } async fn get_translation_review_users( mut db: Connection, projectid: &str, translation_reviewid: u64, ) -> Vec { let mut users = get_project_default_review_users(&mut db, projectid).await; let override_users = sqlx::query!( "SELECT id,name,dn,translation_review_users.role AS role FROM users JOIN translation_review_users ON translation_review_users.user=id WHERE translation_review_users.translation_review=? ORDER BY role,id", translation_reviewid) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::>() .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); } } users } #[utoipa::path( responses( (status = 200, description = "Translation review created", body = api_model::TranslationReview), ), security( ("session" = []), ), )] #[post("/translation//new", data = "")] async fn translation_review_new( mut conn: Connection, roots_state: &State, session: auth::Session, projectid: &str, data: Json>, ) -> Result, Custom> { let title = if data.title == "" { "Unnamed" } else { data.title.as_str() }; let mut base = data.base.unwrap_or("").to_string(); let translation_reviewid: u64; { let mut tx = conn.begin().await.unwrap(); if base == "" { let main_branch = sqlx::query!("SELECT main_branch FROM projects WHERE id=?", projectid) .fetch_one(&mut *tx) .map_ok(|r| r.main_branch) .map_err(|_| Custom(Status::NotFound, "No such project".to_string())) .await?; base = roots_state .fetch_branch(projectid, main_branch.as_str()) .map_err(|_| Custom(Status::InternalServerError, "git error".to_string())) .await?; } let r = sqlx::query!( "INSERT INTO translation_reviews (project, owner, title, description, base, head) VALUES (?, ?, ?, ?, ?, ?)", projectid, session.user_id, title, data.description, base, base, ) .execute(&mut *tx) .map_err(|e| Custom(Status::InternalServerError, format!("Database error: {e:?}"))) .await?; translation_reviewid = r.last_insert_id(); tx.commit().await.unwrap(); } roots_state .new_translation_review(&mut conn, projectid, translation_reviewid, base.as_str()) .map_err(|e| Custom(Status::InternalServerError, format!("{e}"))) .await?; Ok( get_translation_review(conn, projectid, translation_reviewid) .await .unwrap(), ) } #[utoipa::path( responses( (status = 200, description = "Get all translation reviews for project", body = api_model::TranslationReviews), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[get("/project//translations?&")] async fn translation_reviews( mut db: Connection, _session: auth::Session, projectid: &str, limit: Option, offset: Option, ) -> Result, NotFound<&'static str>> { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT translation_reviews.id AS id,title,state,progress,base,head,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM translation_reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?", projectid, uw_limit, uw_offset) .fetch(&mut **db) .map_ok(|r| api_model::TranslationReviewEntry { id: r.id, title: r.title, owner: api_model::User { id: r.user_id, name: r.name, active: r.user_dn.is_some(), }, state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, base: r.base, head: r.head, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!( "SELECT COUNT(id) AS count FROM translation_reviews WHERE project=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if count == 0 { let projects = sqlx::query!( "SELECT COUNT(id) AS count FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if projects == 0 { return Err(NotFound("No such project")); } } let u32_count = u32::try_from(count).unwrap(); Ok(Json(api_model::TranslationReviews { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, reviews: entries, })) } #[utoipa::path( responses( (status = 200, description = "Get all strings for a translation review", body = api_model::LocalizationStrings), (status = 404, description = "No such translation review"), ), security( ("session" = []), ), )] #[get("/translation//strings?&")] async fn translation_review_strings( mut db: Connection, _session: auth::Session, translation_reviewid: u64, limit: Option, offset: Option, ) -> Result, NotFound<&'static str>> { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let (ids, mut entries) = sqlx::query!( "SELECT id,name,file,description,meaning,source,placeholder_offsets FROM localization_strings WHERE translation_review=? ORDER BY id ASC LIMIT ? OFFSET ?", translation_reviewid, uw_limit, uw_offset) .fetch(&mut **db) .try_fold((Vec::new(), Vec::new()), async move |mut vecs, r| { vecs.0.push(r.id); vecs.1.push(api_model::LocalizationString { id: r.name, file: r.file, description: r.description, meaning: r.meaning, source: r.source, placeholders: Vec::new(), placeholder_offset: r.placeholder_offsets.split_terminator(',').map(|x| x.parse::().unwrap()).collect(), translations: Vec::new(), }); Ok(vecs) }) .await .unwrap(); for (i, entry) in entries.iter_mut().enumerate() { if !entry.placeholder_offset.is_empty() { entry.placeholders = sqlx::query!( "SELECT name,content,example FROM localization_placeholders WHERE localization_string=? ORDER BY id ASC", ids[i]) .fetch(&mut **db) .map_ok(|r| api_model::LocalizationPlaceholder { id: r.name, content: r.content, example: r.example, }) .try_collect::>() .await .unwrap(); } entry.translations = sqlx::query!( "SELECT language,head_translation,head_placeholder_offsets,state,comment FROM translation_strings WHERE localization_string=? ORDER BY language ASC", ids[i]) .fetch(&mut **db) .map_ok(|r| api_model::TranslationString { language: r.language, translation: r.head_translation, placeholder_offset: r.head_placeholder_offsets.split_terminator(',').map(|x| x.parse::().unwrap()).collect(), state: api_model::TranslationState::try_from(r.state).unwrap(), comment: r.comment, // TODO reviewer: None, }) .try_collect::>() .await .unwrap(); } let count = sqlx::query!( "SELECT COUNT(id) AS count FROM localization_strings WHERE translation_review=?", translation_reviewid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if count == 0 { let reviews = sqlx::query!( "SELECT COUNT(id) AS count FROM translation_reviews WHERE id=?", translation_reviewid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if reviews == 0 { return Err(NotFound("No such translation review")); } } let u32_count = u32::try_from(count).unwrap(); Ok(Json(api_model::LocalizationStrings { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, strings: entries, })) } async fn run_migrations(rocket: Rocket) -> fairing::Result { match Db::fetch(&rocket) { Some(db) => match sqlx::migrate!().run(&**db).await { Ok(_) => Ok(rocket), Err(e) => { error!("Failed to initialize database: {}", e); Err(rocket) } }, None => Err(rocket), } } fn rocket_from_config(figment: Figment) -> Rocket { let basepath = "/api/v1"; rocket::custom(figment) .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) .mount( basepath, // Remember to update openapi paths when you add something here. routes![ healthcheck, projects, project, project_new, project_update, project_user_add, project_user_update, project_user_del, reviews, review, review_del, review_encoded_path, review_encoded_path_del, review_id, review_id_del, users, user_key_add, user_key_get, user_key_del, user_keys, translation_review_new, translation_review_strings, translation_reviews, ], ) .attach(auth::stage(basepath)) .attach(git_root::stage()) .attach(authorized_keys::stage()) } #[rocket::main] async fn main() -> Result<(), rocket::Error> { let mut api = MainApi::openapi(); api.merge(auth::AuthApi::openapi()); api.servers = Some(vec![utoipa::openapi::ServerBuilder::new() .url("/api/v1") .build()]); let _rocket = rocket_from_config(rocket::Config::figment()) .mount( "/", SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api), ) .launch() .await?; Ok(()) }