diff --git a/Cargo.lock b/Cargo.lock index 3637b7d..defa29a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "thiserror", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index fc9311c..bff2068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] } anyhow = "1.0" argon2 = { version = "0.5", features = ["std", "alloc"] } url = "2.4" +thiserror = "1.0" [profile.dev.package.sqlx-macros] opt-level = 3 \ No newline at end of file diff --git a/src/auth/http.rs b/src/auth/http.rs index 13481b3..fb1f3e3 100644 --- a/src/auth/http.rs +++ b/src/auth/http.rs @@ -16,11 +16,11 @@ use cookie::Cookie; use std::sync::Arc; use uuid::Uuid; -use crate::{error::AppError, state::AppState}; +use crate::{http::error::ApiError, state::AppState}; use super::{session::Session, user::User}; -pub const INVALID_SESSION: AppError = AppError::ClientError { +pub const INVALID_SESSION: ApiError = ApiError::ClientError { status: StatusCode::UNAUTHORIZED, code: "authentication-required", message: "Please log-in and submit a valid session as a cookie", @@ -39,7 +39,7 @@ impl FromRequestParts for RequireUser where S: Send + Sync, { - type Rejection = AppError<'static>; + type Rejection = ApiError<'static>; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { match Extension::::from_request_parts(parts, state).await { @@ -56,7 +56,7 @@ impl FromRequestParts for RequireSession where S: Send + Sync, { - type Rejection = AppError<'static>; + type Rejection = ApiError<'static>; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { match Extension::::from_request_parts(parts, state).await { diff --git a/src/auth/session.rs b/src/auth/session.rs index 44818a4..181bdfb 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -6,11 +6,11 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Pool, Postgres}; use uuid::Uuid; -use crate::error::AppError; +use crate::http::error::ApiError; use super::{hash::random, user::User}; -pub const USER_NOT_FOUND: AppError = AppError::ClientError { +pub const USER_NOT_FOUND: ApiError<'static> = ApiError::ClientError { status: StatusCode::UNAUTHORIZED, code: "user-not-found", message: "The logged-in user was not found", @@ -137,7 +137,7 @@ impl Session { )) } - pub async fn user(self: &Self, pool: &Pool) -> Result> { + pub async fn user(self: &Self, pool: &Pool) -> Result> { match User::get_id(pool, self.actor).await? { Some(user) => Ok(user), None => Err(USER_NOT_FOUND), diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 44e619e..0000000 --- a/src/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; - -pub enum AppError<'a> { - ServerError(anyhow::Error), - ClientError { - status: StatusCode, - code: &'a str, - message: &'a str, - }, -} - -impl IntoResponse for AppError<'_> { - fn into_response(self) -> Response { - match self { - AppError::ServerError(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"code":"server-error", "message": err.to_string()})), - ) - .into_response(), - AppError::ClientError { - status, - code, - message, - } => (status, Json(json!({"code":code, "message": message}))).into_response(), - } - } -} - -impl From for AppError<'_> -where - E: Into, -{ - fn from(err: E) -> Self { - AppError::ServerError(err.into()) - } -} diff --git a/src/http/error.rs b/src/http/error.rs new file mode 100644 index 0000000..2c03a6c --- /dev/null +++ b/src/http/error.rs @@ -0,0 +1,79 @@ +use std::fmt::Display; + +use axum::{ + extract::rejection::JsonRejection, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +// Generic errors +pub const ERR_NOT_FOUND: ApiError<'static> = ApiError::ClientError { + status: StatusCode::NOT_FOUND, + code: "not-found", + message: "resource not found", +}; + +#[derive(Error, Debug)] +pub enum ApiError<'a> { + ClientError { + status: StatusCode, + code: &'a str, + message: &'a str, + }, + InternalError(#[from] anyhow::Error), + DatabaseError(#[from] sqlx::Error), + JSONFormatError(#[from] JsonRejection), +} + +impl Display for ApiError<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::ClientError { + status, + code, + message, + } => write!(f, "Client error: {} {}: {}", status, code, message), + ApiError::InternalError(err) => write!(f, "Unexpected error: {}", err), + ApiError::DatabaseError(err) => write!(f, "Database error: {}", err), + ApiError::JSONFormatError(err) => write!(f, "JSON format error: {}", err), + } + } +} + +impl IntoResponse for ApiError<'_> { + fn into_response(self) -> Response { + match self { + ApiError::InternalError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"code":"server-error", "message": err.to_string()})), + ) + .into_response(), + ApiError::DatabaseError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"code":"server-error", "message": err.to_string()})), + ) + .into_response(), + ApiError::ClientError { + status, + code, + message, + } => (status, Json(json!({"code":code, "message": message}))).into_response(), + ApiError::JSONFormatError(rejection) => { + let status = match rejection { + JsonRejection::JsonDataError(_) => StatusCode::UNPROCESSABLE_ENTITY, + JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST, + JsonRejection::MissingJsonContentType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + ( + status, + Json(json!({"code":"invalid-body", "message": rejection.body_text()})), + ) + .into_response() + } + } + } +} diff --git a/src/http/extract.rs b/src/http/extract.rs new file mode 100644 index 0000000..3021d05 --- /dev/null +++ b/src/http/extract.rs @@ -0,0 +1,7 @@ +use axum_macros::FromRequest; + +use super::error::ApiError; + +#[derive(FromRequest)] +#[from_request(via(axum::Json), rejection(ApiError<'static>))] +pub struct JsonBody(pub T); diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..b8b47f5 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod extract; diff --git a/src/main.rs b/src/main.rs index b9035b4..09ee95c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ mod auth; mod content; -mod error; +mod http; mod roles; mod routes; mod state; diff --git a/src/routes/admin.rs b/src/routes/admin.rs index e0d6739..3defd12 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use crate::{ auth::{hash::random, user::User}, - error::AppError, + http::error::ApiError, roles::ROLE_SUPERADMIN, state::AppState, }; @@ -21,10 +21,10 @@ async fn bootstrap(State(state): State>) -> impl IntoResponse { .map(|row| row.empty.unwrap_or(true)) .fetch_one(&state.database) .await - .map_err(AppError::from)?; + .map_err(anyhow::Error::from)?; if !empty { - return Err(AppError::ClientError { + return Err(ApiError::ClientError { status: StatusCode::BAD_REQUEST, code: "already-setup".into(), message: "The instance was already bootstrapped".into(), @@ -41,7 +41,7 @@ async fn bootstrap(State(state): State>) -> impl IntoResponse { &[ROLE_SUPERADMIN].to_vec(), ) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; Ok(Json(json!({"username": username, "password": password}))) } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index c723331..1488a6a 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -17,7 +17,7 @@ use crate::{ session::Session, user::User, }, - error::AppError, + http::{error::ApiError, extract::JsonBody}, state::AppState, }; @@ -29,14 +29,14 @@ struct LoginRequest { async fn login( State(state): State>, - Json(payload): Json, + JsonBody(payload): JsonBody, ) -> impl IntoResponse { let user = User::find(&state.database, payload.username.as_str()) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; - let invalid = || -> AppError { - AppError::ClientError { + let invalid = || -> ApiError { + ApiError::ClientError { status: StatusCode::UNAUTHORIZED, code: "invalid-login".into(), message: "No matching user was found".into(), @@ -46,7 +46,7 @@ async fn login( let user = user.ok_or_else(invalid)?; let plaintext = user.password.ok_or_else(invalid)?; - if !verify(&payload.password, &plaintext).map_err(AppError::from)? { + if !verify(&payload.password, &plaintext).map_err(ApiError::from)? { return Err(invalid()); } @@ -56,7 +56,7 @@ async fn login( Duration::seconds(state.config.session_duration.into()), ) .await - .map_err(AppError::from)?; + .map_err(ApiError::from)?; let token = session.token(); let mut response: Response = @@ -66,27 +66,29 @@ async fn login( SET_COOKIE, session .cookie(state.config.domain().as_str(), state.config.secure()) - .parse()?, + .parse() + .map_err(anyhow::Error::from)?, ); Ok(response) } -async fn me(RequireUser(user): RequireUser) -> Result> { +async fn me(RequireUser(user): RequireUser) -> Result> { Ok(user.name) } async fn logout( State(state): State>, RequireSession(session): RequireSession, -) -> Result> { +) -> Result> { session.destroy(&state.database).await?; let mut response: Response = Json(json!({ "ok": true })).into_response(); response.headers_mut().insert( SET_COOKIE, Session::cookie_for_delete(state.config.domain().as_str(), state.config.secure()) - .parse()?, + .parse() + .map_err(anyhow::Error::from)?, ); Ok(response) } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index f153899..530fe24 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -6,18 +6,28 @@ use axum::{ }; use std::sync::Arc; -use crate::{content::Page, error::AppError, state::AppState}; +use crate::{ + content::Page, + http::error::{ApiError, ERR_NOT_FOUND}, + state::AppState, +}; async fn get_page( State(state): State>, Path((site, slug)): Path<(String, String)>, -) -> Result> { +) -> Result> { let page_query = sqlx::query_as( "SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2", ) .bind(slug) .bind(site); - let page: Page = page_query.fetch_one(&state.database).await?; + let page: Page = page_query + .fetch_one(&state.database) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => ERR_NOT_FOUND, + _ => e.into(), + })?; Ok(Json(page)) } diff --git a/src/routes/sites.rs b/src/routes/sites.rs index caae226..61f1d92 100644 --- a/src/routes/sites.rs +++ b/src/routes/sites.rs @@ -8,29 +8,50 @@ use serde::Deserialize; use serde_json::json; use std::sync::Arc; -use crate::{content::Site, error::AppError, state::AppState}; +use crate::{ + auth::http::RequireUser, + content::Site, + http::{ + error::{ApiError, ERR_NOT_FOUND}, + extract::JsonBody, + }, + state::AppState, +}; async fn get_site( State(state): State>, Path(name): Path, -) -> Result> { +) -> Result> { let site_query = sqlx::query_as("SELECT * FROM sites WHERE name = $1").bind(name); - let site: Site = site_query.fetch_one(&state.database).await?; + let site: Site = site_query + .fetch_one(&state.database) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => ERR_NOT_FOUND, + _ => e.into(), + })?; Ok(Json(site)) } #[derive(Deserialize)] struct CreateSiteRequest { name: String, + title: String, } async fn create_site( State(state): State>, - Json(payload): Json, -) -> Result> { - sqlx::query!("INSERT INTO sites (name) VALUES ($1)", payload.name) - .execute(&state.database) - .await?; + RequireUser(user): RequireUser, + JsonBody(payload): JsonBody, +) -> Result> { + sqlx::query!( + "INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)", + payload.name, + user.id, + payload.title, + ) + .execute(&state.database) + .await?; Ok(Json(json!({"ok": true, "url": "test"}))) }