backend: config, resources, clean up api methods, api documentation, clean up db methods
This commit is contained in:
parent
1470c9d6ad
commit
97c9528112
968
Cargo.lock
generated
968
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "elnafo"
|
name = "elnafo-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["L-Nafaryus <l.nafaryus@elnafo.ru"]
|
authors = ["L-Nafaryus <l.nafaryus@elnafo.ru>"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7.4", features = [] }
|
axum = { version = "0.7.4", features = ["http2", "macros", "multipart"] }
|
||||||
tokio = { version = "1.36.0", default-features = false, features = [
|
tokio = { version = "1.36.0", default-features = false, features = [
|
||||||
"macros",
|
"macros",
|
||||||
"fs",
|
"fs",
|
||||||
@ -31,10 +31,23 @@ rand_core = { version = "0.6.4", features = ["std"] }
|
|||||||
chrono = { version = "0.4.35", features = ["serde"] }
|
chrono = { version = "0.4.35", features = ["serde"] }
|
||||||
jsonwebtoken = "9.2.0"
|
jsonwebtoken = "9.2.0"
|
||||||
axum-extra = { version = "0.9.2", features = ["cookie"] }
|
axum-extra = { version = "0.9.2", features = ["cookie"] }
|
||||||
tower-http = { version = "0.5.2", features = ["trace", "cors"] }
|
tower-http = { version = "0.5.2", features = [
|
||||||
frontend = { version = "0.1.0", path = "crates/frontend" }
|
"trace",
|
||||||
|
"cors",
|
||||||
|
"compression-gzip",
|
||||||
|
"decompression-gzip",
|
||||||
|
] }
|
||||||
|
elnafo-frontend = { version = "0.1.0", path = "crates/elnafo-frontend" }
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
sqids = "0.4.1"
|
||||||
|
image = "0.25.1"
|
||||||
|
toml = "0.8.12"
|
||||||
|
glob = "0.3.1"
|
||||||
|
deadpool-sync = "0.1.2"
|
||||||
|
utoipa = { version = "4.2.0", features = ["axum_extras"] }
|
||||||
|
utoipa-rapidoc = { version = "3.0.0", features = ["axum"] }
|
||||||
|
deadpool = "0.11.1"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/frontend"]
|
members = ["crates/elnafo-frontend"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
58
src/api/doc.rs
Normal file
58
src/api/doc.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use utoipa::{
|
||||||
|
openapi::{
|
||||||
|
security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme},
|
||||||
|
Components,
|
||||||
|
},
|
||||||
|
Modify, OpenApi,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::errors;
|
||||||
|
use super::user;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
super::healthcheck,
|
||||||
|
user::all,
|
||||||
|
user::register,
|
||||||
|
user::remove,
|
||||||
|
user::login,
|
||||||
|
user::logout,
|
||||||
|
user::profile,
|
||||||
|
user::current,
|
||||||
|
user::avatar
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
crate::db::errors::DatabaseError,
|
||||||
|
user::UserError,
|
||||||
|
user::schema::NewUser,
|
||||||
|
user::schema::User,
|
||||||
|
user::schema::RemoveUser,
|
||||||
|
user::schema::LoginUser,
|
||||||
|
user::schema::Avatar,
|
||||||
|
user::schema::Image,
|
||||||
|
errors::ApiError
|
||||||
|
)),
|
||||||
|
modifiers(&SecurityAddon)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
|
|
||||||
|
pub struct SecurityAddon;
|
||||||
|
|
||||||
|
impl Modify for SecurityAddon {
|
||||||
|
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||||
|
if openapi.components.is_none() {
|
||||||
|
openapi.components = Some(Components::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
openapi.components.as_mut().unwrap().add_security_scheme(
|
||||||
|
"token",
|
||||||
|
SecurityScheme::Http(
|
||||||
|
HttpBuilder::new()
|
||||||
|
.scheme(HttpAuthScheme::Bearer)
|
||||||
|
.bearer_format("JWT")
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
97
src/api/errors.rs
Normal file
97
src/api/errors.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::db::errors::DatabaseError;
|
||||||
|
|
||||||
|
use super::user::UserError;
|
||||||
|
|
||||||
|
#[derive(Debug, utoipa::ToSchema)]
|
||||||
|
pub enum ApiError {
|
||||||
|
Database(DatabaseError),
|
||||||
|
AuthError(AuthError),
|
||||||
|
ReadContent,
|
||||||
|
Query(UserError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ApiError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Database(ref e) => e.fmt(f),
|
||||||
|
Self::AuthError(e) => write!(f, "Authentication error occured: {}", e),
|
||||||
|
Self::ReadContent => write!(f, "Failed to read body content"),
|
||||||
|
Self::Query(ref e) => e.fmt(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DatabaseError> for ApiError {
|
||||||
|
fn from(e: DatabaseError) -> Self {
|
||||||
|
Self::Database(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = match self {
|
||||||
|
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::AuthError(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
Self::ReadContent => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Self::Query(ref e) => match e {
|
||||||
|
UserError::Exists => StatusCode::CONFLICT,
|
||||||
|
UserError::HashPassword | UserError::ParseUuid => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
UserError::MissedCredentials
|
||||||
|
| UserError::InvalidCredentials
|
||||||
|
| UserError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
UserError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, format!("{}", self)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AuthError {
|
||||||
|
MissingCredentials,
|
||||||
|
InvalidCredentials,
|
||||||
|
MissingToken,
|
||||||
|
InvalidToken,
|
||||||
|
MissingUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AuthError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AuthError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingCredentials => write!(f, "Missing credentials"),
|
||||||
|
Self::InvalidCredentials => write!(f, "Invalid credentials"),
|
||||||
|
Self::MissingToken => write!(f, "Missing token"),
|
||||||
|
Self::InvalidToken => write!(f, "Invalid token"),
|
||||||
|
Self::MissingUser => write!(f, "Missing user"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthError> for ApiError {
|
||||||
|
fn from(e: AuthError) -> Self {
|
||||||
|
Self::AuthError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AuthError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let status = match self {
|
||||||
|
Self::MissingCredentials | Self::MissingToken => StatusCode::BAD_REQUEST,
|
||||||
|
Self::InvalidCredentials | Self::InvalidToken | Self::MissingUser => {
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, format!("{}", self)).into_response()
|
||||||
|
}
|
||||||
|
}
|
@ -10,17 +10,22 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
use crate::{db::user::User, state::AppState};
|
use crate::{
|
||||||
|
db::{self, schema::users, user::User},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
use super::errors::AuthError;
|
use super::errors::AuthError;
|
||||||
use super::token::TokenClaims;
|
use super::{errors::ApiError, token::TokenClaims};
|
||||||
|
|
||||||
pub async fn jwt(
|
pub async fn jwt(
|
||||||
cookie_jar: CookieJar,
|
cookie_jar: CookieJar,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
mut req: Request<Body>,
|
mut req: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<impl IntoResponse, AuthError<impl std::error::Error>> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
let token = cookie_jar
|
let token = cookie_jar
|
||||||
.get("token")
|
.get("token")
|
||||||
.map(|cookie| cookie.value().to_string())
|
.map(|cookie| cookie.value().to_string())
|
||||||
@ -32,17 +37,22 @@ pub async fn jwt(
|
|||||||
.map(|auth_token| auth_token.to_owned())
|
.map(|auth_token| auth_token.to_owned())
|
||||||
});
|
});
|
||||||
|
|
||||||
let token = token.ok_or_else(|| AuthError::MissingToken)?;
|
let token = token.ok_or(AuthError::MissingToken)?;
|
||||||
let claims = TokenClaims::validate(token, state.config.jwt.secret.to_owned())
|
let claims = TokenClaims::validate(token, state.config.jwt.secret.to_owned())
|
||||||
.map_err(|_| AuthError::InvalidToken)?;
|
.map_err(|_| AuthError::InvalidToken)?;
|
||||||
|
|
||||||
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidToken)?;
|
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidToken)?;
|
||||||
|
|
||||||
let user = User::find(&state.database, User::by_id(user_id))
|
let user = db::execute(&state.database, move |conn| {
|
||||||
.await
|
users::table
|
||||||
.map_err(AuthError::InternalError)?;
|
.into_boxed()
|
||||||
|
.filter(users::id.eq(user_id))
|
||||||
|
.first::<User>(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
let user = user.ok_or_else(|| AuthError::MissingUser)?;
|
let user = user.ok_or(AuthError::MissingUser)?;
|
||||||
|
|
||||||
req.extensions_mut().insert(user);
|
req.extensions_mut().insert(user);
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
@ -1 +1,76 @@
|
|||||||
pub mod v1;
|
pub mod doc;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod token;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
http::{header::*, Method, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
|
.allow_headers(vec![ORIGIN, AUTHORIZATION, ACCEPT, CONTENT_TYPE, COOKIE])
|
||||||
|
.allow_origin([
|
||||||
|
"http://localhost:54600".parse().unwrap(),
|
||||||
|
"http://localhost:5173".parse().unwrap(),
|
||||||
|
])
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
|
let jwt = axum::middleware::from_fn_with_state(state.to_owned(), middleware::jwt_auth);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/healthcheck", get(healthcheck))
|
||||||
|
.route("/user/all", get(user::all))
|
||||||
|
.route("/user/register", post(user::register))
|
||||||
|
.route("/user/remove", post(user::remove))
|
||||||
|
.route("/user/login", post(user::login))
|
||||||
|
.route("/user/logout", get(user::logout))
|
||||||
|
.route(
|
||||||
|
"/user/current",
|
||||||
|
get(user::current).route_layer(jwt.to_owned()),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/user/:login",
|
||||||
|
get(user::profile).route_layer(jwt.to_owned()),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/user/avatar",
|
||||||
|
post(user::avatar)
|
||||||
|
.route_layer(jwt)
|
||||||
|
.layer(DefaultBodyLimit::max(10 * 10000)),
|
||||||
|
)
|
||||||
|
.layer(cors)
|
||||||
|
.fallback(fallback)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/healthcheck", responses((status = 200, body = String)))]
|
||||||
|
pub async fn healthcheck() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"status": StatusCode::OK.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fallback() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({
|
||||||
|
"status": StatusCode::NOT_FOUND.to_string(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
442
src/api/user.rs
Normal file
442
src/api/user.rs
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||||
|
use argon2::{PasswordHash, PasswordVerifier};
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{Multipart, Path};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Extension;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::{
|
||||||
|
db,
|
||||||
|
db::schema::users,
|
||||||
|
db::user::{NewUser, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::errors::ApiError;
|
||||||
|
use super::token::TokenClaims;
|
||||||
|
|
||||||
|
#[derive(Debug, utoipa::ToSchema)]
|
||||||
|
pub enum UserError {
|
||||||
|
Exists,
|
||||||
|
HashPassword,
|
||||||
|
ParseUuid,
|
||||||
|
MissedCredentials,
|
||||||
|
InvalidCredentials,
|
||||||
|
NotFound,
|
||||||
|
Unauthorized,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for UserError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for UserError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Exists => write!(f, "User already exists"),
|
||||||
|
Self::HashPassword => write!(f, "Failed to create a password hash"),
|
||||||
|
Self::ParseUuid => write!(f, "Failed to parse user UUID"),
|
||||||
|
Self::MissedCredentials => write!(f, "Missed user credentials"),
|
||||||
|
Self::InvalidCredentials => write!(f, "Invalid user credentials"),
|
||||||
|
Self::NotFound => write!(f, "User not found"),
|
||||||
|
Self::Unauthorized => write!(f, "User is not authorized"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod schema {
|
||||||
|
use crate::db::user;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct NewUser {
|
||||||
|
pub login: String,
|
||||||
|
pub password: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub login: String,
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub avatar: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct RemoveUser {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct LoginUser {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub login: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct Avatar {
|
||||||
|
pub content: String,
|
||||||
|
pub mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn from(user: &user::User) -> Self {
|
||||||
|
User {
|
||||||
|
id: user.id.to_string(),
|
||||||
|
login: user.login.to_string(),
|
||||||
|
name: user.name.to_owned(),
|
||||||
|
email: user.email.to_owned(),
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
avatar: user.avatar.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(utoipa::ToSchema)]
|
||||||
|
pub struct Image {
|
||||||
|
#[schema(value_type = String, format = Binary)]
|
||||||
|
pub file_content: Vec<u8>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/user/all",
|
||||||
|
responses((status = 200, body = [User]))
|
||||||
|
)]
|
||||||
|
pub async fn all(State(state): State<Arc<AppState>>) -> Result<Json<Vec<schema::User>>, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let users = db::execute(&state.database, move |conn| {
|
||||||
|
users::table.select(User::as_select()).get_results(conn)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|ref user| schema::User::from(user))
|
||||||
|
.collect::<Vec<schema::User>>();
|
||||||
|
|
||||||
|
Ok(Json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/user/register",
|
||||||
|
request_body = NewUser,
|
||||||
|
responses((status = 200, body = User), (status = 500, body = ApiError))
|
||||||
|
)]
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<schema::NewUser>,
|
||||||
|
) -> Result<Json<schema::User>, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let count = db::execute(&state.database, move |conn| {
|
||||||
|
users::table.into_boxed().count().get_result::<i64>(conn)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (login, email) = (body.login.clone(), body.email.clone());
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
users::table
|
||||||
|
.into_boxed()
|
||||||
|
.filter(users::login.eq(login).or(users::email.eq(email)))
|
||||||
|
.first::<User>(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if user.is_some() {
|
||||||
|
return Err(ApiError::Query(UserError::Exists));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashed_password = Argon2::default()
|
||||||
|
.hash_password(body.password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||||
|
.map_err(|_| ApiError::Query(UserError::HashPassword))
|
||||||
|
.map(|hash| hash.to_string())?;
|
||||||
|
|
||||||
|
let new_user = NewUser {
|
||||||
|
login: body.login.clone(),
|
||||||
|
hashed_password,
|
||||||
|
name: body.login,
|
||||||
|
email: body.email,
|
||||||
|
is_admin: count == 0,
|
||||||
|
avatar: String::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
diesel::insert_into(users::table)
|
||||||
|
.values(new_user)
|
||||||
|
.returning(User::as_returning())
|
||||||
|
.get_result(conn)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(schema::User::from(&user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/user/remove",
|
||||||
|
request_body = RemoveUser,
|
||||||
|
responses((status = 200))
|
||||||
|
)]
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<schema::RemoveUser>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let uuid =
|
||||||
|
uuid::Uuid::parse_str(&body.id).map_err(|_| ApiError::Query(UserError::ParseUuid))?;
|
||||||
|
|
||||||
|
db::execute(&state.database, move |conn| {
|
||||||
|
diesel::delete(users::table.filter(users::id.eq(uuid))).execute(conn)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/api/user/login",
|
||||||
|
request_body = LoginUser,
|
||||||
|
responses((status = 200, body = User), (status = "4XX", body = UserError), (status = 500, body = ApiError))
|
||||||
|
)]
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<schema::LoginUser>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let query = users::table.into_boxed().select(User::as_select());
|
||||||
|
let query = if let Some(login) = body.login {
|
||||||
|
query.filter(users::login.eq(login))
|
||||||
|
} else if let Some(email) = body.email {
|
||||||
|
query.filter(users::email.eq(email))
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::Query(UserError::MissedCredentials));
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
query.first::<User>(conn).optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Err(ApiError::Query(UserError::InvalidCredentials)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !match PasswordHash::new(&user.hashed_password) {
|
||||||
|
Ok(parsed_hash) => Argon2::default()
|
||||||
|
.verify_password(body.password.as_bytes(), &parsed_hash)
|
||||||
|
.map_or(false, |_| true),
|
||||||
|
Err(_) => false,
|
||||||
|
} {
|
||||||
|
return Err(ApiError::Query(UserError::InvalidCredentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = TokenClaims::create(
|
||||||
|
user.id.to_string(),
|
||||||
|
state.config.jwt.secret.to_owned(),
|
||||||
|
state.config.jwt.maxage,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cookie = Cookie::build(("token", token.to_owned()))
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::hours(1))
|
||||||
|
.same_site(SameSite::None)
|
||||||
|
.secure(true)
|
||||||
|
.http_only(true);
|
||||||
|
|
||||||
|
let mut response = Json(schema::User::from(&user)).into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/user/logout", responses((status = 200)))]
|
||||||
|
pub async fn logout() -> Result<axum::response::Response, ApiError> {
|
||||||
|
let cookie = Cookie::build(("token", ""))
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::hours(-1))
|
||||||
|
.same_site(SameSite::None)
|
||||||
|
.secure(true)
|
||||||
|
.http_only(true);
|
||||||
|
|
||||||
|
let response = Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
cookie.to_string().parse::<HeaderValue>().unwrap(),
|
||||||
|
)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/user/{login}",
|
||||||
|
params(("login", Path,)),
|
||||||
|
responses((status = 404, body = UserError), (status = 500, body = ApiError))
|
||||||
|
)]
|
||||||
|
pub async fn profile(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(user_id): Extension<Option<uuid::Uuid>>,
|
||||||
|
Path(login): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
// TODO: Current user priveleges
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
users::table
|
||||||
|
.into_boxed()
|
||||||
|
.filter(users::login.eq(login))
|
||||||
|
.first::<User>(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(Json(schema::User::from(&user))),
|
||||||
|
None => Err(ApiError::Query(UserError::NotFound)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/user/current",
|
||||||
|
security(("token" = []))
|
||||||
|
)]
|
||||||
|
pub async fn current(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(user_id): Extension<Option<uuid::Uuid>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let uuid = match user_id {
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
None => return Err(ApiError::Query(UserError::Unauthorized)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
users::table
|
||||||
|
.into_boxed()
|
||||||
|
.filter(users::id.eq(uuid))
|
||||||
|
.first::<User>(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(Json(schema::User::from(&user))),
|
||||||
|
None => Err(ApiError::Query(UserError::NotFound)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
#[utoipa::path(post, path = "/api/user/avatar",
|
||||||
|
security(("token" = [])),
|
||||||
|
request_body(content = Image, content_type = "multipart/form-data"),
|
||||||
|
responses((status = 200), (status = "4XX", body = UserError), (status = 500, body = ApiError))
|
||||||
|
)]
|
||||||
|
pub async fn avatar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(user_id): Extension<Option<uuid::Uuid>>,
|
||||||
|
//Json(body): Json<Avatar>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
let uuid = match user_id {
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
None => return Err(ApiError::Query(UserError::Unauthorized)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = db::execute(&state.database, move |conn| {
|
||||||
|
users::table
|
||||||
|
.into_boxed()
|
||||||
|
.filter(users::id.eq(uuid))
|
||||||
|
.first::<User>(conn)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(user) => user,
|
||||||
|
None => return Err(ApiError::Query(UserError::NotFound)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data: Bytes = if let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::ReadContent)?
|
||||||
|
{
|
||||||
|
/*if field.name().unwrap() != "file" {
|
||||||
|
continue;
|
||||||
|
}*/
|
||||||
|
field.bytes().await.map_err(|_| ApiError::ReadContent)?
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::ReadContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
let avatars = db::execute(&state.database, move |conn| {
|
||||||
|
users::table
|
||||||
|
.into_boxed()
|
||||||
|
.select(users::avatar)
|
||||||
|
.get_results::<String>(conn)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|avatar_hash| !avatar_hash.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
let avatar_id = sqids::Sqids::builder()
|
||||||
|
.min_length(10)
|
||||||
|
.blocklist(HashSet::from_iter(avatars.clone().into_iter()))
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.encode(&[avatars.len() as u64])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reader = image::io::Reader::new(std::io::Cursor::new(data))
|
||||||
|
.with_guessed_format()
|
||||||
|
.unwrap();
|
||||||
|
let format = reader.format().unwrap();
|
||||||
|
let img = reader.decode().unwrap();
|
||||||
|
|
||||||
|
img.save_with_format(
|
||||||
|
Config::data_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("avatars")
|
||||||
|
.join(avatar_id.clone()),
|
||||||
|
format,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if !user.avatar.is_empty() {
|
||||||
|
std::fs::remove_file(
|
||||||
|
Config::data_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("avatars")
|
||||||
|
.join(user.avatar.clone()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
db::execute(&state.database, move |conn| {
|
||||||
|
diesel::update(&user)
|
||||||
|
.set(users::avatar.eq(avatar_id))
|
||||||
|
.execute(conn)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -1,53 +0,0 @@
|
|||||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AuthError<E> {
|
|
||||||
InternalError(E),
|
|
||||||
InternalE,
|
|
||||||
MissingCredentials,
|
|
||||||
InvalidCredentials,
|
|
||||||
MissingToken,
|
|
||||||
InvalidToken,
|
|
||||||
MissingUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E: std::error::Error> IntoResponse for AuthError<E> {
|
|
||||||
fn into_response(self) -> axum::response::Response {
|
|
||||||
let (status, message) = match self {
|
|
||||||
Self::InternalError(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Internal Error: {}", e.to_string())),
|
|
||||||
Self::InternalE => (StatusCode::INTERNAL_SERVER_ERROR, "Internal E".to_string()),
|
|
||||||
Self::MissingCredentials => {
|
|
||||||
(StatusCode::BAD_REQUEST, "Missing credentials".to_string())
|
|
||||||
}
|
|
||||||
Self::InvalidCredentials => {
|
|
||||||
(StatusCode::UNAUTHORIZED, "Invalid credentials".to_string())
|
|
||||||
}
|
|
||||||
Self::MissingToken => (StatusCode::BAD_REQUEST, "Missing token".to_string()),
|
|
||||||
Self::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()),
|
|
||||||
Self::MissingUser => (StatusCode::UNAUTHORIZED, "User not exists".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
Json(json!({
|
|
||||||
"status": status.to_string(),
|
|
||||||
"message": message
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn internal_error<E>(err: E) -> (StatusCode, Json<serde_json::Value>)
|
|
||||||
where
|
|
||||||
E: std::error::Error,
|
|
||||||
{
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({
|
|
||||||
"status": StatusCode::INTERNAL_SERVER_ERROR.to_string(),
|
|
||||||
"message": err.to_string()
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
pub mod errors;
|
|
||||||
pub mod middleware;
|
|
||||||
pub mod token;
|
|
||||||
pub mod user;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
http::{header::*, Method, StatusCode},
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::{get, post},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use tower_http::cors::CorsLayer;
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
pub fn routes(state: Arc<AppState>) -> Router {
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
|
||||||
.allow_headers(vec![ORIGIN, AUTHORIZATION, ACCEPT, CONTENT_TYPE, COOKIE])
|
|
||||||
.allow_origin([
|
|
||||||
"http://localhost:54600".parse().unwrap(),
|
|
||||||
"http://localhost:5173".parse().unwrap(),
|
|
||||||
])
|
|
||||||
.allow_credentials(true);
|
|
||||||
|
|
||||||
let jwt = axum::middleware::from_fn_with_state(state.to_owned(), middleware::jwt_auth);
|
|
||||||
|
|
||||||
Router::new()
|
|
||||||
.route("/v1/healthcheck", get(healthcheck))
|
|
||||||
.route("/v1/user/register", post(user::register))
|
|
||||||
.route("/v1/user/remove", post(user::remove))
|
|
||||||
.route("/v1/user/login", post(user::login))
|
|
||||||
.route("/v1/user/logout", get(user::logout))
|
|
||||||
.route(
|
|
||||||
"/v1/user/current",
|
|
||||||
get(user::current).route_layer(jwt.to_owned()),
|
|
||||||
)
|
|
||||||
.route("/v1/user/:login", get(user::profile).route_layer(jwt))
|
|
||||||
.layer(cors)
|
|
||||||
.fallback(fallback)
|
|
||||||
.with_state(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn healthcheck() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(json!({
|
|
||||||
"status": StatusCode::OK.to_string(),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fallback() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({
|
|
||||||
"status": StatusCode::NOT_FOUND.to_string(),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,210 +0,0 @@
|
|||||||
use argon2::Argon2;
|
|
||||||
use argon2::{PasswordHash, PasswordVerifier};
|
|
||||||
use axum::extract::Path;
|
|
||||||
use axum::Extension;
|
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
http::{header, StatusCode},
|
|
||||||
response::IntoResponse,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::db::user::User;
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
use super::errors::AuthError;
|
|
||||||
use super::token::TokenClaims;
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct RegisterUser {
|
|
||||||
pub login: String,
|
|
||||||
pub password: String,
|
|
||||||
pub email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct FilteredUser {
|
|
||||||
pub id: String,
|
|
||||||
pub login: String,
|
|
||||||
pub name: String,
|
|
||||||
pub email: String,
|
|
||||||
pub is_admin: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct RemoveUser {
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FilteredUser {
|
|
||||||
pub fn from(user: &User) -> Self {
|
|
||||||
FilteredUser {
|
|
||||||
id: user.id.to_string(),
|
|
||||||
login: user.login.to_string(),
|
|
||||||
name: user.name.to_owned(),
|
|
||||||
email: user.email.to_owned(),
|
|
||||||
is_admin: user.is_admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct LoginUser {
|
|
||||||
pub email: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<RegisterUser>,
|
|
||||||
) -> Result<impl IntoResponse, AuthError<impl std::error::Error>> {
|
|
||||||
let user = User::register(
|
|
||||||
&state.database,
|
|
||||||
body.login.to_owned(),
|
|
||||||
body.password,
|
|
||||||
body.login, //body.name,
|
|
||||||
body.email,
|
|
||||||
false, //body.is_admin,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AuthError::InternalError)?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"status": StatusCode::OK.to_string(),
|
|
||||||
"user": FilteredUser::from(&user)
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<RemoveUser>,
|
|
||||||
) -> Result<impl IntoResponse, AuthError<impl std::error::Error>> {
|
|
||||||
let user = User::find(
|
|
||||||
&state.database,
|
|
||||||
User::by_id(uuid::Uuid::parse_str(&body.id).map_err(|_| AuthError::InvalidCredentials)?),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(AuthError::InternalError)?;
|
|
||||||
|
|
||||||
let user = match user {
|
|
||||||
Some(user) => user,
|
|
||||||
None => return Err(AuthError::MissingUser),
|
|
||||||
};
|
|
||||||
|
|
||||||
User::remove(&state.database, user)
|
|
||||||
.await
|
|
||||||
.map_err(|_| AuthError::InternalE)?;
|
|
||||||
|
|
||||||
Ok(Json(json!({"status": StatusCode::OK.to_string()})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(body): Json<LoginUser>,
|
|
||||||
) -> Result<impl IntoResponse, AuthError<impl std::error::Error>> {
|
|
||||||
let user = User::find(&state.database, User::by_email(body.email))
|
|
||||||
.await
|
|
||||||
.map_err(AuthError::InternalError)?;
|
|
||||||
|
|
||||||
let user = match user {
|
|
||||||
Some(user) => user,
|
|
||||||
None => return Err(AuthError::InvalidCredentials),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !match PasswordHash::new(&user.hashed_password) {
|
|
||||||
Ok(parsed_hash) => Argon2::default()
|
|
||||||
.verify_password(body.password.as_bytes(), &parsed_hash)
|
|
||||||
.map_or(false, |_| true),
|
|
||||||
Err(_) => false,
|
|
||||||
} {
|
|
||||||
return Err(AuthError::InvalidCredentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = TokenClaims::create(
|
|
||||||
user.id.to_string(),
|
|
||||||
state.config.jwt.secret.to_owned(),
|
|
||||||
state.config.jwt.maxage,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let cookie = Cookie::build(("token", token.to_owned()))
|
|
||||||
.path("/")
|
|
||||||
.max_age(time::Duration::hours(1))
|
|
||||||
.same_site(SameSite::None)
|
|
||||||
.secure(true)
|
|
||||||
.http_only(true);
|
|
||||||
|
|
||||||
let mut response =
|
|
||||||
Json(json!({"status": StatusCode::OK.to_string(), "token": token, "user": json!(FilteredUser::from(&user))})).into_response();
|
|
||||||
response
|
|
||||||
.headers_mut()
|
|
||||||
.insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let cookie = Cookie::build(("token", ""))
|
|
||||||
.path("/")
|
|
||||||
.max_age(time::Duration::hours(-1))
|
|
||||||
.same_site(SameSite::None)
|
|
||||||
.secure(true)
|
|
||||||
.http_only(true);
|
|
||||||
|
|
||||||
let mut response = Json(json!({"status": StatusCode::OK.to_string()})).into_response();
|
|
||||||
response
|
|
||||||
.headers_mut()
|
|
||||||
.insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn profile(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Extension(user_id): Extension<Option<uuid::Uuid>>,
|
|
||||||
Path(login): Path<String>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let user = User::find(&state.database, User::by_login(login))
|
|
||||||
.await
|
|
||||||
.map_err(|_| ())
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let response = if let Some(user) = user {
|
|
||||||
json!({"status": StatusCode::OK.to_string(), "user": json!(FilteredUser::from(&user))})
|
|
||||||
} else {
|
|
||||||
json!({"status": StatusCode::NOT_FOUND.to_string()})
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn current(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Extension(user_id): Extension<Option<uuid::Uuid>>,
|
|
||||||
) -> Result<impl IntoResponse, AuthError<impl std::error::Error>> {
|
|
||||||
let user = get_user(state, user_id).await?;
|
|
||||||
|
|
||||||
Ok(Json(
|
|
||||||
json!({"status": StatusCode::OK.to_string(), "user": json!(FilteredUser::from(&user))}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user(
|
|
||||||
state: Arc<AppState>,
|
|
||||||
user_id: Option<uuid::Uuid>,
|
|
||||||
) -> Result<User, AuthError<impl std::error::Error>> {
|
|
||||||
let user = if let Some(user_id) = user_id {
|
|
||||||
User::find(&state.database, User::by_id(user_id))
|
|
||||||
.await
|
|
||||||
.map_err(AuthError::InternalError)
|
|
||||||
} else {
|
|
||||||
Err(AuthError::InvalidCredentials)
|
|
||||||
};
|
|
||||||
|
|
||||||
let user = user?.ok_or_else(|| AuthError::MissingUser)?;
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
151
src/config.rs
151
src/config.rs
@ -1,14 +1,15 @@
|
|||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use std::env;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{env, fs};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub database: Database,
|
pub database: Database,
|
||||||
pub server: Server,
|
pub server: Server,
|
||||||
pub jwt: Jwt,
|
pub jwt: Jwt,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: i32,
|
pub port: i32,
|
||||||
@ -17,55 +18,141 @@ pub struct Database {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: i32,
|
pub port: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Jwt {
|
pub struct Jwt {
|
||||||
pub secret: String,
|
pub secret: String,
|
||||||
pub expires_in: String,
|
pub expires_in: String,
|
||||||
pub maxage: i64,
|
pub maxage: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn evar(key: &str) -> Result<String, env::VarError> {
|
||||||
|
env::var(format!("ELNAFO_{}", key))
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_env(&mut self) -> Result<&Self, ConfigError> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
Config {
|
self.database.host = evar("DATABASE_HOST").unwrap_or(self.database.host.to_owned());
|
||||||
database: Database {
|
self.database.port = evar("DATABASE_PORT")
|
||||||
host: env::var("DATABASE_HOST").unwrap_or("localhost".to_string()),
|
.unwrap_or(self.database.port.to_string())
|
||||||
port: env::var("DATABASE_PORT")
|
.parse()?;
|
||||||
.unwrap_or("5432".to_string())
|
self.database.user = evar("DATABASE_USER").unwrap_or(self.database.user.to_owned());
|
||||||
.parse()
|
self.database.password =
|
||||||
.unwrap(),
|
evar("DATABASE_PASSWORD").unwrap_or(self.database.password.to_owned());
|
||||||
user: env::var("DATABASE_USER").unwrap_or("elnafo".to_string()),
|
self.database.name = evar("DATABASE_NAME").unwrap_or(self.database.name.to_owned());
|
||||||
password: env::var("DATABASE_PASSWORD").unwrap_or("test".to_string()),
|
|
||||||
name: env::var("DATABASE_NAME").unwrap_or("elnafo".to_string()),
|
Ok(self)
|
||||||
},
|
}
|
||||||
server: Server {
|
|
||||||
address: env::var("SERVER_ADDRESS").unwrap_or("127.0.0.1".to_string()),
|
pub fn open(path: &std::path::Path) -> Result<Config, ConfigError> {
|
||||||
port: env::var("SERVER_PORT")
|
fs::read_to_string(path)?.parse()
|
||||||
.unwrap_or("54600".to_string())
|
}
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
pub fn data_dir() -> Result<std::path::PathBuf, ConfigError> {
|
||||||
},
|
let cwd = std::env::current_dir()?;
|
||||||
jwt: Jwt {
|
if cfg!(debug_assertions) {
|
||||||
secret: env::var("JWT_SECRET").unwrap_or("change_this_secret".to_string()),
|
Ok(cwd.join("temp"))
|
||||||
expires_in: env::var("JWT_EXPIRES_IN").unwrap_or("60m".to_string()),
|
} else {
|
||||||
maxage: env::var("JWT_MAXAGE")
|
Ok(cwd)
|
||||||
.unwrap_or("3600".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_string(&self) -> Result<String, ConfigError> {
|
||||||
|
Ok(toml::to_string(self)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&self, path: &std::path::Path) -> Result<(), ConfigError> {
|
||||||
|
Ok(fs::write(path, self.to_string()?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn database_url(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"postgres://{}:{}@{}:{}/{}",
|
||||||
|
self.database.user,
|
||||||
|
self.database.password,
|
||||||
|
self.database.host,
|
||||||
|
self.database.port,
|
||||||
|
self.database.name
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Config::new()
|
Config {
|
||||||
|
database: Database {
|
||||||
|
host: String::from("localhost"),
|
||||||
|
port: 5432,
|
||||||
|
user: String::from("elnafo"),
|
||||||
|
password: String::from("test"),
|
||||||
|
name: String::from("elnafo"),
|
||||||
|
},
|
||||||
|
server: Server {
|
||||||
|
address: String::from("127.0.0.1"),
|
||||||
|
port: 54600,
|
||||||
|
},
|
||||||
|
jwt: Jwt {
|
||||||
|
secret: String::from("change_this_secret"),
|
||||||
|
expires_in: String::from("60m"),
|
||||||
|
maxage: 3600,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Config {
|
||||||
|
type Err = ConfigError;
|
||||||
|
fn from_str(s: &str) -> Result<Self, ConfigError> {
|
||||||
|
toml::from_str(s).map_err(|_| ConfigError::Parse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
Parse,
|
||||||
|
StringParse,
|
||||||
|
Serialize,
|
||||||
|
IO,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Parse => write!(f, "Failed to parse Config from string"),
|
||||||
|
Self::StringParse => write!(f, "Failed to parse environment variable"),
|
||||||
|
Self::Serialize => write!(f, "Failed to serialize Config to TOML"),
|
||||||
|
Self::IO => write!(f, "Faild to write file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<toml::ser::Error> for ConfigError {
|
||||||
|
fn from(_: toml::ser::Error) -> Self {
|
||||||
|
ConfigError::Serialize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for ConfigError {
|
||||||
|
fn from(_: std::io::Error) -> Self {
|
||||||
|
ConfigError::IO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::num::ParseIntError> for ConfigError {
|
||||||
|
fn from(_: std::num::ParseIntError) -> Self {
|
||||||
|
ConfigError::StringParse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,63 +1,48 @@
|
|||||||
use deadpool_diesel::postgres::PoolError;
|
use deadpool_diesel::postgres::PoolError;
|
||||||
|
use deadpool_sync::InteractError;
|
||||||
|
use diesel::result::Error as DieselError;
|
||||||
|
use std::error::Error as StdError;
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, utoipa::ToSchema)]
|
||||||
pub enum DatabaseError<E> {
|
pub enum DatabaseError {
|
||||||
Connection(E),
|
Connection,
|
||||||
Interaction,
|
Interaction(InteractError),
|
||||||
Operation(E),
|
Operation(DieselError),
|
||||||
|
Query(DieselError),
|
||||||
Migration,
|
Migration,
|
||||||
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: std::fmt::Display> std::fmt::Display for DatabaseError<E> {
|
impl StdError for DatabaseError {}
|
||||||
|
|
||||||
|
impl Display for DatabaseError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Connection(e) => write!(f, "Failed to connect to database: {}", e),
|
Self::Connection => write!(f, "Failed pool connection"),
|
||||||
Self::Interaction => write!(f, "Failed to interact with database"),
|
Self::Interaction(ref e) => e.fmt(f),
|
||||||
Self::Operation(e) => write!(f, "Failed operation: {}", e),
|
Self::Operation(ref e) => e.fmt(f),
|
||||||
|
Self::Query(ref e) => e.fmt(f),
|
||||||
Self::Migration => write!(f, "Failed to run migrations"),
|
Self::Migration => write!(f, "Failed to run migrations"),
|
||||||
|
Self::Internal => write!(f, "Internal error ..."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: std::error::Error + 'static> std::error::Error for DatabaseError<E> {
|
impl From<PoolError> for DatabaseError {
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn from(_: PoolError) -> Self {
|
||||||
match self {
|
Self::Connection
|
||||||
Self::Interaction | Self::Migration => None,
|
|
||||||
Self::Connection(e) => Some(e),
|
|
||||||
Self::Operation(e) => Some(e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PoolError> for DatabaseError<PoolError> {
|
impl From<InteractError> for DatabaseError {
|
||||||
fn from(e: PoolError) -> Self {
|
fn from(e: InteractError) -> Self {
|
||||||
Self::Connection(e)
|
Self::Interaction(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
impl From<DieselError> for DatabaseError {
|
||||||
pub enum UserError {
|
fn from(e: DieselError) -> Self {
|
||||||
Query,
|
Self::Query(e)
|
||||||
Exists,
|
|
||||||
NotFound,
|
|
||||||
HashPassword,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for UserError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Query => write!(f, "User query failed"),
|
|
||||||
UserError::Exists => write!(f, "User already exists"),
|
|
||||||
UserError::NotFound => write!(f, "User not found"),
|
|
||||||
UserError::HashPassword => write!(f, "Failed to hash user password"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for UserError {}
|
|
||||||
|
|
||||||
impl From<UserError> for DatabaseError<UserError> {
|
|
||||||
fn from(e: UserError) -> Self {
|
|
||||||
Self::Operation(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,6 @@ CREATE TABLE "users"(
|
|||||||
"hashed_password" TEXT NOT NULL,
|
"hashed_password" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"email" TEXT NOT NULL,
|
"email" TEXT NOT NULL,
|
||||||
"is_admin" BOOL NOT NULL
|
"is_admin" BOOL NOT NULL,
|
||||||
|
"avatar" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
@ -4,6 +4,7 @@ pub mod user;
|
|||||||
|
|
||||||
use deadpool_diesel::postgres::Manager;
|
use deadpool_diesel::postgres::Manager;
|
||||||
pub use deadpool_diesel::postgres::Pool;
|
pub use deadpool_diesel::postgres::Pool;
|
||||||
|
use diesel::prelude::*;
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
|
||||||
use errors::DatabaseError;
|
use errors::DatabaseError;
|
||||||
@ -16,12 +17,26 @@ pub fn create_pool(database_url: String) -> Pool {
|
|||||||
Pool::builder(manager).build().unwrap()
|
Pool::builder(manager).build().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_migrations(pool: &Pool) -> Result<(), DatabaseError<impl std::error::Error>> {
|
pub async fn execute<F, T>(pool: &Pool, f: F) -> Result<T, DatabaseError>
|
||||||
let connection = pool.get().await.map_err(DatabaseError::Connection)?;
|
where
|
||||||
|
F: FnOnce(&mut PgConnection) -> Result<T, diesel::result::Error> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
let connection = pool.get().await.map_err(|_| DatabaseError::Connection)?;
|
||||||
|
|
||||||
connection
|
connection
|
||||||
.interact(move |connection| connection.run_pending_migrations(MIGRATIONS).map(|_| ()))
|
.interact(move |connection| f(connection))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DatabaseError::Interaction)?
|
.map_err(DatabaseError::Interaction)?
|
||||||
.map_err(|_| DatabaseError::Migration)?;
|
.map_err(DatabaseError::Query)
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
pub async fn run_migrations(pool: &Pool) -> Result<(), DatabaseError> {
|
||||||
|
execute(pool, move |connection| {
|
||||||
|
Ok(connection
|
||||||
|
.run_pending_migrations(MIGRATIONS)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| DatabaseError::Migration))
|
||||||
|
})
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
|
@ -8,5 +8,6 @@ diesel::table! {
|
|||||||
name -> Text,
|
name -> Text,
|
||||||
email -> Text,
|
email -> Text,
|
||||||
is_admin -> Bool,
|
is_admin -> Bool,
|
||||||
|
avatar -> Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
119
src/db/user.rs
119
src/db/user.rs
@ -1,13 +1,11 @@
|
|||||||
use crate::db::{errors::*, schema::users, Pool};
|
use crate::db::schema::users;
|
||||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
|
||||||
use diesel::{
|
use diesel::{
|
||||||
dsl::{AsSelect, SqlTypeOf},
|
dsl::{AsSelect, SqlTypeOf},
|
||||||
pg::Pg,
|
pg::Pg,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
use rand_core::OsRng;
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, Queryable, Selectable, Clone)]
|
#[derive(serde::Serialize, Queryable, Selectable, Clone, Identifiable, AsChangeset)]
|
||||||
#[diesel(table_name = users)]
|
#[diesel(table_name = users)]
|
||||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
@ -17,6 +15,7 @@ pub struct User {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub avatar: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Insertable)]
|
#[derive(serde::Deserialize, Insertable)]
|
||||||
@ -27,115 +26,11 @@ pub struct NewUser {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub avatar: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
type SqlType = SqlTypeOf<AsSelect<User, Pg>>;
|
type SqlType = SqlTypeOf<AsSelect<User, Pg>>;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
type BoxedQuery<'a> = users::BoxedQuery<'a, Pg, SqlType>;
|
type BoxedQuery<'a> = users::BoxedQuery<'a, Pg, SqlType>;
|
||||||
|
|
||||||
impl User {
|
|
||||||
pub async fn register(
|
|
||||||
pool: &Pool,
|
|
||||||
login: String,
|
|
||||||
password: String,
|
|
||||||
name: String,
|
|
||||||
email: String,
|
|
||||||
is_admin: bool,
|
|
||||||
) -> Result<User, DatabaseError<impl std::error::Error>> {
|
|
||||||
let user = User::find(pool, User::by_email(email.to_owned()))
|
|
||||||
.await
|
|
||||||
.map_err(|_| UserError::Query)?;
|
|
||||||
|
|
||||||
if user.is_some() {
|
|
||||||
return Err(DatabaseError::Operation(UserError::Exists));
|
|
||||||
}
|
|
||||||
|
|
||||||
let hashed_password = Argon2::default()
|
|
||||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
|
||||||
.map_err(|_| UserError::HashPassword)
|
|
||||||
.map(|hash| hash.to_string())?;
|
|
||||||
|
|
||||||
let new_user = NewUser {
|
|
||||||
login,
|
|
||||||
hashed_password,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
is_admin,
|
|
||||||
};
|
|
||||||
|
|
||||||
let user = User::create(pool, new_user)
|
|
||||||
.await
|
|
||||||
.map_err(|_| UserError::Query)?;
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create(
|
|
||||||
pool: &Pool,
|
|
||||||
new_user: NewUser,
|
|
||||||
) -> Result<User, DatabaseError<impl std::error::Error>> {
|
|
||||||
let connection = pool.get().await.map_err(DatabaseError::Connection)?;
|
|
||||||
let user = connection
|
|
||||||
.interact(move |connection| {
|
|
||||||
diesel::insert_into(users::table)
|
|
||||||
.values(new_user)
|
|
||||||
.returning(User::as_returning())
|
|
||||||
.get_result(connection)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?;
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn by_email(email: String) -> BoxedQuery<'static> {
|
|
||||||
users::table
|
|
||||||
.into_boxed()
|
|
||||||
.select(User::as_select())
|
|
||||||
.filter(users::email.eq(email))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn by_id(id: uuid::Uuid) -> BoxedQuery<'static> {
|
|
||||||
users::table
|
|
||||||
.into_boxed()
|
|
||||||
.select(User::as_select())
|
|
||||||
.filter(users::id.eq(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn by_login(login: String) -> BoxedQuery<'static> {
|
|
||||||
users::table
|
|
||||||
.into_boxed()
|
|
||||||
.select(User::as_select())
|
|
||||||
.filter(users::login.eq(login))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find(
|
|
||||||
pool: &Pool,
|
|
||||||
query: BoxedQuery<'static>,
|
|
||||||
) -> Result<Option<User>, DatabaseError<impl std::error::Error>> {
|
|
||||||
let connection = pool.get().await.map_err(DatabaseError::Connection)?;
|
|
||||||
let user = connection
|
|
||||||
.interact(move |connection| query.first(connection).optional())
|
|
||||||
.await
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?;
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove(
|
|
||||||
pool: &Pool,
|
|
||||||
user: User,
|
|
||||||
) -> Result<(), DatabaseError<impl std::error::Error>> {
|
|
||||||
let connection = pool.get().await.map_err(DatabaseError::Connection)?;
|
|
||||||
connection
|
|
||||||
.interact(move |connection| {
|
|
||||||
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(connection)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?
|
|
||||||
.map_err(|_| DatabaseError::Interaction)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
use axum::{http::StatusCode, Json};
|
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
||||||
|
|
||||||
pub fn init_tracing() {
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "elnafo=debug".into()),
|
|
||||||
)
|
|
||||||
.with(tracing_subscriber::fmt::layer())
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn internal_error<E>(err: E) -> (StatusCode, Json<serde_json::Value>)
|
|
||||||
where
|
|
||||||
E: std::error::Error,
|
|
||||||
{
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(serde_json::json!({
|
|
||||||
"status": "fail",
|
|
||||||
"message": err.to_string()
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
104
src/main.rs
104
src/main.rs
@ -1,112 +1,68 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error_handle;
|
pub mod resources;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use axum::{
|
use axum::{http::Uri, response::IntoResponse, routing::get, Router};
|
||||||
extract::Path,
|
|
||||||
http::{header::CONTENT_TYPE, StatusCode, Uri},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
routing::get,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::trace::{self, TraceLayer};
|
use tower_http::trace::{self, TraceLayer};
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
use utoipa_rapidoc::RapiDoc;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error_handle::*;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
//init_tracing();
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_target(false)
|
.with_target(false)
|
||||||
.compact()
|
.compact()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let config = Config::new();
|
let config = match Config::open(Config::data_dir()?.join("config.toml").as_path()) {
|
||||||
let database_url = format!(
|
Ok(config) => config,
|
||||||
"postgres://{}:{}@{}:{}/{}",
|
Err(_) => Config::new(),
|
||||||
config.database.user,
|
};
|
||||||
config.database.password,
|
|
||||||
config.database.host,
|
|
||||||
config.database.port,
|
|
||||||
config.database.name
|
|
||||||
);
|
|
||||||
|
|
||||||
let pool = db::create_pool(database_url);
|
let pool = db::create_pool(config.database_url());
|
||||||
|
|
||||||
db::run_migrations(&pool).await;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
database: pool.clone(),
|
database: pool.clone(),
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let address: SocketAddr = format!("{}:{}", config.server.address, config.server.port)
|
|
||||||
.parse()
|
|
||||||
.unwrap(); //SocketAddr::from((Ipv4Addr::UNSPECIFIED, 54600));
|
|
||||||
|
|
||||||
let lister = tokio::net::TcpListener::bind(&address).await.unwrap();
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home))
|
.nest("/resources", resources::routes(state.clone()))
|
||||||
.route("/user/login", get(user_login))
|
.nest("/api", api::routes(state))
|
||||||
.route("/assets/*file", get(static_handler))
|
.merge(
|
||||||
.nest("/api", api::v1::routes(state))
|
RapiDoc::with_openapi("/api/openapi.json", api::doc::ApiDoc::openapi())
|
||||||
|
.path("/api/rapidoc"),
|
||||||
|
)
|
||||||
|
.route("/", get(frontend_handler))
|
||||||
|
.route("/*frontend", get(frontend_handler))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||||
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("listening on http://{}", address);
|
let address: SocketAddr =
|
||||||
|
format!("{}:{}", config.server.address, config.server.port).parse()?;
|
||||||
|
|
||||||
axum::serve(lister, app.into_make_service())
|
let lister = tokio::net::TcpListener::bind(&address).await?;
|
||||||
.await
|
|
||||||
.map_err(internal_error)
|
println!("listening on {}", address);
|
||||||
.unwrap();
|
|
||||||
|
axum::serve(lister, app.into_make_service()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn home() -> impl IntoResponse {
|
async fn frontend_handler(_: Uri) -> impl IntoResponse {
|
||||||
frontend::BaseTemplate { view: "app" }
|
elnafo_frontend::BaseTemplate { view: "app" }
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_login() -> impl IntoResponse {
|
|
||||||
frontend::BaseTemplate { view: "app" }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user(Path(user): Path<String>) -> impl IntoResponse {}
|
|
||||||
|
|
||||||
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|
||||||
let mut path = uri.path().trim_start_matches('/').to_string();
|
|
||||||
|
|
||||||
if path.starts_with("assets/") {
|
|
||||||
path = path.replace("assets/", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
StaticFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct StaticFile<T>(pub T);
|
|
||||||
|
|
||||||
impl<T> IntoResponse for StaticFile<T>
|
|
||||||
where
|
|
||||||
T: Into<String>,
|
|
||||||
{
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let path = self.0.into();
|
|
||||||
|
|
||||||
match frontend::Assets::get(path.as_str()) {
|
|
||||||
Some(content) => {
|
|
||||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
|
||||||
([(CONTENT_TYPE, mime.as_ref())], content.data).into_response()
|
|
||||||
}
|
|
||||||
None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
104
src/resources/mod.rs
Normal file
104
src/resources/mod.rs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::{
|
||||||
|
header::{self, ACCEPT_ENCODING, CONTENT_TYPE, ORIGIN},
|
||||||
|
Method, StatusCode, Uri,
|
||||||
|
},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
compression::{CompressionLayer, DefaultPredicate},
|
||||||
|
cors::CorsLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{config::Config, state::AppState};
|
||||||
|
|
||||||
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET])
|
||||||
|
.allow_headers(vec![ORIGIN, CONTENT_TYPE, ACCEPT_ENCODING])
|
||||||
|
.allow_origin([
|
||||||
|
"http://localhost:54600".parse().unwrap(),
|
||||||
|
"http://localhost:5173".parse().unwrap(),
|
||||||
|
])
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
|
let compression = CompressionLayer::new().gzip(true);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/assets/*file", get(assets))
|
||||||
|
.route("/avatars/*avatar_id", get(avatars).layer(compression))
|
||||||
|
.layer(cors)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assets(uri: Uri) -> Result<impl IntoResponse, ResourceError> {
|
||||||
|
let path = uri.path().trim_start_matches("/assets/").to_string();
|
||||||
|
|
||||||
|
match elnafo_frontend::Assets::get(&path) {
|
||||||
|
Some(content) => {
|
||||||
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||||
|
Ok(([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response())
|
||||||
|
}
|
||||||
|
None => Err(ResourceError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn avatars(uri: Uri) -> Result<impl IntoResponse, ResourceError> {
|
||||||
|
let avatar_id = uri.path().trim_start_matches("/avatars/").to_string();
|
||||||
|
let path = Config::data_dir().unwrap().join("avatars").join(avatar_id);
|
||||||
|
|
||||||
|
let reader = image::io::Reader::open(path.clone())
|
||||||
|
.map_err(|_| ResourceError::NotFound)?
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|_| ResourceError::BadFormat)?;
|
||||||
|
let format = reader.format();
|
||||||
|
|
||||||
|
let mime = format.map_or("application/octet-stream", |f| f.to_mime_type());
|
||||||
|
let content = reader.decode().map_err(|_| ResourceError::BadContent)?;
|
||||||
|
|
||||||
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
|
let _ = match format {
|
||||||
|
Some(format) => content
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut bytes), format)
|
||||||
|
.map_err(|_| ResourceError::BadContent),
|
||||||
|
None => return Err(ResourceError::BadFormat),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(([(header::CONTENT_TYPE, mime)], bytes).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ResourceError {
|
||||||
|
NotFound,
|
||||||
|
NotExists,
|
||||||
|
BadFormat,
|
||||||
|
BadContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ResourceError {}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ResourceError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotFound => write!(f, "Resource was not found"),
|
||||||
|
Self::NotExists => write!(f, "Resource was not found"),
|
||||||
|
Self::BadFormat => write!(f, "Cannot determine file format"),
|
||||||
|
Self::BadContent => write!(f, "Failed to read a file content"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ResourceError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = match self {
|
||||||
|
Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
Self::NotExists => StatusCode::NO_CONTENT,
|
||||||
|
Self::BadFormat | Self::BadContent => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, format!("{}", self)).into_response()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user