forked from jmug/stoatchat
feat: scaffold services/january
This commit is contained in:
@@ -11,9 +11,10 @@ description = "Revolt Backend: API Models"
|
||||
[features]
|
||||
serde = ["dep:serde", "revolt-permissions/serde", "indexmap/serde"]
|
||||
schemas = ["dep:schemars", "revolt-permissions/schemas"]
|
||||
utoipa = ["dep:utoipa"]
|
||||
validator = ["dep:validator"]
|
||||
rocket = ["dep:rocket"]
|
||||
partials = ["dep:revolt_optional_struct", "serde", "schemas"]
|
||||
partials = ["dep:revolt_optional_struct", "serde", "schemas", "utoipa"]
|
||||
|
||||
default = ["serde", "partials", "rocket"]
|
||||
|
||||
@@ -37,6 +38,7 @@ iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
|
||||
|
||||
# Spec Generation
|
||||
schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
|
||||
utoipa = { version = "4.2.3", optional = true }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.16.0", optional = true, features = ["derive"] }
|
||||
|
||||
@@ -6,6 +6,10 @@ extern crate serde;
|
||||
#[macro_use]
|
||||
extern crate schemars;
|
||||
|
||||
#[cfg(feature = "utoipa")]
|
||||
#[macro_use]
|
||||
extern crate utoipa;
|
||||
|
||||
#[cfg(feature = "partials")]
|
||||
#[macro_use]
|
||||
extern crate revolt_optional_struct;
|
||||
@@ -18,6 +22,7 @@ macro_rules! auto_derived {
|
||||
$(
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
|
||||
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
$item
|
||||
)+
|
||||
|
||||
@@ -11,7 +11,9 @@ description = "Revolt Backend: Result and Error types"
|
||||
[features]
|
||||
serde = ["dep:serde"]
|
||||
schemas = ["dep:schemars"]
|
||||
utoipa = ["dep:utoipa"]
|
||||
rocket = ["dep:rocket", "dep:serde_json"]
|
||||
axum = ["dep:axum", "dep:serde_json"]
|
||||
okapi = ["dep:revolt_rocket_okapi", "dep:revolt_okapi", "schemas"]
|
||||
|
||||
default = ["serde"]
|
||||
@@ -23,8 +25,12 @@ serde = { version = "1", features = ["derive"], optional = true }
|
||||
|
||||
# Spec Generation
|
||||
schemars = { version = "0.8.8", optional = true }
|
||||
utoipa = { version = "4.2.3", optional = true }
|
||||
|
||||
# Rocket
|
||||
rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
|
||||
revolt_rocket_okapi = { version = "0.9.1", optional = true }
|
||||
revolt_okapi = { version = "0.9.1", optional = true }
|
||||
|
||||
# Axum
|
||||
axum = { version = "0.7.5", optional = true }
|
||||
|
||||
81
crates/core/result/src/axum.rs
Normal file
81
crates/core/result/src/axum.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
|
||||
use crate::{Error, ErrorType};
|
||||
|
||||
/// HTTP response builder for Error enum
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let status = match self.error_type {
|
||||
ErrorType::LabelMe => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
ErrorType::AlreadyOnboarded => StatusCode::FORBIDDEN,
|
||||
|
||||
ErrorType::UnknownUser => StatusCode::NOT_FOUND,
|
||||
ErrorType::InvalidUsername => StatusCode::BAD_REQUEST,
|
||||
ErrorType::UsernameTaken => StatusCode::CONFLICT,
|
||||
ErrorType::DiscriminatorChangeRatelimited => StatusCode::TOO_MANY_REQUESTS,
|
||||
ErrorType::AlreadyFriends => StatusCode::CONFLICT,
|
||||
ErrorType::AlreadySentRequest => StatusCode::CONFLICT,
|
||||
ErrorType::Blocked => StatusCode::CONFLICT,
|
||||
ErrorType::BlockedByOther => StatusCode::FORBIDDEN,
|
||||
ErrorType::NotFriends => StatusCode::FORBIDDEN,
|
||||
ErrorType::TooManyPendingFriendRequests { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::UnknownChannel => StatusCode::NOT_FOUND,
|
||||
ErrorType::UnknownMessage => StatusCode::NOT_FOUND,
|
||||
ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST,
|
||||
ErrorType::CannotEditMessage => StatusCode::FORBIDDEN,
|
||||
ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyReplies { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::EmptyMessage => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::PayloadTooLarge => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::CannotRemoveYourself => StatusCode::BAD_REQUEST,
|
||||
ErrorType::GroupTooLarge { .. } => StatusCode::FORBIDDEN,
|
||||
ErrorType::AlreadyInGroup => StatusCode::CONFLICT,
|
||||
ErrorType::NotInGroup => StatusCode::NOT_FOUND,
|
||||
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
|
||||
ErrorType::NotPinned => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::UnknownServer => StatusCode::NOT_FOUND,
|
||||
ErrorType::InvalidRole => StatusCode::NOT_FOUND,
|
||||
ErrorType::Banned => StatusCode::FORBIDDEN,
|
||||
ErrorType::AlreadyInServer => StatusCode::CONFLICT,
|
||||
|
||||
ErrorType::TooManyServers { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyEmbeds { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyEmoji { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyChannels { .. } => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyRoles { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::ReachedMaximumBots => StatusCode::BAD_REQUEST,
|
||||
ErrorType::IsBot => StatusCode::BAD_REQUEST,
|
||||
ErrorType::BotIsPrivate => StatusCode::FORBIDDEN,
|
||||
|
||||
ErrorType::CannotReportYourself => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::MissingPermission { .. } => StatusCode::FORBIDDEN,
|
||||
ErrorType::MissingUserPermission { .. } => StatusCode::FORBIDDEN,
|
||||
ErrorType::NotElevated => StatusCode::FORBIDDEN,
|
||||
ErrorType::NotPrivileged => StatusCode::FORBIDDEN,
|
||||
ErrorType::CannotGiveMissingPermissions => StatusCode::FORBIDDEN,
|
||||
ErrorType::NotOwner => StatusCode::FORBIDDEN,
|
||||
|
||||
ErrorType::DatabaseError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorType::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorType::InvalidOperation => StatusCode::BAD_REQUEST,
|
||||
ErrorType::InvalidCredentials => StatusCode::UNAUTHORIZED,
|
||||
ErrorType::InvalidProperty => StatusCode::BAD_REQUEST,
|
||||
ErrorType::InvalidSession => StatusCode::UNAUTHORIZED,
|
||||
ErrorType::DuplicateNonce => StatusCode::CONFLICT,
|
||||
ErrorType::VosoUnavailable => StatusCode::BAD_REQUEST,
|
||||
ErrorType::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorType::NoEffect => StatusCode::OK,
|
||||
ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::ProxyError => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
|
||||
(status, Json(&self)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@ extern crate serde;
|
||||
#[macro_use]
|
||||
extern crate schemars;
|
||||
|
||||
#[cfg(feature = "utoipa")]
|
||||
#[macro_use]
|
||||
extern crate utoipa;
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub mod rocket;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
|
||||
#[cfg(feature = "okapi")]
|
||||
pub mod okapi;
|
||||
|
||||
@@ -20,6 +27,7 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
/// Error information
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
|
||||
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Error {
|
||||
/// Type of error and additional information
|
||||
@@ -42,6 +50,7 @@ impl std::error::Error for Error {}
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(tag = "type"))]
|
||||
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
|
||||
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ErrorType {
|
||||
/// This error was not labeled :(
|
||||
@@ -145,6 +154,9 @@ pub enum ErrorType {
|
||||
error: String,
|
||||
},
|
||||
|
||||
// ? Micro-service errors
|
||||
ProxyError,
|
||||
|
||||
// ? Legacy errors
|
||||
VosoUnavailable,
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ impl<'r> Responder<'r, 'static> for Error {
|
||||
ErrorType::NotFound => Status::NotFound,
|
||||
ErrorType::NoEffect => Status::Ok,
|
||||
ErrorType::FailedValidation { .. } => Status::BadRequest,
|
||||
|
||||
ErrorType::ProxyError => Status::BadRequest,
|
||||
};
|
||||
|
||||
// Serialize the error data structure into JSON.
|
||||
|
||||
@@ -4,19 +4,29 @@ version = "0.7.14"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Serialisation
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.68"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.7.16", path = "../../core/config" }
|
||||
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
|
||||
# Axum / web server
|
||||
tempfile = "3.12.0"
|
||||
axum_typed_multipart = "0.12.1"
|
||||
axum = { version = "0.7.5", features = ["multipart"] }
|
||||
|
||||
# OpenAPI & documentation generation
|
||||
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
||||
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
||||
|
||||
@@ -28,6 +28,8 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
revolt_result::Error,
|
||||
revolt_result::ErrorType,
|
||||
api::RootResponse,
|
||||
api::Tag,
|
||||
api::UploadPayload,
|
||||
|
||||
40
crates/services/january/Cargo.toml
Normal file
40
crates/services/january/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "revolt-january"
|
||||
version = "0.7.14"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Utility
|
||||
mime = "0.3.17"
|
||||
lazy_static = "1.5.0"
|
||||
moka = { version = "0.12.8", features = ["future"] }
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.68"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Web requests
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.7.16", path = "../../core/config" }
|
||||
revolt-models = { version = "0.7.16", path = "../../core/models" }
|
||||
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
|
||||
# Axum / web server
|
||||
axum = { version = "0.7.5" }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
|
||||
# OpenAPI & documentation generation
|
||||
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
||||
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
||||
85
crates/services/january/src/api.rs
Normal file
85
crates/services/january/src/api.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use axum::{body::Bytes, extract::Query, routing::get, Json, Router};
|
||||
use revolt_models::v0::Embed;
|
||||
use revolt_result::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
TypedHeader,
|
||||
};
|
||||
|
||||
use crate::requests::Request;
|
||||
|
||||
pub async fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/proxy", get(proxy))
|
||||
.route("/embed", get(embed))
|
||||
}
|
||||
|
||||
/// Successful root response
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
pub struct RootResponse {
|
||||
january: &'static str,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
/// Capture crate version from Cargo
|
||||
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Root response from service
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/",
|
||||
responses(
|
||||
(status = 200, description = "Echo response", body = RootResponse)
|
||||
)
|
||||
)]
|
||||
async fn root() -> Json<RootResponse> {
|
||||
Json(RootResponse {
|
||||
january: "Hello, I am a media proxy server!",
|
||||
version: CRATE_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlQuery {
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Proxy a given URL and load media
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy",
|
||||
responses(
|
||||
(status = 200, description = "Requested media file", body = Vec<u8>)
|
||||
),
|
||||
params(
|
||||
("url" = String, Query, description = "URL to fetch")
|
||||
),
|
||||
)]
|
||||
async fn proxy(Query(UrlQuery { url }): Query<UrlQuery>) -> Result<Bytes> {
|
||||
Request::proxy_file(&url).await
|
||||
}
|
||||
|
||||
/// Generate embed for a given URL
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/embed",
|
||||
responses(
|
||||
(status = 200, description = "Generated embed information", body = Embed)
|
||||
),
|
||||
params(
|
||||
("url" = String, Query, description = "URL to fetch")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
)
|
||||
)]
|
||||
async fn embed(
|
||||
Query(UrlQuery { url }): Query<UrlQuery>,
|
||||
TypedHeader(Authorization(_bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<Json<Embed>> {
|
||||
Request::generate_embed(&url).await.map(Json)
|
||||
}
|
||||
71
crates/services/january/src/main.rs
Normal file
71
crates/services/january/src/main.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use utoipa::{
|
||||
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
|
||||
Modify, OpenApi,
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||
|
||||
mod api;
|
||||
pub mod requests;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
// Configure logging and environment
|
||||
revolt_config::configure!(api);
|
||||
|
||||
// Configure API schema
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
modifiers(&SecurityAddon),
|
||||
paths(
|
||||
api::root,
|
||||
api::proxy,
|
||||
api::embed
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
api::RootResponse,
|
||||
revolt_result::Error,
|
||||
revolt_result::ErrorType,
|
||||
revolt_models::v0::ImageSize,
|
||||
revolt_models::v0::Image,
|
||||
revolt_models::v0::Video,
|
||||
revolt_models::v0::TwitchType,
|
||||
revolt_models::v0::LightspeedType,
|
||||
revolt_models::v0::BandcampType,
|
||||
revolt_models::v0::Special,
|
||||
revolt_models::v0::WebsiteMetadata,
|
||||
revolt_models::v0::Text,
|
||||
revolt_models::v0::Embed
|
||||
)
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"api_key",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure Axum and router
|
||||
let app = Router::new()
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.nest("/", api::router().await);
|
||||
|
||||
// Configure TCP listener and bind
|
||||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 3000));
|
||||
let listener = TcpListener::bind(&address).await?;
|
||||
axum::serve(listener, app.into_make_service()).await
|
||||
}
|
||||
91
crates/services/january/src/requests.rs
Normal file
91
crates/services/january/src/requests.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use lazy_static::lazy_static;
|
||||
use mime::Mime;
|
||||
use reqwest::{header::CONTENT_TYPE, redirect, Client, Response};
|
||||
use revolt_models::v0::Embed;
|
||||
use revolt_result::{create_error, Result};
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: Client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; January/2.0; +https://github.com/revoltchat/backend)")
|
||||
.timeout(Duration::from_secs(10)) // TODO config
|
||||
.connect_timeout(Duration::from_secs(5)) // TODO config
|
||||
.redirect(redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() > 5 { // TODO config
|
||||
attempt.error("too many redirects")
|
||||
} else if attempt.url().host_str() == Some("jan.revolt.chat") { // TODO config
|
||||
attempt.stop()
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
}))
|
||||
.build()
|
||||
.expect("reqwest Client");
|
||||
|
||||
/// Cache for proxy results
|
||||
static ref PROXY_CACHE: moka::future::Cache<String, Result<Bytes>> = moka::future::Cache::builder()
|
||||
.max_capacity(10_000) // TODO config
|
||||
.time_to_live(Duration::from_secs(60)) // TODO config
|
||||
.build();
|
||||
|
||||
/// Cache for embed results
|
||||
static ref EMBED_CACHE: moka::future::Cache<String, Result<Embed>> = moka::future::Cache::builder()
|
||||
.max_capacity(1_000) // TODO config
|
||||
.time_to_live(Duration::from_secs(60)) // TODO config
|
||||
.build();
|
||||
}
|
||||
|
||||
/// Information about a successful request
|
||||
pub struct Request {
|
||||
response: Response,
|
||||
mime: Mime,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Proxy a given URL
|
||||
pub async fn proxy_file(url: &str) -> Result<Bytes> {
|
||||
if let Some(hit) = PROXY_CACHE.get(url).await {
|
||||
hit
|
||||
} else {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate embed for a given URL
|
||||
pub async fn generate_embed(url: &str) -> Result<Embed> {
|
||||
if let Some(hit) = EMBED_CACHE.get(url).await {
|
||||
hit
|
||||
} else {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a new request to a service
|
||||
pub async fn new(url: &str) -> Result<Request> {
|
||||
let response = CLIENT
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| create_error!(ProxyError))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
tracing::error!("{:?}", response);
|
||||
return Err(create_error!(ProxyError));
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.ok_or(create_error!(ProxyError))?
|
||||
.to_str()
|
||||
.map_err(|_| create_error!(ProxyError))?;
|
||||
|
||||
let mime: mime::Mime = content_type
|
||||
.parse()
|
||||
.map_err(|_| create_error!(ProxyError))?;
|
||||
|
||||
Ok(Request { response, mime })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user