mirror of
https://github.com/matrix-org/synapse.git
synced 2025-02-15 03:35:48 +00:00
Merge branch 'develop' of github.com:matrix-org/synapse into matrix-org-hotfixes
This commit is contained in:
commit
97359ca4ec
@ -36,15 +36,13 @@ class HttpClient(object):
|
|||||||
the request body. This will be encoded as JSON.
|
the request body. This will be encoded as JSON.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get *any* HTTP response.
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
|
will be the decoded JSON body.
|
||||||
The result of the deferred is a tuple of `(code, response)`,
|
|
||||||
where `response` is a dict representing the decoded JSON body.
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_json(self, url, args=None):
|
def get_json(self, url, args=None):
|
||||||
""" Get's some json from the given host homeserver and path
|
""" Gets some json from the given host homeserver and path
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url (str): The URL to GET data from.
|
url (str): The URL to GET data from.
|
||||||
@ -54,10 +52,8 @@ class HttpClient(object):
|
|||||||
and *not* a string.
|
and *not* a string.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get *any* HTTP response.
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
|
will be the decoded JSON body.
|
||||||
The result of the deferred is a tuple of `(code, response)`,
|
|
||||||
where `response` is a dict representing the decoded JSON body.
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -214,4 +210,4 @@ class _JsonProducer(object):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def stopProducing(self):
|
def stopProducing(self):
|
||||||
pass
|
pass
|
||||||
|
@ -474,8 +474,13 @@ class FederationClient(FederationBase):
|
|||||||
content (object): Any additional data to put into the content field
|
content (object): Any additional data to put into the content field
|
||||||
of the event.
|
of the event.
|
||||||
Return:
|
Return:
|
||||||
A tuple of (origin (str), event (object)) where origin is the remote
|
Deferred: resolves to a tuple of (origin (str), event (object))
|
||||||
homeserver which generated the event.
|
where origin is the remote homeserver which generated the event.
|
||||||
|
|
||||||
|
Fails with a ``CodeMessageException`` if the chosen remote server
|
||||||
|
returns a 300/400 code.
|
||||||
|
|
||||||
|
Fails with a ``RuntimeError`` if no servers were reachable.
|
||||||
"""
|
"""
|
||||||
valid_memberships = {Membership.JOIN, Membership.LEAVE}
|
valid_memberships = {Membership.JOIN, Membership.LEAVE}
|
||||||
if membership not in valid_memberships:
|
if membership not in valid_memberships:
|
||||||
@ -528,6 +533,27 @@ class FederationClient(FederationBase):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def send_join(self, destinations, pdu):
|
def send_join(self, destinations, pdu):
|
||||||
|
"""Sends a join event to one of a list of homeservers.
|
||||||
|
|
||||||
|
Doing so will cause the remote server to add the event to the graph,
|
||||||
|
and send the event out to the rest of the federation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
destinations (str): Candidate homeservers which are probably
|
||||||
|
participating in the room.
|
||||||
|
pdu (BaseEvent): event to be sent
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Deferred: resolves to a dict with members ``origin`` (a string
|
||||||
|
giving the serer the event was sent to, ``state`` (?) and
|
||||||
|
``auth_chain``.
|
||||||
|
|
||||||
|
Fails with a ``CodeMessageException`` if the chosen remote server
|
||||||
|
returns a 300/400 code.
|
||||||
|
|
||||||
|
Fails with a ``RuntimeError`` if no servers were reachable.
|
||||||
|
"""
|
||||||
|
|
||||||
for destination in destinations:
|
for destination in destinations:
|
||||||
if destination == self.server_name:
|
if destination == self.server_name:
|
||||||
continue
|
continue
|
||||||
@ -635,6 +661,26 @@ class FederationClient(FederationBase):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def send_leave(self, destinations, pdu):
|
def send_leave(self, destinations, pdu):
|
||||||
|
"""Sends a leave event to one of a list of homeservers.
|
||||||
|
|
||||||
|
Doing so will cause the remote server to add the event to the graph,
|
||||||
|
and send the event out to the rest of the federation.
|
||||||
|
|
||||||
|
This is mostly useful to reject received invites.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
destinations (str): Candidate homeservers which are probably
|
||||||
|
participating in the room.
|
||||||
|
pdu (BaseEvent): event to be sent
|
||||||
|
|
||||||
|
Return:
|
||||||
|
Deferred: resolves to None.
|
||||||
|
|
||||||
|
Fails with a ``CodeMessageException`` if the chosen remote server
|
||||||
|
returns a non-200 code.
|
||||||
|
|
||||||
|
Fails with a ``RuntimeError`` if no servers were reachable.
|
||||||
|
"""
|
||||||
for destination in destinations:
|
for destination in destinations:
|
||||||
if destination == self.server_name:
|
if destination == self.server_name:
|
||||||
continue
|
continue
|
||||||
|
@ -193,6 +193,26 @@ class TransportLayerClient(object):
|
|||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
@log_function
|
@log_function
|
||||||
def make_membership_event(self, destination, room_id, user_id, membership):
|
def make_membership_event(self, destination, room_id, user_id, membership):
|
||||||
|
"""Asks a remote server to build and sign us a membership event
|
||||||
|
|
||||||
|
Note that this does not append any events to any graphs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
destination (str): address of remote homeserver
|
||||||
|
room_id (str): room to join/leave
|
||||||
|
user_id (str): user to be joined/left
|
||||||
|
membership (str): one of join/leave
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
|
will be the decoded JSON body (ie, the new event).
|
||||||
|
|
||||||
|
Fails with ``HTTPRequestException`` if we get an HTTP response
|
||||||
|
code >= 300.
|
||||||
|
|
||||||
|
Fails with ``NotRetryingDestination`` if we are not yet ready
|
||||||
|
to retry this server.
|
||||||
|
"""
|
||||||
valid_memberships = {Membership.JOIN, Membership.LEAVE}
|
valid_memberships = {Membership.JOIN, Membership.LEAVE}
|
||||||
if membership not in valid_memberships:
|
if membership not in valid_memberships:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@ -201,11 +221,23 @@ class TransportLayerClient(object):
|
|||||||
)
|
)
|
||||||
path = PREFIX + "/make_%s/%s/%s" % (membership, room_id, user_id)
|
path = PREFIX + "/make_%s/%s/%s" % (membership, room_id, user_id)
|
||||||
|
|
||||||
|
ignore_backoff = False
|
||||||
|
retry_on_dns_fail = False
|
||||||
|
|
||||||
|
if membership == Membership.LEAVE:
|
||||||
|
# we particularly want to do our best to send leave events. The
|
||||||
|
# problem is that if it fails, we won't retry it later, so if the
|
||||||
|
# remote server was just having a momentary blip, the room will be
|
||||||
|
# out of sync.
|
||||||
|
ignore_backoff = True
|
||||||
|
retry_on_dns_fail = True
|
||||||
|
|
||||||
content = yield self.client.get_json(
|
content = yield self.client.get_json(
|
||||||
destination=destination,
|
destination=destination,
|
||||||
path=path,
|
path=path,
|
||||||
retry_on_dns_fail=False,
|
retry_on_dns_fail=retry_on_dns_fail,
|
||||||
timeout=20000,
|
timeout=20000,
|
||||||
|
ignore_backoff=ignore_backoff,
|
||||||
)
|
)
|
||||||
|
|
||||||
defer.returnValue(content)
|
defer.returnValue(content)
|
||||||
@ -232,6 +264,12 @@ class TransportLayerClient(object):
|
|||||||
destination=destination,
|
destination=destination,
|
||||||
path=path,
|
path=path,
|
||||||
data=content,
|
data=content,
|
||||||
|
|
||||||
|
# we want to do our best to send this through. The problem is
|
||||||
|
# that if it fails, we won't retry it later, so if the remote
|
||||||
|
# server was just having a momentary blip, the room will be out of
|
||||||
|
# sync.
|
||||||
|
ignore_backoff=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
defer.returnValue(response)
|
defer.returnValue(response)
|
||||||
|
@ -1090,19 +1090,13 @@ class FederationHandler(BaseHandler):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def do_remotely_reject_invite(self, target_hosts, room_id, user_id):
|
def do_remotely_reject_invite(self, target_hosts, room_id, user_id):
|
||||||
try:
|
origin, event = yield self._make_and_verify_event(
|
||||||
origin, event = yield self._make_and_verify_event(
|
target_hosts,
|
||||||
target_hosts,
|
room_id,
|
||||||
room_id,
|
user_id,
|
||||||
user_id,
|
"leave"
|
||||||
"leave"
|
)
|
||||||
)
|
event = self._sign_event(event)
|
||||||
event = self._sign_event(event)
|
|
||||||
except SynapseError:
|
|
||||||
raise
|
|
||||||
except CodeMessageException as e:
|
|
||||||
logger.warn("Failed to reject invite: %s", e)
|
|
||||||
raise SynapseError(500, "Failed to reject invite")
|
|
||||||
|
|
||||||
# Try the host that we succesfully called /make_leave/ on first for
|
# Try the host that we succesfully called /make_leave/ on first for
|
||||||
# the /send_leave/ request.
|
# the /send_leave/ request.
|
||||||
@ -1112,16 +1106,10 @@ class FederationHandler(BaseHandler):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
yield self.replication_layer.send_leave(
|
||||||
yield self.replication_layer.send_leave(
|
target_hosts,
|
||||||
target_hosts,
|
event
|
||||||
event
|
)
|
||||||
)
|
|
||||||
except SynapseError:
|
|
||||||
raise
|
|
||||||
except CodeMessageException as e:
|
|
||||||
logger.warn("Failed to reject invite: %s", e)
|
|
||||||
raise SynapseError(500, "Failed to reject invite")
|
|
||||||
|
|
||||||
context = yield self.state_handler.compute_event_context(event)
|
context = yield self.state_handler.compute_event_context(event)
|
||||||
|
|
||||||
|
@ -139,13 +139,6 @@ class RoomMemberHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
yield user_joined_room(self.distributor, user, room_id)
|
yield user_joined_room(self.distributor, user, room_id)
|
||||||
|
|
||||||
def reject_remote_invite(self, user_id, room_id, remote_room_hosts):
|
|
||||||
return self.hs.get_handlers().federation_handler.do_remotely_reject_invite(
|
|
||||||
remote_room_hosts,
|
|
||||||
room_id,
|
|
||||||
user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def update_membership(
|
def update_membership(
|
||||||
self,
|
self,
|
||||||
@ -286,13 +279,21 @@ class RoomMemberHandler(BaseHandler):
|
|||||||
else:
|
else:
|
||||||
# send the rejection to the inviter's HS.
|
# send the rejection to the inviter's HS.
|
||||||
remote_room_hosts = remote_room_hosts + [inviter.domain]
|
remote_room_hosts = remote_room_hosts + [inviter.domain]
|
||||||
|
fed_handler = self.hs.get_handlers().federation_handler
|
||||||
try:
|
try:
|
||||||
ret = yield self.reject_remote_invite(
|
ret = yield fed_handler.do_remotely_reject_invite(
|
||||||
target.to_string(), room_id, remote_room_hosts
|
remote_room_hosts,
|
||||||
|
room_id,
|
||||||
|
target.to_string(),
|
||||||
)
|
)
|
||||||
defer.returnValue(ret)
|
defer.returnValue(ret)
|
||||||
except SynapseError as e:
|
except Exception as e:
|
||||||
|
# if we were unable to reject the exception, just mark
|
||||||
|
# it as rejected on our end and plough ahead.
|
||||||
|
#
|
||||||
|
# The 'except' clause is very broad, but we need to
|
||||||
|
# capture everything from DNS failures upwards
|
||||||
|
#
|
||||||
logger.warn("Failed to reject invite: %s", e)
|
logger.warn("Failed to reject invite: %s", e)
|
||||||
|
|
||||||
yield self.store.locally_reject_invite(
|
yield self.store.locally_reject_invite(
|
||||||
|
@ -125,6 +125,8 @@ class MatrixFederationHttpClient(object):
|
|||||||
code >= 300.
|
code >= 300.
|
||||||
Fails with ``NotRetryingDestination`` if we are not yet ready
|
Fails with ``NotRetryingDestination`` if we are not yet ready
|
||||||
to retry this server.
|
to retry this server.
|
||||||
|
(May also fail with plenty of other Exceptions for things like DNS
|
||||||
|
failures, connection failures, SSL failures.)
|
||||||
"""
|
"""
|
||||||
limiter = yield synapse.util.retryutils.get_retry_limiter(
|
limiter = yield synapse.util.retryutils.get_retry_limiter(
|
||||||
destination,
|
destination,
|
||||||
@ -302,8 +304,10 @@ class MatrixFederationHttpClient(object):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
will be the decoded JSON body. On a 4xx or 5xx error response a
|
will be the decoded JSON body.
|
||||||
CodeMessageException is raised.
|
|
||||||
|
Fails with ``HTTPRequestException`` if we get an HTTP response
|
||||||
|
code >= 300.
|
||||||
|
|
||||||
Fails with ``NotRetryingDestination`` if we are not yet ready
|
Fails with ``NotRetryingDestination`` if we are not yet ready
|
||||||
to retry this server.
|
to retry this server.
|
||||||
@ -360,8 +364,10 @@ class MatrixFederationHttpClient(object):
|
|||||||
try the request anyway.
|
try the request anyway.
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
will be the decoded JSON body. On a 4xx or 5xx error response a
|
will be the decoded JSON body.
|
||||||
CodeMessageException is raised.
|
|
||||||
|
Fails with ``HTTPRequestException`` if we get an HTTP response
|
||||||
|
code >= 300.
|
||||||
|
|
||||||
Fails with ``NotRetryingDestination`` if we are not yet ready
|
Fails with ``NotRetryingDestination`` if we are not yet ready
|
||||||
to retry this server.
|
to retry this server.
|
||||||
@ -410,10 +416,11 @@ class MatrixFederationHttpClient(object):
|
|||||||
ignore_backoff (bool): true to ignore the historical backoff data
|
ignore_backoff (bool): true to ignore the historical backoff data
|
||||||
and try the request anyway.
|
and try the request anyway.
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get *any* HTTP response.
|
Deferred: Succeeds when we get a 2xx HTTP response. The result
|
||||||
|
will be the decoded JSON body.
|
||||||
|
|
||||||
The result of the deferred is a tuple of `(code, response)`,
|
Fails with ``HTTPRequestException`` if we get an HTTP response
|
||||||
where `response` is a dict representing the decoded JSON body.
|
code >= 300.
|
||||||
|
|
||||||
Fails with ``NotRetryingDestination`` if we are not yet ready
|
Fails with ``NotRetryingDestination`` if we are not yet ready
|
||||||
to retry this server.
|
to retry this server.
|
||||||
|
@ -84,12 +84,11 @@ class LocalKey(Resource):
|
|||||||
}
|
}
|
||||||
|
|
||||||
old_verify_keys = {}
|
old_verify_keys = {}
|
||||||
for key in self.config.old_signing_keys:
|
for key_id, key in self.config.old_signing_keys.items():
|
||||||
key_id = "%s:%s" % (key.alg, key.version)
|
|
||||||
verify_key_bytes = key.encode()
|
verify_key_bytes = key.encode()
|
||||||
old_verify_keys[key_id] = {
|
old_verify_keys[key_id] = {
|
||||||
u"key": encode_base64(verify_key_bytes),
|
u"key": encode_base64(verify_key_bytes),
|
||||||
u"expired_ts": key.expired,
|
u"expired_ts": key.expired_ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
tls_fingerprints = self.config.tls_fingerprints
|
tls_fingerprints = self.config.tls_fingerprints
|
||||||
|
@ -47,10 +47,13 @@ class ReceiptsStore(SQLBaseStore):
|
|||||||
# Returns an ObservableDeferred
|
# Returns an ObservableDeferred
|
||||||
res = self.get_users_with_read_receipts_in_room.cache.get((room_id,), None)
|
res = self.get_users_with_read_receipts_in_room.cache.get((room_id,), None)
|
||||||
|
|
||||||
if res and res.called and user_id in res.result:
|
if res:
|
||||||
# We'd only be adding to the set, so no point invalidating if the
|
if isinstance(res, defer.Deferred) and res.called:
|
||||||
# user is already there
|
res = res.result
|
||||||
return
|
if user_id in res:
|
||||||
|
# We'd only be adding to the set, so no point invalidating if the
|
||||||
|
# user is already there
|
||||||
|
return
|
||||||
|
|
||||||
self.get_users_with_read_receipts_in_room.invalidate((room_id,))
|
self.get_users_with_read_receipts_in_room.invalidate((room_id,))
|
||||||
|
|
||||||
|
@ -18,8 +18,6 @@ import os
|
|||||||
|
|
||||||
CACHE_SIZE_FACTOR = float(os.environ.get("SYNAPSE_CACHE_FACTOR", 0.1))
|
CACHE_SIZE_FACTOR = float(os.environ.get("SYNAPSE_CACHE_FACTOR", 0.1))
|
||||||
|
|
||||||
DEBUG_CACHES = False
|
|
||||||
|
|
||||||
metrics = synapse.metrics.get_metrics_for("synapse.util.caches")
|
metrics = synapse.metrics.get_metrics_for("synapse.util.caches")
|
||||||
|
|
||||||
caches_by_name = {}
|
caches_by_name = {}
|
||||||
|
@ -19,7 +19,7 @@ from synapse.util import unwrapFirstError, logcontext
|
|||||||
from synapse.util.caches.lrucache import LruCache
|
from synapse.util.caches.lrucache import LruCache
|
||||||
from synapse.util.caches.treecache import TreeCache, iterate_tree_cache_entry
|
from synapse.util.caches.treecache import TreeCache, iterate_tree_cache_entry
|
||||||
|
|
||||||
from . import DEBUG_CACHES, register_cache
|
from . import register_cache
|
||||||
|
|
||||||
from twisted.internet import defer
|
from twisted.internet import defer
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
@ -76,7 +76,7 @@ class Cache(object):
|
|||||||
|
|
||||||
self.cache = LruCache(
|
self.cache = LruCache(
|
||||||
max_size=max_entries, keylen=keylen, cache_type=cache_type,
|
max_size=max_entries, keylen=keylen, cache_type=cache_type,
|
||||||
size_callback=(lambda d: len(d.result)) if iterable else None,
|
size_callback=(lambda d: len(d)) if iterable else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
@ -96,6 +96,17 @@ class Cache(object):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self, key, default=_CacheSentinel, callback=None):
|
def get(self, key, default=_CacheSentinel, callback=None):
|
||||||
|
"""Looks the key up in the caches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key(tuple)
|
||||||
|
default: What is returned if key is not in the caches. If not
|
||||||
|
specified then function throws KeyError instead
|
||||||
|
callback(fn): Gets called when the entry in the cache is invalidated
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Either a Deferred or the raw result
|
||||||
|
"""
|
||||||
callbacks = [callback] if callback else []
|
callbacks = [callback] if callback else []
|
||||||
val = self._pending_deferred_cache.get(key, _CacheSentinel)
|
val = self._pending_deferred_cache.get(key, _CacheSentinel)
|
||||||
if val is not _CacheSentinel:
|
if val is not _CacheSentinel:
|
||||||
@ -137,7 +148,7 @@ class Cache(object):
|
|||||||
if self.sequence == entry.sequence:
|
if self.sequence == entry.sequence:
|
||||||
existing_entry = self._pending_deferred_cache.pop(key, None)
|
existing_entry = self._pending_deferred_cache.pop(key, None)
|
||||||
if existing_entry is entry:
|
if existing_entry is entry:
|
||||||
self.cache.set(key, entry.deferred, entry.callbacks)
|
self.cache.set(key, result, entry.callbacks)
|
||||||
else:
|
else:
|
||||||
entry.invalidate()
|
entry.invalidate()
|
||||||
else:
|
else:
|
||||||
@ -335,20 +346,10 @@ class CacheDescriptor(_CacheDescriptorBase):
|
|||||||
try:
|
try:
|
||||||
cached_result_d = cache.get(cache_key, callback=invalidate_callback)
|
cached_result_d = cache.get(cache_key, callback=invalidate_callback)
|
||||||
|
|
||||||
observer = cached_result_d.observe()
|
if isinstance(cached_result_d, ObservableDeferred):
|
||||||
if DEBUG_CACHES:
|
observer = cached_result_d.observe()
|
||||||
@defer.inlineCallbacks
|
else:
|
||||||
def check_result(cached_result):
|
observer = cached_result_d
|
||||||
actual_result = yield self.function_to_call(obj, *args, **kwargs)
|
|
||||||
if actual_result != cached_result:
|
|
||||||
logger.error(
|
|
||||||
"Stale cache entry %s%r: cached: %r, actual %r",
|
|
||||||
self.orig.__name__, cache_key,
|
|
||||||
cached_result, actual_result,
|
|
||||||
)
|
|
||||||
raise ValueError("Stale cache entry")
|
|
||||||
defer.returnValue(cached_result)
|
|
||||||
observer.addCallback(check_result)
|
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
ret = defer.maybeDeferred(
|
ret = defer.maybeDeferred(
|
||||||
@ -447,7 +448,9 @@ class CacheListDescriptor(_CacheDescriptorBase):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
res = cache.get(tuple(key), callback=invalidate_callback)
|
res = cache.get(tuple(key), callback=invalidate_callback)
|
||||||
if not res.has_succeeded():
|
if not isinstance(res, ObservableDeferred):
|
||||||
|
results[arg] = res
|
||||||
|
elif not res.has_succeeded():
|
||||||
res = res.observe()
|
res = res.observe()
|
||||||
res.addCallback(lambda r, arg: (arg, r), arg)
|
res.addCallback(lambda r, arg: (arg, r), arg)
|
||||||
cached_defers[arg] = res
|
cached_defers[arg] = res
|
||||||
|
Loading…
Reference in New Issue
Block a user