feat: HTTP-Server der Recovery-Station
Liefert ausschliesslich Images aus, deren Name dem Muster ninlight_<target>_ota.img entspricht, unterstuetzt Range-Anfragen fuer den Wiederaufnahmeversuch der Firmware und schreibt seinen Zustand nach status.json - die Schnittstelle fuer die E-Paper-Anzeige in 4c. Behebt zwei Races aus der Referenzimplementierung des Task-Briefs: der Server trug Erfolg/404 erst nach dem letzten gesendeten Byte in status.json ein, wodurch ein Client, der die Antwort vollstaendig gelesen hat, noch den alten Zustand sehen konnte. Ausserdem wurde ein ganzer 32-KiB-Chunk auch dann komplett gesendet, wenn --stall-after schon ueberschritten war; die Lesegroesse wird jetzt an die Stall-Grenze gekappt, und ein Verbindungsabbruch waehrend des Stalls wird per select/MSG_PEEK erkannt und als "aborted" vermerkt statt den Thread endlos zu blockieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
76a3253322
commit
37f4dd425e
@@ -0,0 +1,8 @@
|
|||||||
|
[project]
|
||||||
|
name = "ninlight-station"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "recovery station HTTP server for ninlight lamps"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
Executable
+230
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ninlight recovery station.
|
||||||
|
|
||||||
|
Serves firmware images to lamps that look for a rescue station while booting. Deliberately
|
||||||
|
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a
|
||||||
|
stray file in the image directory can never leave the machine.
|
||||||
|
|
||||||
|
Standard library only - the Pi gets no extra packages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw")
|
||||||
|
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||||
|
TRAILER_SIZE = 512
|
||||||
|
TRAILER_MAGIC = b"NINL"
|
||||||
|
VERSION_OFF = 21
|
||||||
|
VERSION_LEN = 16
|
||||||
|
RECENT_MAX = 8
|
||||||
|
CHUNK = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def trailer_version(path: Path) -> str:
|
||||||
|
"""Firmware version from the image trailer, or '?' if the file carries none."""
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
if path.stat().st_size < TRAILER_SIZE:
|
||||||
|
return "?"
|
||||||
|
f.seek(-TRAILER_SIZE, os.SEEK_END)
|
||||||
|
tail = f.read(TRAILER_SIZE)
|
||||||
|
except OSError:
|
||||||
|
return "?"
|
||||||
|
if tail[0:4] != TRAILER_MAGIC:
|
||||||
|
return "?"
|
||||||
|
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
class Station:
|
||||||
|
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None:
|
||||||
|
self.image_dir = image_dir
|
||||||
|
self.state_path = state_path
|
||||||
|
self.stall_after = stall_after
|
||||||
|
self.since = now_iso()
|
||||||
|
self.served = 0
|
||||||
|
self.recent: list[dict] = []
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
|
def image_path(self, target: str) -> Path:
|
||||||
|
return self.image_dir / f"ninlight_{target}_ota.img"
|
||||||
|
|
||||||
|
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
|
||||||
|
if status == "ok":
|
||||||
|
self.served += 1
|
||||||
|
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
|
||||||
|
"version": version, "bytes": nbytes, "status": status})
|
||||||
|
del self.recent[:-RECENT_MAX]
|
||||||
|
self.write_state()
|
||||||
|
|
||||||
|
def write_state(self) -> None:
|
||||||
|
payload = {"since": self.since, "served": self.served, "recent": self.recent}
|
||||||
|
try:
|
||||||
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
os.replace(tmp, self.state_path)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
server_version = "ninlight-station"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def station(self) -> Station:
|
||||||
|
return self.server.station # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
|
||||||
|
pass
|
||||||
|
|
||||||
|
def log(self, status: int, nbytes: int, started: float) -> None:
|
||||||
|
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
|
||||||
|
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
self.reject(405, "method not allowed")
|
||||||
|
|
||||||
|
do_PUT = do_DELETE = do_HEAD = do_POST
|
||||||
|
|
||||||
|
def reject(self, status: int, text: str) -> None:
|
||||||
|
body = (text + "\n").encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
started = time.monotonic()
|
||||||
|
m = PATH_RE.match(self.path)
|
||||||
|
if not m:
|
||||||
|
self.reject(404, "not found")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
target = m.group(1)
|
||||||
|
path = self.station.image_path(target)
|
||||||
|
if not path.is_file():
|
||||||
|
# Record before responding: a client that sees the 404 complete may check
|
||||||
|
# status.json right away, so the entry must already be on disk by then.
|
||||||
|
self.station.record(self.client_address[0], target, "?", 0, "notfound")
|
||||||
|
self.reject(404, "no image for this device")
|
||||||
|
self.log(404, 0, started)
|
||||||
|
return
|
||||||
|
|
||||||
|
size = path.stat().st_size
|
||||||
|
start = 0
|
||||||
|
rng = self.headers.get("Range", "")
|
||||||
|
if rng.startswith("bytes="):
|
||||||
|
first = rng.split("=", 1)[1].split("-", 1)[0]
|
||||||
|
if first.isdigit() and int(first) < size:
|
||||||
|
start = int(first)
|
||||||
|
|
||||||
|
version = trailer_version(path)
|
||||||
|
self.send_response(206 if start else 200)
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(size - start))
|
||||||
|
if start:
|
||||||
|
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
|
||||||
|
self.send_header("Accept-Ranges", "bytes")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
status = "ok"
|
||||||
|
recorded = False
|
||||||
|
remaining = size - start
|
||||||
|
try:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
f.seek(start)
|
||||||
|
while sent < remaining:
|
||||||
|
if self.station.stall_after is not None and sent >= self.station.stall_after:
|
||||||
|
# test aid: hold the connection open and send nothing more, so the
|
||||||
|
# firmware's stall handling can be exercised on real hardware. We
|
||||||
|
# still notice if the peer gives up and closes its side, so a test
|
||||||
|
# (or an impatient client) that hangs up is recorded as "aborted"
|
||||||
|
# instead of leaking the thread forever.
|
||||||
|
while True:
|
||||||
|
readable, _, _ = select.select([self.connection], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
try:
|
||||||
|
peek = self.connection.recv(1, socket.MSG_PEEK)
|
||||||
|
except OSError:
|
||||||
|
peek = b""
|
||||||
|
if not peek:
|
||||||
|
raise ConnectionResetError("peer closed during stall")
|
||||||
|
read_size = CHUNK
|
||||||
|
if self.station.stall_after is not None:
|
||||||
|
# Don't overshoot the stall boundary within a single read: without
|
||||||
|
# this a whole CHUNK could be sent before the check above ever
|
||||||
|
# notices we've passed --stall-after.
|
||||||
|
read_size = min(read_size, max(1, self.station.stall_after - sent))
|
||||||
|
chunk = f.read(read_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if sent + len(chunk) >= remaining:
|
||||||
|
# Record success before the last bytes go out: the lamp (and our
|
||||||
|
# tests) may observe the response completing and immediately check
|
||||||
|
# status.json, so the state must already be on disk by then.
|
||||||
|
self.station.record(self.client_address[0], target, version,
|
||||||
|
sent + len(chunk), "ok")
|
||||||
|
recorded = True
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
sent += len(chunk)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||||
|
status = "aborted"
|
||||||
|
self.log(206 if start else 200, sent, started)
|
||||||
|
if not recorded:
|
||||||
|
self.station.record(self.client_address[0], target, version, sent, status)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server")
|
||||||
|
p.add_argument("--image-dir", required=True, type=Path)
|
||||||
|
p.add_argument("--bind", default="192.168.8.1")
|
||||||
|
p.add_argument("--port", type=int, default=80)
|
||||||
|
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
|
||||||
|
p.add_argument("--stall-after", type=int, default=None,
|
||||||
|
help="test aid: stop sending after N bytes but keep the connection open")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
|
||||||
|
if not a.image_dir.is_dir():
|
||||||
|
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
station = Station(a.image_dir, a.state, a.stall_after)
|
||||||
|
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
|
||||||
|
httpd.daemon_threads = True
|
||||||
|
httpd.station = station # type: ignore[attr-defined]
|
||||||
|
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
|
||||||
|
print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): "
|
||||||
|
f"{', '.join(images) or 'none'}", flush=True)
|
||||||
|
if a.stall_after is not None:
|
||||||
|
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import hashlib
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SERVER = Path(__file__).resolve().parents[1] / "server.py"
|
||||||
|
TRAILER_SIZE = 512
|
||||||
|
|
||||||
|
|
||||||
|
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
|
||||||
|
"""Builds a file with the same trailer layout the firmware verifies."""
|
||||||
|
trailer = bytearray(b"\xff" * TRAILER_SIZE)
|
||||||
|
trailer[0:4] = b"NINL"
|
||||||
|
trailer[4] = 1
|
||||||
|
trailer[5:21] = device.encode().ljust(16, b"\x00")
|
||||||
|
trailer[21:37] = version.encode().ljust(16, b"\x00")
|
||||||
|
trailer[37:69] = hashlib.sha256(payload).digest()
|
||||||
|
data = payload + bytes(trailer)
|
||||||
|
path.write_bytes(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
class Station:
|
||||||
|
def __init__(self, tmp_path, *extra):
|
||||||
|
self.image_dir = tmp_path / "images"
|
||||||
|
self.image_dir.mkdir()
|
||||||
|
self.state = tmp_path / "status.json"
|
||||||
|
self.port = free_port()
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
|
||||||
|
"--bind", "127.0.0.1", "--port", str(self.port),
|
||||||
|
"--state", str(self.state), *extra],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
)
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise RuntimeError("server did not come up")
|
||||||
|
|
||||||
|
def conn(self):
|
||||||
|
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||||
|
|
||||||
|
def read_state(self):
|
||||||
|
for _ in range(50):
|
||||||
|
if self.state.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(self.state.read_text())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError("no state file written")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.proc.terminate()
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def station(tmp_path):
|
||||||
|
s = Station(tmp_path)
|
||||||
|
yield s
|
||||||
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serves_a_known_image(station):
|
||||||
|
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
|
r = c.getresponse()
|
||||||
|
body = r.read()
|
||||||
|
assert r.status == 200
|
||||||
|
assert int(r.getheader("Content-Length")) == len(data)
|
||||||
|
assert body == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_image_is_404(station):
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||||
|
assert c.getresponse().status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_target_is_404(station):
|
||||||
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
||||||
|
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", path)
|
||||||
|
assert c.getresponse().status == 404, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_traversal_is_refused(station):
|
||||||
|
secret = station.image_dir.parent / "secret.txt"
|
||||||
|
secret.write_text("nicht ausliefern")
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/../secret.txt")
|
||||||
|
assert c.getresponse().status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_methods_are_405(station):
|
||||||
|
c = station.conn()
|
||||||
|
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
||||||
|
assert c.getresponse().status == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_request_returns_206_and_the_right_slice(station):
|
||||||
|
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
||||||
|
r = c.getresponse()
|
||||||
|
body = r.read()
|
||||||
|
assert r.status == 206
|
||||||
|
assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}"
|
||||||
|
assert body == data[1000:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_file_records_a_delivery(station):
|
||||||
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
|
c.getresponse().read()
|
||||||
|
state = station.read_state()
|
||||||
|
assert state["served"] == 1
|
||||||
|
entry = state["recent"][-1]
|
||||||
|
assert entry["target"] == "evk"
|
||||||
|
assert entry["version"] == "0.3.4"
|
||||||
|
assert entry["status"] == "ok"
|
||||||
|
assert entry["ip"] == "127.0.0.1"
|
||||||
|
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_file_records_a_missing_image(station):
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
||||||
|
c.getresponse().read()
|
||||||
|
state = station.read_state()
|
||||||
|
assert state["served"] == 0
|
||||||
|
assert state["recent"][-1]["status"] == "notfound"
|
||||||
|
assert state["recent"][-1]["target"] == "g95_cw"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_is_capped_at_eight(station):
|
||||||
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
||||||
|
for _ in range(12):
|
||||||
|
c = station.conn()
|
||||||
|
c.request("GET", "/ninlight_evk_ota.img")
|
||||||
|
c.getresponse().read()
|
||||||
|
state = station.read_state()
|
||||||
|
assert state["served"] == 12
|
||||||
|
assert len(state["recent"]) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_aborted_transfer_is_recorded(tmp_path):
|
||||||
|
s = Station(tmp_path, "--stall-after", "256")
|
||||||
|
try:
|
||||||
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
||||||
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
|
sock.recv(4096)
|
||||||
|
time.sleep(0.3)
|
||||||
|
sock.close()
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
entry = json.loads(s.state.read_text())["recent"][-1]
|
||||||
|
if entry["status"] == "aborted":
|
||||||
|
assert entry["bytes"] < 100000
|
||||||
|
return
|
||||||
|
except (OSError, json.JSONDecodeError, IndexError, KeyError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise AssertionError("abort was not recorded")
|
||||||
|
finally:
|
||||||
|
s.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
||||||
|
s = Station(tmp_path, "--stall-after", "512")
|
||||||
|
try:
|
||||||
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
||||||
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
||||||
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
|
got = b""
|
||||||
|
sock.settimeout(2)
|
||||||
|
try:
|
||||||
|
while len(got) < 200000:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
got += chunk
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
# far less than the full image, and the peer never closed the connection
|
||||||
|
assert len(got) < 20000
|
||||||
|
sock.close()
|
||||||
|
finally:
|
||||||
|
s.stop()
|
||||||
Reference in New Issue
Block a user