mirror of
https://github.com/Jackzmc/storage.git
synced 2025-05-06 17:23:20 +00:00
login with bcrypt
This commit is contained in:
parent
dc21e50a8a
commit
b2889eac1d
9 changed files with 201 additions and 37 deletions
43
server/Cargo.lock
generated
43
server/Cargo.lock
generated
|
@ -143,6 +143,19 @@ version = "1.7.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92758ad6077e4c76a6cadbce5005f666df70d4f13b19976b1a8062eef880040f"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"blowfish",
|
||||||
|
"getrandom 0.3.2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "binascii"
|
name = "binascii"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
@ -173,6 +186,16 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blowfish"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.17.0"
|
version = "3.17.0"
|
||||||
|
@ -227,6 +250,16 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
@ -1055,6 +1088,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "int-enum"
|
name = "int-enum"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
@ -2318,6 +2360,7 @@ name = "storage-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"humanize-bytes",
|
"humanize-bytes",
|
||||||
|
|
|
@ -19,4 +19,5 @@ rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] }
|
||||||
humanize-bytes = "1.0.6"
|
humanize-bytes = "1.0.6"
|
||||||
rocket-session-store = "0.2.1"
|
rocket-session-store = "0.2.1"
|
||||||
uuid = { version = "1.16.0", features = ["v4"] }
|
uuid = { version = "1.16.0", features = ["v4"] }
|
||||||
rand = { version = "0.9.0", features = ["thread_rng"] }
|
rand = { version = "0.9.0", features = ["thread_rng"] }
|
||||||
|
bcrypt = "0.17.0"
|
12
server/src/consts.rs
Normal file
12
server/src/consts.rs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
use rocket::data::ByteUnit;
|
||||||
|
|
||||||
|
/// The maximum amount of bytes that can be uploaded at once
|
||||||
|
pub const MAX_UPLOAD_SIZE: ByteUnit = ByteUnit::Mebibyte(100_000);
|
||||||
|
|
||||||
|
/// The number of encryption rounds
|
||||||
|
pub const ENCRYPTION_ROUNDS: u32 = 20;
|
||||||
|
|
||||||
|
pub const SESSION_LIFETIME_SECONDS: u64 = 3600 * 24 * 14; // 14 days
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE_NAME: &'static str = "storage-session";
|
|
@ -22,6 +22,7 @@ use crate::managers::repos::RepoManager;
|
||||||
use crate::objs::library::Library;
|
use crate::objs::library::Library;
|
||||||
use crate::util::{setup_logger, JsonErrorResponse, ResponseError};
|
use crate::util::{setup_logger, JsonErrorResponse, ResponseError};
|
||||||
use routes::api;
|
use routes::api;
|
||||||
|
use crate::consts::{SESSION_COOKIE_NAME, SESSION_LIFETIME_SECONDS};
|
||||||
use crate::models::user::UserModel;
|
use crate::models::user::UserModel;
|
||||||
use crate::routes::ui;
|
use crate::routes::ui;
|
||||||
|
|
||||||
|
@ -34,10 +35,10 @@ mod models;
|
||||||
mod managers;
|
mod managers;
|
||||||
mod objs;
|
mod objs;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
mod consts;
|
||||||
|
|
||||||
pub type DB = Pool<Postgres>;
|
pub type DB = Pool<Postgres>;
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE: ByteUnit = ByteUnit::Mebibyte(100_000);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Default)]
|
#[derive(Clone, Debug, Serialize, Default)]
|
||||||
struct SessionData {
|
struct SessionData {
|
||||||
|
@ -89,8 +90,8 @@ async fn rocket() -> _ {
|
||||||
let memory_store: MemoryStore::<SessionData> = MemoryStore::default();
|
let memory_store: MemoryStore::<SessionData> = MemoryStore::default();
|
||||||
let store: SessionStore<SessionData> = SessionStore {
|
let store: SessionStore<SessionData> = SessionStore {
|
||||||
store: Box::new(memory_store),
|
store: Box::new(memory_store),
|
||||||
name: "storage-session".into(),
|
name: SESSION_COOKIE_NAME.into(),
|
||||||
duration: Duration::from_secs(3600 * 24 * 14),
|
duration: Duration::from_secs(SESSION_LIFETIME_SECONDS),
|
||||||
// The cookie builder is used to set the cookie's path and other options.
|
// The cookie builder is used to set the cookie's path and other options.
|
||||||
// Name and value don't matter, they'll be overridden on each request.
|
// Name and value don't matter, they'll be overridden on each request.
|
||||||
cookie_builder: CookieBuilder::new("", "")
|
cookie_builder: CookieBuilder::new("", "")
|
||||||
|
|
|
@ -1,22 +1,120 @@
|
||||||
|
use bcrypt::BcryptError;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::Request;
|
||||||
|
use rocket::response::Responder;
|
||||||
use rocket::serde::Serialize;
|
use rocket::serde::Serialize;
|
||||||
use rocket::serde::uuid::Uuid;
|
use rocket::serde::uuid::Uuid;
|
||||||
use sqlx::query_as;
|
use sqlx::{query_as, FromRow};
|
||||||
|
use crate::consts::ENCRYPTION_ROUNDS;
|
||||||
use crate::DB;
|
use crate::DB;
|
||||||
use crate::models::repo::RepoModel;
|
use crate::models::repo::RepoModel;
|
||||||
|
use crate::util::JsonErrorResponse;
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug, FromRow)]
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
// email
|
pub username: String,
|
||||||
// password
|
pub email: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub name: String
|
||||||
|
}
|
||||||
|
#[derive(Serialize, Clone, Debug, FromRow)]
|
||||||
|
pub struct UserModelWithPassword {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub name: String
|
pub name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum UserAuthError {
|
||||||
|
DatabaseError(sqlx::Error),
|
||||||
|
UserNotFound,
|
||||||
|
UserAlreadyExists,
|
||||||
|
PasswordInvalid,
|
||||||
|
EncryptionError(BcryptError),
|
||||||
|
}
|
||||||
|
impl UserAuthError {
|
||||||
|
fn get_err_code(&self) -> String {
|
||||||
|
match self {
|
||||||
|
UserAuthError::DatabaseError(_) => "DATABASE_ERROR",
|
||||||
|
UserAuthError::UserNotFound => "USER_NOT_FOUND",
|
||||||
|
UserAuthError::UserAlreadyExists => "USER_EXISTS",
|
||||||
|
UserAuthError::PasswordInvalid => "PASSWORD_INVALID",
|
||||||
|
UserAuthError::EncryptionError(_) => "ENCRYPTION_ERROR",
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
|
fn get_err_msg(&self) -> String {
|
||||||
|
match self {
|
||||||
|
UserAuthError::DatabaseError(e) => format!("Error from database: {}", e.to_string()),
|
||||||
|
UserAuthError::UserNotFound => "No user found with provided username or email".to_string(),
|
||||||
|
UserAuthError::UserAlreadyExists => "User already exists".to_string(),
|
||||||
|
UserAuthError::PasswordInvalid => "Password is invalid or incorrect".to_string(),
|
||||||
|
UserAuthError::EncryptionError(_) => "Error occurred during password encryption".to_string()
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_response_code(&self) -> Status {
|
||||||
|
match self {
|
||||||
|
UserAuthError::DatabaseError(_) => Status::InternalServerError,
|
||||||
|
UserAuthError::UserNotFound => Status::NotFound,
|
||||||
|
UserAuthError::UserAlreadyExists => Status::Conflict,
|
||||||
|
UserAuthError::PasswordInvalid => Status::Unauthorized,
|
||||||
|
UserAuthError::EncryptionError(_) => Status::InternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn into_response_err(self) -> JsonErrorResponse {
|
||||||
|
JsonErrorResponse {
|
||||||
|
code: self.get_err_code(),
|
||||||
|
message: self.get_err_msg(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn get_user(pool: &DB, user_id: &str) -> Result<Option<UserModel>, anyhow::Error> {
|
pub async fn get_user(pool: &DB, user_id: &str) -> Result<Option<UserModel>, anyhow::Error> {
|
||||||
let user_id = Uuid::parse_str(user_id)?;
|
let user_id = Uuid::parse_str(user_id)?;
|
||||||
query_as!(UserModel, "select id, created_at, name from storage.users where id = $1", user_id)
|
query_as!(UserModel, "select id, username, created_at, email, name from storage.users where id = $1", user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await.map_err(anyhow::Error::from)
|
.await.map_err(anyhow::Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result<UserModel, UserAuthError> {
|
||||||
|
let user = query_as!(UserModelWithPassword,
|
||||||
|
"select id, username, password, created_at, email, name from storage.users where email = $1 OR username = $1", email_or_usrname
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserAuthError::DatabaseError(e))?;
|
||||||
|
let Some(user) = user else {
|
||||||
|
return Err(UserAuthError::UserNotFound);
|
||||||
|
};
|
||||||
|
if let Some(db_password) = user.password {
|
||||||
|
if bcrypt::verify(password, &db_password).map_err(|e| UserAuthError::EncryptionError(e))? {
|
||||||
|
return Ok(UserModel {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
created_at: user.created_at,
|
||||||
|
name: user.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(UserAuthError::PasswordInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreateUserModel {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub name: String
|
||||||
|
}
|
||||||
|
pub async fn create_user(pool: &DB, user: CreateUserModel) -> Result<UserModel, UserAuthError> {
|
||||||
|
let encrypted_pass = bcrypt::hash(user.password, ENCRYPTION_ROUNDS)
|
||||||
|
.map_err(|e| UserAuthError::EncryptionError(e))?;
|
||||||
|
todo!()
|
||||||
}
|
}
|
|
@ -1,22 +1,2 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use log::debug;
|
|
||||||
use rocket::{get, post, Data, State};
|
|
||||||
use rocket::fs::TempFile;
|
|
||||||
use rocket::http::Status;
|
|
||||||
use rocket::response::status;
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use sqlx::{query, Postgres};
|
|
||||||
use sqlx::types::{Uuid};
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use crate::{library, models, DB, MAX_UPLOAD_SIZE};
|
|
||||||
use crate::managers::libraries::LibraryManager;
|
|
||||||
use crate::managers::repos::RepoManager;
|
|
||||||
use crate::models::library::{LibraryModel, LibraryWithRepoModel};
|
|
||||||
use crate::models::user;
|
|
||||||
use crate::storage::FileEntry;
|
|
||||||
use crate::util::{JsonErrorResponse, ResponseError};
|
|
||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
|
@ -10,7 +10,8 @@ use sqlx::{query, Postgres};
|
||||||
use sqlx::types::{Uuid};
|
use sqlx::types::{Uuid};
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use crate::{library, models, DB, MAX_UPLOAD_SIZE};
|
use crate::{library, models, DB};
|
||||||
|
use crate::consts::MAX_UPLOAD_SIZE;
|
||||||
use crate::managers::libraries::LibraryManager;
|
use crate::managers::libraries::LibraryManager;
|
||||||
use crate::managers::repos::RepoManager;
|
use crate::managers::repos::RepoManager;
|
||||||
use crate::models::library::{LibraryModel, LibraryWithRepoModel};
|
use crate::models::library::{LibraryModel, LibraryWithRepoModel};
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use rocket::{get, post, FromForm, Route};
|
use rocket::{get, post, FromForm, Route, State};
|
||||||
use rocket::form::Form;
|
use rocket::form::Form;
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
use rocket_session_store::Session;
|
use rocket_session_store::Session;
|
||||||
use crate::models::user::UserModel;
|
use crate::models::user::{validate_user, UserAuthError, UserModel};
|
||||||
use crate::{LoginSessionData, SessionData};
|
use crate::{LoginSessionData, SessionData, DB};
|
||||||
use crate::util::{gen_csrf_token, set_csrf, validate_csrf};
|
use crate::util::{gen_csrf_token, set_csrf, validate_csrf, JsonErrorResponse, ResponseError};
|
||||||
|
|
||||||
#[get("/login")]
|
#[get("/login")]
|
||||||
pub async fn login(route: &Route, session: Session<'_, SessionData>) -> Template {
|
pub async fn login(route: &Route, session: Session<'_, SessionData>) -> Template {
|
||||||
|
@ -27,24 +27,33 @@ struct LoginForm<'r> {
|
||||||
remember_me: bool
|
remember_me: bool
|
||||||
}
|
}
|
||||||
#[post("/login", data = "<form>")]
|
#[post("/login", data = "<form>")]
|
||||||
pub async fn login_handler(route: &Route, ip_addr: IpAddr, form: Form<LoginForm<'_>>, session: Session<'_, SessionData>) -> String {
|
pub async fn login_handler(
|
||||||
|
pool: &State<DB>,
|
||||||
|
route: &Route,
|
||||||
|
ip_addr: IpAddr,
|
||||||
|
session: Session<'_, SessionData>,
|
||||||
|
form: Form<LoginForm<'_>>,
|
||||||
|
) -> Result<String, ResponseError> {
|
||||||
if let Ok(true) = validate_csrf(&session, &form._csrf).await {
|
if let Ok(true) = validate_csrf(&session, &form._csrf).await {
|
||||||
|
let user = validate_user(pool, form.username, form.password).await?;
|
||||||
if let Ok(sess) = session.get().await.map(|s| s.unwrap_or_default()) {
|
if let Ok(sess) = session.get().await.map(|s| s.unwrap_or_default()) {
|
||||||
session.set(SessionData {
|
session.set(SessionData {
|
||||||
csrf_token: None,
|
csrf_token: None,
|
||||||
login: Some(LoginSessionData {
|
login: Some(LoginSessionData {
|
||||||
user: UserModel {
|
user: UserModel {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
|
username: "".to_string(),
|
||||||
|
email: "".to_string(),
|
||||||
created_at: Default::default(),
|
created_at: Default::default(),
|
||||||
name: form.username.to_string(),
|
name: form.username.to_string(),
|
||||||
},
|
},
|
||||||
ip_address: ip_addr,
|
ip_address: ip_addr,
|
||||||
}),
|
}),
|
||||||
}).await.unwrap();
|
}).await.unwrap();
|
||||||
return format!("login success")
|
return Ok(format!("login success"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
format!("login bad. csrf failed!")
|
Err(ResponseError::CSRFError)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/register")]
|
#[get("/register")]
|
||||||
|
|
|
@ -15,6 +15,7 @@ use sqlx::Error;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use crate::models::user::{UserAuthError,};
|
||||||
use crate::SessionData;
|
use crate::SessionData;
|
||||||
use crate::util::ResponseError::DatabaseError;
|
use crate::util::ResponseError::DatabaseError;
|
||||||
|
|
||||||
|
@ -95,6 +96,8 @@ pub enum ResponseError {
|
||||||
GenericError,
|
GenericError,
|
||||||
InternalServerError(JsonErrorResponse),
|
InternalServerError(JsonErrorResponse),
|
||||||
DatabaseError(JsonErrorResponse),
|
DatabaseError(JsonErrorResponse),
|
||||||
|
AuthError(UserAuthError),
|
||||||
|
CSRFError
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseError {
|
impl ResponseError {
|
||||||
|
@ -103,6 +106,9 @@ impl ResponseError {
|
||||||
ResponseError::InternalServerError(_) => Status::InternalServerError,
|
ResponseError::InternalServerError(_) => Status::InternalServerError,
|
||||||
ResponseError::GenericError => Status::InternalServerError,
|
ResponseError::GenericError => Status::InternalServerError,
|
||||||
ResponseError::NotFound(_) => Status::NotFound,
|
ResponseError::NotFound(_) => Status::NotFound,
|
||||||
|
ResponseError::DatabaseError(_) => Status::InternalServerError,
|
||||||
|
ResponseError::AuthError(e) => e.get_response_code(),
|
||||||
|
ResponseError::CSRFError => Status::Unauthorized,
|
||||||
_ => Status::BadRequest,
|
_ => Status::BadRequest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,6 +124,13 @@ impl ResponseError {
|
||||||
},
|
},
|
||||||
ResponseError::InternalServerError(e) => e,
|
ResponseError::InternalServerError(e) => e,
|
||||||
DatabaseError(e) => e,
|
DatabaseError(e) => e,
|
||||||
|
ResponseError::AuthError(e) => e.into_response_err(),
|
||||||
|
ResponseError::CSRFError => {
|
||||||
|
JsonErrorResponse {
|
||||||
|
code: "CSRF_VALIDATION_FAILED".to_string(),
|
||||||
|
message: "CSRF Token is invalid / expired or does not exist. Reload the form and try again".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,6 +144,12 @@ impl From<sqlx::Error> for ResponseError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<UserAuthError> for ResponseError {
|
||||||
|
fn from(value: UserAuthError) -> Self {
|
||||||
|
ResponseError::AuthError(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ResponseError {
|
impl std::fmt::Display for ResponseError {
|
||||||
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||||
write!(fmt, "Error {}.", self.get_http_status())
|
write!(fmt, "Error {}.", self.get_http_status())
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue