fix(marks): v0.2.2 — nine findings from the cross-frontier bug-hunt panel
`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were real against live code; a ninth was already closed by v0.2.1 and is recorded as declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md. THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place) `_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had never been marked was left exactly as it was found. `flock` binds to an INODE: unlinking it under a blocked waiter leaves that waiter holding an exclusive lock on a deleted file while the next writer creates a fresh lock and takes it immediately. Two processes then run the read-modify-write concurrently, the later os.replace drops the earlier one's mark, and both obeyed the protocol. The cleanup existed to protect the booth's TTL, and was failing at that too: creating or removing a directory entry bumps the DIRECTORY's mtime, which is what `_newest_mtime` seeds from. The guard's comment reasons about the lock file's own mtime and misses that the directory moved underneath it. One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and restore the directory's mtime after creating one. THE READ PATH'S BLAST RADIUS `_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on `(created, id)`, so a stored `text` that was a dict or a `created` that was a number raised out of the read path. `list_booths` reads every booth's marks on every index load, so one hand-edited file returned 500 for `/` and `/healthz` across all 25 booths. Guarded in two layers — a named type check and a `_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders as ⚠ broken rather than as an empty note. ALSO - import_legacy_asks stamped `created` at whole-second resolution, so two sidecars from the same second lost the ordering the importer had just established and re-sorted alphabetically. Microseconds, per the stated `(mtime, name)` rule. - The five mark-write routes ran a blocking flock on the event loop; they now dispatch through run_in_threadpool, asserted structurally like INV-1. - `/answer` 500'd on a non-string `notes` form value where `/note` handled it. - The inline-doc tile had a flag control and no note field. - The marks panel was suppressed on any booth carrying a links.md. - The viewer's arrow keys and Escape threw away a note being typed. CLI `booth marks` printed a traceback and exited 0 on a failed read, and `--wait` emitted a whole JSON document per poll. `booth answer --wait` read a damaged file as "not yet" and spun the full hour. Both now use real exit codes — 0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait` prints once. `marks.read_error()` lets the CLI ask what the page must not: the browser stays lenient, the machine consumer gets the truth. `scripts/booth` had no tests; it has five now, run against the real script under the system python3, which also makes them a live check on INV-1. 275 tests (253 before). Live service restarted, 25/25 booth pages verified 200.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""`scripts/booth` — the surface every fleet session actually calls.
|
||||
|
||||
It had no tests at all, which the 2026-09-22 bug-hunt panel found the hard way:
|
||||
its guard-strength table returned UNVERIFIED for every CLI claim because nothing
|
||||
in the suite executes the script. Two of that round's findings live in here.
|
||||
|
||||
These run the real script under the real system `python3` with no venv, which
|
||||
also makes them a live check on INV-1 (stdlib-only): a third-party import in
|
||||
`marks.py` fails here the same way it fails on a fleet host.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
|
||||
|
||||
# Exit codes the verbs promise. 0 is a successful read; a reader that CRASHED
|
||||
# must never be one of the meaningful codes, or a caller cannot tell "no" from
|
||||
# "broken" — which is the whole finding.
|
||||
OK, UNANSWERED, NO_SUCH_PICK, READER_FAILED = 0, 1, 2, 3
|
||||
|
||||
|
||||
def run(data, *args, **kw):
|
||||
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
|
||||
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True,
|
||||
env=env, timeout=30, **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booth(tmp_path):
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
return tmp_path, b
|
||||
|
||||
|
||||
def _declare(booth_dir, mark_id="winner"):
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import declare_pick
|
||||
declare_pick(booth_dir, mark_id,
|
||||
{"prompt": "Which one?", "options": ["A", "B"]})
|
||||
|
||||
|
||||
def test_marks_prints_one_json_document(booth):
|
||||
"""`booth marks <name>` is a read. Its stdout is parsed by the session that
|
||||
called it, so it has to be ONE document — and exit 0, because the read
|
||||
succeeded. Whether a pick is open is in the payload's `open` list, which is
|
||||
where a caller should read it from."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
r = run(data, "marks", "b")
|
||||
assert r.returncode == OK, r.stderr
|
||||
doc = json.loads(r.stdout)
|
||||
assert doc["open"] == ["winner"]
|
||||
|
||||
|
||||
def test_marks_wait_prints_once_not_once_per_poll(booth):
|
||||
"""`--wait` polls every 2 s and printed the whole document on every pass, so
|
||||
a capture held several concatenated JSON values and `jq` could not read any
|
||||
of them. The wait is a wait; the print is the result."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import answer_pick
|
||||
|
||||
# Answer it after the first poll so --wait genuinely loops at least once.
|
||||
r = subprocess.Popen([str(SCRIPT), "marks", "b", "--wait", "20"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
env={**os.environ, "BOOTH_DATA_DIR": str(data),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
import time
|
||||
time.sleep(3)
|
||||
answer_pick(b, "winner", "A")
|
||||
out, err = r.communicate(timeout=30)
|
||||
assert r.returncode == OK, err
|
||||
json.loads(out) # ONE document, or this raises
|
||||
|
||||
|
||||
def test_marks_reports_a_reader_failure_instead_of_printing_garbage(booth):
|
||||
"""A traceback on stdout with exit 0 is the worst of both: the caller's `jq`
|
||||
sees success and gets nothing. A read that could not happen is its own
|
||||
answer and gets its own code."""
|
||||
data, b = booth
|
||||
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
r = run(data, "marks", "b")
|
||||
assert r.returncode == READER_FAILED, f"rc={r.returncode} out={r.stdout!r}"
|
||||
|
||||
|
||||
def test_answer_distinguishes_a_crash_from_an_unanswered_pick(booth):
|
||||
"""`answer` funnelled a reader crash and "not yet answered" through the same
|
||||
exit 1, so `--wait` spun for the full hour on a broken file and then blamed
|
||||
the operator for not answering."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
r = run(data, "answer", "b", "winner")
|
||||
assert r.returncode == UNANSWERED
|
||||
|
||||
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
r = run(data, "answer", "b", "winner", "--wait", "6")
|
||||
assert r.returncode == READER_FAILED, (
|
||||
"a crash was read as 'unanswered' and waited out the timeout"
|
||||
)
|
||||
|
||||
|
||||
def test_answer_on_a_note_id_says_no_such_pick(booth):
|
||||
"""`answer` matched on id alone while the web route filters on shape, so a
|
||||
note id was reported 'unanswered' and polled forever — a question that could
|
||||
never be answered because it was never a question."""
|
||||
data, b = booth
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import write_note
|
||||
write_note(b, "a.png", "just a note")
|
||||
|
||||
r = run(data, "answer", "b", "note-1")
|
||||
assert r.returncode == NO_SUCH_PICK
|
||||
assert "no such pick" in r.stderr
|
||||
@@ -881,3 +881,313 @@ def test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500(client):
|
||||
# the page still renders, so the operator can see the booth at all
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert c.get("/b/b/marks.json").status_code == 200
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier BUG-HUNT panel, 2026-09-22 -------------
|
||||
#
|
||||
# Heid panel (thread 01M33XEC1H0298C0D968FWBN7A). Four arms, artifact-only,
|
||||
# diff-scoped. The headline was 4/4 convergent and none of it had a guard: the
|
||||
# panel's own mutation tables showed the lock lifecycle SURVIVED every existing
|
||||
# test, because `test_a_no_op_write_does_not_touch_the_booth` asserts only that
|
||||
# `.marks.json` is absent and never looks at the lock or at the clock the
|
||||
# sweeper actually reads.
|
||||
|
||||
|
||||
def test_the_lock_file_is_never_unlinked(tmp_path):
|
||||
"""The lock must outlive the operation that created it.
|
||||
|
||||
`flock` binds to an INODE, not to a path. Unlinking `.marks.lock` while a
|
||||
second writer is blocked on it leaves that writer holding an exclusive lock
|
||||
on a deleted inode — and the next writer along creates a FRESH lock file and
|
||||
takes it immediately. Two processes then run the read-modify-write
|
||||
concurrently and the later `os.replace` drops the earlier one's mark, with
|
||||
no error anywhere. Both of them obeyed the protocol.
|
||||
|
||||
The cleanup existed to keep a no-op from leaving a lock file as its only
|
||||
trace. That is a tidiness goal, and it bought a lost-update race.
|
||||
"""
|
||||
from booth.marks import MARKS_LOCK, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
assert set_flag(booth, "ghost.png", False) is None # a no-op
|
||||
assert (booth / MARKS_LOCK).exists(), "the no-op path unlinked the lock file"
|
||||
|
||||
|
||||
def test_a_no_op_does_not_reset_the_ttl_clock(tmp_path):
|
||||
"""The property the no-op guard actually exists for, asserted against the
|
||||
clock the sweeper reads instead of against one file's absence.
|
||||
|
||||
Creating or removing a directory entry bumps the DIRECTORY's mtime, and
|
||||
`_newest_mtime` seeds from exactly that. So `touch` + `unlink` of the lock
|
||||
reset the booth's age to zero while leaving no trace behind — the comment on
|
||||
the create-only guard reasons about the lock FILE's mtime and misses that
|
||||
the directory moved underneath it. Repeated, it kept a dead booth alive
|
||||
forever, which is the precise outcome the guard was written to prevent.
|
||||
"""
|
||||
import os
|
||||
|
||||
from booth.app import booth_age_seconds
|
||||
from booth.marks import delete_mark, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(booth, (old, old))
|
||||
|
||||
set_flag(booth, "ghost.png", False) # no-op: never flagged
|
||||
delete_mark(booth, "nothing") # no-op: no such mark
|
||||
|
||||
age = booth_age_seconds(booth, now=old + 90_000)
|
||||
assert age > 86_400, f"a no-op reset the TTL clock (age fell to {age:.0f}s)"
|
||||
|
||||
|
||||
def test_a_real_mark_still_resets_the_ttl_clock(tmp_path):
|
||||
"""The other half of the same rule, so the fix cannot overshoot into
|
||||
'marking is never activity'. Marking IS activity and must reset the clock;
|
||||
only a write that changes nothing must not."""
|
||||
import os
|
||||
|
||||
from booth.app import booth_age_seconds
|
||||
from booth.marks import set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(booth, (old, old))
|
||||
|
||||
set_flag(booth, "a.png", True) # a real mark
|
||||
|
||||
assert booth_age_seconds(booth, now=old + 90_000) < 86_400
|
||||
|
||||
|
||||
def test_a_non_string_note_text_does_not_crash_the_read(tmp_path):
|
||||
"""`_clean_text` did `(text or "").replace(...)`, so a stored `text` that is
|
||||
valid JSON but not a string raised AttributeError out of the READ path.
|
||||
|
||||
That is not a marks bug, it is an INDEX bug: `list_booths` reads every
|
||||
booth's marks on every page load, so one poisoned file took down `/` and
|
||||
`/healthz` for all 25 booths. The module's stated posture is that a mark it
|
||||
cannot read renders as broken, never as a 500.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": 7,
|
||||
"created": "2026-09-21T00:00:00+00:00"}],
|
||||
}))
|
||||
|
||||
marks = marks_for(booth)
|
||||
assert len(marks) == 1
|
||||
assert marks[0].error, "a poisoned note read clean instead of reading broken"
|
||||
|
||||
|
||||
def test_a_non_string_created_does_not_crash_the_sort(tmp_path):
|
||||
"""`marks_for` sorts on `(created, id)`. A stored `created` of the wrong type
|
||||
made that comparison raise TypeError — same blast radius as the note above,
|
||||
reached through the sort rather than through hydration."""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [
|
||||
{"id": "a", "shape": "note", "text": "fine",
|
||||
"created": "2026-09-21T00:00:00+00:00"},
|
||||
{"id": "b", "shape": "note", "text": "also fine", "created": 17},
|
||||
],
|
||||
}))
|
||||
|
||||
marks = marks_for(booth)
|
||||
assert len(marks) == 2
|
||||
# An unreadable mark loses its `created` and so sorts FIRST — the stated
|
||||
# rule is `("", id)` against `(created, id)`. A mark nobody can read is the
|
||||
# one that wants looking at, and the alternative is it landing at an
|
||||
# arbitrary position in the middle of the panel.
|
||||
assert [m.id for m in marks] == ["b", "a"]
|
||||
assert marks[0].error and not marks[1].error
|
||||
|
||||
|
||||
def test_legacy_import_order_survives_same_second_mtimes(tmp_path):
|
||||
"""ROADMAP states the legacy import's order is `(mtime, name)`. It was
|
||||
stamping `created` at whole-second resolution, so two sidecars written in
|
||||
the same second lost the fractional part that distinguished them and
|
||||
`marks_for`'s `(created, id)` tie-break silently re-sorted them into
|
||||
alphabetical order — reversing the pair the importer had just ordered.
|
||||
|
||||
Deterministic order is a v1 invariant precisely because the operator refers
|
||||
to things positionally. An order that is stated and not kept is worse than
|
||||
one that was never claimed.
|
||||
"""
|
||||
import os
|
||||
|
||||
from booth.marks import import_legacy_asks
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
for stem in ("zeta", "alpha"):
|
||||
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(_single()))
|
||||
# Same whole second, different fractions: `zeta` is OLDER and must come first.
|
||||
os.utime(booth / f"zeta{ASK_SUFFIX}", (1_700_000_000.10, 1_700_000_000.10))
|
||||
os.utime(booth / f"alpha{ASK_SUFFIX}", (1_700_000_000.90, 1_700_000_000.90))
|
||||
|
||||
imported = [m.id for m in import_legacy_asks(booth)]
|
||||
assert imported == ["zeta", "alpha"], "the importer's own order is wrong"
|
||||
assert [m.id for m in marks_for(booth)] == imported, (
|
||||
"the read path re-sorted what the importer ordered"
|
||||
)
|
||||
|
||||
|
||||
def test_the_index_survives_a_poisoned_marks_file(client):
|
||||
"""The blast radius, asserted where it actually hurts.
|
||||
|
||||
`list_booths` reads every booth's marks on every index load and `/healthz`
|
||||
does the same. One hand-edited or foreign-written `.marks.json` therefore
|
||||
took down the front page for all 25 booths — the single-booth failure the
|
||||
lenient reader exists to contain, escaping the booth it belongs to.
|
||||
"""
|
||||
c, data = client
|
||||
good = data / "good"
|
||||
good.mkdir()
|
||||
_png(good / "a.png")
|
||||
bad = data / "bad"
|
||||
bad.mkdir()
|
||||
(bad / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True}, "created": 3}],
|
||||
}))
|
||||
|
||||
assert c.get("/").status_code == 200
|
||||
assert c.get("/healthz").status_code == 200
|
||||
assert c.get("/b/bad/").status_code == 200
|
||||
|
||||
|
||||
def test_answer_treats_a_non_string_notes_field_as_no_notes(client):
|
||||
"""`booth_note` guards `text` with `isinstance(..., str)`; `booth_answer`
|
||||
passed `notes` straight to `_clean_notes`, which calls `.replace` on it. A
|
||||
multipart FILE part named `notes` is a str to nobody, so the route 500'd on
|
||||
hostile-but-legal input where its sibling handled the same class of value.
|
||||
|
||||
Both routes now read the field the same way: a value that is not text is no
|
||||
value. The CHOICE is the judgment and it still lands — throwing the whole
|
||||
answer away over a junk optional field would be the wrong trade."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
declare_pick(b, "winner", _single())
|
||||
|
||||
r = c.post(
|
||||
"/b/b/answer",
|
||||
data={"ask": "winner", "choice": "A — baseline"},
|
||||
files={"notes": ("n.txt", b"surprise", "text/plain")},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
mark = next(m for m in marks_for(b) if m.id == "winner")
|
||||
assert mark.answer["choice"] == "A — baseline"
|
||||
assert not mark.answer.get("notes")
|
||||
|
||||
|
||||
def test_an_inline_doc_tile_offers_a_note_control(client):
|
||||
"""Three item branches, two of them call `marknotes`. The doc branch got the
|
||||
flag button and not the note field, so the operator could point at a report
|
||||
and not write down why — on the one item kind whose whole purpose is prose.
|
||||
|
||||
This is the exact failure the `blurtoggle` macro comment names ("patched two
|
||||
of three"), recurring on the macro that was written to prevent it.
|
||||
"""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "report.md").write_text("# report\n\nprose here\n")
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert 'value="report.md"' in html, "the doc tile has no mark controls at all"
|
||||
# `marknotes`' add-field, which only that macro emits. The booth-level panel
|
||||
# has its own note form, so the presence of /note on the page proves nothing.
|
||||
assert 'placeholder="a note on this item"' in html, (
|
||||
"an inline doc tile has no way to add a note"
|
||||
)
|
||||
|
||||
|
||||
def test_the_marks_panel_survives_a_booth_that_also_has_a_link_board(client):
|
||||
"""The board booth renders as a board instead of a gallery, which is right —
|
||||
but the suppression was unconditional, so a pick declared on a booth that
|
||||
happens to carry a `links.md` had no form to answer it and no way to say so."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "links.md").write_text("- [a thing](http://example.invalid) <sub>· who · when</sub>\n")
|
||||
declare_pick(b, "winner", _single())
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert "Which render wins?" in html, "a pick on a board booth was unanswerable"
|
||||
|
||||
|
||||
def test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed(client):
|
||||
"""The viewer's arrow keys move between images and Escape goes back. The
|
||||
note textarea landed in the same page, and the handler is on `document`, so
|
||||
an arrow key meant for the caret threw away the draft instead of moving it.
|
||||
|
||||
Asserted structurally: the handler must bail on events from an editable
|
||||
target. There is no browser in this suite, and a guard nobody can test is
|
||||
exactly how this shipped."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
_png(b / "a.png")
|
||||
|
||||
js = c.get("/b/b/view?f=a.png").text
|
||||
assert "isEditable" in js, "the viewer's key handler has no editing guard"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["booth_answer", "booth_note", "booth_flag",
|
||||
"booth_unmark", "booth_import_asks"])
|
||||
def test_mark_writes_do_not_block_the_event_loop(route):
|
||||
"""Every mark write takes a blocking `flock` and does synchronous disk I/O.
|
||||
In an `async def` handler that runs ON the event loop, so a lock held by
|
||||
another process — the CLI mid-`marks-import`, a second browser tab — freezes
|
||||
every other request, including the index and `/healthz`.
|
||||
|
||||
Structural, like `test_stdlib_only`, and for the same reason: the failure is
|
||||
a property of where the call runs, which no single-process response
|
||||
assertion can see. The rule is that an async mark-write handler hands the
|
||||
locked section to a worker thread and never calls the writer inline.
|
||||
"""
|
||||
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
|
||||
fn = next(
|
||||
n for n in ast.walk(ast.parse(src.read_text()))
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == route
|
||||
)
|
||||
writers = {"answer_pick", "write_note", "set_flag", "delete_mark",
|
||||
"import_legacy_asks"}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
||||
if name in writers:
|
||||
pytest.fail(f"{route} calls {name}() on the event loop; "
|
||||
"dispatch it through run_in_threadpool")
|
||||
|
||||
|
||||
def test_an_unreadable_mark_is_visible_on_the_page(client):
|
||||
"""Surviving the poisoned file is half of it. A note whose stored `text` is
|
||||
unreadable hydrates with empty text, and the panel rendered that as an empty
|
||||
`<pre>` with a withdraw button beside it — which looks exactly like a note
|
||||
the operator wrote and then cleared.
|
||||
|
||||
`_hydrate`'s own docstring forbids this for picks ("a broken question the
|
||||
session believes it posted has to be visible — silently hiding it is the one
|
||||
outcome nobody can debug"). It is the same argument for every shape."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True},
|
||||
"created": "2026-09-21T00:00:00+00:00"}],
|
||||
}))
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
|
||||
assert "n1" in html
|
||||
|
||||
Reference in New Issue
Block a user