big error refactor, also almost working post /sites
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
cce6272373
commit
dd45fd26f3
13 changed files with 157 additions and 75 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -769,6 +769,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
@ -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
|
|
@ -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 {
|
||||||
|
|
|
@ -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),
|
||||||
|
|
41
src/error.rs
41
src/error.rs
|
@ -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
79
src/http/error.rs
Normal 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
7
src/http/extract.rs
Normal 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
2
src/http/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod error;
|
||||||
|
pub mod extract;
|
|
@ -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;
|
||||||
|
|
|
@ -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})))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"})))
|
||||||
|
|
Loading…
Reference in a new issue