restore the extract function for server functions (#4849)
* restore the extract function for server functions * fix compilation * Move most fields under one shared lock * fix typo * fix clippy * restore somewhat misleading FromRequest bound * remove unused import * fix doc links
This commit is contained in:
1 parent
5786c9396f
commit
e656bace5c
8 files changed
+169
-52
No files matched your search
Generated
+2
@@ -5861,9 +5861,11 @@ dependencies = [
|
|||||||
"generational-box",
|
"generational-box",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
"parking_lot",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ inventory = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
generational-box = { workspace = true }
|
generational-box = { workspace = true }
|
||||||
futures-util = { workspace = true, features = ["std"] }
|
futures-util = { workspace = true, features = ["std"] }
|
||||||
|
tokio = { workspace = true, features = ["rt"] }
|
||||||
|
parking_lot = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
web = []
|
web = []
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
use crate::{HttpError, ServerFnError};
|
use crate::{HttpError, ServerFnError};
|
||||||
use axum_core::{extract::FromRequest, response::IntoResponse};
|
use axum_core::extract::FromRequest;
|
||||||
use dioxus_core::{try_consume_context, CapturedError};
|
use axum_core::response::IntoResponse;
|
||||||
use dioxus_signals::{ReadableExt, Signal, WritableExt};
|
use dioxus_core::{CapturedError, ReactiveContext};
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use http::{request::Parts, HeaderMap};
|
use http::{request::Parts, HeaderMap};
|
||||||
use std::{cell::RefCell, rc::Rc};
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
static FULLSTACK_CONTEXT: FullstackContext;
|
||||||
|
}
|
||||||
|
|
||||||
/// The context provided by dioxus fullstack for server-side rendering.
|
/// The context provided by dioxus fullstack for server-side rendering.
|
||||||
///
|
///
|
||||||
/// This context will only be set on the server during a streaming response.
|
/// This context will only be set on the server during the initial streaming response
|
||||||
|
/// and inside server functions.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct FullstackContext {
|
pub struct FullstackContext {
|
||||||
current_status: Signal<StreamingStatus>,
|
// We expose the lock for request headers directly so it needs to be in a separate lock
|
||||||
request_headers: Rc<RefCell<http::request::Parts>>,
|
request_headers: Arc<RwLock<http::request::Parts>>,
|
||||||
response_headers: Rc<RefCell<Option<HeaderMap>>>,
|
// The rest of the fields are only held internally, so we can group them together
|
||||||
route_http_status: Signal<HttpError>,
|
lock: Arc<RwLock<FullstackContextInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FullstackContextInner {
|
||||||
|
current_status: StreamingStatus,
|
||||||
|
current_status_subscribers: HashSet<ReactiveContext>,
|
||||||
|
response_headers: Option<HeaderMap>,
|
||||||
|
route_http_status: HttpError,
|
||||||
|
route_http_status_subscribers: HashSet<ReactiveContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for FullstackContextInner {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("FullstackContextInner")
|
||||||
|
.field("current_status", &self.current_status)
|
||||||
|
.field("response_headers", &self.response_headers)
|
||||||
|
.field("route_http_status", &self.route_http_status)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for FullstackContext {
|
impl PartialEq for FullstackContext {
|
||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
self.current_status == other.current_status
|
Arc::ptr_eq(&self.lock, &other.lock)
|
||||||
&& Rc::ptr_eq(&self.request_headers, &other.request_headers)
|
&& Arc::ptr_eq(&self.request_headers, &other.request_headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +55,18 @@ impl FullstackContext {
|
|||||||
/// provide this context for you.
|
/// provide this context for you.
|
||||||
pub fn new(parts: Parts) -> Self {
|
pub fn new(parts: Parts) -> Self {
|
||||||
Self {
|
Self {
|
||||||
current_status: Signal::new(StreamingStatus::RenderingInitialChunk),
|
request_headers: RwLock::new(parts).into(),
|
||||||
request_headers: Rc::new(RefCell::new(parts)),
|
lock: RwLock::new(FullstackContextInner {
|
||||||
route_http_status: Signal::new(HttpError {
|
current_status: StreamingStatus::RenderingInitialChunk,
|
||||||
status: http::StatusCode::OK,
|
current_status_subscribers: Default::default(),
|
||||||
message: None,
|
route_http_status: HttpError {
|
||||||
}),
|
status: http::StatusCode::OK,
|
||||||
response_headers: Rc::new(RefCell::new(Some(HeaderMap::new()))),
|
message: None,
|
||||||
|
},
|
||||||
|
route_http_status_subscribers: Default::default(),
|
||||||
|
response_headers: Some(HeaderMap::new()),
|
||||||
|
})
|
||||||
|
.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,30 +76,50 @@ impl FullstackContext {
|
|||||||
///
|
///
|
||||||
/// Once this method has been called, the http response parts can no longer be modified.
|
/// Once this method has been called, the http response parts can no longer be modified.
|
||||||
pub fn commit_initial_chunk(&mut self) {
|
pub fn commit_initial_chunk(&mut self) {
|
||||||
self.current_status
|
let mut lock = self.lock.write();
|
||||||
.set(StreamingStatus::InitialChunkCommitted);
|
lock.current_status = StreamingStatus::InitialChunkCommitted;
|
||||||
|
// The key type is mutable, but the hash is stable through mutations because we hash by pointer
|
||||||
|
#[allow(clippy::mutable_key_type)]
|
||||||
|
let subscribers = std::mem::take(&mut lock.current_status_subscribers);
|
||||||
|
for subscriber in subscribers {
|
||||||
|
subscriber.mark_dirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current status of the streaming response. This method is reactive and will cause
|
/// Get the current status of the streaming response. This method is reactive and will cause
|
||||||
/// the current reactive context to rerun when the status changes.
|
/// the current reactive context to rerun when the status changes.
|
||||||
pub fn streaming_state(&self) -> StreamingStatus {
|
pub fn streaming_state(&self) -> StreamingStatus {
|
||||||
*self.current_status.read()
|
let mut lock = self.lock.write();
|
||||||
|
// Register the current reactive context as a subscriber to changes in the streaming status
|
||||||
|
if let Some(ctx) = ReactiveContext::current() {
|
||||||
|
lock.current_status_subscribers.insert(ctx);
|
||||||
|
}
|
||||||
|
lock.current_status
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the http request parts mutably. This will allow you to modify headers and other parts of the request.
|
/// Access the http request parts mutably. This will allow you to modify headers and other parts of the request.
|
||||||
pub fn parts_mut(&self) -> std::cell::RefMut<'_, http::request::Parts> {
|
pub fn parts_mut(&self) -> parking_lot::RwLockWriteGuard<'_, http::request::Parts> {
|
||||||
self.request_headers.borrow_mut()
|
self.request_headers.write()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract an axum extractor from the current request. This will always use an empty body for the request,
|
/// Run a future within the scope of this FullstackContext.
|
||||||
/// since it's assumed that rendering the app is done under a `GET` request.
|
pub async fn scope<F, R>(self, fut: F) -> R
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = R>,
|
||||||
|
{
|
||||||
|
FULLSTACK_CONTEXT.scope(self, fut).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract an axum extractor from the current request.
|
||||||
|
///
|
||||||
|
/// The body of the request is always empty when using this method, as the body can only be consumed once in the server
|
||||||
|
/// function extractors.
|
||||||
pub async fn extract<T: FromRequest<(), M>, M>() -> Result<T, ServerFnError> {
|
pub async fn extract<T: FromRequest<(), M>, M>() -> Result<T, ServerFnError> {
|
||||||
let this = Self::current()
|
let this = Self::current()
|
||||||
.ok_or_else(|| ServerFnError::new("No FullstackContext found".to_string()))?;
|
.ok_or_else(|| ServerFnError::new("No FullstackContext found".to_string()))?;
|
||||||
|
|
||||||
let parts = this.request_headers.borrow_mut().clone();
|
let parts = this.request_headers.read().clone();
|
||||||
let request =
|
let request = axum_core::extract::Request::from_parts(parts, Default::default());
|
||||||
axum_core::extract::Request::from_parts(parts, axum_core::body::Body::empty());
|
|
||||||
match T::from_request(request, &()).await {
|
match T::from_request(request, &()).await {
|
||||||
Ok(res) => Ok(res),
|
Ok(res) => Ok(res),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -79,8 +130,14 @@ impl FullstackContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current `FullstackContext` if it exists. This will return `None` if called on the client
|
/// Get the current `FullstackContext` if it exists. This will return `None` if called on the client
|
||||||
/// or outside of a streaming response on the server.
|
/// or outside of a streaming response on the server or server function.
|
||||||
pub fn current() -> Option<Self> {
|
pub fn current() -> Option<Self> {
|
||||||
|
// Try to get the context from the task local (for server functions)
|
||||||
|
if let Ok(context) = FULLSTACK_CONTEXT.try_get() {
|
||||||
|
return Some(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to get it from the dioxus runtime context (for streaming SSR)
|
||||||
if let Some(rt) = dioxus_core::Runtime::try_current() {
|
if let Some(rt) = dioxus_core::Runtime::try_current() {
|
||||||
let id = rt.try_current_scope_id()?;
|
let id = rt.try_current_scope_id()?;
|
||||||
if let Some(ctx) = rt.consume_context::<FullstackContext>(id) {
|
if let Some(ctx) = rt.consume_context::<FullstackContext>(id) {
|
||||||
@@ -94,11 +151,23 @@ impl FullstackContext {
|
|||||||
/// Get the current HTTP status for the route. This will default to 200 OK, but can be modified
|
/// Get the current HTTP status for the route. This will default to 200 OK, but can be modified
|
||||||
/// by calling `FullstackContext::commit_error_status` with an error.
|
/// by calling `FullstackContext::commit_error_status` with an error.
|
||||||
pub fn current_http_status(&self) -> HttpError {
|
pub fn current_http_status(&self) -> HttpError {
|
||||||
self.route_http_status.read().clone()
|
let mut lock = self.lock.write();
|
||||||
|
// Register the current reactive context as a subscriber to changes in the http status
|
||||||
|
if let Some(ctx) = ReactiveContext::current() {
|
||||||
|
lock.route_http_status_subscribers.insert(ctx);
|
||||||
|
}
|
||||||
|
lock.route_http_status.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_current_http_status(&mut self, status: HttpError) {
|
pub fn set_current_http_status(&mut self, status: HttpError) {
|
||||||
self.route_http_status.set(status);
|
let mut lock = self.lock.write();
|
||||||
|
lock.route_http_status = status;
|
||||||
|
// The key type is mutable, but the hash is stable through mutations because we hash by pointer
|
||||||
|
#[allow(clippy::mutable_key_type)]
|
||||||
|
let subscribers = std::mem::take(&mut lock.route_http_status_subscribers);
|
||||||
|
for subscriber in subscribers {
|
||||||
|
subscriber.mark_dirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a header to the response. This will be sent to the client when the response is committed.
|
/// Add a header to the response. This will be sent to the client when the response is committed.
|
||||||
@@ -107,7 +176,8 @@ impl FullstackContext {
|
|||||||
key: impl Into<http::header::HeaderName>,
|
key: impl Into<http::header::HeaderName>,
|
||||||
value: impl Into<http::header::HeaderValue>,
|
value: impl Into<http::header::HeaderValue>,
|
||||||
) {
|
) {
|
||||||
if let Some(headers) = self.response_headers.borrow_mut().as_mut() {
|
let mut lock = self.lock.write();
|
||||||
|
if let Some(headers) = lock.response_headers.as_mut() {
|
||||||
headers.insert(key.into(), value.into());
|
headers.insert(key.into(), value.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +185,8 @@ impl FullstackContext {
|
|||||||
/// Take the response headers out of the context. This will leave the context without any headers,
|
/// Take the response headers out of the context. This will leave the context without any headers,
|
||||||
/// so it should only be called once when the response is being committed.
|
/// so it should only be called once when the response is being committed.
|
||||||
pub fn take_response_headers(&self) -> Option<HeaderMap> {
|
pub fn take_response_headers(&self) -> Option<HeaderMap> {
|
||||||
self.response_headers.borrow_mut().take()
|
let mut lock = self.lock.write();
|
||||||
|
lock.response_headers.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the current HTTP status for the route. This will be used when committing the response
|
/// Set the current HTTP status for the route. This will be used when committing the response
|
||||||
@@ -177,11 +248,18 @@ pub enum StreamingStatus {
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn commit_initial_chunk() {
|
pub fn commit_initial_chunk() {
|
||||||
crate::history::finalize_route();
|
crate::history::finalize_route();
|
||||||
if let Some(mut streaming) = try_consume_context::<FullstackContext>() {
|
if let Some(mut streaming) = FullstackContext::current() {
|
||||||
streaming.commit_initial_chunk();
|
streaming.commit_initial_chunk();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract an axum extractor from the current request.
|
||||||
|
#[deprecated(note = "Use FullstackContext::extract instead", since = "0.7.0")]
|
||||||
|
pub fn extract<T: FromRequest<(), M>, M>(
|
||||||
|
) -> impl std::future::Future<Output = Result<T, ServerFnError>> {
|
||||||
|
FullstackContext::extract::<T, M>()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the current status of the streaming response. This method is reactive and will cause
|
/// Get the current status of the streaming response. This method is reactive and will cause
|
||||||
/// the current reactive context to rerun when the status changes.
|
/// the current reactive context to rerun when the status changes.
|
||||||
///
|
///
|
||||||
@@ -205,7 +283,7 @@ pub fn commit_initial_chunk() {
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn current_status() -> StreamingStatus {
|
pub fn current_status() -> StreamingStatus {
|
||||||
if let Some(streaming) = try_consume_context::<FullstackContext>() {
|
if let Some(streaming) = FullstackContext::current() {
|
||||||
streaming.streaming_state()
|
streaming.streaming_state()
|
||||||
} else {
|
} else {
|
||||||
StreamingStatus::InitialChunkCommitted
|
StreamingStatus::InitialChunkCommitted
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use axum::body::Body;
|
|||||||
use axum::routing::MethodRouter;
|
use axum::routing::MethodRouter;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use dioxus_fullstack_core::DioxusServerState;
|
use dioxus_fullstack_core::{DioxusServerState, FullstackContext};
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use std::{marker::PhantomData, sync::LazyLock};
|
use std::{marker::PhantomData, sync::LazyLock};
|
||||||
|
|
||||||
@@ -76,9 +76,39 @@ impl ServerFunction {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
async fn server_context_middleware(
|
||||||
|
request: axum::extract::Request,
|
||||||
|
next: axum::middleware::Next,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let server_context = FullstackContext::new(parts.clone());
|
||||||
|
let request = axum::extract::Request::from_parts(parts, body);
|
||||||
|
|
||||||
|
server_context
|
||||||
|
.scope(async move {
|
||||||
|
// Run the next middleware / handler inside the server context
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
|
||||||
|
let server_context = FullstackContext::current().expect(
|
||||||
|
"Server context should be available inside the server context scope",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the extra response headers set during the handler and add them to the response
|
||||||
|
let headers = server_context.take_response_headers();
|
||||||
|
if let Some(headers) = headers {
|
||||||
|
response.headers_mut().extend(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
router.route(
|
router.route(
|
||||||
self.path(),
|
self.path(),
|
||||||
((self.handler)()).with_state(DioxusServerState {}),
|
((self.handler)())
|
||||||
|
.with_state(DioxusServerState {})
|
||||||
|
.layer(axum::middleware::from_fn(server_context_middleware)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
|||||||
/// *last* if there are multiple extractors in a handler.
|
/// *last* if there are multiple extractors in a handler.
|
||||||
/// See ["the order of extractors"][order-of-extractors]
|
/// See ["the order of extractors"][order-of-extractors]
|
||||||
///
|
///
|
||||||
/// [order-of-extractors]: crate::extract#the-order-of-extractors
|
/// [order-of-extractors]: mod@crate::extract#the-order-of-extractors
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub struct Cbor<T>(pub T);
|
pub struct Cbor<T>(pub T);
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ use axum::extract::multipart::{Field, MultipartError};
|
|||||||
/// `Multipart` extractor must be *last* if there are multiple extractors in a handler.
|
/// `Multipart` extractor must be *last* if there are multiple extractors in a handler.
|
||||||
/// See ["the order of extractors"][order-of-extractors]
|
/// See ["the order of extractors"][order-of-extractors]
|
||||||
///
|
///
|
||||||
/// [order-of-extractors]: crate::extract#the-order-of-extractors
|
/// [order-of-extractors]: mod@crate::extract#the-order-of-extractors
|
||||||
///
|
///
|
||||||
/// # Large Files
|
/// # Large Files
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use std::future::Future;
|
|||||||
/// *last* if there are multiple extractors in a handler.
|
/// *last* if there are multiple extractors in a handler.
|
||||||
/// See ["the order of extractors"][order-of-extractors]
|
/// See ["the order of extractors"][order-of-extractors]
|
||||||
///
|
///
|
||||||
/// [order-of-extractors]: crate::extract#the-order-of-extractors
|
/// [order-of-extractors]: mod@crate::extract#the-order-of-extractors
|
||||||
pub struct Postcard<T>(pub T);
|
pub struct Postcard<T>(pub T);
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ use dioxus::fullstack::{commit_initial_chunk, Websocket};
|
|||||||
use dioxus::{fullstack::WebSocketOptions, prelude::*};
|
use dioxus::{fullstack::WebSocketOptions, prelude::*};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
dioxus::LaunchBuilder::new()
|
#[cfg(feature = "server")]
|
||||||
.with_cfg(server_only! {
|
dioxus::serve(|| async move {
|
||||||
dioxus::server::ServeConfig::builder().enable_out_of_order_streaming()
|
use dioxus::server::axum::{self, Extension};
|
||||||
})
|
|
||||||
.with_context(1234u32)
|
let cfg = dioxus::server::ServeConfig::builder().enable_out_of_order_streaming();
|
||||||
.launch(app);
|
let router = axum::Router::new()
|
||||||
|
.serve_dioxus_application(cfg, app)
|
||||||
|
.layer(Extension(1234u32));
|
||||||
|
|
||||||
|
Ok(router)
|
||||||
|
});
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
launch(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app() -> Element {
|
fn app() -> Element {
|
||||||
@@ -74,13 +81,12 @@ fn DefaultServerFnCodec() -> Element {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
async fn assert_server_context_provided() {
|
async fn assert_server_context_provided() {
|
||||||
// todo!("replace server context....")
|
use dioxus::{fullstack::FullstackContext, server::axum::Extension};
|
||||||
// use dioxus::server::{extract, FromContext};
|
// Just make sure the server context is provided
|
||||||
// let FromContext(i): FromContext<u32> = extract().await.unwrap();
|
let Extension(id): Extension<u32> = FullstackContext::extract().await.unwrap();
|
||||||
// assert_eq!(i, 1234u32);
|
assert_eq!(id, 1234u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[server(PostServerData)]
|
|
||||||
#[server]
|
#[server]
|
||||||
async fn post_server_data(data: String) -> ServerFnResult {
|
async fn post_server_data(data: String) -> ServerFnResult {
|
||||||
assert_server_context_provided().await;
|
assert_server_context_provided().await;
|
||||||
@@ -89,7 +95,6 @@ async fn post_server_data(data: String) -> ServerFnResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// #[server(GetServerData)]
|
|
||||||
#[server]
|
#[server]
|
||||||
async fn get_server_data() -> ServerFnResult<String> {
|
async fn get_server_data() -> ServerFnResult<String> {
|
||||||
assert_server_context_provided().await;
|
assert_server_context_provided().await;
|
||||||
|
|||||||
Reference in new issue
Block a user