mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-14 05:26:59 +00:00
refactor: remove quark/web
This commit is contained in:
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -63,6 +63,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.18"
|
||||
@@ -72,6 +83,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5"
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.12.1"
|
||||
@@ -515,7 +532,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60a2c7c80a7850b56df4b8e98e8e4932c34877b8add4f13e8350499cc1e4572"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.7.6",
|
||||
"base64 0.13.0",
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -1343,7 +1360,7 @@ version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.7.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1352,6 +1369,16 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
|
||||
dependencies = [
|
||||
"ahash 0.8.3",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.0"
|
||||
@@ -1815,6 +1842,15 @@ dependencies = [
|
||||
"hashbrown 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eedb2bdbad7e0634f83989bf596f497b070130daaa398ab22d84c39e266deec5"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-cache"
|
||||
version = "0.1.2"
|
||||
@@ -1872,7 +1908,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e52eb6380b6d2a10eb3434aec0885374490f5b82c8aaf5cd487a183c98be834"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.7.6",
|
||||
"metrics-macros",
|
||||
]
|
||||
|
||||
@@ -2906,6 +2942,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"iso8601-timestamp 0.2.11",
|
||||
"log",
|
||||
"lru 0.11.0",
|
||||
"mongodb",
|
||||
"nanoid",
|
||||
"once_cell",
|
||||
@@ -2943,7 +2980,7 @@ dependencies = [
|
||||
"lettre",
|
||||
"linkify 0.6.0",
|
||||
"log",
|
||||
"lru",
|
||||
"lru 0.7.6",
|
||||
"nanoid",
|
||||
"num_enum 0.5.7",
|
||||
"once_cell",
|
||||
@@ -2957,6 +2994,7 @@ dependencies = [
|
||||
"revolt_rocket_okapi",
|
||||
"rocket",
|
||||
"rocket_authifier",
|
||||
"rocket_cors",
|
||||
"rocket_empty",
|
||||
"rocket_prometheus",
|
||||
"schemars",
|
||||
@@ -3032,7 +3070,7 @@ dependencies = [
|
||||
"iso8601-timestamp 0.1.10",
|
||||
"linkify 0.8.1",
|
||||
"log",
|
||||
"lru",
|
||||
"lru 0.7.6",
|
||||
"mongodb",
|
||||
"nanoid",
|
||||
"num_enum 0.5.7",
|
||||
|
||||
@@ -32,6 +32,7 @@ revolt-permissions = { version = "0.6.7", path = "../permissions", features = [
|
||||
|
||||
# Utility
|
||||
log = "0.4"
|
||||
lru = "0.11.0"
|
||||
rand = "0.8.5"
|
||||
ulid = "1.0.0"
|
||||
nanoid = "0.4.0"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::{Error, Result};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use revolt_result::{create_error, Error, Result};
|
||||
|
||||
use async_std::sync::Mutex;
|
||||
use once_cell::sync::Lazy;
|
||||
use revolt_rocket_okapi::gen::OpenApiGenerator;
|
||||
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::{Parameter, ParameterValue};
|
||||
@@ -8,16 +11,14 @@ use rocket::http::Status;
|
||||
use rocket::request::{FromRequest, Outcome};
|
||||
use schemars::schema::{InstanceType, SchemaObject, SingleOrVec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
#[derive(Validate, Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct IdempotencyKey {
|
||||
#[validate(length(min = 1, max = 64))]
|
||||
key: String,
|
||||
}
|
||||
|
||||
static TOKEN_CACHE: Lazy<Mutex<lru::LruCache<String, ()>>> = Lazy::new(|| Mutex::new(lru::LruCache::new(100)));
|
||||
static TOKEN_CACHE: Lazy<Mutex<lru::LruCache<String, ()>>> =
|
||||
Lazy::new(|| Mutex::new(lru::LruCache::new(NonZeroUsize::new(1000).unwrap())));
|
||||
|
||||
impl IdempotencyKey {
|
||||
// Backwards compatibility.
|
||||
@@ -26,7 +27,7 @@ impl IdempotencyKey {
|
||||
if let Some(v) = v {
|
||||
let mut cache = TOKEN_CACHE.lock().await;
|
||||
if cache.get(&v).is_some() {
|
||||
return Err(Error::DuplicateNonce);
|
||||
return Err(create_error!(DuplicateNonce));
|
||||
}
|
||||
|
||||
cache.put(v.clone(), ());
|
||||
@@ -81,14 +82,19 @@ impl<'r> FromRequest<'r> for IdempotencyKey {
|
||||
.next()
|
||||
.map(|k| k.to_string())
|
||||
{
|
||||
let idempotency = IdempotencyKey { key };
|
||||
if let Err(error) = idempotency.validate() {
|
||||
return Outcome::Failure((Status::BadRequest, Error::FailedValidation { error }));
|
||||
if key.len() > 64 {
|
||||
return Outcome::Failure((
|
||||
Status::BadRequest,
|
||||
create_error!(FailedValidation {
|
||||
error: "idempotency key too long".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let idempotency = IdempotencyKey { key };
|
||||
let mut cache = TOKEN_CACHE.lock().await;
|
||||
if cache.get(&idempotency.key).is_some() {
|
||||
return Outcome::Failure((Status::Conflict, Error::DuplicateNonce));
|
||||
return Outcome::Failure((Status::Conflict, create_error!(DuplicateNonce)));
|
||||
}
|
||||
|
||||
cache.put(idempotency.key.clone(), ());
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod bridge;
|
||||
pub mod idempotency;
|
||||
pub mod permissions;
|
||||
pub mod reference;
|
||||
|
||||
@@ -51,6 +51,7 @@ lettre = "0.10.0-alpha.4"
|
||||
rocket = { version = "0.5.0-rc.2", default-features = false, features = [
|
||||
"json",
|
||||
] }
|
||||
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "c17e8145baa4790319fdb6a473e465b960f55e7c" }
|
||||
rocket_empty = { version = "0.1.1", features = ["schema"] }
|
||||
rocket_authifier = { version = "1.0.7" }
|
||||
rocket_prometheus = "0.10.0-rc.3"
|
||||
|
||||
@@ -8,8 +8,10 @@ extern crate serde_json;
|
||||
pub mod routes;
|
||||
pub mod util;
|
||||
|
||||
use rocket_cors::{AllowedOrigins, CorsOptions};
|
||||
use rocket_prometheus::PrometheusMetrics;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_std::channel::unbounded;
|
||||
use revolt_quark::authifier::{Authifier, AuthifierEvent};
|
||||
@@ -62,7 +64,27 @@ async fn rocket() -> _ {
|
||||
async_std::task::spawn(revolt_quark::tasks::start_workers(legacy_db.clone()));
|
||||
|
||||
// Configure CORS
|
||||
let cors = revolt_quark::web::cors::new();
|
||||
let cors = CorsOptions {
|
||||
allowed_origins: AllowedOrigins::All,
|
||||
allowed_methods: [
|
||||
"Get", "Put", "Post", "Delete", "Options", "Head", "Trace", "Connect", "Patch",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| FromStr::from_str(s).unwrap())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
.to_cors()
|
||||
.expect("Failed to create CORS.");
|
||||
|
||||
// Configure Swagger
|
||||
let swagger = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
|
||||
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
|
||||
url: "../openapi.json".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.into();
|
||||
|
||||
// Configure Rocket
|
||||
let rocket = rocket::build();
|
||||
@@ -71,14 +93,14 @@ async fn rocket() -> _ {
|
||||
routes::mount(rocket)
|
||||
.attach(prometheus.clone())
|
||||
.mount("/metrics", prometheus)
|
||||
.mount("/", revolt_quark::web::cors::catch_all_options_routes())
|
||||
.mount("/", revolt_quark::web::ratelimiter::routes())
|
||||
.mount("/swagger/", revolt_quark::web::swagger::routes())
|
||||
.mount("/", rocket_cors::catch_all_options_routes())
|
||||
.mount("/", util::ratelimiter::routes())
|
||||
.mount("/swagger/", swagger)
|
||||
.manage(authifier)
|
||||
.manage(db)
|
||||
.manage(legacy_db)
|
||||
.manage(cors.clone())
|
||||
.attach(revolt_quark::web::ratelimiter::RatelimitFairing)
|
||||
.attach(util::ratelimiter::RatelimitFairing)
|
||||
.attach(cors)
|
||||
.configure(rocket::Config {
|
||||
limits: rocket::data::Limits::default().limit("string", 5.megabytes()),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use revolt_database::util::idempotency::IdempotencyKey;
|
||||
use revolt_quark::{
|
||||
models::{message::DataMessageSend, Message, User},
|
||||
perms,
|
||||
types::push::MessageAuthor,
|
||||
web::idempotency::IdempotencyKey,
|
||||
Db, Error, Permission, Ref, Result,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_quark::models::emoji::EmojiParent;
|
||||
use revolt_quark::models::{Emoji, File, User};
|
||||
use revolt_quark::variables::delta::MAX_EMOJI_COUNT;
|
||||
@@ -5,10 +7,13 @@ use revolt_quark::{perms, Db, Error, Permission, Result};
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::util::regex::RE_EMOJI;
|
||||
|
||||
use rocket::serde::json::Json;
|
||||
|
||||
/// Regex for valid emoji names
|
||||
///
|
||||
/// Alphanumeric and underscores
|
||||
pub static RE_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9_]+$").unwrap());
|
||||
|
||||
/// # Emoji Data
|
||||
#[derive(Validate, Deserialize, JsonSchema)]
|
||||
pub struct DataCreateEmoji {
|
||||
@@ -57,7 +62,9 @@ pub async fn create_emoji(
|
||||
// ! FIXME: hardcoded upper limit
|
||||
let emojis = db.fetch_emoji_by_parent_id(&server.id).await?;
|
||||
if emojis.len() > *MAX_EMOJI_COUNT {
|
||||
return Err(Error::TooManyEmoji { max: *MAX_EMOJI_COUNT });
|
||||
return Err(Error::TooManyEmoji {
|
||||
max: *MAX_EMOJI_COUNT,
|
||||
});
|
||||
}
|
||||
}
|
||||
EmojiParent::Detached => return Err(Error::InvalidOperation),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::util::regex::RE_USERNAME;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_database::{Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_quark::authifier::models::Session;
|
||||
@@ -8,6 +9,12 @@ use rocket::{serde::json::Json, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
/// Regex for valid usernames
|
||||
///
|
||||
/// Block zero width space
|
||||
/// Block lookalike characters
|
||||
pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
|
||||
|
||||
/// # New User Data
|
||||
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct DataOnboard {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
use crate::util::regex::RE_USERNAME;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_quark::{authifier::models::Account, models::User, Database, Error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
/// Regex for valid usernames
|
||||
///
|
||||
/// Block zero width space
|
||||
/// Block lookalike characters
|
||||
pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
|
||||
|
||||
/// # Username Information
|
||||
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct DataChangeUsername {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_quark::models::user::{FieldsUser, PartialUser, User};
|
||||
use revolt_quark::models::File;
|
||||
use revolt_quark::{Database, Error, Ref, Result};
|
||||
@@ -8,7 +10,11 @@ use rocket::State;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::util::regex::RE_DISPLAY_NAME;
|
||||
/// Regex for valid display names
|
||||
///
|
||||
/// Block zero width space
|
||||
/// Block newline and carriage return
|
||||
pub static RE_DISPLAY_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap());
|
||||
|
||||
/// # Profile Data
|
||||
#[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use revolt_database::{util::reference::Reference, Database};
|
||||
use revolt_database::{
|
||||
util::{idempotency::IdempotencyKey, reference::Reference},
|
||||
Database,
|
||||
};
|
||||
use revolt_quark::{
|
||||
models::message::{DataMessageSend, Message},
|
||||
types::push::MessageAuthor,
|
||||
web::idempotency::IdempotencyKey,
|
||||
Db, Error, Result,
|
||||
};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub mod regex;
|
||||
pub mod ratelimiter;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
//! Pulled from lightspeed-tv/backend.
|
||||
//!
|
||||
//! This will be replaced again in the near future since
|
||||
//! I don't want duplication between two different projects.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
use std::ops::Add;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::authifier::models::Session;
|
||||
use revolt_quark::authifier::models::Session;
|
||||
use rocket::fairing::{Fairing, Info, Kind};
|
||||
use rocket::http::uri::Origin;
|
||||
use rocket::http::{Method, Status};
|
||||
@@ -1,19 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
/// Regex for valid display names
|
||||
///
|
||||
/// Block zero width space
|
||||
/// Block newline and carriage return
|
||||
pub static RE_DISPLAY_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap());
|
||||
|
||||
/// Regex for valid usernames
|
||||
///
|
||||
/// Block zero width space
|
||||
/// Block lookalike characters
|
||||
pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
|
||||
|
||||
/// Regex for valid emoji names
|
||||
///
|
||||
/// Alphanumeric and underscores
|
||||
pub static RE_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9_]+$").unwrap());
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use revolt_database::util::idempotency::IdempotencyKey;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
@@ -11,8 +12,7 @@ use crate::{
|
||||
},
|
||||
tasks::{ack::AckEvent, process_embeds},
|
||||
types::push::MessageAuthor,
|
||||
variables::delta::{MAX_ATTACHMENT_COUNT, MAX_REPLY_COUNT, MAX_EMBED_COUNT},
|
||||
web::idempotency::IdempotencyKey,
|
||||
variables::delta::{MAX_ATTACHMENT_COUNT, MAX_EMBED_COUNT, MAX_REPLY_COUNT},
|
||||
Database, Error, OverrideField, Ref, Result,
|
||||
};
|
||||
|
||||
@@ -413,7 +413,10 @@ impl Channel {
|
||||
) -> Result<Message> {
|
||||
Message::validate_sum(&data.content, data.embeds.as_deref().unwrap_or_default())?;
|
||||
|
||||
idempotency.consume_nonce(data.nonce).await?;
|
||||
idempotency
|
||||
.consume_nonce(data.nonce)
|
||||
.await
|
||||
.map_err(|_| Error::InvalidOperation)?;
|
||||
|
||||
// Check the message is not empty
|
||||
if (data.content.as_ref().map_or(true, |v| v.is_empty()))
|
||||
@@ -497,16 +500,24 @@ impl Channel {
|
||||
|
||||
// Add attachments to message.
|
||||
let mut attachments = vec![];
|
||||
if data.attachments.as_ref().is_some_and(|v| v.len() > *MAX_ATTACHMENT_COUNT) {
|
||||
if data
|
||||
.attachments
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.len() > *MAX_ATTACHMENT_COUNT)
|
||||
{
|
||||
return Err(Error::TooManyAttachments {
|
||||
max: *MAX_ATTACHMENT_COUNT,
|
||||
});
|
||||
}
|
||||
|
||||
if data.embeds.as_ref().is_some_and(|v| v.len() > *MAX_EMBED_COUNT) {
|
||||
if data
|
||||
.embeds
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.len() > *MAX_EMBED_COUNT)
|
||||
{
|
||||
return Err(Error::TooManyEmbeds {
|
||||
max: *MAX_EMBED_COUNT
|
||||
})
|
||||
max: *MAX_EMBED_COUNT,
|
||||
});
|
||||
}
|
||||
|
||||
for attachment_id in data.attachments.as_deref().unwrap_or_default() {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
pub use rocket_cors::catch_all_options_routes;
|
||||
use rocket_cors::{AllowedOrigins, Cors};
|
||||
|
||||
pub fn new() -> Cors {
|
||||
rocket_cors::CorsOptions {
|
||||
allowed_origins: AllowedOrigins::All,
|
||||
allowed_methods: [
|
||||
"Get", "Put", "Post", "Delete", "Options", "Head", "Trace", "Connect", "Patch",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| FromStr::from_str(s).unwrap())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
.to_cors()
|
||||
.expect("Failed to create CORS.")
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
use crate::Database;
|
||||
use rocket::State;
|
||||
|
||||
pub mod cors;
|
||||
pub mod idempotency;
|
||||
pub mod ratelimiter;
|
||||
pub mod swagger;
|
||||
|
||||
pub use rocket_empty::EmptyResponse;
|
||||
pub type Db = State<Database>;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
use rocket::Route;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
revolt_rocket_okapi::swagger_ui::make_swagger_ui(&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
|
||||
url: "../openapi.json".to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
Reference in New Issue
Block a user