big error refactor, also almost working post /sites
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Hamcha 2023-07-06 12:34:45 +02:00
parent cce6272373
commit dd45fd26f3
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
13 changed files with 157 additions and 75 deletions

1
Cargo.lock generated
View file

@ -769,6 +769,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View file

@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0" anyhow = "1.0"
argon2 = { version = "0.5", features = ["std", "alloc"] } argon2 = { version = "0.5", features = ["std", "alloc"] }
url = "2.4" url = "2.4"
thiserror = "1.0"
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

View file

@ -16,11 +16,11 @@ use cookie::Cookie;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::{error::AppError, state::AppState}; use crate::{http::error::ApiError, state::AppState};
use super::{session::Session, user::User}; use super::{session::Session, user::User};
pub const INVALID_SESSION: AppError = AppError::ClientError { pub const INVALID_SESSION: ApiError = ApiError::ClientError {
status: StatusCode::UNAUTHORIZED, status: StatusCode::UNAUTHORIZED,
code: "authentication-required", code: "authentication-required",
message: "Please log-in and submit a valid session as a cookie", message: "Please log-in and submit a valid session as a cookie",
@ -39,7 +39,7 @@ impl<S> FromRequestParts<S> for RequireUser
where where
S: Send + Sync, S: Send + Sync,
{ {
type Rejection = AppError<'static>; type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match Extension::<User>::from_request_parts(parts, state).await { match Extension::<User>::from_request_parts(parts, state).await {
@ -56,7 +56,7 @@ impl<S> FromRequestParts<S> for RequireSession
where where
S: Send + Sync, S: Send + Sync,
{ {
type Rejection = AppError<'static>; type Rejection = ApiError<'static>;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match Extension::<Session>::from_request_parts(parts, state).await { match Extension::<Session>::from_request_parts(parts, state).await {

View file

@ -6,11 +6,11 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Pool, Postgres}; use sqlx::{FromRow, Pool, Postgres};
use uuid::Uuid; use uuid::Uuid;
use crate::error::AppError; use crate::http::error::ApiError;
use super::{hash::random, user::User}; 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, status: StatusCode::UNAUTHORIZED,
code: "user-not-found", code: "user-not-found",
message: "The logged-in user was not found", message: "The logged-in user was not found",
@ -137,7 +137,7 @@ impl Session {
)) ))
} }
pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, AppError<'static>> { pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, ApiError<'static>> {
match User::get_id(pool, self.actor).await? { match User::get_id(pool, self.actor).await? {
Some(user) => Ok(user), Some(user) => Ok(user),
None => Err(USER_NOT_FOUND), None => Err(USER_NOT_FOUND),

View file

@ -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<E> From<E> for AppError<'_>
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError::ServerError(err.into())
}
}

79
src/http/error.rs Normal file
View file

@ -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()
}
}
}
}

7
src/http/extract.rs Normal file
View file

@ -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<T>(pub T);

2
src/http/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod error;
pub mod extract;

View file

@ -1,6 +1,6 @@
mod auth; mod auth;
mod content; mod content;
mod error; mod http;
mod roles; mod roles;
mod routes; mod routes;
mod state; mod state;

View file

