mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 16:01:01 +00:00
fix(document): ignore destructive writes during module execution
Track nested ignore-destructive-writes counters per Document across module evaluation and its cleanup checkpoint. Preserve explicit input streams, cross-realm Document identity, and writes from later tasks or pending top-level await continuations. Validate root and child module behavior with five regression tests. The 149-case dynamic-markup WPT run gains four passes with unchanged remaining outcomes; update their recorded case status. cargo fmt, workspace all-feature clippy, and all 17835 nextest tests pass.
This commit is contained in:
@@ -2960,11 +2960,7 @@ html/syntax/charset/xhr.html
|
||||
html/syntax/parsing/cdata-in-integration-point-fragment.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-delayed.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-static-import-delayed.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-delayed.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/mutation-observer.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/write-active-document.html
|
||||
html/webappapis/scripting/event-loops/microtask_before_prepare_the_script_element-01.html
|
||||
|
||||
@@ -6940,7 +6940,11 @@ html/webappapis/dynamic-markup-insertion/document-write/iframe_007.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/iframe_008.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/iframe_009.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-dynamic-import.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-import.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/module.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/script_001.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/script_002.html
|
||||
html/webappapis/dynamic-markup-insertion/document-write/script_003.html
|
||||
|
||||
@@ -31,6 +31,9 @@ mod child_markup_insertion;
|
||||
#[path = "scripts/child_script_text.rs"]
|
||||
mod child_script_text;
|
||||
|
||||
#[path = "scripts/module_document_write.rs"]
|
||||
mod module_document_write;
|
||||
|
||||
fn diagnostic_global<'a>(
|
||||
page: &'a moli_core::page::Page,
|
||||
name: &str,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
use super::*;
|
||||
|
||||
fn markup_url(server: &FixtureServer, markup: &str) -> String {
|
||||
let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap();
|
||||
url.query_pairs_mut().append_pair("markup", markup);
|
||||
url.into()
|
||||
}
|
||||
|
||||
fn module_url(source: &str) -> String {
|
||||
format!(
|
||||
"data:text/javascript,{}",
|
||||
url::form_urlencoded::byte_serialize(source.as_bytes())
|
||||
.collect::<String>()
|
||||
.replace('+', "%20")
|
||||
)
|
||||
}
|
||||
|
||||
const WRITE_ATTEMPTS: &str = r#"
|
||||
for (const method of ['write', 'writeln']) {
|
||||
let converted = false;
|
||||
const result = document[method]({toString() {
|
||||
converted = true;
|
||||
return '<p id="written">written</p>';
|
||||
}});
|
||||
writeLog.push({method, converted, undefinedResult: result === undefined,
|
||||
original: !!document.getElementById('original'),
|
||||
written: !!document.getElementById('written'),
|
||||
currentScriptNull: document.currentScript === null});
|
||||
}
|
||||
document.close();
|
||||
"#;
|
||||
|
||||
async fn module_document_write_matrix(child: bool) -> Result<()> {
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
let imported = serde_json::to_string(&module_url(WRITE_ATTEMPTS))?;
|
||||
let variants = [
|
||||
("sync", WRITE_ATTEMPTS.to_owned()),
|
||||
(
|
||||
"microtask",
|
||||
format!("Promise.resolve().then(() => {{ {WRITE_ATTEMPTS} }});"),
|
||||
),
|
||||
(
|
||||
"tla-immediate",
|
||||
format!("await Promise.resolve(); {WRITE_ATTEMPTS}"),
|
||||
),
|
||||
("static-import", format!("import {imported};")),
|
||||
("external", WRITE_ATTEMPTS.to_owned()),
|
||||
(
|
||||
"throw",
|
||||
format!("{WRITE_ATTEMPTS} throw new Error('module write probe');"),
|
||||
),
|
||||
];
|
||||
let mut results = Vec::new();
|
||||
for (name, source) in variants {
|
||||
let script = if name == "external" {
|
||||
format!(
|
||||
"<script type=module src=\"{}\"></script>",
|
||||
module_url(&source)
|
||||
)
|
||||
} else {
|
||||
format!("<script type=module>{source}</script>")
|
||||
};
|
||||
let markup = format!(
|
||||
"<!doctype html><body><p id=original>original</p><script>window.writeLog = [];</script>{script}"
|
||||
);
|
||||
let source_url = markup_url(&server, &markup);
|
||||
let url = if child {
|
||||
markup_url(
|
||||
&server,
|
||||
&format!(
|
||||
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
|
||||
source_url.replace('&', "&")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
source_url
|
||||
};
|
||||
let mut page = browser.fetch(&url).await?;
|
||||
let observed = page
|
||||
.evaluate_runtime_expression_with_await_async(
|
||||
r#"(() => {
|
||||
const target = document.getElementById('target')?.contentWindow || window;
|
||||
const duringModule = target.writeLog;
|
||||
// The initial module evaluation, its cleanup checkpoint and any
|
||||
// exception handling have ended. This later write must work.
|
||||
target.document.write('<p id="late">late</p>');
|
||||
target.document.close();
|
||||
return JSON.stringify({duringModule,
|
||||
laterWrite: target.document.getElementById('late')?.textContent});
|
||||
})()"#,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let observed: serde_json::Value =
|
||||
serde_json::from_str(observed["value"].as_str().expect("module write probe"))?;
|
||||
results.push(serde_json::json!({"name": name, "observed": observed}));
|
||||
}
|
||||
server.shutdown().await;
|
||||
let expected_log = ["write", "writeln"].map(|method| {
|
||||
serde_json::json!({"method": method, "converted": true, "undefinedResult": true,
|
||||
"original": true, "written": false, "currentScriptNull": true})
|
||||
});
|
||||
let expected: Vec<_> = [
|
||||
"sync",
|
||||
"microtask",
|
||||
"tla-immediate",
|
||||
"static-import",
|
||||
"external",
|
||||
"throw",
|
||||
]
|
||||
.map(|name| {
|
||||
serde_json::json!({"name": name, "observed": {
|
||||
"duringModule": expected_log, "laterWrite": "late"
|
||||
}})
|
||||
})
|
||||
.into();
|
||||
assert_eq!(results, expected, "child={child}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn root_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> {
|
||||
module_document_write_matrix(false).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn child_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> {
|
||||
module_document_write_matrix(true).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn modules_can_explicitly_open_a_document_stream() -> Result<()> {
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
for child in [false, true] {
|
||||
let source_url = markup_url(
|
||||
&server,
|
||||
&format!(
|
||||
"<!doctype html><body><p id=original>original</p><script>window.writeLog=[];</script><script type=module>document.open();{WRITE_ATTEMPTS}</script>"
|
||||
),
|
||||
);
|
||||
let url = if child {
|
||||
markup_url(
|
||||
&server,
|
||||
&format!(
|
||||
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
|
||||
source_url.replace('&', "&")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
source_url
|
||||
};
|
||||
let mut page = browser.fetch(&url).await?;
|
||||
let observed = page
|
||||
.evaluate_runtime_expression_with_await_async(
|
||||
r#"(() => {
|
||||
const target = document.getElementById('target')?.contentWindow || window;
|
||||
return JSON.stringify({log: target.writeLog,
|
||||
body: target.document.body.textContent});
|
||||
})()"#,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let observed: serde_json::Value =
|
||||
serde_json::from_str(observed["value"].as_str().expect("explicit stream"))?;
|
||||
let expected_log = ["write", "writeln"].map(|method| {
|
||||
serde_json::json!({"method": method, "converted": true, "undefinedResult": true,
|
||||
"original": false, "written": true, "currentScriptNull": true})
|
||||
});
|
||||
assert_eq!(
|
||||
observed,
|
||||
serde_json::json!({"log": expected_log,
|
||||
"body": "writtenwritten\n"}),
|
||||
"child={child}"
|
||||
);
|
||||
}
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_write_guard_is_released_before_pending_tla_continues() -> Result<()> {
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
for child in [false, true] {
|
||||
let source_url = markup_url(
|
||||
&server,
|
||||
r#"<!doctype html><body><p id=original>original</p>
|
||||
<script type=module>
|
||||
window.moduleFinished = new Promise(resolve => window.finishModule = resolve);
|
||||
await new Promise(resolve => window.resumeModule = resolve);
|
||||
document.write('<p id="later">later</p>');
|
||||
document.close();
|
||||
finishModule();
|
||||
</script>"#,
|
||||
);
|
||||
let url = if child {
|
||||
markup_url(
|
||||
&server,
|
||||
&format!(
|
||||
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
|
||||
source_url.replace('&', "&")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
source_url
|
||||
};
|
||||
let mut page = browser
|
||||
.fetch_with_wait_until(&url, RenderedDomWaitUntil::Load, Duration::from_secs(5))
|
||||
.await?;
|
||||
let observed = page
|
||||
.evaluate_runtime_expression_with_await_async(
|
||||
r#"(async () => {
|
||||
const target = document.getElementById('target')?.contentWindow || window;
|
||||
const original = !!target.document.getElementById('original');
|
||||
const finished = target.moduleFinished;
|
||||
target.resumeModule();
|
||||
await finished;
|
||||
return JSON.stringify({original,
|
||||
later: target.document.getElementById('later')?.textContent});
|
||||
})()"#,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
let observed: serde_json::Value =
|
||||
serde_json::from_str(observed["value"].as_str().expect("pending TLA write"))?;
|
||||
assert_eq!(
|
||||
observed,
|
||||
serde_json::json!({"original": true, "later": "later"}),
|
||||
"child={child}"
|
||||
);
|
||||
}
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn module_write_guard_follows_the_document_across_callback_realms() -> Result<()> {
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
let child_url = markup_url(
|
||||
&server,
|
||||
r#"<!doctype html><body><p id=original>original</p>
|
||||
<script type=module>parent.tryWrites(document);</script>"#,
|
||||
);
|
||||
let parent = format!(
|
||||
r#"<!doctype html><body><iframe id=other></iframe>
|
||||
<script>
|
||||
window.tryWrites = protectedDocument => {{
|
||||
protectedDocument.write('<p id="blocked">blocked</p>');
|
||||
protectedDocument.close();
|
||||
const other = document.getElementById('other').contentDocument;
|
||||
other.write('<p id="allowed">allowed</p>');
|
||||
other.close();
|
||||
window.writeResult = {{protected: !!protectedDocument.getElementById('original'),
|
||||
blocked: !!protectedDocument.getElementById('blocked'),
|
||||
other: other.getElementById('allowed')?.textContent}};
|
||||
}};
|
||||
</script><iframe src="{}"></iframe>"#,
|
||||
child_url.replace('&', "&")
|
||||
);
|
||||
let mut page = browser.fetch(&markup_url(&server, &parent)).await?;
|
||||
let observed = page
|
||||
.evaluate_runtime_expression_with_await_async("JSON.stringify(writeResult)", true)
|
||||
.await?;
|
||||
server.shutdown().await;
|
||||
let observed: serde_json::Value =
|
||||
serde_json::from_str(observed["value"].as_str().expect("cross-realm writes"))?;
|
||||
assert_eq!(
|
||||
observed,
|
||||
serde_json::json!({"protected": true, "blocked": false, "other": "allowed"})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
|
||||
use url::Url;
|
||||
|
||||
mod destructive_writes;
|
||||
mod devtools_mutations;
|
||||
mod document_write;
|
||||
mod dom_facade;
|
||||
@@ -774,6 +775,7 @@ pub(super) struct DocumentRuntime {
|
||||
document_character_set: String,
|
||||
resource_loader_binding: Option<DocumentResourceLoaderBinding>,
|
||||
script_context_stack: Vec<CurrentScriptContext>,
|
||||
destructive_write_counters: destructive_writes::DocumentWriteCounters,
|
||||
root_document_parser: Option<DocumentParserSession>,
|
||||
post_parse_schedule_invalidated: bool,
|
||||
stylesheet_lifecycle: StylesheetLifecycleState,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::{cell::RefCell, collections::HashMap, rc::Rc};
|
||||
|
||||
use super::{DocumentRuntime, DomHandle};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct DocumentWriteCounters(Rc<RefCell<HashMap<DomHandle, usize>>>);
|
||||
|
||||
/// Owns an execute-script-element counter through script cleanup, including
|
||||
/// its microtask checkpoint, but not subsequent tasks or pending TLA work.
|
||||
/// The shared state avoids borrowing the runtime across JavaScript reentry.
|
||||
pub(crate) struct IgnoreDestructiveWritesGuard {
|
||||
counters: Rc<RefCell<HashMap<DomHandle, usize>>>,
|
||||
document: DomHandle,
|
||||
}
|
||||
|
||||
impl DocumentWriteCounters {
|
||||
fn enter(&self, document: DomHandle) -> IgnoreDestructiveWritesGuard {
|
||||
let mut counters = self.0.borrow_mut();
|
||||
let counter = counters.entry(document).or_default();
|
||||
*counter = counter
|
||||
.checked_add(1)
|
||||
.expect("document write counter overflow");
|
||||
IgnoreDestructiveWritesGuard {
|
||||
counters: Rc::clone(&self.0),
|
||||
document,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self, document: DomHandle) -> bool {
|
||||
self.0.borrow().contains_key(&document)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IgnoreDestructiveWritesGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut counters = self.counters.borrow_mut();
|
||||
let counter = counters
|
||||
.get_mut(&self.document)
|
||||
.expect("document write guard requires a matching counter");
|
||||
*counter -= 1;
|
||||
if *counter == 0 {
|
||||
counters.remove(&self.document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentRuntime {
|
||||
pub(crate) fn enter_ignore_destructive_writes(
|
||||
&self,
|
||||
document: DomHandle,
|
||||
) -> IgnoreDestructiveWritesGuard {
|
||||
self.destructive_write_counters.enter(document)
|
||||
}
|
||||
|
||||
pub(crate) fn has_ignore_destructive_writes_counter(&self, document: DomHandle) -> bool {
|
||||
self.destructive_write_counters.is_active(document)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn destructive_write_counters_are_nested_and_document_scoped() {
|
||||
let counters = DocumentWriteCounters::default();
|
||||
let document = DomHandle::new(0);
|
||||
let other = DomHandle::new(1);
|
||||
let outer = counters.enter(document);
|
||||
assert!(counters.is_active(document));
|
||||
assert!(!counters.is_active(other));
|
||||
let inner = counters.enter(document);
|
||||
let other_guard = counters.enter(other);
|
||||
drop(outer);
|
||||
assert!(counters.is_active(document));
|
||||
drop(inner);
|
||||
assert!(!counters.is_active(document));
|
||||
assert!(counters.is_active(other));
|
||||
drop(other_guard);
|
||||
assert!(counters.0.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_write_guard_can_outlive_retired_runtime_state() {
|
||||
let counters = DocumentWriteCounters::default();
|
||||
let guard = counters.enter(DomHandle::new(0));
|
||||
drop(counters);
|
||||
drop(guard);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ impl DocumentRuntime {
|
||||
document_character_set: "UTF-8".to_owned(),
|
||||
resource_loader_binding: None,
|
||||
script_context_stack: Vec::new(),
|
||||
destructive_write_counters: Default::default(),
|
||||
root_document_parser: None,
|
||||
post_parse_schedule_invalidated: false,
|
||||
stylesheet_lifecycle,
|
||||
@@ -215,6 +216,7 @@ impl DocumentRuntime {
|
||||
document_character_set: _,
|
||||
resource_loader_binding: _,
|
||||
script_context_stack: _,
|
||||
destructive_write_counters: _,
|
||||
root_document_parser: _,
|
||||
post_parse_schedule_invalidated: _,
|
||||
stylesheet_lifecycle: _,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#[cfg(test)]
|
||||
use crate::live_document_parser::DocumentParserLifetime;
|
||||
use crate::{
|
||||
frame_owner_model::FrameDocumentOwner,
|
||||
@@ -37,7 +36,6 @@ impl ChildDocumentParserStore {
|
||||
self.sessions.remove(&owner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::native_bridge::context_host) fn has_open_stream(
|
||||
&self,
|
||||
owner: FrameDocumentOwner,
|
||||
|
||||
@@ -346,6 +346,22 @@ fn child_document_write_or_writeln_callback<'s>(
|
||||
rv.set_undefined();
|
||||
return;
|
||||
}
|
||||
let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document)
|
||||
else {
|
||||
rv.set_undefined();
|
||||
return;
|
||||
};
|
||||
let has_open_stream = host
|
||||
.frame_owner_store
|
||||
.current_child_document_owner(handle)
|
||||
.is_some_and(|owner| host.child_document_parsers.has_open_stream(owner));
|
||||
if host.has_ignore_destructive_writes_counter(document_handle)
|
||||
&& !has_open_stream
|
||||
&& !host.child_document_is_executing_parser_script(document_handle)
|
||||
{
|
||||
rv.set_undefined();
|
||||
return;
|
||||
}
|
||||
let script_context = if host.child_document_parser_is_active(handle) {
|
||||
match unsafe { &mut *host_ptr }.ensure_prebootstrapped_child_default_context(scope, handle)
|
||||
{
|
||||
@@ -367,11 +383,6 @@ fn child_document_write_or_writeln_callback<'s>(
|
||||
};
|
||||
context
|
||||
};
|
||||
let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document)
|
||||
else {
|
||||
rv.set_undefined();
|
||||
return;
|
||||
};
|
||||
let _ = unsafe { &mut *host_ptr }.pump_child_document_write_parser(
|
||||
scope,
|
||||
script_context,
|
||||
|
||||
@@ -141,7 +141,8 @@ fn node_document_write_or_writeln_callback<'s>(
|
||||
let implicit_replacement_session = !runtime.has_active_parser_write_insertion_point()
|
||||
&& !runtime.host_document().replace_on_close();
|
||||
if implicit_replacement_session
|
||||
&& current_script_ignores_document_write_without_parser_insertion_point(runtime)
|
||||
&& (runtime.has_ignore_destructive_writes_counter(handle)
|
||||
|| current_script_ignores_document_write_without_parser_insertion_point(runtime))
|
||||
{
|
||||
rv.set_undefined();
|
||||
return;
|
||||
|
||||
@@ -3734,6 +3734,14 @@ impl ScriptVm {
|
||||
})?;
|
||||
self.document_runtime
|
||||
.mark_native_module_evaluating(root_entry);
|
||||
// currentScript is null for modules. The execute-script-element guard
|
||||
// belongs to its Document and lasts through the cleanup checkpoint,
|
||||
// not the lifetime of the module's evaluation promise.
|
||||
let _ignore_destructive_writes =
|
||||
(owner == NativeModuleEvaluationOwner::Script).then(|| {
|
||||
self.document_runtime
|
||||
.enter_ignore_destructive_writes(self.document_runtime.document_handle())
|
||||
});
|
||||
let promise = self
|
||||
.renderer_document_isolate
|
||||
.with_entered_renderer_document_isolate(|isolate| {
|
||||
|
||||
@@ -1042,6 +1042,25 @@ impl ScriptVm {
|
||||
)
|
||||
})?;
|
||||
document_modulator.mark_evaluating(root_entry);
|
||||
let _ignore_destructive_writes = if owner == NativeModuleEvaluationOwner::Script {
|
||||
let host = self._context_host.borrow();
|
||||
let document = host
|
||||
.frame_owner_current_child_snapshot_for_realm(realm_id)
|
||||
.filter(|snapshot| {
|
||||
snapshot.local_window_id == document_owner.local_window_id
|
||||
&& snapshot.document_id == document_owner.document_id
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ModuleLoadError::new(
|
||||
ModuleLoadStage::Evaluate,
|
||||
"module script has no current child Document for its execution guard",
|
||||
)
|
||||
})?;
|
||||
Some(host.enter_ignore_destructive_writes(document.document_handle))
|
||||
} else {
|
||||
// import() is not execution of a script element.
|
||||
None
|
||||
};
|
||||
let promise = self
|
||||
.renderer_document_isolate
|
||||
.with_entered_renderer_document_isolate(|isolate| {
|
||||
|
||||
Reference in New Issue
Block a user