chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
24
inputremapper/ipc/__init__.py
Normal file
24
inputremapper/ipc/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Since I'm not forking, I can't use multiprocessing.Pipe.
|
||||
|
||||
Processes that need privileges are spawned with pkexec, which connect to
|
||||
known pipe paths to communicate with the non-privileged parent process.
|
||||
"""
|
||||
183
inputremapper/ipc/pipe.py
Normal file
183
inputremapper/ipc/pipe.py
Normal file
@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Named bidirectional non-blocking pipes.
|
||||
|
||||
>>> p1 = Pipe('foo')
|
||||
>>> p2 = Pipe('foo')
|
||||
|
||||
>>> p1.send(1)
|
||||
>>> p2.poll()
|
||||
>>> p2.recv()
|
||||
|
||||
>>> p2.send(2)
|
||||
>>> p1.poll()
|
||||
>>> p1.recv()
|
||||
|
||||
Beware that pipes read any available messages,
|
||||
even those written by themselves.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, AsyncIterator, Union
|
||||
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class Pipe:
|
||||
"""Pipe object.
|
||||
|
||||
This is not for secure communication. If pipes already exist, they will be used,
|
||||
but existing pipes might have open permissions! Only use this for stuff that
|
||||
non-privileged users would be allowed to read.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
"""Create a pipe, or open it if it already exists."""
|
||||
self._path = path
|
||||
self._unread = []
|
||||
self._created_at = time.time()
|
||||
|
||||
self._transport: Optional[asyncio.ReadTransport] = None
|
||||
self._async_iterator: Optional[AsyncIterator] = None
|
||||
|
||||
paths = (f"{path}r", f"{path}w")
|
||||
|
||||
PathUtils.mkdir(os.path.dirname(path))
|
||||
|
||||
if not os.path.exists(paths[0]):
|
||||
logger.debug("Creating new pipes %s", paths)
|
||||
# The fd the link points to is closed, or none ever existed
|
||||
# If there is a link, remove it.
|
||||
if os.path.islink(paths[0]):
|
||||
os.remove(paths[0])
|
||||
if os.path.islink(paths[1]):
|
||||
os.remove(paths[1])
|
||||
|
||||
self._fds = os.pipe()
|
||||
fds_dir = f"/proc/{os.getpid()}/fd/"
|
||||
PathUtils.chown(f"{fds_dir}{self._fds[0]}")
|
||||
PathUtils.chown(f"{fds_dir}{self._fds[1]}")
|
||||
|
||||
# to make it accessible by path constants, create symlinks
|
||||
os.symlink(f"{fds_dir}{self._fds[0]}", paths[0])
|
||||
os.symlink(f"{fds_dir}{self._fds[1]}", paths[1])
|
||||
else:
|
||||
logger.debug("Using existing pipes %s", paths)
|
||||
|
||||
# thanks to os.O_NONBLOCK, readline will return b'' when there
|
||||
# is nothing to read
|
||||
self._fds = (
|
||||
os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK),
|
||||
os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK),
|
||||
)
|
||||
|
||||
self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w"))
|
||||
|
||||
# clear the pipe of any contents, to avoid leftover messages from breaking
|
||||
# the reader-client or reader-service
|
||||
while self.poll():
|
||||
leftover = self.recv()
|
||||
logger.debug('Cleared leftover message "%s"', leftover)
|
||||
|
||||
def __del__(self):
|
||||
if self._transport:
|
||||
logger.debug("closing transport")
|
||||
self._transport.close()
|
||||
for file in self._handles:
|
||||
file.close()
|
||||
|
||||
def recv(self):
|
||||
"""Read an object from the pipe or None if nothing available.
|
||||
|
||||
Doesn't transmit pickles, to avoid injection attacks on the
|
||||
privileged reader-service. Only messages that can be converted to json
|
||||
are allowed.
|
||||
"""
|
||||
if len(self._unread) > 0:
|
||||
return self._unread.pop(0)
|
||||
|
||||
line = self._handles[0].readline()
|
||||
if len(line) == 0:
|
||||
return None
|
||||
|
||||
return self._get_msg(line)
|
||||
|
||||
def _get_msg(self, line: str):
|
||||
parsed = json.loads(line)
|
||||
if parsed[0] < self._created_at and os.environ.get("UNITTEST"):
|
||||
# important to avoid race conditions between multiple unittests,
|
||||
# for example old terminate messages reaching a new instance of
|
||||
# the reader-service.
|
||||
logger.debug("Ignoring old message %s", parsed)
|
||||
return None
|
||||
|
||||
return parsed[1]
|
||||
|
||||
def send(self, message: Union[str, int, float, dict, list, tuple]):
|
||||
"""Write a serializable object to the pipe."""
|
||||
dump = json.dumps((time.time(), message))
|
||||
# there aren't any newlines supposed to be,
|
||||
# but if there are it breaks readline().
|
||||
self._handles[1].write(dump.replace("\n", ""))
|
||||
self._handles[1].write("\n")
|
||||
self._handles[1].flush()
|
||||
|
||||
def poll(self):
|
||||
"""Check if there is anything that can be read."""
|
||||
if len(self._unread) > 0:
|
||||
return True
|
||||
|
||||
# using select.select apparently won't mark the pipe as ready
|
||||
# anymore when there are multiple lines to read but only a single
|
||||
# line is retreived. Using read instead.
|
||||
msg = self.recv()
|
||||
if msg is not None:
|
||||
self._unread.append(msg)
|
||||
|
||||
return len(self._unread) > 0
|
||||
|
||||
def fileno(self):
|
||||
"""Compatibility to select.select."""
|
||||
return self._handles[0].fileno()
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if not self._async_iterator:
|
||||
loop = asyncio.get_running_loop()
|
||||
reader = asyncio.StreamReader()
|
||||
|
||||
self._transport, _ = await loop.connect_read_pipe(
|
||||
lambda: asyncio.StreamReaderProtocol(reader), self._handles[0]
|
||||
)
|
||||
self._async_iterator = reader.__aiter__()
|
||||
|
||||
return self._get_msg(await self._async_iterator.__anext__())
|
||||
|
||||
async def recv_async(self):
|
||||
"""Read the next line with async. Do not use this when using
|
||||
the async for loop."""
|
||||
return await self.__aiter__().__anext__()
|
||||
122
inputremapper/ipc/shared_dict.py
Normal file
122
inputremapper/ipc/shared_dict.py
Normal file
@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Share a dictionary across processes."""
|
||||
|
||||
|
||||
import atexit
|
||||
import multiprocessing
|
||||
import select
|
||||
from typing import Optional, Any
|
||||
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
|
||||
class SharedDict:
|
||||
"""Share a dictionary across processes."""
|
||||
|
||||
# because unittests terminate all child processes in cleanup I can't use
|
||||
# multiprocessing.Manager
|
||||
def __init__(self):
|
||||
"""Create a shared dictionary."""
|
||||
super().__init__()
|
||||
|
||||
# To avoid blocking forever if something goes wrong. The maximum
|
||||
# observed time communication takes was 0.001 for me on a slow pc
|
||||
self._timeout = 0.02
|
||||
|
||||
self.pipe = multiprocessing.Pipe()
|
||||
self.process = None
|
||||
atexit.register(self._stop)
|
||||
|
||||
def start(self):
|
||||
"""Ensure the process to manage the dictionary is running."""
|
||||
if self.process is not None and self.process.is_alive():
|
||||
logger.debug("SharedDict process already running")
|
||||
return
|
||||
|
||||
# if the manager has already been running in the past but stopped
|
||||
# for some reason, the dictionary contents are lost.
|
||||
logger.debug("Starting SharedDict process")
|
||||
self.process = multiprocessing.Process(target=self.manage)
|
||||
self.process.start()
|
||||
|
||||
def manage(self):
|
||||
"""Manage the dictionary, handle read and write requests."""
|
||||
logger.debug("SharedDict process started")
|
||||
shared_dict = {}
|
||||
while True:
|
||||
message = self.pipe[0].recv()
|
||||
logger.debug("SharedDict got %s", message)
|
||||
|
||||
if message[0] == "stop":
|
||||
return
|
||||
|
||||
if message[0] == "set":
|
||||
shared_dict[message[1]] = message[2]
|
||||
|
||||
if message[0] == "clear":
|
||||
shared_dict.clear()
|
||||
|
||||
if message[0] == "get":
|
||||
self.pipe[0].send(shared_dict.get(message[1]))
|
||||
|
||||
if message[0] == "ping":
|
||||
self.pipe[0].send("pong")
|
||||
|
||||
def _stop(self):
|
||||
"""Stop the managing process."""
|
||||
self.pipe[1].send(("stop",))
|
||||
|
||||
def _clear(self):
|
||||
"""Clears the memory."""
|
||||
self.pipe[1].send(("clear",))
|
||||
|
||||
def get(self, key: str):
|
||||
"""Get a value from the dictionary.
|
||||
|
||||
If it doesn't exist, returns None.
|
||||
"""
|
||||
return self[key]
|
||||
|
||||
def is_alive(self, timeout: Optional[int] = None):
|
||||
"""Check if the manager process is running."""
|
||||
self.pipe[1].send(("ping",))
|
||||
select.select([self.pipe[1]], [], [], timeout or self._timeout)
|
||||
if self.pipe[1].poll():
|
||||
return self.pipe[1].recv() == "pong"
|
||||
|
||||
return False
|
||||
|
||||
def __setitem__(self, key: str, value: Any):
|
||||
self.pipe[1].send(("set", key, value))
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
self.pipe[1].send(("get", key))
|
||||
|
||||
select.select([self.pipe[1]], [], [], self._timeout)
|
||||
if self.pipe[1].poll():
|
||||
return self.pipe[1].recv()
|
||||
|
||||
logger.error("select.select timed out")
|
||||
return None
|
||||
|
||||
def __del__(self):
|
||||
self._stop()
|
||||
303
inputremapper/ipc/socket.py
Normal file
303
inputremapper/ipc/socket.py
Normal file
@ -0,0 +1,303 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
"""Non-blocking abstraction of unix domain sockets.
|
||||
|
||||
>>> server = Server('foo')
|
||||
>>> client = Client('foo')
|
||||
|
||||
>>> server.send(1)
|
||||
>>> client.poll()
|
||||
>>> client.recv()
|
||||
|
||||
>>> client.send(2)
|
||||
>>> server.poll()
|
||||
>>> server.recv()
|
||||
|
||||
I seems harder to sniff on a socket than using pipes for other non-root
|
||||
processes, but it doesn't guarantee security. As long as the GUI is open
|
||||
and not running as root user, it is most likely possible to somehow log
|
||||
keycodes by looking into the memory of the gui process (just like with most
|
||||
other applications because they end up receiving keyboard input as well).
|
||||
It still appears to be a bit overkill to use a socket considering pipes
|
||||
are much easier to handle.
|
||||
"""
|
||||
|
||||
|
||||
# Issues:
|
||||
# - Tests don't pass with Server and Client instead of Pipe for reader-client
|
||||
# and service communication or something
|
||||
# - Had one case of a test that was blocking forever, seems very rare.
|
||||
# - Hard to debug, generally very problematic compared to Pipes
|
||||
# The tool works fine, it's just the tests. BrokenPipe errors reported
|
||||
# by _Server all the time.
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
from typing import Union
|
||||
|
||||
from inputremapper.configs.paths import PathUtils
|
||||
from inputremapper.logging.logger import logger
|
||||
|
||||
# something funny that most likely won't appear in messages.
|
||||
# also add some ones so that 01 in the payload won't offset
|
||||
# a match by 2 bits
|
||||
END = b"\x55\x55\xff\x55" # should be 01010101 01010101 11111111 01010101
|
||||
|
||||
ENCODING = "utf8"
|
||||
|
||||
|
||||
# reusing existing objects makes tests easier, no headaches about closing
|
||||
# and reopening anymore. The ui also only runs only one instance of each all
|
||||
# the time.
|
||||
existing_servers = {}
|
||||
existing_clients = {}
|
||||
|
||||
|
||||
class Base:
|
||||
"""Abstract base class for Socket and Client."""
|
||||
|
||||
def __init__(self, path):
|
||||
self._path = path
|
||||
self._unread = []
|
||||
self.unsent = []
|
||||
PathUtils.mkdir(os.path.dirname(path))
|
||||
self.connection = None
|
||||
self.socket = None
|
||||
self._created_at = 0
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
"""Ignore older messages than now."""
|
||||
# ensure it is connected
|
||||
self.connect()
|
||||
self._created_at = time.time()
|
||||
|
||||
def connect(self):
|
||||
"""Returns True if connected, and if not attempts to connect."""
|
||||
raise NotImplementedError
|
||||
|
||||
def fileno(self):
|
||||
"""For compatibility with select.select."""
|
||||
raise NotImplementedError
|
||||
|
||||
def reconnect(self):
|
||||
"""Try to make a new connection."""
|
||||
raise NotImplementedError
|
||||
|
||||
def _receive_new_messages(self):
|
||||
if not self.connect():
|
||||
logger.debug("Not connected")
|
||||
return
|
||||
|
||||
messages = b""
|
||||
attempts = 0
|
||||
while True:
|
||||
try:
|
||||
chunk = self.connection.recvmsg(4096)[0]
|
||||
messages += chunk
|
||||
|
||||
if len(chunk) == 0:
|
||||
# select keeps telling me the socket has messages
|
||||
# ready to be received, and I keep getting empty
|
||||
# buffers. Happened during a test that ran two reader-service
|
||||
# processes without stopping the first one.
|
||||
attempts += 1
|
||||
if attempts == 2 or not self.reconnect():
|
||||
return
|
||||
|
||||
except (socket.timeout, BlockingIOError):
|
||||
break
|
||||
|
||||
split = messages.split(END)
|
||||
for message in split:
|
||||
if len(message) > 0:
|
||||
parsed = json.loads(message.decode(ENCODING))
|
||||
if parsed[0] < self._created_at:
|
||||
# important to avoid race conditions between multiple
|
||||
# unittests, for example old terminate messages reaching
|
||||
# a new instance of the reader-service.
|
||||
logger.debug("Ignoring old message %s", parsed)
|
||||
continue
|
||||
|
||||
self._unread.append(parsed[1])
|
||||
|
||||
def recv(self):
|
||||
"""Get the next message or None if nothing to read.
|
||||
|
||||
Doesn't transmit pickles, to avoid injection attacks on the
|
||||
privileged reader-service. Only messages that can be converted to json
|
||||
are allowed.
|
||||
"""
|
||||
self._receive_new_messages()
|
||||
|
||||
if len(self._unread) == 0:
|
||||
return None
|
||||
|
||||
return self._unread.pop(0)
|
||||
|
||||
def poll(self):
|
||||
"""Check if a message to read is available."""
|
||||
if len(self._unread) > 0:
|
||||
return True
|
||||
|
||||
self._receive_new_messages()
|
||||
return len(self._unread) > 0
|
||||
|
||||
def send(self, message: Union[str, int, float, dict, list, tuple]):
|
||||
"""Send json-serializable messages."""
|
||||
dump = bytes(json.dumps((time.time(), message)), ENCODING)
|
||||
self.unsent.append(dump)
|
||||
|
||||
if not self.connect():
|
||||
logger.debug("Not connected")
|
||||
return
|
||||
|
||||
def send_all():
|
||||
while len(self.unsent) > 0:
|
||||
unsent = self.unsent[0]
|
||||
self.connection.sendall(unsent + END)
|
||||
# sending worked, remove message
|
||||
self.unsent.pop(0)
|
||||
|
||||
# attempt sending twice in case it fails
|
||||
try:
|
||||
send_all()
|
||||
except BrokenPipeError:
|
||||
if not self.reconnect():
|
||||
logger.error(
|
||||
'%s: The other side of "%s" disappeared',
|
||||
type(self).__name__,
|
||||
self._path,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
send_all()
|
||||
except BrokenPipeError as error:
|
||||
logger.error(
|
||||
'%s: Failed to send via "%s": %s',
|
||||
type(self).__name__,
|
||||
self._path,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
class _Client(Base):
|
||||
"""A socket that can be written to and read from."""
|
||||
|
||||
def connect(self):
|
||||
if self.socket is not None:
|
||||
return True
|
||||
|
||||
try:
|
||||
_socket = socket.socket(socket.AF_UNIX)
|
||||
_socket.connect(self._path)
|
||||
logger.debug('Connected to socket: "%s"', self._path)
|
||||
_socket.setblocking(False)
|
||||
except Exception as error:
|
||||
logger.debug('Failed to connect to "%s": "%s"', self._path, error)
|
||||
return False
|
||||
|
||||
self.socket = _socket
|
||||
self.connection = _socket
|
||||
existing_clients[self._path] = self
|
||||
return True
|
||||
|
||||
def fileno(self):
|
||||
"""For compatibility with select.select."""
|
||||
self.connect()
|
||||
return self.socket.fileno()
|
||||
|
||||
def reconnect(self):
|
||||
self.connection = None
|
||||
self.socket = None
|
||||
return self.connect()
|
||||
|
||||
|
||||
def Client(path):
|
||||
if path in existing_clients:
|
||||
# ensure it is running, might have been closed
|
||||
existing_clients[path].reset()
|
||||
return existing_clients[path]
|
||||
|
||||
return _Client(path)
|
||||
|
||||
|
||||
class _Server(Base):
|
||||
"""A socket that can be written to and read from.
|
||||
|
||||
It accepts one connection at a time, and drops old connections if
|
||||
a new one is in sight.
|
||||
"""
|
||||
|
||||
def connect(self):
|
||||
if self.socket is None:
|
||||
if os.path.exists(self._path):
|
||||
# leftover from the previous execution
|
||||
os.remove(self._path)
|
||||
|
||||
_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
_socket.bind(self._path)
|
||||
_socket.listen(1)
|
||||
PathUtils.chown(self._path)
|
||||
logger.debug('Created socket: "%s"', self._path)
|
||||
self.socket = _socket
|
||||
self.socket.setblocking(False)
|
||||
existing_servers[self._path] = self
|
||||
|
||||
incoming = len(select.select([self.socket], [], [], 0)[0]) != 0
|
||||
if not incoming and self.connection is None:
|
||||
# no existing connection, no client attempting to connect
|
||||
return False
|
||||
|
||||
if not incoming and self.connection is not None:
|
||||
# old connection
|
||||
return True
|
||||
|
||||
if incoming:
|
||||
logger.debug('Incoming connection: "%s"', self._path)
|
||||
connection = self.socket.accept()[0]
|
||||
self.connection = connection
|
||||
self.connection.setblocking(False)
|
||||
|
||||
return True
|
||||
|
||||
def fileno(self):
|
||||
"""For compatibility with select.select."""
|
||||
self.connect()
|
||||
return self.connection.fileno()
|
||||
|
||||
def reconnect(self):
|
||||
self.connection = None
|
||||
return self.connect()
|
||||
|
||||
|
||||
def Server(path):
|
||||
if path in existing_servers:
|
||||
# ensure it is running, might have been closed
|
||||
existing_servers[path].reset()
|
||||
return existing_servers[path]
|
||||
|
||||
return _Server(path)
|
||||
Reference in New Issue
Block a user