@ -8,7 +8,7 @@ use std::sync::Arc;
use crate::{ use crate::{
auth::{hash::random, user::User}, auth::{hash::random, user::User},
error::AppError, http::error::ApiError,
roles::ROLE_SUPERADMIN, roles::ROLE_SUPERADMIN,
state::AppState, state::AppState,
}; };
@ -21,10 +21,10 @@ async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
.map(|row| row.empty.unwrap_or(true)) .map(|row| row.empty.unwrap_or(true))
.fetch_one(&state.database) .fetch_one(&state.database)
.await .await
.map_err(AppError::from)?; .map_err(anyhow::Error::from)?;
if !empty { if !empty {
return Err(AppError::ClientError { return Err(ApiError::ClientError {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
code: "already-setup".into(), code: "already-setup".into(),
message: "The instance was already bootstrapped".into(), message: "The instance was already bootstrapped".into(),
@ -41,7 +41,7 @@ async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
&[ROLE_SUPERADMIN].to_vec(), &[ROLE_SUPERADMIN].to_vec(),
) )
.await .await
.map_err(AppError::from)?; .map_err(ApiError::from)?;
Ok(Json(json!({"username": username, "password": password}))) Ok(Json(json!({"username": username, "password": password})))
} }

View file

@ -17,7 +17,7 @@ use crate::{
session::Session, session::Session,
user::User, user::User,
}, },
error::AppError, http::{error::ApiError, extract::JsonBody},
state::AppState, state::AppState,
}; };
@ -29,14 +29,14 @@ struct LoginRequest {
async fn login( async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>, JsonBody(payload): JsonBody<LoginRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let user = User::find(&state.database, payload.username.as_str()) let user = User::find(&state.database, payload.username.as_str())
.await .await
.map_err(AppError::from)?; .map_err(ApiError::from)?;
let invalid = || -> AppError { let invalid = || -> ApiError {
AppError::ClientError { ApiError::ClientError {
status: StatusCode::UNAUTHORIZED, status: StatusCode::UNAUTHORIZED,
code: "invalid-login".into(), code: "invalid-login".into(),
message: "No matching user was found".into(), message: "No matching user was found".into(),
@ -46,7 +46,7 @@ async fn login(
let user = user.ok_or_else(invalid)?; let user = user.ok_or_else(invalid)?;
let plaintext = user.password.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()); return Err(invalid());
} }
@ -56,7 +56,7 @@ async fn login(
Duration::seconds(state.config.session_duration.into()), Duration::seconds(state.config.session_duration.into()),
) )
.await .await
.map_err(AppError::from)?; .map_err(ApiError::from)?;
let token = session.token(); let token = session.token();
let mut response: Response = let mut response: Response =
@ -66,27 +66,29 @@ async fn login(
SET_COOKIE, SET_COOKIE,
session session
.cookie(state.config.domain().as_str(), state.config.secure()) .cookie(state.config.domain().as_str(), state.config.secure())
.parse()?, .parse()
.map_err(anyhow::Error::from)?,
); );
Ok(response) Ok(response)
} }
async fn me(RequireUser(user): RequireUser) -> Result<String, AppError<'static>> { async fn me(RequireUser(user): RequireUser) -> Result<String, ApiError<'static>> {
Ok(user.name) Ok(user.name)
} }
async fn logout( async fn logout(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireSession(session): RequireSession, RequireSession(session): RequireSession,
) -> Result<impl IntoResponse, AppError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
session.destroy(&state.database).await?; session.destroy(&state.database).await?;
let mut response: Response = Json(json!({ "ok": true })).into_response(); let mut response: Response = Json(json!({ "ok": true })).into_response();
response.headers_mut().insert( response.headers_mut().insert(
SET_COOKIE, SET_COOKIE,
Session::cookie_for_delete(state.config.domain().as_str(), state.config.secure()) Session::cookie_for_delete(state.config.domain().as_str(), state.config.secure())
.parse()?, .parse()
.map_err(anyhow::Error::from)?,
); );
Ok(response) Ok(response)
} }

View file

@ -6,18 +6,28 @@ use axum::{
}; };
use std::sync::Arc; 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( async fn get_page(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
) -> Result<impl IntoResponse, AppError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
let page_query = sqlx::query_as( 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", "SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2",
) )
.bind(slug) .bind(slug)
.bind(site); .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)) Ok(Json(page))
} }

View file

@ -8,27 +8,48 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::Arc; 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( async fn get_site(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(name): Path<String>, Path(name): Path<String>,
) -> Result<impl IntoResponse, AppError<'static>> { ) -> Result<impl IntoResponse, ApiError<'static>> {
let site_query = sqlx::query_as("SELECT * FROM sites WHERE name = $1").bind(name); 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)) Ok(Json(site))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateSiteRequest { struct CreateSiteRequest {
name: String, name: String,
title: String,
} }
async fn create_site( async fn create_site(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<CreateSiteRequest>, RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, AppError<'static>> { JsonBody(payload): JsonBody<CreateSiteRequest>,
sqlx::query!("INSERT INTO sites (name) VALUES ($1)", payload.name) ) -> Result<impl IntoResponse, ApiError<'static>> {
sqlx::query!(
"INSERT INTO sites (name, owner, title) VALUES ($1, $2, $3)",
payload.name,
user.id,
payload.title,
)
.execute(&state.database) .execute(&state.database)
.await?; .await?;
Ok(Json(json!({"ok": true, "url": "test"}))) Ok(Json(json!({"ok": true, "url": "test"})))