diff --git a/backend/src/handlers_prelude/github_hook.rs b/backend/src/handlers_prelude/github_hook.rs index 2a67df7..787f3d3 100644 --- a/backend/src/handlers_prelude/github_hook.rs +++ b/backend/src/handlers_prelude/github_hook.rs @@ -2,22 +2,26 @@ use axum::routing::post; use axum::{extract::State, http::HeaderMap, Router}; -use tracing::{debug, error, info}; +use tracing::{debug, info}; +use color_eyre::eyre::WrapErr; +use crate::handlers_prelude::ApiError; use crate::AppState; -pub async fn github_hook_handler(State(state): State, headers: HeaderMap) { +pub async fn github_hook_handler( + State(state): State, + headers: HeaderMap, +) -> Result<(), ApiError> { let event_type = headers.get("x-github-event").unwrap().to_str().unwrap(); - debug!("Received Github webhook event of type {event_type:?}"); + + debug!("Received Github webhook event of type {:?}", event_type); + if event_type == "push" { info!("New changes pushed to Github, pulling changes..."); - match state.git.pull() { - Ok(_) => {} - Err(e) => { - error!("Failed to auto-pull changes with error: {e:?}"); - } - } + state.git.pull().context("Failed during automatic pull triggered by GitHub push event")?; } + + Ok(()) } pub async fn create_github_route() -> Router { diff --git a/backend/src/handlers_prelude/groups.rs b/backend/src/handlers_prelude/groups.rs index ce8349a..dedcfbd 100644 --- a/backend/src/handlers_prelude/groups.rs +++ b/backend/src/handlers_prelude/groups.rs @@ -6,11 +6,10 @@ use axum::{ }; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use tracing::error; use crate::{ db::{Database, Group}, - eyre_to_axum_err, + handlers_prelude::ApiError, perms::Permission, require_perms, AppState, }; @@ -33,16 +32,9 @@ pub struct GroupResponse { pub async fn create_group_response( db: &Database, group: Group, -) -> Result { - let permissions = db - .get_group_permissions(group.id) - .await - .map_err(eyre_to_axum_err)?; - - let members = db - .get_group_members(group.id) - .await - .map_err(eyre_to_axum_err)?; +) -> Result { + let permissions = db.get_group_permissions(group.id).await?; + let members = db.get_group_members(group.id).await?; Ok(GroupResponse { id: group.id, @@ -62,29 +54,17 @@ pub async fn create_group_response( pub async fn get_groups_handler( State(state): State, headers: HeaderMap, -) -> Result>, (StatusCode, String)> { +) -> Result>, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - match state.db.get_all_groups().await { - Ok(groups) => { - let mut get_groups_response = Vec::new(); - - for group in groups { - get_groups_response.push(create_group_response(&state.db, group).await?); - } - - Ok(Json(get_groups_response)) - } - Err(e) => { - error!("An error was encountered fetching all groups: {e:?}"); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "An internal error was encountered fetching all groups, \ - check server logs for more info" - .to_owned(), - )) - } + let groups = state.db.get_all_groups().await?; + let mut get_groups_response = Vec::with_capacity(groups.len()); + + for group in groups { + get_groups_response.push(create_group_response(&state.db, group).await?); } + + Ok(Json(get_groups_response)) } #[derive(Serialize, Deserialize)] @@ -96,20 +76,13 @@ pub async fn post_group_handler( State(state): State, headers: HeaderMap, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - Ok(Json( - create_group_response( - &state.db, - state - .db - .create_group(body.group_name) - .await - .map_err(eyre_to_axum_err)?, - ) - .await?, - )) + let group = state.db.create_group(body.group_name).await?; + let response = create_group_response(&state.db, group).await?; + + Ok(Json(response)) } #[derive(Serialize, Deserialize)] @@ -122,15 +95,10 @@ pub async fn put_group_permissions_handler( headers: HeaderMap, Path(group_id): Path, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - let current_permissions = state - .db - .get_group_permissions(group_id) - .await - .map_err(eyre_to_axum_err)?; - + let current_permissions = state.db.get_group_permissions(group_id).await?; let new_permissions = body.permissions; let permissions_to_remove = current_permissions @@ -144,47 +112,30 @@ pub async fn put_group_permissions_handler( .collect::>(); for perm in permissions_to_remove { - state - .db - .remove_group_permission(group_id, *perm) - .await - .map_err(eyre_to_axum_err)?; + state.db.remove_group_permission(group_id, *perm).await?; } for perm in permissions_to_add { - state - .db - .add_group_permission(group_id, *perm) - .await - .map_err(eyre_to_axum_err)?; + state.db.add_group_permission(group_id, *perm).await?; } - Ok(Json( - create_group_response( - &state.db, - state - .db - .get_group(group_id) - .await - .map_err(eyre_to_axum_err)? - .unwrap(), - ) - .await?, - )) + let updated_group = state.db.get_group(group_id).await?.ok_or_else(|| { + ApiError::from((StatusCode::NOT_FOUND, "Group not found in the database".to_string())) + })?; + + Ok(Json(create_group_response(&state.db, updated_group).await?)) } pub async fn delete_group_handler( State(state): State, headers: HeaderMap, Path(group_id): Path, -) -> Result<(), (StatusCode, String)> { +) -> Result<(), ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - state - .db - .delete_group(group_id) - .await - .map_err(eyre_to_axum_err) + state.db.delete_group(group_id).await?; + + Ok(()) } pub async fn create_group_route() -> Router { diff --git a/backend/src/handlers_prelude/mod.rs b/backend/src/handlers_prelude/mod.rs index 75f3a47..d3284b5 100644 --- a/backend/src/handlers_prelude/mod.rs +++ b/backend/src/handlers_prelude/mod.rs @@ -1,7 +1,6 @@ //! All Axum handlers are exported from this module use std::collections::HashMap; - use axum::response::{IntoResponse, Response}; use axum::{extract::State, http::HeaderMap}; use chrono::{DateTime, Utc}; @@ -31,38 +30,71 @@ use tracing::{debug, error, trace}; use crate::{db::User, perms::Permission, AppState}; -pub struct ApiError(eyre::Error); +pub struct ApiError { + status: Option, + error: Report, +} + +impl ApiError { + pub fn new(status: Option, error: impl Into) -> Self { + Self { + status, + error: error.into(), + } + } +} impl IntoResponse for ApiError { fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {}", self.0), - ) - .into_response() + error!(error = %self.error, "Error returned from handler"); + let status = self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, self.error.to_string()).into_response() } } -impl From for ApiError { - fn from(err: eyre::Error) -> Self { - Self(err) +impl From for ApiError { + fn from(message: String) -> Self { + Self { + status: Some(StatusCode::INTERNAL_SERVER_ERROR), + error: eyre::eyre!(message), + } } } -impl From for ApiError { - fn from(err: String) -> Self { - Self(eyre::eyre!(err)) +impl From<(StatusCode, String)> for ApiError { + fn from((status, message): (StatusCode, String)) -> Self { + Self { + status: Some(status), + error: eyre::eyre!(message), + } } } -/// Quick and dirty way to convert an eyre error to a (StatusCode, message) response, meant for use with `map_err`, so that errors can be propagated out of -/// axum handlers with `?`. -pub fn eyre_to_axum_err(e: Report) -> (StatusCode, String) { - error!("An error was encountered in an axum handler: {e:?}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("An error was encountered, check server logs for more info: {e}"), - ) +impl From<&str> for ApiError { + fn from(message: &str) -> Self { + Self { + status: Some(StatusCode::INTERNAL_SERVER_ERROR), + error: eyre::eyre!(message.to_string()), + } + } +} + +impl From<(StatusCode, &str)> for ApiError { + fn from((status, message): (StatusCode, &str)) -> Self { + Self { + status: Some(status), + error: eyre::eyre!(message.to_string()), + } + } +} + +impl From for ApiError { + fn from(error: Report) -> Self { + Self { + status: Some(StatusCode::INTERNAL_SERVER_ERROR), + error, + } + } } /// The output of a find_user call, used to differentiate between expired users and valid users @@ -118,13 +150,13 @@ pub async fn require_perms( State(state): State<&AppState>, headers: HeaderMap, perms: &[Permission], -) -> Result { - let maybe_user = find_user(state, headers).await.map_err(eyre_to_axum_err)?; +) -> Result { + let maybe_user = find_user(state, headers).await?; match maybe_user { Some(user) => match user { - FoundUser::ExpiredUser(u) => Err(( - StatusCode::UNAUTHORIZED, - format!( + FoundUser::ExpiredUser(u) => Err(ApiError::new( + Some(StatusCode::UNAUTHORIZED), + eyre::eyre!( "The access token has expired for the user {}, they must authenticate again.", u.username ), @@ -133,15 +165,14 @@ pub async fn require_perms( let user_perms = &state .db .get_user_permissions(u.id) - .await - .map_err(eyre_to_axum_err)?; + .await?; let has_permissions = perms.iter().all(|perm| user_perms.contains(perm)); if has_permissions { Ok(u) } else { - Err(( - StatusCode::FORBIDDEN, - format!( + Err(ApiError::new( + Some(StatusCode::FORBIDDEN), + eyre::eyre!( "User {:?} lacks the permission to edit documents.", u.username ), @@ -149,9 +180,9 @@ pub async fn require_perms( } } }, - None => Err(( - StatusCode::UNAUTHORIZED, - "No valid user is authenticated, perhaps you forgot to add `{credentials: \"include\"}` in your fetch options?.".to_string(), + None => Err(ApiError::new( + Some(StatusCode::UNAUTHORIZED), + eyre::eyre!("No valid user is authenticated, perhaps you forgot to add `{{credentials: \"include\"}}` in your fetch options?"), )), } } diff --git a/backend/src/handlers_prelude/reclone.rs b/backend/src/handlers_prelude/reclone.rs index ddcce33..8816228 100644 --- a/backend/src/handlers_prelude/reclone.rs +++ b/backend/src/handlers_prelude/reclone.rs @@ -1,17 +1,16 @@ use axum::routing::post; use axum::{extract::State, http::HeaderMap, Router}; -use reqwest::StatusCode; use crate::{perms::Permission, AppState}; -use super::{eyre_to_axum_err, require_perms}; +use super::{ApiError, require_perms}; pub async fn post_reclone_handler( State(state): State, headers: HeaderMap, -) -> Result<(), (StatusCode, String)> { +) -> Result<(), ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - state.git.reclone().map_err(eyre_to_axum_err)?; + state.git.reclone()?; Ok(()) } diff --git a/backend/src/handlers_prelude/repo_fs.rs b/backend/src/handlers_prelude/repo_fs.rs index 7fd1cf3..b44404a 100644 --- a/backend/src/handlers_prelude/repo_fs.rs +++ b/backend/src/handlers_prelude/repo_fs.rs @@ -11,12 +11,10 @@ use axum::{ }; use reqwest::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; -use tracing::{error, warn}; +use crate::handlers_prelude::ApiError; use crate::{perms::Permission, require_perms, AppState}; -use super::eyre_to_axum_err; - #[derive(Debug, Deserialize, Serialize)] pub struct GetDocQuery { pub path: String, @@ -27,11 +25,13 @@ pub struct GetDocResponse { pub contents: String, } -async fn get_gh_token(state: &AppState) -> Result { - state.gh_client.get_token().await.map_err(|e| { - error!("Failed to retrieve GitHub token: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) - }) +async fn get_gh_token(state: &AppState) -> Result { + let token = state + .gh_client + .get_token() + .await?; + + Ok(token) } /// This handler accepts a `GET` request to `/api/doc?path=`. @@ -39,28 +39,17 @@ async fn get_gh_token(state: &AppState) -> Result pub async fn get_doc_handler( State(state): State, Query(query): Query, -) -> Result, (StatusCode, &'static str)> { - match state.git.get_doc(&query.path) { - Ok(maybe_doc) => maybe_doc.map_or( - Err(( - StatusCode::NOT_FOUND, - "The file at the provided path was not found.", - )), - |doc| Ok(Json(GetDocResponse { contents: doc })), - ), - Err(e) => { - warn!( - "Failed to fetch doc with path: {:?}; error: {:?}", - query.path, e - ); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "Fetch failed, check server logs for more info", - )) - } - } +) -> Result, ApiError> { + let maybe_doc = state.git.get_doc(&query.path)?; + + let doc = maybe_doc.ok_or_else(|| { + ApiError::from("The file at the provided path was not found.".to_string()) + })?; + + Ok(Json(GetDocResponse { contents: doc })) } + #[derive(Serialize, Deserialize)] pub struct PutDocRequestBody { contents: String, @@ -74,7 +63,7 @@ pub async fn put_doc_handler( State(state): State, headers: HeaderMap, Json(body): Json, -) -> Result { +) -> Result { let author = require_perms( axum::extract::State(&state), headers, @@ -89,22 +78,15 @@ pub async fn put_doc_handler( // Use the branch name from the request body let branch_name = &body.branch_name; - match state.git.put_doc( + state.git.put_doc( &body.path, &body.contents, &final_commit_message, &get_gh_token(&state).await?, branch_name, - ) { - Ok(_) => Ok(StatusCode::CREATED), - Err(e) => { - error!("Failed to complete put_doc call with error: {e:?}"); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to create document, check server logs for more info".to_string(), - )) - } - } + )?; + + Ok(StatusCode::CREATED) } /// Deletes the document at the provided path, if the user has perms. @@ -112,74 +94,56 @@ pub async fn delete_doc_handler( State(state): State, headers: HeaderMap, Query(query): Query, -) -> Result { - let author = require_perms( - axum::extract::State(&state), - headers, - &[Permission::ManageContent], - ) - .await?; +) -> Result { + let author = require_perms(axum::extract::State(&state), headers, &[Permission::ManageContent]) + .await?; - state - .git - .delete_doc( - &query.path, - &format!("{} deleted {}", author.username, query.path), - &get_gh_token(&state).await?, - ) - .map_err(eyre_to_axum_err)?; + state.git.delete_doc( + &query.path, + &format!("{} deleted {}", author.username, query.path), + &get_gh_token(&state).await?, + )?; Ok(StatusCode::NO_CONTENT) } + /// This handler reads the document folder and builds a tree style object /// representing the state of the tree. This is used in the viewer for directory navigation. pub async fn get_doc_tree_handler( State(state): State, -) -> Result, (StatusCode, &'static str)> { - match state.git.get_doc_tree() { - Ok(t) => Ok(Json(t)), - Err(e) => { - error!("An error was encountered fetching the document tree: {e:?}"); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "An internal error was encountered fetching the doc tree, \ - check server logs for more info", - )) - } - } +) -> Result, ApiError> { + let tree = state.git.get_doc_tree()?; + + Ok(Json(tree)) } /// This handler reads the assets folder and builds a tree style object /// representing the state of the tree. This is used in the viewer for directory navigation. pub async fn get_asset_tree_handler( State(state): State, -) -> Result, (StatusCode, &'static str)> { - match state.git.get_asset_tree() { - Ok(t) => Ok(Json(t)), - Err(e) => { - error!("An error was encountered fetching the asset tree: {e:?}"); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "An internal error was encountered fetching the asset tree, \ - check server logs for more info", - )) - } - } +) -> Result, ApiError> { + let tree = state.git.get_asset_tree()?; + + Ok(Json(tree)) } /// This handler fetches an asset from the repo's asset folder pub async fn get_asset_handler( State(state): State, Path(path): Path>, -) -> impl IntoResponse { +) -> Result { let file_name = path.last().unwrap().clone(); let path = path.join("/"); - // https://github.com/tokio-rs/axum/discussions/608#discussioncomment-1789020 - let file = match state.git.get_asset(&path).map_err(eyre_to_axum_err)? { - Some(file) => file, - None => return Err((StatusCode::NOT_FOUND, format!("File not found: {}", path))), - }; + + let file = state + .git + .get_asset(&path)? + .ok_or_else(|| ApiError::from(( + StatusCode::NOT_FOUND, + format!("File not found: {}", path), + )))?; + let mut headers = HeaderMap::new(); headers.insert( CONTENT_TYPE, @@ -191,9 +155,12 @@ pub async fn get_asset_handler( CONTENT_DISPOSITION, format!("inline; filename={file_name:?}").parse().unwrap(), ); + Ok((headers, file)) } + + /// This handler creates or replaces the asset at the provided path /// with a new asset pub async fn put_asset_handler( @@ -201,7 +168,7 @@ pub async fn put_asset_handler( headers: HeaderMap, Path(path): Path>, body: Bytes, -) -> Result { +) -> Result { let path = path.join("/"); let author = require_perms( axum::extract::State(&state), @@ -215,11 +182,7 @@ pub async fn put_asset_handler( // Call put_asset to update the asset, passing the required parameters state .git - .put_asset(&path, &body, &message, &get_gh_token(&state).await?) - .map_err(|e| { - error!("Failed to update asset: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) - })?; + .put_asset(&path, &body, &message, &get_gh_token(&state).await?)?; Ok(StatusCode::CREATED) } @@ -230,15 +193,14 @@ pub async fn delete_asset_handler( State(state): State, headers: HeaderMap, Path(path): Path>, -) -> Result { +) -> Result { let path = path.join("/"); let author = require_perms(State(&state), headers, &[Permission::ManageContent]).await?; // Generate commit message combining author and default update message let message = format!("{} deleted {}", author.username, path); state .git - .delete_asset(&path, &message, &get_gh_token(&state).await?) - .map_err(eyre_to_axum_err)?; + .delete_asset(&path, &message, &get_gh_token(&state).await?)?; Ok(StatusCode::OK) } diff --git a/backend/src/handlers_prelude/users.rs b/backend/src/handlers_prelude/users.rs index edf3b0f..1baa6d4 100644 --- a/backend/src/handlers_prelude/users.rs +++ b/backend/src/handlers_prelude/users.rs @@ -8,9 +8,9 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use tracing::error; +use crate::handlers_prelude::ApiError; use crate::{ db::{Database, Group, User}, - eyre_to_axum_err, perms::Permission, require_perms, AppState, }; @@ -27,16 +27,14 @@ pub struct UserResponse { pub async fn create_user_response( db: &Database, user: User, -) -> Result { +) -> Result { let groups = db .get_user_groups(user.id) - .await - .map_err(eyre_to_axum_err)?; + .await?; let permissions = db .get_user_permissions(user.id) - .await - .map_err(eyre_to_axum_err)?; + .await?; Ok(UserResponse { id: user.id, @@ -50,7 +48,7 @@ pub async fn create_user_response( pub async fn get_users_handler( State(state): State, headers: HeaderMap, -) -> Result>, (StatusCode, String)> { +) -> Result>, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; match state.db.get_all_users().await { @@ -65,12 +63,12 @@ pub async fn get_users_handler( } Err(e) => { error!("An error was encountered fetching all users: {e:?}"); - Err(( + Err(ApiError::from(( StatusCode::INTERNAL_SERVER_ERROR, "An internal error was encountered fetching all users, \ check server logs for more info" .to_owned(), - )) + ))) } } } @@ -78,7 +76,7 @@ pub async fn get_users_handler( pub async fn get_current_user_handler( State(state): State, headers: HeaderMap, -) -> Result, (StatusCode, String)> { +) -> Result, ApiError> { let user = require_perms(axum::extract::State(&state), headers, &[]).await?; Ok(Json(create_user_response(&state.db, user).await?)) } @@ -93,22 +91,20 @@ pub async fn post_user_membership_handler( headers: HeaderMap, Path(user_id): Path, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; for group_id in body.group_ids { state .db .add_group_membership(group_id, user_id) - .await - .map_err(eyre_to_axum_err)?; + .await?; } let user = state .db .get_user(user_id) - .await - .map_err(eyre_to_axum_err)? + .await? .unwrap(); Ok(Json(create_user_response(&state.db, user).await?)) @@ -119,22 +115,20 @@ pub async fn delete_user_membership_handler( headers: HeaderMap, Path(user_id): Path, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; for group_id in body.group_ids { state .db .remove_group_membership(group_id, user_id) - .await - .map_err(eyre_to_axum_err)?; + .await?; } let user = state .db .get_user(user_id) - .await - .map_err(eyre_to_axum_err)? + .await? .unwrap(); Ok(Json(create_user_response(&state.db, user).await?)) @@ -144,27 +138,37 @@ pub async fn delete_user_handler( State(state): State, headers: HeaderMap, Path(user_id): Path, -) -> Result<(), (StatusCode, String)> { +) -> Result<(), ApiError> { require_perms(State(&state), headers, &[Permission::ManageUsers]).await?; - state - .db - .delete_user(user_id) - .await - .map_err(eyre_to_axum_err) + match state.db.delete_user(user_id).await { + Ok(_) => Ok(()), + Err(e) => { + error!("Failed to delete user with ID {}: {}", user_id, e); + Err(ApiError::from(( + StatusCode::INTERNAL_SERVER_ERROR, + "An internal error occurred while deleting the user, check server logs for more info", + ))) + } + } } pub async fn delete_current_user( State(state): State, headers: HeaderMap, -) -> Result<(), (StatusCode, String)> { +) -> Result<(), ApiError> { let user = require_perms(axum::extract::State(&state), headers, &[]).await?; - state - .db - .delete_user(user.id) - .await - .map_err(eyre_to_axum_err) + match state.db.delete_user(user.id).await { + Ok(_) => Ok(()), + Err(e) => { + error!("Failed to delete current user with ID {}: {}", user.id, e); + Err(ApiError::from(( + StatusCode::INTERNAL_SERVER_ERROR, + "An internal error occurred while deleting the user, check server logs for more info", + ))) + } + } } pub async fn create_user_route() -> Router { diff --git a/frontend/static/css/theme.css b/frontend/static/css/theme.css index ad4fbf5..b170d84 100644 --- a/frontend/static/css/theme.css +++ b/frontend/static/css/theme.css @@ -7,7 +7,6 @@ --font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - --green: #329932; --red: #993232; --toast-success: #36a331;