Authentication service
This commit is contained in:
54
3/coursework/src/Cargo.lock
generated
54
3/coursework/src/Cargo.lock
generated
@ -57,6 +57,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aliasable"
|
||||
version = "0.1.3"
|
||||
@ -1420,7 +1429,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a989bd2fd12136080f7825ff410d9239ce84a2a639487fc9d924ee42e2fb84f"
|
||||
dependencies = [
|
||||
"compact_str",
|
||||
"garde_derive",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"smallvec",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "garde_derive"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f7f0545bbbba0a37d4d445890fa5759814e0716f02417b39f6fab292193df68"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3213,6 +3238,35 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
|
14
3/coursework/src/app/authentication/Cargo.toml
Normal file
14
3/coursework/src/app/authentication/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "authentication"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
|
||||
thiserror = "2.0.11"
|
||||
garde = { version = "0.22.0", features = ["email", "url", "derive"] }
|
||||
derive_more = { version = "1.0.0", features = ["deref", "deref_mut", "into"] }
|
||||
|
||||
[dependencies.database]
|
||||
path = "../../database"
|
4
3/coursework/src/app/authentication/src/lib.rs
Normal file
4
3/coursework/src/app/authentication/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
// use database::port::user;
|
||||
|
||||
mod repository;
|
||||
mod service;
|
86
3/coursework/src/app/authentication/src/repository.rs
Normal file
86
3/coursework/src/app/authentication/src/repository.rs
Normal file
@ -0,0 +1,86 @@
|
||||
pub use database::port::user::*;
|
||||
|
||||
use derive_more::{Deref, DerefMut};
|
||||
|
||||
#[derive(Deref, DerefMut)]
|
||||
pub struct Authenticated(User);
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait AuthenticationRepository {
|
||||
async fn get_user(&self, get: Get) -> Result<Option<User>>;
|
||||
async fn create_user(&self, new: New) -> Result<User>;
|
||||
async fn start_session(&self, user: User) -> Result<Authenticated>;
|
||||
}
|
||||
|
||||
|
||||
pub enum Get {
|
||||
Name(Name),
|
||||
Email(Email),
|
||||
}
|
||||
|
||||
impl From<Get> for Unique {
|
||||
fn from(value: Get) -> Self {
|
||||
match value {
|
||||
Get::Name(s) => Self::Name(s),
|
||||
Get::Email(s) => Self::Email(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter
|
||||
|
||||
use database::connect::Connect;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub struct AuthenticationAdapter<D, C, UR>
|
||||
where
|
||||
D: Connect<Connection = C>,
|
||||
UR: UserRepository<C>,
|
||||
{
|
||||
driver: D,
|
||||
_user_repository: PhantomData<UR>,
|
||||
}
|
||||
|
||||
impl<D, C, UR> AuthenticationAdapter<D, C, UR>
|
||||
where
|
||||
D: Connect<Connection = C>,
|
||||
UR: UserRepository<C>,
|
||||
{
|
||||
pub const fn new(driver: D) -> Self {
|
||||
Self {
|
||||
driver,
|
||||
_user_repository: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D, C, UR> AuthenticationRepository for AuthenticationAdapter<D, C, UR>
|
||||
where
|
||||
D: Connect<Connection = C>,
|
||||
UR: UserRepository<C>,
|
||||
{
|
||||
async fn get_user(&self, get: Get) -> Result<Option<User>> {
|
||||
let c = self.driver.open_connection().await?;
|
||||
let user = UR::read(&c, get.into()).await?;
|
||||
D::close_connection(c).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn create_user(&self, new: New) -> Result<User> {
|
||||
let mut c = self.driver.open_connection().await?;
|
||||
let user = UR::create(&mut c, new).await?;
|
||||
D::close_connection(c).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn start_session(&self, mut user: User) -> Result<Authenticated> {
|
||||
let mut c = self.driver.open_connection().await?;
|
||||
UR::update(&mut c, &mut user, Field::LastUsed(Some(Utc::now()))).await?;
|
||||
D::close_connection(c).await?;
|
||||
|
||||
Ok(Authenticated(user))
|
||||
}
|
||||
}
|
||||
|
212
3/coursework/src/app/authentication/src/service.rs
Normal file
212
3/coursework/src/app/authentication/src/service.rs
Normal file
@ -0,0 +1,212 @@
|
||||
use crate::repository::{Authenticated, AuthenticationRepository};
|
||||
use database::port::user;
|
||||
|
||||
use derive_more::{Deref, Into};
|
||||
use garde::Validate;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
// Login
|
||||
#[error("login not found")]
|
||||
LoginNotFound,
|
||||
#[error("incorrect password")]
|
||||
IncorrectPassword,
|
||||
// Register
|
||||
#[error("username is taken")]
|
||||
NameExists,
|
||||
#[error("email is already in use")]
|
||||
EmailExists,
|
||||
// Shared
|
||||
#[error("invalid password: {0}")]
|
||||
InvalidPassword(Box<dyn std::error::Error>),
|
||||
#[error("data source error: {0}")]
|
||||
Repository(Box<dyn std::error::Error>),
|
||||
}
|
||||
|
||||
pub type Result<T = (), E = Error> = std::result::Result<T, E>;
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Name(user::Name);
|
||||
|
||||
impl TryFrom<String> for Name {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
#[derive(Validate)]
|
||||
#[garde(transparent)]
|
||||
struct Username<'a>(#[garde(alphanumeric, length(chars, min = 2, max = 31))] &'a str);
|
||||
|
||||
Username(&value).validate()?;
|
||||
Ok(Self(user::Name::try_from(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Email(user::Email);
|
||||
impl TryFrom<String> for Email {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
#[derive(Validate)]
|
||||
#[garde(transparent)]
|
||||
pub struct Email<'a>(#[garde(email, length(chars, max = 255))] &'a str);
|
||||
|
||||
Email(&value).validate()?;
|
||||
Ok(Self(user::Email::try_from(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
// #[derive(Validate, Deref, From, Into)]
|
||||
// #[garde(transparent)]
|
||||
// pub struct Password(#[garde(length(chars, min = 8))] pub String);
|
||||
|
||||
pub enum LoginBy {
|
||||
Name(Name),
|
||||
Email(Email),
|
||||
}
|
||||
|
||||
pub struct LoginData {
|
||||
login: LoginBy,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub struct RegisterData {
|
||||
pub name: Name,
|
||||
pub email: Email,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub trait AuthenticationContract {
|
||||
async fn name_available(&self, name: Name) -> Result;
|
||||
async fn email_available(&self, email: Email) -> Result;
|
||||
|
||||
async fn login(&mut self, data: LoginData) -> Result<Authenticated>;
|
||||
async fn register(&mut self, data: RegisterData) -> Result<Authenticated>;
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
use crate::repository::Get;
|
||||
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{
|
||||
self, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
|
||||
},
|
||||
};
|
||||
|
||||
impl From<password_hash::Error> for Error {
|
||||
fn from(error: password_hash::Error) -> Self {
|
||||
match error {
|
||||
password_hash::Error::Password => Self::IncorrectPassword,
|
||||
_ => Self::InvalidPassword(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthenticationService<R>
|
||||
where
|
||||
R: AuthenticationRepository,
|
||||
{
|
||||
repository: R,
|
||||
}
|
||||
|
||||
impl<R> AuthenticationService<R>
|
||||
where
|
||||
R: AuthenticationRepository,
|
||||
{
|
||||
pub const fn new(repository: R) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> AuthenticationContract for AuthenticationService<R>
|
||||
where
|
||||
R: AuthenticationRepository,
|
||||
{
|
||||
async fn name_available(&self, name: Name) -> Result {
|
||||
if self
|
||||
.repository
|
||||
.get_user(Get::Name(name.0))
|
||||
.await
|
||||
.map_err(Error::Repository)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::NameExists);
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
async fn email_available(&self, email: Email) -> Result {
|
||||
if self
|
||||
.repository
|
||||
.get_user(Get::Email(email.0))
|
||||
.await
|
||||
.map_err(Error::Repository)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::EmailExists);
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login(&mut self, data: LoginData) -> Result<Authenticated> {
|
||||
if data.password.chars().count() < 8 {
|
||||
return Err(Error::InvalidPassword(
|
||||
"password must be longer than 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = match data.login {
|
||||
LoginBy::Name(name) => self.repository.get_user(Get::Name(name.0)),
|
||||
LoginBy::Email(email) => self.repository.get_user(Get::Email(email.0)),
|
||||
}
|
||||
.await
|
||||
.map_err(Error::Repository)?
|
||||
.ok_or(Error::LoginNotFound)?;
|
||||
|
||||
Argon2::default().verify_password(
|
||||
data.password.as_bytes(),
|
||||
&PasswordHash::new(user.password())?,
|
||||
)?;
|
||||
|
||||
self.repository
|
||||
.start_session(user)
|
||||
.await
|
||||
.map_err(Error::Repository)
|
||||
}
|
||||
|
||||
async fn register(&mut self, data: RegisterData) -> Result<Authenticated> {
|
||||
if data.password.chars().count() < 8 {
|
||||
return Err(Error::InvalidPassword(
|
||||
"password must be longer than 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.name_available(data.name.clone()).await?;
|
||||
self.email_available(data.email.clone()).await?;
|
||||
|
||||
// Get PHC string ($argon2id$v=19$...)
|
||||
let password = Argon2::default()
|
||||
.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng))?
|
||||
.to_string()
|
||||
.try_into()
|
||||
.map_err(|e| Error::InvalidPassword(Box::from(e)))?;
|
||||
|
||||
let user = self
|
||||
.repository
|
||||
.create_user(user::New {
|
||||
name: data.name.0,
|
||||
email: data.email.0,
|
||||
password,
|
||||
last_used: None,
|
||||
})
|
||||
.await
|
||||
.map_err(Error::Repository)?;
|
||||
|
||||
self.repository
|
||||
.start_session(user)
|
||||
.await
|
||||
.map_err(Error::Repository)
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
pub type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
pub type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait CRUD<C> {
|
||||
@ -17,7 +17,34 @@ pub trait CRUD<C> {
|
||||
async fn delete(connection: &mut C, data: Self::Unique) -> Result;
|
||||
}
|
||||
|
||||
const TOO_LONG: &str = "too long";
|
||||
trait CharLength {
|
||||
fn length(&self) -> usize;
|
||||
}
|
||||
impl CharLength for String {
|
||||
fn length(&self) -> usize {
|
||||
self.chars().count()
|
||||
}
|
||||
}
|
||||
impl CharLength for Option<String> {
|
||||
fn length(&self) -> usize {
|
||||
self.as_ref().map_or(0, CharLength::length)
|
||||
}
|
||||
}
|
||||
|
||||
trait MaxLength {
|
||||
type Inner: CharLength;
|
||||
const MAX_LENGTH: usize;
|
||||
|
||||
fn validate(value: &Self::Inner) -> Result<(), &'static str> {
|
||||
if value.length() > Self::MAX_LENGTH {
|
||||
Err("too long")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// const TOO_LONG: &str = "too long";
|
||||
|
||||
pub mod base;
|
||||
pub mod package;
|
||||
|
@ -1,3 +1,4 @@
|
||||
use super::MaxLength;
|
||||
pub use super::{CRUD, Result};
|
||||
|
||||
pub use chrono::{DateTime, Utc};
|
||||
@ -14,29 +15,30 @@ pub trait BaseRepository<C>:
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Name(String);
|
||||
impl MaxLength for Name {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 127;
|
||||
}
|
||||
impl TryFrom<String> for Name {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 127 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Description(Option<String>);
|
||||
impl MaxLength for Description {
|
||||
type Inner = Option<String>;
|
||||
const MAX_LENGTH: usize = 510;
|
||||
}
|
||||
impl TryFrom<Option<String>> for Description {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: Option<String>) -> std::result::Result<Self, Self::Error> {
|
||||
if let Some(x) = &value {
|
||||
if x.chars().count() > 510 {
|
||||
return Err(super::TOO_LONG);
|
||||
}
|
||||
}
|
||||
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
use super::MaxLength;
|
||||
pub use super::{CRUD, Result, base::Base};
|
||||
|
||||
pub use chrono::{DateTime, Utc};
|
||||
@ -11,58 +12,60 @@ pub trait PackageRepository<C>:
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Name(String);
|
||||
impl MaxLength for Name {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 127;
|
||||
}
|
||||
impl TryFrom<String> for Name {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 127 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Version(String);
|
||||
impl MaxLength for Version {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 127;
|
||||
}
|
||||
impl TryFrom<String> for Version {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 127 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Description(Option<String>);
|
||||
impl MaxLength for Description {
|
||||
type Inner = Option<String>;
|
||||
const MAX_LENGTH: usize = 255;
|
||||
}
|
||||
impl TryFrom<Option<String>> for Description {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: Option<String>) -> std::result::Result<Self, Self::Error> {
|
||||
if let Some(x) = &value {
|
||||
if x.chars().count() > 255 {
|
||||
return Err(super::TOO_LONG);
|
||||
}
|
||||
}
|
||||
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct URL(Option<String>);
|
||||
impl MaxLength for URL {
|
||||
type Inner = Option<String>;
|
||||
const MAX_LENGTH: usize = 510;
|
||||
}
|
||||
impl TryFrom<Option<String>> for URL {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: Option<String>) -> std::result::Result<Self, Self::Error> {
|
||||
if let Some(x) = &value {
|
||||
if x.chars().count() > 510 {
|
||||
return Err(super::TOO_LONG);
|
||||
}
|
||||
}
|
||||
fn try_from(value: Option<String>) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
use super::MaxLength;
|
||||
pub use super::{CRUD, Result};
|
||||
|
||||
pub use chrono::{DateTime, Utc};
|
||||
@ -11,43 +12,46 @@ pub trait UserRepository<C>:
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Name(String);
|
||||
impl MaxLength for Name {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 31;
|
||||
}
|
||||
impl TryFrom<String> for Name {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 31 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Email(String);
|
||||
impl MaxLength for Email {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 255;
|
||||
}
|
||||
impl TryFrom<String> for Email {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 255 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deref, Into)]
|
||||
pub struct Password(String);
|
||||
impl MaxLength for Password {
|
||||
type Inner = String;
|
||||
const MAX_LENGTH: usize = 255;
|
||||
}
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = &'static str;
|
||||
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
if value.chars().count() > 255 {
|
||||
Err(super::TOO_LONG)
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user