mirror of
https://git.anonymousland.org/anonymousland/synapse-product.git
synced 2024-10-01 08:25:44 -04:00
Merge pull request #50 from matrix-org/application-services
Application Services
This commit is contained in:
commit
0d872f5aa6
@ -306,6 +306,34 @@ class Auth(object):
|
|||||||
# Can optionally look elsewhere in the request (e.g. headers)
|
# Can optionally look elsewhere in the request (e.g. headers)
|
||||||
try:
|
try:
|
||||||
access_token = request.args["access_token"][0]
|
access_token = request.args["access_token"][0]
|
||||||
|
|
||||||
|
# Check for application service tokens with a user_id override
|
||||||
|
try:
|
||||||
|
app_service = yield self.store.get_app_service_by_token(
|
||||||
|
access_token
|
||||||
|
)
|
||||||
|
if not app_service:
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
user_id = app_service.sender
|
||||||
|
if "user_id" in request.args:
|
||||||
|
user_id = request.args["user_id"][0]
|
||||||
|
if not app_service.is_interested_in_user(user_id):
|
||||||
|
raise AuthError(
|
||||||
|
403,
|
||||||
|
"Application service cannot masquerade as this user."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
defer.returnValue(
|
||||||
|
(UserID.from_string(user_id), ClientInfo("", ""))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except KeyError:
|
||||||
|
pass # normal users won't have this query parameter set
|
||||||
|
|
||||||
user_info = yield self.get_user_by_token(access_token)
|
user_info = yield self.get_user_by_token(access_token)
|
||||||
user = user_info["user"]
|
user = user_info["user"]
|
||||||
device_id = user_info["device_id"]
|
device_id = user_info["device_id"]
|
||||||
@ -344,8 +372,7 @@ class Auth(object):
|
|||||||
try:
|
try:
|
||||||
ret = yield self.store.get_user_by_token(token=token)
|
ret = yield self.store.get_user_by_token(token=token)
|
||||||
if not ret:
|
if not ret:
|
||||||
raise StoreError()
|
raise StoreError(400, "Unknown token")
|
||||||
|
|
||||||
user_info = {
|
user_info = {
|
||||||
"admin": bool(ret.get("admin", False)),
|
"admin": bool(ret.get("admin", False)),
|
||||||
"device_id": ret.get("device_id"),
|
"device_id": ret.get("device_id"),
|
||||||
@ -358,6 +385,18 @@ class Auth(object):
|
|||||||
raise AuthError(403, "Unrecognised access token.",
|
raise AuthError(403, "Unrecognised access token.",
|
||||||
errcode=Codes.UNKNOWN_TOKEN)
|
errcode=Codes.UNKNOWN_TOKEN)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def get_appservice_by_req(self, request):
|
||||||
|
try:
|
||||||
|
token = request.args["access_token"][0]
|
||||||
|
service = yield self.store.get_app_service_by_token(token)
|
||||||
|
if not service:
|
||||||
|
raise AuthError(403, "Unrecognised access token.",
|
||||||
|
errcode=Codes.UNKNOWN_TOKEN)
|
||||||
|
defer.returnValue(service)
|
||||||
|
except KeyError:
|
||||||
|
raise AuthError(403, "Missing access token.")
|
||||||
|
|
||||||
def is_server_admin(self, user):
|
def is_server_admin(self, user):
|
||||||
return self.store.is_server_admin(user)
|
return self.store.is_server_admin(user)
|
||||||
|
|
||||||
|
@ -59,6 +59,7 @@ class LoginType(object):
|
|||||||
EMAIL_URL = u"m.login.email.url"
|
EMAIL_URL = u"m.login.email.url"
|
||||||
EMAIL_IDENTITY = u"m.login.email.identity"
|
EMAIL_IDENTITY = u"m.login.email.identity"
|
||||||
RECAPTCHA = u"m.login.recaptcha"
|
RECAPTCHA = u"m.login.recaptcha"
|
||||||
|
APPLICATION_SERVICE = u"m.login.application_service"
|
||||||
|
|
||||||
|
|
||||||
class EventTypes(object):
|
class EventTypes(object):
|
||||||
|
@ -36,7 +36,8 @@ class Codes(object):
|
|||||||
CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
|
CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
|
||||||
CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
|
CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
|
||||||
MISSING_PARAM = "M_MISSING_PARAM",
|
MISSING_PARAM = "M_MISSING_PARAM",
|
||||||
TOO_LARGE = "M_TOO_LARGE"
|
TOO_LARGE = "M_TOO_LARGE",
|
||||||
|
EXCLUSIVE = "M_EXCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
class CodeMessageException(RuntimeError):
|
class CodeMessageException(RuntimeError):
|
||||||
|
@ -22,3 +22,4 @@ WEB_CLIENT_PREFIX = "/_matrix/client"
|
|||||||
CONTENT_REPO_PREFIX = "/_matrix/content"
|
CONTENT_REPO_PREFIX = "/_matrix/content"
|
||||||
SERVER_KEY_PREFIX = "/_matrix/key/v1"
|
SERVER_KEY_PREFIX = "/_matrix/key/v1"
|
||||||
MEDIA_PREFIX = "/_matrix/media/v1"
|
MEDIA_PREFIX = "/_matrix/media/v1"
|
||||||
|
APP_SERVICE_PREFIX = "/_matrix/appservice/v1"
|
||||||
|
@ -26,13 +26,14 @@ from twisted.web.resource import Resource
|
|||||||
from twisted.web.static import File
|
from twisted.web.static import File
|
||||||
from twisted.web.server import Site
|
from twisted.web.server import Site
|
||||||
from synapse.http.server import JsonResource, RootRedirect
|
from synapse.http.server import JsonResource, RootRedirect
|
||||||
|
from synapse.rest.appservice.v1 import AppServiceRestResource
|
||||||
from synapse.rest.media.v0.content_repository import ContentRepoResource
|
from synapse.rest.media.v0.content_repository import ContentRepoResource
|
||||||
from synapse.rest.media.v1.media_repository import MediaRepositoryResource
|
from synapse.rest.media.v1.media_repository import MediaRepositoryResource
|
||||||
from synapse.http.server_key_resource import LocalKey
|
from synapse.http.server_key_resource import LocalKey
|
||||||
from synapse.http.matrixfederationclient import MatrixFederationHttpClient
|
from synapse.http.matrixfederationclient import MatrixFederationHttpClient
|
||||||
from synapse.api.urls import (
|
from synapse.api.urls import (
|
||||||
CLIENT_PREFIX, FEDERATION_PREFIX, WEB_CLIENT_PREFIX, CONTENT_REPO_PREFIX,
|
CLIENT_PREFIX, FEDERATION_PREFIX, WEB_CLIENT_PREFIX, CONTENT_REPO_PREFIX,
|
||||||
SERVER_KEY_PREFIX, MEDIA_PREFIX, CLIENT_V2_ALPHA_PREFIX,
|
SERVER_KEY_PREFIX, MEDIA_PREFIX, CLIENT_V2_ALPHA_PREFIX, APP_SERVICE_PREFIX
|
||||||
)
|
)
|
||||||
from synapse.config.homeserver import HomeServerConfig
|
from synapse.config.homeserver import HomeServerConfig
|
||||||
from synapse.crypto import context_factory
|
from synapse.crypto import context_factory
|
||||||
@ -69,6 +70,9 @@ class SynapseHomeServer(HomeServer):
|
|||||||
def build_resource_for_federation(self):
|
def build_resource_for_federation(self):
|
||||||
return JsonResource(self)
|
return JsonResource(self)
|
||||||
|
|
||||||
|
def build_resource_for_app_services(self):
|
||||||
|
return AppServiceRestResource(self)
|
||||||
|
|
||||||
def build_resource_for_web_client(self):
|
def build_resource_for_web_client(self):
|
||||||
syweb_path = os.path.dirname(syweb.__file__)
|
syweb_path = os.path.dirname(syweb.__file__)
|
||||||
webclient_path = os.path.join(syweb_path, "webclient")
|
webclient_path = os.path.join(syweb_path, "webclient")
|
||||||
@ -116,6 +120,7 @@ class SynapseHomeServer(HomeServer):
|
|||||||
(CONTENT_REPO_PREFIX, self.get_resource_for_content_repo()),
|
(CONTENT_REPO_PREFIX, self.get_resource_for_content_repo()),
|
||||||
(SERVER_KEY_PREFIX, self.get_resource_for_server_key()),
|
(SERVER_KEY_PREFIX, self.get_resource_for_server_key()),
|
||||||
(MEDIA_PREFIX, self.get_resource_for_media_repository()),
|
(MEDIA_PREFIX, self.get_resource_for_media_repository()),
|
||||||
|
(APP_SERVICE_PREFIX, self.get_resource_for_app_services()),
|
||||||
]
|
]
|
||||||
if web_client:
|
if web_client:
|
||||||
logger.info("Adding the web client.")
|
logger.info("Adding the web client.")
|
||||||
|
147
synapse/appservice/__init__.py
Normal file
147
synapse/appservice/__init__.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from synapse.api.constants import EventTypes
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationService(object):
|
||||||
|
"""Defines an application service. This definition is mostly what is
|
||||||
|
provided to the /register AS API.
|
||||||
|
|
||||||
|
Provides methods to check if this service is "interested" in events.
|
||||||
|
"""
|
||||||
|
NS_USERS = "users"
|
||||||
|
NS_ALIASES = "aliases"
|
||||||
|
NS_ROOMS = "rooms"
|
||||||
|
# The ordering here is important as it is used to map database values (which
|
||||||
|
# are stored as ints representing the position in this list) to namespace
|
||||||
|
# values.
|
||||||
|
NS_LIST = [NS_USERS, NS_ALIASES, NS_ROOMS]
|
||||||
|
|
||||||
|
def __init__(self, token, url=None, namespaces=None, hs_token=None,
|
||||||
|
sender=None, txn_id=None):
|
||||||
|
self.token = token
|
||||||
|
self.url = url
|
||||||
|
self.hs_token = hs_token
|
||||||
|
self.sender = sender
|
||||||
|
self.namespaces = self._check_namespaces(namespaces)
|
||||||
|
self.txn_id = txn_id
|
||||||
|
|
||||||
|
def _check_namespaces(self, namespaces):
|
||||||
|
# Sanity check that it is of the form:
|
||||||
|
# {
|
||||||
|
# users: ["regex",...],
|
||||||
|
# aliases: ["regex",...],
|
||||||
|
# rooms: ["regex",...],
|
||||||
|
# }
|
||||||
|
if not namespaces:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for ns in ApplicationService.NS_LIST:
|
||||||
|
if type(namespaces[ns]) != list:
|
||||||
|
raise ValueError("Bad namespace value for '%s'", ns)
|
||||||
|
for regex in namespaces[ns]:
|
||||||
|
if not isinstance(regex, basestring):
|
||||||
|
raise ValueError("Expected string regex for ns '%s'", ns)
|
||||||
|
return namespaces
|
||||||
|
|
||||||
|
def _matches_regex(self, test_string, namespace_key):
|
||||||
|
if not isinstance(test_string, basestring):
|
||||||
|
logger.error(
|
||||||
|
"Expected a string to test regex against, but got %s",
|
||||||
|
test_string
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for regex in self.namespaces[namespace_key]:
|
||||||
|
if re.match(regex, test_string):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _matches_user(self, event, member_list):
|
||||||
|
if (hasattr(event, "sender") and
|
||||||
|
self.is_interested_in_user(event.sender)):
|
||||||
|
return True
|
||||||
|
# also check m.room.member state key
|
||||||
|
if (hasattr(event, "type") and event.type == EventTypes.Member
|
||||||
|
and hasattr(event, "state_key")
|
||||||
|
and self.is_interested_in_user(event.state_key)):
|
||||||
|
return True
|
||||||
|
# check joined member events
|
||||||
|
for member in member_list:
|
||||||
|
if self.is_interested_in_user(member.state_key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _matches_room_id(self, event):
|
||||||
|
if hasattr(event, "room_id"):
|
||||||
|
return self.is_interested_in_room(event.room_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _matches_aliases(self, event, alias_list):
|
||||||
|
for alias in alias_list:
|
||||||
|
if self.is_interested_in_alias(alias):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_interested(self, event, restrict_to=None, aliases_for_event=None,
|
||||||
|
member_list=None):
|
||||||
|
"""Check if this service is interested in this event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event(Event): The event to check.
|
||||||
|
restrict_to(str): The namespace to restrict regex tests to.
|
||||||
|
aliases_for_event(list): A list of all the known room aliases for
|
||||||
|
this event.
|
||||||
|
member_list(list): A list of all joined room members in this room.
|
||||||
|
Returns:
|
||||||
|
bool: True if this service would like to know about this event.
|
||||||
|
"""
|
||||||
|
if aliases_for_event is None:
|
||||||
|
aliases_for_event = []
|
||||||
|
if member_list is None:
|
||||||
|
member_list = []
|
||||||
|
|
||||||
|
if restrict_to and restrict_to not in ApplicationService.NS_LIST:
|
||||||
|
# this is a programming error, so fail early and raise a general
|
||||||
|
# exception
|
||||||
|
raise Exception("Unexpected restrict_to value: %s". restrict_to)
|
||||||
|
|
||||||
|
if not restrict_to:
|
||||||
|
return (self._matches_user(event, member_list)
|
||||||
|
or self._matches_aliases(event, aliases_for_event)
|
||||||
|
or self._matches_room_id(event))
|
||||||
|
elif restrict_to == ApplicationService.NS_ALIASES:
|
||||||
|
return self._matches_aliases(event, aliases_for_event)
|
||||||
|
elif restrict_to == ApplicationService.NS_ROOMS:
|
||||||
|
return self._matches_room_id(event)
|
||||||
|
elif restrict_to == ApplicationService.NS_USERS:
|
||||||
|
return self._matches_user(event, member_list)
|
||||||
|
|
||||||
|
def is_interested_in_user(self, user_id):
|
||||||
|
return self._matches_regex(user_id, ApplicationService.NS_USERS)
|
||||||
|
|
||||||
|
def is_interested_in_alias(self, alias):
|
||||||
|
return self._matches_regex(alias, ApplicationService.NS_ALIASES)
|
||||||
|
|
||||||
|
def is_interested_in_room(self, room_id):
|
||||||
|
return self._matches_regex(room_id, ApplicationService.NS_ROOMS)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "ApplicationService: %s" % (self.__dict__,)
|
108
synapse/appservice/api.py
Normal file
108
synapse/appservice/api.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from synapse.api.errors import CodeMessageException
|
||||||
|
from synapse.http.client import SimpleHttpClient
|
||||||
|
from synapse.events.utils import serialize_event
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationServiceApi(SimpleHttpClient):
|
||||||
|
"""This class manages HS -> AS communications, including querying and
|
||||||
|
pushing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hs):
|
||||||
|
super(ApplicationServiceApi, self).__init__(hs)
|
||||||
|
self.clock = hs.get_clock()
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def query_user(self, service, user_id):
|
||||||
|
uri = service.url + ("/users/%s" % urllib.quote(user_id))
|
||||||
|
response = None
|
||||||
|
try:
|
||||||
|
response = yield self.get_json(uri, {
|
||||||
|
"access_token": service.hs_token
|
||||||
|
})
|
||||||
|
if response is not None: # just an empty json object
|
||||||
|
defer.returnValue(True)
|
||||||
|
except CodeMessageException as e:
|
||||||
|
if e.code == 404:
|
||||||
|
defer.returnValue(False)
|
||||||
|
return
|
||||||
|
logger.warning("query_user to %s received %s", uri, e.code)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("query_user to %s threw exception %s", uri, ex)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def query_alias(self, service, alias):
|
||||||
|
uri = service.url + ("/rooms/%s" % urllib.quote(alias))
|
||||||
|
response = None
|
||||||
|
try:
|
||||||
|
response = yield self.get_json(uri, {
|
||||||
|
"access_token": service.hs_token
|
||||||
|
})
|
||||||
|
if response is not None: # just an empty json object
|
||||||
|
defer.returnValue(True)
|
||||||
|
except CodeMessageException as e:
|
||||||
|
logger.warning("query_alias to %s received %s", uri, e.code)
|
||||||
|
if e.code == 404:
|
||||||
|
defer.returnValue(False)
|
||||||
|
return
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("query_alias to %s threw exception %s", uri, ex)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def push_bulk(self, service, events):
|
||||||
|
events = self._serialize(events)
|
||||||
|
|
||||||
|
uri = service.url + ("/transactions/%s" %
|
||||||
|
urllib.quote(str(0))) # TODO txn_ids
|
||||||
|
response = None
|
||||||
|
try:
|
||||||
|
response = yield self.put_json(
|
||||||
|
uri=uri,
|
||||||
|
json_body={
|
||||||
|
"events": events
|
||||||
|
},
|
||||||
|
args={
|
||||||
|
"access_token": service.hs_token
|
||||||
|
})
|
||||||
|
if response: # just an empty json object
|
||||||
|
# TODO: Mark txn as sent successfully
|
||||||
|
defer.returnValue(True)
|
||||||
|
except CodeMessageException as e:
|
||||||
|
logger.warning("push_bulk to %s received %s", uri, e.code)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("push_bulk to %s threw exception %s", uri, ex)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def push(self, service, event):
|
||||||
|
response = yield self.push_bulk(service, [event])
|
||||||
|
defer.returnValue(response)
|
||||||
|
|
||||||
|
def _serialize(self, events):
|
||||||
|
time_now = self.clock.time_msec()
|
||||||
|
return [
|
||||||
|
serialize_event(e, time_now, as_client_event=True) for e in events
|
||||||
|
]
|
@ -13,6 +13,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from synapse.appservice.api import ApplicationServiceApi
|
||||||
from .register import RegistrationHandler
|
from .register import RegistrationHandler
|
||||||
from .room import (
|
from .room import (
|
||||||
RoomCreationHandler, RoomMemberHandler, RoomListHandler
|
RoomCreationHandler, RoomMemberHandler, RoomListHandler
|
||||||
@ -26,6 +27,7 @@ from .presence import PresenceHandler
|
|||||||
from .directory import DirectoryHandler
|
from .directory import DirectoryHandler
|
||||||
from .typing import TypingNotificationHandler
|
from .typing import TypingNotificationHandler
|
||||||
from .admin import AdminHandler
|
from .admin import AdminHandler
|
||||||
|
from .appservice import ApplicationServicesHandler
|
||||||
from .sync import SyncHandler
|
from .sync import SyncHandler
|
||||||
|
|
||||||
|
|
||||||
@ -52,4 +54,7 @@ class Handlers(object):
|
|||||||
self.directory_handler = DirectoryHandler(hs)
|
self.directory_handler = DirectoryHandler(hs)
|
||||||
self.typing_notification_handler = TypingNotificationHandler(hs)
|
self.typing_notification_handler = TypingNotificationHandler(hs)
|
||||||
self.admin_handler = AdminHandler(hs)
|
self.admin_handler = AdminHandler(hs)
|
||||||
|
self.appservice_handler = ApplicationServicesHandler(
|
||||||
|
hs, ApplicationServiceApi(hs)
|
||||||
|
)
|
||||||
self.sync_handler = SyncHandler(hs)
|
self.sync_handler = SyncHandler(hs)
|
||||||
|
211
synapse/handlers/appservice.py
Normal file
211
synapse/handlers/appservice.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from synapse.api.constants import EventTypes, Membership
|
||||||
|
from synapse.api.errors import Codes, StoreError, SynapseError
|
||||||
|
from synapse.appservice import ApplicationService
|
||||||
|
from synapse.types import UserID
|
||||||
|
import synapse.util.stringutils as stringutils
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# NB: Purposefully not inheriting BaseHandler since that contains way too much
|
||||||
|
# setup code which this handler does not need or use. This makes testing a lot
|
||||||
|
# easier.
|
||||||
|
class ApplicationServicesHandler(object):
|
||||||
|
|
||||||
|
def __init__(self, hs, appservice_api):
|
||||||
|
self.store = hs.get_datastore()
|
||||||
|
self.hs = hs
|
||||||
|
self.appservice_api = appservice_api
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def register(self, app_service):
|
||||||
|
logger.info("Register -> %s", app_service)
|
||||||
|
# check the token is recognised
|
||||||
|
try:
|
||||||
|
stored_service = yield self.store.get_app_service_by_token(
|
||||||
|
app_service.token
|
||||||
|
)
|
||||||
|
if not stored_service:
|
||||||
|
raise StoreError(404, "Application service not found")
|
||||||
|
except StoreError:
|
||||||
|
raise SynapseError(
|
||||||
|
403, "Unrecognised application services token. "
|
||||||
|
"Consult the home server admin.",
|
||||||
|
errcode=Codes.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
app_service.hs_token = self._generate_hs_token()
|
||||||
|
|
||||||
|
# create a sender for this application service which is used when
|
||||||
|
# creating rooms, etc..
|
||||||
|
account = yield self.hs.get_handlers().registration_handler.register()
|
||||||
|
app_service.sender = account[0]
|
||||||
|
|
||||||
|
yield self.store.update_app_service(app_service)
|
||||||
|
defer.returnValue(app_service)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def unregister(self, token):
|
||||||
|
logger.info("Unregister as_token=%s", token)
|
||||||
|
yield self.store.unregister_app_service(token)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def notify_interested_services(self, event):
|
||||||
|
"""Notifies (pushes) all application services interested in this event.
|
||||||
|
|
||||||
|
Pushing is done asynchronously, so this method won't block for any
|
||||||
|
prolonged length of time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event(Event): The event to push out to interested services.
|
||||||
|
"""
|
||||||
|
# Gather interested services
|
||||||
|
services = yield self._get_services_for_event(event)
|
||||||
|
if len(services) == 0:
|
||||||
|
return # no services need notifying
|
||||||
|
|
||||||
|
# Do we know this user exists? If not, poke the user query API for
|
||||||
|
# all services which match that user regex. This needs to block as these
|
||||||
|
# user queries need to be made BEFORE pushing the event.
|
||||||
|
yield self._check_user_exists(event.sender)
|
||||||
|
if event.type == EventTypes.Member:
|
||||||
|
yield self._check_user_exists(event.state_key)
|
||||||
|
|
||||||
|
# Fork off pushes to these services - XXX First cut, best effort
|
||||||
|
for service in services:
|
||||||
|
self.appservice_api.push(service, event)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def query_user_exists(self, user_id):
|
||||||
|
"""Check if any application service knows this user_id exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id(str): The user to query if they exist on any AS.
|
||||||
|
Returns:
|
||||||
|
True if this user exists on at least one application service.
|
||||||
|
"""
|
||||||
|
user_query_services = yield self._get_services_for_user(
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
for user_service in user_query_services:
|
||||||
|
is_known_user = yield self.appservice_api.query_user(
|
||||||
|
user_service, user_id
|
||||||
|
)
|
||||||
|
if is_known_user:
|
||||||
|
defer.returnValue(True)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def query_room_alias_exists(self, room_alias):
|
||||||
|
"""Check if an application service knows this room alias exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_alias(RoomAlias): The room alias to query.
|
||||||
|
Returns:
|
||||||
|
namedtuple: with keys "room_id" and "servers" or None if no
|
||||||
|
association can be found.
|
||||||
|
"""
|
||||||
|
room_alias_str = room_alias.to_string()
|
||||||
|
alias_query_services = yield self._get_services_for_event(
|
||||||
|
event=None,
|
||||||
|
restrict_to=ApplicationService.NS_ALIASES,
|
||||||
|
alias_list=[room_alias_str]
|
||||||
|
)
|
||||||
|
for alias_service in alias_query_services:
|
||||||
|
is_known_alias = yield self.appservice_api.query_alias(
|
||||||
|
alias_service, room_alias_str
|
||||||
|
)
|
||||||
|
if is_known_alias:
|
||||||
|
# the alias exists now so don't query more ASes.
|
||||||
|
result = yield self.store.get_association_from_room_alias(
|
||||||
|
room_alias
|
||||||
|
)
|
||||||
|
defer.returnValue(result)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _get_services_for_event(self, event, restrict_to="", alias_list=None):
|
||||||
|
"""Retrieve a list of application services interested in this event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event(Event): The event to check. Can be None if alias_list is not.
|
||||||
|
restrict_to(str): The namespace to restrict regex tests to.
|
||||||
|
alias_list: A list of aliases to get services for. If None, this
|
||||||
|
list is obtained from the database.
|
||||||
|
Returns:
|
||||||
|
list<ApplicationService>: A list of services interested in this
|
||||||
|
event based on the service regex.
|
||||||
|
"""
|
||||||
|
member_list = None
|
||||||
|
if hasattr(event, "room_id"):
|
||||||
|
# We need to know the aliases associated with this event.room_id,
|
||||||
|
# if any.
|
||||||
|
if not alias_list:
|
||||||
|
alias_list = yield self.store.get_aliases_for_room(
|
||||||
|
event.room_id
|
||||||
|
)
|
||||||
|
# We need to know the members associated with this event.room_id,
|
||||||
|
# if any.
|
||||||
|
member_list = yield self.store.get_room_members(
|
||||||
|
room_id=event.room_id,
|
||||||
|
membership=Membership.JOIN
|
||||||
|
)
|
||||||
|
|
||||||
|
services = yield self.store.get_app_services()
|
||||||
|
interested_list = [
|
||||||
|
s for s in services if (
|
||||||
|
s.is_interested(event, restrict_to, alias_list, member_list)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
defer.returnValue(interested_list)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _get_services_for_user(self, user_id):
|
||||||
|
services = yield self.store.get_app_services()
|
||||||
|
interested_list = [
|
||||||
|
s for s in services if (
|
||||||
|
s.is_interested_in_user(user_id)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
defer.returnValue(interested_list)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _is_unknown_user(self, user_id):
|
||||||
|
user = UserID.from_string(user_id)
|
||||||
|
if not self.hs.is_mine(user):
|
||||||
|
# we don't know if they are unknown or not since it isn't one of our
|
||||||
|
# users. We can't poke ASes.
|
||||||
|
defer.returnValue(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_info = yield self.store.get_user_by_id(user_id)
|
||||||
|
defer.returnValue(len(user_info) == 0)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _check_user_exists(self, user_id):
|
||||||
|
unknown_user = yield self._is_unknown_user(user_id)
|
||||||
|
if unknown_user:
|
||||||
|
exists = yield self.query_user_exists(user_id)
|
||||||
|
defer.returnValue(exists)
|
||||||
|
defer.returnValue(True)
|
||||||
|
|
||||||
|
def _generate_hs_token(self):
|
||||||
|
return stringutils.random_string(24)
|
@ -37,18 +37,15 @@ class DirectoryHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def create_association(self, user_id, room_alias, room_id, servers=None):
|
def _create_association(self, room_alias, room_id, servers=None):
|
||||||
|
# general association creation for both human users and app services
|
||||||
# TODO(erikj): Do auth.
|
|
||||||
|
|
||||||
if not self.hs.is_mine(room_alias):
|
if not self.hs.is_mine(room_alias):
|
||||||
raise SynapseError(400, "Room alias must be local")
|
raise SynapseError(400, "Room alias must be local")
|
||||||
# TODO(erikj): Change this.
|
# TODO(erikj): Change this.
|
||||||
|
|
||||||
# TODO(erikj): Add transactions.
|
# TODO(erikj): Add transactions.
|
||||||
|
|
||||||
# TODO(erikj): Check if there is a current association.
|
# TODO(erikj): Check if there is a current association.
|
||||||
|
|
||||||
if not servers:
|
if not servers:
|
||||||
servers = yield self.store.get_joined_hosts_for_room(room_id)
|
servers = yield self.store.get_joined_hosts_for_room(room_id)
|
||||||
|
|
||||||
@ -61,23 +58,78 @@ class DirectoryHandler(BaseHandler):
|
|||||||
servers
|
servers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def create_association(self, user_id, room_alias, room_id, servers=None):
|
||||||
|
# association creation for human users
|
||||||
|
# TODO(erikj): Do user auth.
|
||||||
|
|
||||||
|
can_create = yield self.can_modify_alias(
|
||||||
|
room_alias,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
if not can_create:
|
||||||
|
raise SynapseError(
|
||||||
|
400, "This alias is reserved by an application service.",
|
||||||
|
errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
yield self._create_association(room_alias, room_id, servers)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def create_appservice_association(self, service, room_alias, room_id,
|
||||||
|
servers=None):
|
||||||
|
if not service.is_interested_in_alias(room_alias.to_string()):
|
||||||
|
raise SynapseError(
|
||||||
|
400, "This application service has not reserved"
|
||||||
|
" this kind of alias.", errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
# association creation for app services
|
||||||
|
yield self._create_association(room_alias, room_id, servers)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def delete_association(self, user_id, room_alias):
|
def delete_association(self, user_id, room_alias):
|
||||||
|
# association deletion for human users
|
||||||
|
|
||||||
# TODO Check if server admin
|
# TODO Check if server admin
|
||||||
|
|
||||||
|
can_delete = yield self.can_modify_alias(
|
||||||
|
room_alias,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
if not can_delete:
|
||||||
|
raise SynapseError(
|
||||||
|
400, "This alias is reserved by an application service.",
|
||||||
|
errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._delete_association(room_alias)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def delete_appservice_association(self, service, room_alias):
|
||||||
|
if not service.is_interested_in_alias(room_alias.to_string()):
|
||||||
|
raise SynapseError(
|
||||||
|
400,
|
||||||
|
"This application service has not reserved this kind of alias",
|
||||||
|
errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
yield self._delete_association(room_alias)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _delete_association(self, room_alias):
|
||||||
if not self.hs.is_mine(room_alias):
|
if not self.hs.is_mine(room_alias):
|
||||||
raise SynapseError(400, "Room alias must be local")
|
raise SynapseError(400, "Room alias must be local")
|
||||||
|
|
||||||
room_id = yield self.store.delete_room_alias(room_alias)
|
yield self.store.delete_room_alias(room_alias)
|
||||||
|
|
||||||
if room_id:
|
# TODO - Looks like _update_room_alias_event has never been implemented
|
||||||
yield self._update_room_alias_events(user_id, room_id)
|
# if room_id:
|
||||||
|
# yield self._update_room_alias_events(user_id, room_id)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_association(self, room_alias):
|
def get_association(self, room_alias):
|
||||||
room_id = None
|
room_id = None
|
||||||
if self.hs.is_mine(room_alias):
|
if self.hs.is_mine(room_alias):
|
||||||
result = yield self.store.get_association_from_room_alias(
|
result = yield self.get_association_from_room_alias(
|
||||||
room_alias
|
room_alias
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -138,7 +190,7 @@ class DirectoryHandler(BaseHandler):
|
|||||||
400, "Room Alias is not hosted on this Home Server"
|
400, "Room Alias is not hosted on this Home Server"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = yield self.store.get_association_from_room_alias(
|
result = yield self.get_association_from_room_alias(
|
||||||
room_alias
|
room_alias
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -166,3 +218,27 @@ class DirectoryHandler(BaseHandler):
|
|||||||
"sender": user_id,
|
"sender": user_id,
|
||||||
"content": {"aliases": aliases},
|
"content": {"aliases": aliases},
|
||||||
}, ratelimit=False)
|
}, ratelimit=False)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def get_association_from_room_alias(self, room_alias):
|
||||||
|
result = yield self.store.get_association_from_room_alias(
|
||||||
|
room_alias
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
# Query AS to see if it exists
|
||||||
|
as_handler = self.hs.get_handlers().appservice_handler
|
||||||
|
result = yield as_handler.query_room_alias_exists(room_alias)
|
||||||
|
defer.returnValue(result)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def can_modify_alias(self, alias, user_id=None):
|
||||||
|
services = yield self.store.get_app_services()
|
||||||
|
interested_services = [
|
||||||
|
s for s in services if s.is_interested_in_alias(alias.to_string())
|
||||||
|
]
|
||||||
|
for service in interested_services:
|
||||||
|
if user_id == service.sender:
|
||||||
|
# this user IS the app service
|
||||||
|
defer.returnValue(True)
|
||||||
|
return
|
||||||
|
defer.returnValue(len(interested_services) == 0)
|
||||||
|
@ -16,12 +16,13 @@
|
|||||||
from twisted.internet import defer
|
from twisted.internet import defer
|
||||||
|
|
||||||
from ._base import BaseHandler
|
from ._base import BaseHandler
|
||||||
from synapse.api.errors import LoginError, Codes
|
from synapse.api.errors import LoginError, Codes, CodeMessageException
|
||||||
from synapse.http.client import SimpleHttpClient
|
from synapse.http.client import SimpleHttpClient
|
||||||
from synapse.util.emailutils import EmailException
|
from synapse.util.emailutils import EmailException
|
||||||
import synapse.util.emailutils as emailutils
|
import synapse.util.emailutils as emailutils
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -96,16 +97,20 @@ class LoginHandler(BaseHandler):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def _query_email(self, email):
|
def _query_email(self, email):
|
||||||
httpCli = SimpleHttpClient(self.hs)
|
http_client = SimpleHttpClient(self.hs)
|
||||||
data = yield httpCli.get_json(
|
try:
|
||||||
# TODO FIXME This should be configurable.
|
data = yield http_client.get_json(
|
||||||
# XXX: ID servers need to use HTTPS
|
# TODO FIXME This should be configurable.
|
||||||
"http://%s%s" % (
|
# XXX: ID servers need to use HTTPS
|
||||||
"matrix.org:8090", "/_matrix/identity/api/v1/lookup"
|
"http://%s%s" % (
|
||||||
),
|
"matrix.org:8090", "/_matrix/identity/api/v1/lookup"
|
||||||
{
|
),
|
||||||
'medium': 'email',
|
{
|
||||||
'address': email
|
'medium': 'email',
|
||||||
}
|
'address': email
|
||||||
)
|
}
|
||||||
defer.returnValue(data)
|
)
|
||||||
|
defer.returnValue(data)
|
||||||
|
except CodeMessageException as e:
|
||||||
|
data = json.loads(e.msg)
|
||||||
|
defer.returnValue(data)
|
||||||
|
@ -18,7 +18,8 @@ from twisted.internet import defer
|
|||||||
|
|
||||||
from synapse.types import UserID
|
from synapse.types import UserID
|
||||||
from synapse.api.errors import (
|
from synapse.api.errors import (
|
||||||
SynapseError, RegistrationError, InvalidCaptchaError
|
AuthError, Codes, SynapseError, RegistrationError, InvalidCaptchaError,
|
||||||
|
CodeMessageException
|
||||||
)
|
)
|
||||||
from ._base import BaseHandler
|
from ._base import BaseHandler
|
||||||
import synapse.util.stringutils as stringutils
|
import synapse.util.stringutils as stringutils
|
||||||
@ -28,6 +29,7 @@ from synapse.http.client import CaptchaServerHttpClient
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -64,6 +66,8 @@ class RegistrationHandler(BaseHandler):
|
|||||||
user = UserID(localpart, self.hs.hostname)
|
user = UserID(localpart, self.hs.hostname)
|
||||||
user_id = user.to_string()
|
user_id = user.to_string()
|
||||||
|
|
||||||
|
yield self.check_user_id_is_valid(user_id)
|
||||||
|
|
||||||
token = self._generate_token(user_id)
|
token = self._generate_token(user_id)
|
||||||
yield self.store.register(
|
yield self.store.register(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@ -82,6 +86,7 @@ class RegistrationHandler(BaseHandler):
|
|||||||
localpart = self._generate_user_id()
|
localpart = self._generate_user_id()
|
||||||
user = UserID(localpart, self.hs.hostname)
|
user = UserID(localpart, self.hs.hostname)
|
||||||
user_id = user.to_string()
|
user_id = user.to_string()
|
||||||
|
yield self.check_user_id_is_valid(user_id)
|
||||||
|
|
||||||
token = self._generate_token(user_id)
|
token = self._generate_token(user_id)
|
||||||
yield self.store.register(
|
yield self.store.register(
|
||||||
@ -121,6 +126,27 @@ class RegistrationHandler(BaseHandler):
|
|||||||
|
|
||||||
defer.returnValue((user_id, token))
|
defer.returnValue((user_id, token))
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def appservice_register(self, user_localpart, as_token):
|
||||||
|
user = UserID(user_localpart, self.hs.hostname)
|
||||||
|
user_id = user.to_string()
|
||||||
|
service = yield self.store.get_app_service_by_token(as_token)
|
||||||
|
if not service:
|
||||||
|
raise AuthError(403, "Invalid application service token.")
|
||||||
|
if not service.is_interested_in_user(user_id):
|
||||||
|
raise SynapseError(
|
||||||
|
400, "Invalid user localpart for this application service.",
|
||||||
|
errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
token = self._generate_token(user_id)
|
||||||
|
yield self.store.register(
|
||||||
|
user_id=user_id,
|
||||||
|
token=token,
|
||||||
|
password_hash=""
|
||||||
|
)
|
||||||
|
self.distributor.fire("registered_user", user)
|
||||||
|
defer.returnValue((user_id, token))
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def check_recaptcha(self, ip, private_key, challenge, response):
|
def check_recaptcha(self, ip, private_key, challenge, response):
|
||||||
"""Checks a recaptcha is correct."""
|
"""Checks a recaptcha is correct."""
|
||||||
@ -167,6 +193,20 @@ class RegistrationHandler(BaseHandler):
|
|||||||
# XXX: This should be a deferred list, shouldn't it?
|
# XXX: This should be a deferred list, shouldn't it?
|
||||||
yield self._bind_threepid(c, user_id)
|
yield self._bind_threepid(c, user_id)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def check_user_id_is_valid(self, user_id):
|
||||||
|
# valid user IDs must not clash with any user ID namespaces claimed by
|
||||||
|
# application services.
|
||||||
|
services = yield self.store.get_app_services()
|
||||||
|
interested_services = [
|
||||||
|
s for s in services if s.is_interested_in_user(user_id)
|
||||||
|
]
|
||||||
|
if len(interested_services) > 0:
|
||||||
|
raise SynapseError(
|
||||||
|
400, "This user ID is reserved by an application service.",
|
||||||
|
errcode=Codes.EXCLUSIVE
|
||||||
|
)
|
||||||
|
|
||||||
def _generate_token(self, user_id):
|
def _generate_token(self, user_id):
|
||||||
# urlsafe variant uses _ and - so use . as the separator and replace
|
# urlsafe variant uses _ and - so use . as the separator and replace
|
||||||
# all =s with .s so http clients don't quote =s when it is used as
|
# all =s with .s so http clients don't quote =s when it is used as
|
||||||
@ -181,21 +221,26 @@ class RegistrationHandler(BaseHandler):
|
|||||||
def _threepid_from_creds(self, creds):
|
def _threepid_from_creds(self, creds):
|
||||||
# TODO: get this from the homeserver rather than creating a new one for
|
# TODO: get this from the homeserver rather than creating a new one for
|
||||||
# each request
|
# each request
|
||||||
httpCli = SimpleHttpClient(self.hs)
|
http_client = SimpleHttpClient(self.hs)
|
||||||
# XXX: make this configurable!
|
# XXX: make this configurable!
|
||||||
trustedIdServers = ['matrix.org:8090', 'matrix.org']
|
trustedIdServers = ['matrix.org:8090', 'matrix.org']
|
||||||
if not creds['idServer'] in trustedIdServers:
|
if not creds['idServer'] in trustedIdServers:
|
||||||
logger.warn('%s is not a trusted ID server: rejecting 3pid ' +
|
logger.warn('%s is not a trusted ID server: rejecting 3pid ' +
|
||||||
'credentials', creds['idServer'])
|
'credentials', creds['idServer'])
|
||||||
defer.returnValue(None)
|
defer.returnValue(None)
|
||||||
data = yield httpCli.get_json(
|
|
||||||
# XXX: This should be HTTPS
|
data = {}
|
||||||
"http://%s%s" % (
|
try:
|
||||||
creds['idServer'],
|
data = yield http_client.get_json(
|
||||||
"/_matrix/identity/api/v1/3pid/getValidated3pid"
|
# XXX: This should be HTTPS
|
||||||
),
|
"http://%s%s" % (
|
||||||
{'sid': creds['sid'], 'clientSecret': creds['clientSecret']}
|
creds['idServer'],
|
||||||
)
|
"/_matrix/identity/api/v1/3pid/getValidated3pid"
|
||||||
|
),
|
||||||
|
{'sid': creds['sid'], 'clientSecret': creds['clientSecret']}
|
||||||
|
)
|
||||||
|
except CodeMessageException as e:
|
||||||
|
data = json.loads(e.msg)
|
||||||
|
|
||||||
if 'medium' in data:
|
if 'medium' in data:
|
||||||
defer.returnValue(data)
|
defer.returnValue(data)
|
||||||
@ -205,19 +250,23 @@ class RegistrationHandler(BaseHandler):
|
|||||||
def _bind_threepid(self, creds, mxid):
|
def _bind_threepid(self, creds, mxid):
|
||||||
yield
|
yield
|
||||||
logger.debug("binding threepid")
|
logger.debug("binding threepid")
|
||||||
httpCli = SimpleHttpClient(self.hs)
|
http_client = SimpleHttpClient(self.hs)
|
||||||
data = yield httpCli.post_urlencoded_get_json(
|
data = None
|
||||||
# XXX: Change when ID servers are all HTTPS
|
try:
|
||||||
"http://%s%s" % (
|
data = yield http_client.post_urlencoded_get_json(
|
||||||
creds['idServer'], "/_matrix/identity/api/v1/3pid/bind"
|
# XXX: Change when ID servers are all HTTPS
|
||||||
),
|
"http://%s%s" % (
|
||||||
{
|
creds['idServer'], "/_matrix/identity/api/v1/3pid/bind"
|
||||||
'sid': creds['sid'],
|
),
|
||||||
'clientSecret': creds['clientSecret'],
|
{
|
||||||
'mxid': mxid,
|
'sid': creds['sid'],
|
||||||
}
|
'clientSecret': creds['clientSecret'],
|
||||||
)
|
'mxid': mxid,
|
||||||
logger.debug("bound threepid")
|
}
|
||||||
|
)
|
||||||
|
logger.debug("bound threepid")
|
||||||
|
except CodeMessageException as e:
|
||||||
|
data = json.loads(e.msg)
|
||||||
defer.returnValue(data)
|
defer.returnValue(data)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
from synapse.api.errors import CodeMessageException
|
||||||
from synapse.http.agent_name import AGENT_NAME
|
from synapse.http.agent_name import AGENT_NAME
|
||||||
from syutil.jsonutil import encode_canonical_json
|
from syutil.jsonutil import encode_canonical_json
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ class SimpleHttpClient(object):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_json(self, uri, args={}):
|
def get_json(self, uri, args={}):
|
||||||
""" Get's some json from the given host and path
|
""" Gets some json from the given URI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
uri (str): The URI to request, not including query parameters
|
uri (str): The URI to request, not including query parameters
|
||||||
@ -93,15 +93,13 @@ class SimpleHttpClient(object):
|
|||||||
None.
|
None.
|
||||||
**Note**: The value of each key is assumed to be an iterable
|
**Note**: The value of each key is assumed to be an iterable
|
||||||
and *not* a string.
|
and *not* a string.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Deferred: Succeeds when we get *any* HTTP response.
|
Deferred: Succeeds when we get *any* 2xx HTTP response, with the
|
||||||
|
HTTP body as JSON.
|
||||||
The result of the deferred is a tuple of `(code, response)`,
|
Raises:
|
||||||
where `response` is a dict representing the decoded JSON body.
|
On a non-2xx HTTP response. The response body will be used as the
|
||||||
|
error message.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
yield
|
|
||||||
if len(args):
|
if len(args):
|
||||||
query_bytes = urllib.urlencode(args, True)
|
query_bytes = urllib.urlencode(args, True)
|
||||||
uri = "%s?%s" % (uri, query_bytes)
|
uri = "%s?%s" % (uri, query_bytes)
|
||||||
@ -116,7 +114,56 @@ class SimpleHttpClient(object):
|
|||||||
|
|
||||||
body = yield readBody(response)
|
body = yield readBody(response)
|
||||||
|
|
||||||
defer.returnValue(json.loads(body))
|
if 200 <= response.code < 300:
|
||||||
|
defer.returnValue(json.loads(body))
|
||||||
|
else:
|
||||||
|
# NB: This is explicitly not json.loads(body)'d because the contract
|
||||||
|
# of CodeMessageException is a *string* message. Callers can always
|
||||||
|
# load it into JSON if they want.
|
||||||
|
raise CodeMessageException(response.code, body)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def put_json(self, uri, json_body, args={}):
|
||||||
|
""" Puts some json to the given URI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uri (str): The URI to request, not including query parameters
|
||||||
|
json_body (dict): The JSON to put in the HTTP body,
|
||||||
|
args (dict): A dictionary used to create query strings, defaults to
|
||||||
|
None.
|
||||||
|
**Note**: The value of each key is assumed to be an iterable
|
||||||
|
and *not* a string.
|
||||||
|
Returns:
|
||||||
|
Deferred: Succeeds when we get *any* 2xx HTTP response, with the
|
||||||
|
HTTP body as JSON.
|
||||||
|
Raises:
|
||||||
|
On a non-2xx HTTP response.
|
||||||
|
"""
|
||||||
|
if len(args):
|
||||||
|
query_bytes = urllib.urlencode(args, True)
|
||||||
|
uri = "%s?%s" % (uri, query_bytes)
|
||||||
|
|
||||||
|
json_str = json.dumps(json_body)
|
||||||
|
|
||||||
|
response = yield self.agent.request(
|
||||||
|
"PUT",
|
||||||
|
uri.encode("ascii"),
|
||||||
|
headers=Headers({
|
||||||
|
b"User-Agent": [AGENT_NAME],
|
||||||
|
"Content-Type": ["application/json"]
|
||||||
|
}),
|
||||||
|
bodyProducer=FileBodyProducer(StringIO(json_str))
|
||||||
|
)
|
||||||
|
|
||||||
|
body = yield readBody(response)
|
||||||
|
|
||||||
|
if 200 <= response.code < 300:
|
||||||
|
defer.returnValue(json.loads(body))
|
||||||
|
else:
|
||||||
|
# NB: This is explicitly not json.loads(body)'d because the contract
|
||||||
|
# of CodeMessageException is a *string* message. Callers can always
|
||||||
|
# load it into JSON if they want.
|
||||||
|
raise CodeMessageException(response.code, body)
|
||||||
|
|
||||||
|
|
||||||
class CaptchaServerHttpClient(SimpleHttpClient):
|
class CaptchaServerHttpClient(SimpleHttpClient):
|
||||||
|
@ -99,6 +99,12 @@ class Notifier(object):
|
|||||||
`extra_users` param.
|
`extra_users` param.
|
||||||
"""
|
"""
|
||||||
yield run_on_reactor()
|
yield run_on_reactor()
|
||||||
|
|
||||||
|
# poke any interested application service.
|
||||||
|
self.hs.get_handlers().appservice_handler.notify_interested_services(
|
||||||
|
event
|
||||||
|
)
|
||||||
|
|
||||||
room_id = event.room_id
|
room_id = event.room_id
|
||||||
|
|
||||||
room_source = self.event_sources.sources["room"]
|
room_source = self.event_sources.sources["room"]
|
||||||
|
14
synapse/rest/appservice/__init__.py
Normal file
14
synapse/rest/appservice/__init__.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
29
synapse/rest/appservice/v1/__init__.py
Normal file
29
synapse/rest/appservice/v1/__init__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from . import register
|
||||||
|
|
||||||
|
from synapse.http.server import JsonResource
|
||||||
|
|
||||||
|
|
||||||
|
class AppServiceRestResource(JsonResource):
|
||||||
|
"""A resource for version 1 of the matrix application service API."""
|
||||||
|
|
||||||
|
def __init__(self, hs):
|
||||||
|
JsonResource.__init__(self, hs)
|
||||||
|
self.register_servlets(self, hs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def register_servlets(appservice_resource, hs):
|
||||||
|
register.register_servlets(hs, appservice_resource)
|
48
synapse/rest/appservice/v1/base.py
Normal file
48
synapse/rest/appservice/v1/base.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""This module contains base REST classes for constructing client v1 servlets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from synapse.http.servlet import RestServlet
|
||||||
|
from synapse.api.urls import APP_SERVICE_PREFIX
|
||||||
|
import re
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def as_path_pattern(path_regex):
|
||||||
|
"""Creates a regex compiled appservice path with the correct path
|
||||||
|
prefix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path_regex (str): The regex string to match. This should NOT have a ^
|
||||||
|
as this will be prefixed.
|
||||||
|
Returns:
|
||||||
|
SRE_Pattern
|
||||||
|
"""
|
||||||
|
return re.compile("^" + APP_SERVICE_PREFIX + path_regex)
|
||||||
|
|
||||||
|
|
||||||
|
class AppServiceRestServlet(RestServlet):
|
||||||
|
"""A base Synapse REST Servlet for the application services version 1 API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hs):
|
||||||
|
self.hs = hs
|
||||||
|
self.handler = hs.get_handlers().appservice_handler
|
121
synapse/rest/appservice/v1/register.py
Normal file
121
synapse/rest/appservice/v1/register.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""This module contains REST servlets to do with registration: /register"""
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from base import AppServiceRestServlet, as_path_pattern
|
||||||
|
from synapse.api.errors import CodeMessageException, SynapseError
|
||||||
|
from synapse.storage.appservice import ApplicationService
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRestServlet(AppServiceRestServlet):
|
||||||
|
"""Handles AS registration with the home server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PATTERN = as_path_pattern("/register$")
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def on_POST(self, request):
|
||||||
|
params = _parse_json(request)
|
||||||
|
|
||||||
|
# sanity check required params
|
||||||
|
try:
|
||||||
|
as_token = params["as_token"]
|
||||||
|
as_url = params["url"]
|
||||||
|
if (not isinstance(as_token, basestring) or
|
||||||
|
not isinstance(as_url, basestring)):
|
||||||
|
raise ValueError
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
raise SynapseError(
|
||||||
|
400, "Missed required keys: as_token(str) / url(str)."
|
||||||
|
)
|
||||||
|
|
||||||
|
namespaces = {
|
||||||
|
"users": [],
|
||||||
|
"rooms": [],
|
||||||
|
"aliases": []
|
||||||
|
}
|
||||||
|
|
||||||
|
if "namespaces" in params:
|
||||||
|
self._parse_namespace(namespaces, params["namespaces"], "users")
|
||||||
|
self._parse_namespace(namespaces, params["namespaces"], "rooms")
|
||||||
|
self._parse_namespace(namespaces, params["namespaces"], "aliases")
|
||||||
|
|
||||||
|
app_service = ApplicationService(as_token, as_url, namespaces)
|
||||||
|
|
||||||
|
app_service = yield self.handler.register(app_service)
|
||||||
|
hs_token = app_service.hs_token
|
||||||
|
|
||||||
|
defer.returnValue((200, {
|
||||||
|
"hs_token": hs_token
|
||||||
|
}))
|
||||||
|
|
||||||
|
def _parse_namespace(self, target_ns, origin_ns, ns):
|
||||||
|
if ns not in target_ns or ns not in origin_ns:
|
||||||
|
return # nothing to parse / map through to.
|
||||||
|
|
||||||
|
possible_regex_list = origin_ns[ns]
|
||||||
|
if not type(possible_regex_list) == list:
|
||||||
|
raise SynapseError(400, "Namespace %s isn't an array." % ns)
|
||||||
|
|
||||||
|
for regex in possible_regex_list:
|
||||||
|
if not isinstance(regex, basestring):
|
||||||
|
raise SynapseError(
|
||||||
|
400, "Regex '%s' isn't a string in namespace %s" %
|
||||||
|
(regex, ns)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_ns[ns] = origin_ns[ns]
|
||||||
|
|
||||||
|
|
||||||
|
class UnregisterRestServlet(AppServiceRestServlet):
|
||||||
|
"""Handles AS registration with the home server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PATTERN = as_path_pattern("/unregister$")
|
||||||
|
|
||||||
|
def on_POST(self, request):
|
||||||
|
params = _parse_json(request)
|
||||||
|
try:
|
||||||
|
as_token = params["as_token"]
|
||||||
|
if not isinstance(as_token, basestring):
|
||||||
|
raise ValueError
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
raise SynapseError(400, "Missing required key: as_token(str)")
|
||||||
|
|
||||||
|
yield self.handler.unregister(as_token)
|
||||||
|
|
||||||
|
raise CodeMessageException(500, "Not implemented")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(request):
|
||||||
|
try:
|
||||||
|
content = json.loads(request.content.read())
|
||||||
|
if type(content) != dict:
|
||||||
|
raise SynapseError(400, "Content must be a JSON object.")
|
||||||
|
return content
|
||||||
|
except ValueError:
|
||||||
|
raise SynapseError(400, "Content not JSON.")
|
||||||
|
|
||||||
|
|
||||||
|
def register_servlets(hs, http_server):
|
||||||
|
RegisterRestServlet(hs).register(http_server)
|
||||||
|
UnregisterRestServlet(hs).register(http_server)
|
@ -45,8 +45,6 @@ class ClientDirectoryServer(ClientV1RestServlet):
|
|||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_PUT(self, request, room_alias):
|
def on_PUT(self, request, room_alias):
|
||||||
user, client = yield self.auth.get_user_by_req(request)
|
|
||||||
|
|
||||||
content = _parse_json(request)
|
content = _parse_json(request)
|
||||||
if "room_id" not in content:
|
if "room_id" not in content:
|
||||||
raise SynapseError(400, "Missing room_id key",
|
raise SynapseError(400, "Missing room_id key",
|
||||||
@ -70,34 +68,70 @@ class ClientDirectoryServer(ClientV1RestServlet):
|
|||||||
dir_handler = self.handlers.directory_handler
|
dir_handler = self.handlers.directory_handler
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_id = user.to_string()
|
# try to auth as a user
|
||||||
yield dir_handler.create_association(
|
user, client = yield self.auth.get_user_by_req(request)
|
||||||
user_id, room_alias, room_id, servers
|
try:
|
||||||
|
user_id = user.to_string()
|
||||||
|
yield dir_handler.create_association(
|
||||||
|
user_id, room_alias, room_id, servers
|
||||||
|
)
|
||||||
|
yield dir_handler.send_room_alias_update_event(user_id, room_id)
|
||||||
|
except SynapseError as e:
|
||||||
|
raise e
|
||||||
|
except:
|
||||||
|
logger.exception("Failed to create association")
|
||||||
|
raise
|
||||||
|
except AuthError:
|
||||||
|
# try to auth as an application service
|
||||||
|
service = yield self.auth.get_appservice_by_req(request)
|
||||||
|
yield dir_handler.create_appservice_association(
|
||||||
|
service, room_alias, room_id, servers
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Application service at %s created alias %s pointing to %s",
|
||||||
|
service.url,
|
||||||
|
room_alias.to_string(),
|
||||||
|
room_id
|
||||||
)
|
)
|
||||||
yield dir_handler.send_room_alias_update_event(user_id, room_id)
|
|
||||||
except SynapseError as e:
|
|
||||||
raise e
|
|
||||||
except:
|
|
||||||
logger.exception("Failed to create association")
|
|
||||||
raise
|
|
||||||
|
|
||||||
defer.returnValue((200, {}))
|
defer.returnValue((200, {}))
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def on_DELETE(self, request, room_alias):
|
def on_DELETE(self, request, room_alias):
|
||||||
|
dir_handler = self.handlers.directory_handler
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = yield self.auth.get_appservice_by_req(request)
|
||||||
|
room_alias = RoomAlias.from_string(room_alias)
|
||||||
|
yield dir_handler.delete_appservice_association(
|
||||||
|
service, room_alias
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Application service at %s deleted alias %s",
|
||||||
|
service.url,
|
||||||
|
room_alias.to_string()
|
||||||
|
)
|
||||||
|
defer.returnValue((200, {}))
|
||||||
|
except AuthError:
|
||||||
|
# fallback to default user behaviour if they aren't an AS
|
||||||
|
pass
|
||||||
|
|
||||||
user, client = yield self.auth.get_user_by_req(request)
|
user, client = yield self.auth.get_user_by_req(request)
|
||||||
|
|
||||||
is_admin = yield self.auth.is_server_admin(user)
|
is_admin = yield self.auth.is_server_admin(user)
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
raise AuthError(403, "You need to be a server admin")
|
raise AuthError(403, "You need to be a server admin")
|
||||||
|
|
||||||
dir_handler = self.handlers.directory_handler
|
|
||||||
|
|
||||||
room_alias = RoomAlias.from_string(room_alias)
|
room_alias = RoomAlias.from_string(room_alias)
|
||||||
|
|
||||||
yield dir_handler.delete_association(
|
yield dir_handler.delete_association(
|
||||||
user.to_string(), room_alias
|
user.to_string(), room_alias
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"User %s deleted alias %s",
|
||||||
|
user.to_string(),
|
||||||
|
room_alias.to_string()
|
||||||
|
)
|
||||||
|
|
||||||
defer.returnValue((200, {}))
|
defer.returnValue((200, {}))
|
||||||
|
|
||||||
|
@ -110,7 +110,8 @@ class RegisterRestServlet(ClientV1RestServlet):
|
|||||||
stages = {
|
stages = {
|
||||||
LoginType.RECAPTCHA: self._do_recaptcha,
|
LoginType.RECAPTCHA: self._do_recaptcha,
|
||||||
LoginType.PASSWORD: self._do_password,
|
LoginType.PASSWORD: self._do_password,
|
||||||
LoginType.EMAIL_IDENTITY: self._do_email_identity
|
LoginType.EMAIL_IDENTITY: self._do_email_identity,
|
||||||
|
LoginType.APPLICATION_SERVICE: self._do_app_service
|
||||||
}
|
}
|
||||||
|
|
||||||
session_info = self._get_session_info(request, session)
|
session_info = self._get_session_info(request, session)
|
||||||
@ -276,6 +277,27 @@ class RegisterRestServlet(ClientV1RestServlet):
|
|||||||
self._remove_session(session)
|
self._remove_session(session)
|
||||||
defer.returnValue(result)
|
defer.returnValue(result)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _do_app_service(self, request, register_json, session):
|
||||||
|
if "access_token" not in request.args:
|
||||||
|
raise SynapseError(400, "Expected application service token.")
|
||||||
|
if "user" not in register_json:
|
||||||
|
raise SynapseError(400, "Expected 'user' key.")
|
||||||
|
|
||||||
|
as_token = request.args["access_token"][0]
|
||||||
|
user_localpart = register_json["user"].encode("utf-8")
|
||||||
|
|
||||||
|
handler = self.handlers.registration_handler
|
||||||
|
(user_id, token) = yield handler.appservice_register(
|
||||||
|
user_localpart, as_token
|
||||||
|
)
|
||||||
|
self._remove_session(session)
|
||||||
|
defer.returnValue({
|
||||||
|
"user_id": user_id,
|
||||||
|
"access_token": token,
|
||||||
|
"home_server": self.hs.hostname,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _parse_json(request):
|
def _parse_json(request):
|
||||||
try:
|
try:
|
||||||
|
@ -77,6 +77,7 @@ class BaseHomeServer(object):
|
|||||||
'resource_for_content_repo',
|
'resource_for_content_repo',
|
||||||
'resource_for_server_key',
|
'resource_for_server_key',
|
||||||
'resource_for_media_repository',
|
'resource_for_media_repository',
|
||||||
|
'resource_for_app_services',
|
||||||
'event_sources',
|
'event_sources',
|
||||||
'ratelimiter',
|
'ratelimiter',
|
||||||
'keyring',
|
'keyring',
|
||||||
|
@ -18,6 +18,7 @@ from twisted.internet import defer
|
|||||||
from synapse.util.logutils import log_function
|
from synapse.util.logutils import log_function
|
||||||
from synapse.api.constants import EventTypes
|
from synapse.api.constants import EventTypes
|
||||||
|
|
||||||
|
from .appservice import ApplicationServiceStore
|
||||||
from .directory import DirectoryStore
|
from .directory import DirectoryStore
|
||||||
from .feedback import FeedbackStore
|
from .feedback import FeedbackStore
|
||||||
from .presence import PresenceStore
|
from .presence import PresenceStore
|
||||||
@ -65,6 +66,7 @@ SCHEMAS = [
|
|||||||
"event_signatures",
|
"event_signatures",
|
||||||
"pusher",
|
"pusher",
|
||||||
"media_repository",
|
"media_repository",
|
||||||
|
"application_services",
|
||||||
"filtering",
|
"filtering",
|
||||||
"rejections",
|
"rejections",
|
||||||
]
|
]
|
||||||
@ -86,6 +88,7 @@ class DataStore(RoomMemberStore, RoomStore,
|
|||||||
RegistrationStore, StreamStore, ProfileStore, FeedbackStore,
|
RegistrationStore, StreamStore, ProfileStore, FeedbackStore,
|
||||||
PresenceStore, TransactionStore,
|
PresenceStore, TransactionStore,
|
||||||
DirectoryStore, KeyStore, StateStore, SignatureStore,
|
DirectoryStore, KeyStore, StateStore, SignatureStore,
|
||||||
|
ApplicationServiceStore,
|
||||||
EventFederationStore,
|
EventFederationStore,
|
||||||
MediaRepositoryStore,
|
MediaRepositoryStore,
|
||||||
RejectionsStore,
|
RejectionsStore,
|
||||||
|
244
synapse/storage/appservice.py
Normal file
244
synapse/storage/appservice.py
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
import logging
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from synapse.api.errors import StoreError
|
||||||
|
from synapse.appservice import ApplicationService
|
||||||
|
from ._base import SQLBaseStore
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationServiceCache(object):
|
||||||
|
"""Caches ApplicationServices and provides utility functions on top.
|
||||||
|
|
||||||
|
This class is designed to be invoked on incoming events in order to avoid
|
||||||
|
hammering the database every time to extract a list of application service
|
||||||
|
regexes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.services = []
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationServiceStore(SQLBaseStore):
|
||||||
|
|
||||||
|
def __init__(self, hs):
|
||||||
|
super(ApplicationServiceStore, self).__init__(hs)
|
||||||
|
self.cache = ApplicationServiceCache()
|
||||||
|
self.cache_defer = self._populate_cache()
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def unregister_app_service(self, token):
|
||||||
|
"""Unregisters this service.
|
||||||
|
|
||||||
|
This removes all AS specific regex and the base URL. The token is the
|
||||||
|
only thing preserved for future registration attempts.
|
||||||
|
"""
|
||||||
|
yield self.cache_defer # make sure the cache is ready
|
||||||
|
yield self.runInteraction(
|
||||||
|
"unregister_app_service",
|
||||||
|
self._unregister_app_service_txn,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
# update cache TODO: Should this be in the txn?
|
||||||
|
for service in self.cache.services:
|
||||||
|
if service.token == token:
|
||||||
|
service.url = None
|
||||||
|
service.namespaces = None
|
||||||
|
service.hs_token = None
|
||||||
|
|
||||||
|
def _unregister_app_service_txn(self, txn, token):
|
||||||
|
# kill the url to prevent pushes
|
||||||
|
txn.execute(
|
||||||
|
"UPDATE application_services SET url=NULL WHERE token=?",
|
||||||
|
(token,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# cleanup regex
|
||||||
|
as_id = self._get_as_id_txn(txn, token)
|
||||||
|
if not as_id:
|
||||||
|
logger.warning(
|
||||||
|
"unregister_app_service_txn: Failed to find as_id for token=",
|
||||||
|
token
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
txn.execute(
|
||||||
|
"DELETE FROM application_services_regex WHERE as_id=?",
|
||||||
|
(as_id,)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def update_app_service(self, service):
|
||||||
|
"""Update an application service, clobbering what was previously there.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service(ApplicationService): The updated service.
|
||||||
|
"""
|
||||||
|
yield self.cache_defer # make sure the cache is ready
|
||||||
|
|
||||||
|
# NB: There is no "insert" since we provide no public-facing API to
|
||||||
|
# allocate new ASes. It relies on the server admin inserting the AS
|
||||||
|
# token into the database manually.
|
||||||
|
|
||||||
|
if not service.token or not service.url:
|
||||||
|
raise StoreError(400, "Token and url must be specified.")
|
||||||
|
|
||||||
|
if not service.hs_token:
|
||||||
|
raise StoreError(500, "No HS token")
|
||||||
|
|
||||||
|
yield self.runInteraction(
|
||||||
|
"update_app_service",
|
||||||
|
self._update_app_service_txn,
|
||||||
|
service
|
||||||
|
)
|
||||||
|
|
||||||
|
# update cache TODO: Should this be in the txn?
|
||||||
|
for (index, cache_service) in enumerate(self.cache.services):
|
||||||
|
if service.token == cache_service.token:
|
||||||
|
self.cache.services[index] = service
|
||||||
|
logger.info("Updated: %s", service)
|
||||||
|
return
|
||||||
|
# new entry
|
||||||
|
self.cache.services.append(service)
|
||||||
|
logger.info("Updated(new): %s", service)
|
||||||
|
|
||||||
|
def _update_app_service_txn(self, txn, service):
|
||||||
|
as_id = self._get_as_id_txn(txn, service.token)
|
||||||
|
if not as_id:
|
||||||
|
logger.warning(
|
||||||
|
"update_app_service_txn: Failed to find as_id for token=",
|
||||||
|
service.token
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
txn.execute(
|
||||||
|
"UPDATE application_services SET url=?, hs_token=?, sender=? "
|
||||||
|
"WHERE id=?",
|
||||||
|
(service.url, service.hs_token, service.sender, as_id,)
|
||||||
|
)
|
||||||
|
# cleanup regex
|
||||||
|
txn.execute(
|
||||||
|
"DELETE FROM application_services_regex WHERE as_id=?",
|
||||||
|
(as_id,)
|
||||||
|
)
|
||||||
|
for (ns_int, ns_str) in enumerate(ApplicationService.NS_LIST):
|
||||||
|
if ns_str in service.namespaces:
|
||||||
|
for regex in service.namespaces[ns_str]:
|
||||||
|
txn.execute(
|
||||||
|
"INSERT INTO application_services_regex("
|
||||||
|
"as_id, namespace, regex) values(?,?,?)",
|
||||||
|
(as_id, ns_int, regex)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_as_id_txn(self, txn, token):
|
||||||
|
cursor = txn.execute(
|
||||||
|
"SELECT id FROM application_services WHERE token=?",
|
||||||
|
(token,)
|
||||||
|
)
|
||||||
|
res = cursor.fetchone()
|
||||||
|
if res:
|
||||||
|
return res[0]
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def get_app_services(self):
|
||||||
|
yield self.cache_defer # make sure the cache is ready
|
||||||
|
defer.returnValue(self.cache.services)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def get_app_service_by_token(self, token, from_cache=True):
|
||||||
|
"""Get the application service with the given token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token (str): The application service token.
|
||||||
|
from_cache (bool): True to get this service from the cache, False to
|
||||||
|
check the database.
|
||||||
|
Raises:
|
||||||
|
StoreError if there was a problem retrieving this service.
|
||||||
|
"""
|
||||||
|
yield self.cache_defer # make sure the cache is ready
|
||||||
|
|
||||||
|
if from_cache:
|
||||||
|
for service in self.cache.services:
|
||||||
|
if service.token == token:
|
||||||
|
defer.returnValue(service)
|
||||||
|
return
|
||||||
|
defer.returnValue(None)
|
||||||
|
|
||||||
|
# TODO: The from_cache=False impl
|
||||||
|
# TODO: This should be JOINed with the application_services_regex table.
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _populate_cache(self):
|
||||||
|
"""Populates the ApplicationServiceCache from the database."""
|
||||||
|
sql = ("SELECT * FROM application_services LEFT JOIN "
|
||||||
|
"application_services_regex ON application_services.id = "
|
||||||
|
"application_services_regex.as_id")
|
||||||
|
# SQL results in the form:
|
||||||
|
# [
|
||||||
|
# {
|
||||||
|
# 'regex': "something",
|
||||||
|
# 'url': "something",
|
||||||
|
# 'namespace': enum,
|
||||||
|
# 'as_id': 0,
|
||||||
|
# 'token': "something",
|
||||||
|
# 'hs_token': "otherthing",
|
||||||
|
# 'id': 0
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
services = {}
|
||||||
|
results = yield self._execute_and_decode(sql)
|
||||||
|
for res in results:
|
||||||
|
as_token = res["token"]
|
||||||
|
if as_token not in services:
|
||||||
|
# add the service
|
||||||
|
services[as_token] = {
|
||||||
|
"url": res["url"],
|
||||||
|
"token": as_token,
|
||||||
|
"hs_token": res["hs_token"],
|
||||||
|
"sender": res["sender"],
|
||||||
|
"namespaces": {
|
||||||
|
ApplicationService.NS_USERS: [],
|
||||||
|
ApplicationService.NS_ALIASES: [],
|
||||||
|
ApplicationService.NS_ROOMS: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# add the namespace regex if one exists
|
||||||
|
ns_int = res["namespace"]
|
||||||
|
if ns_int is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
services[as_token]["namespaces"][
|
||||||
|
ApplicationService.NS_LIST[ns_int]].append(
|
||||||
|
res["regex"]
|
||||||
|
)
|
||||||
|
except IndexError:
|
||||||
|
logger.error("Bad namespace enum '%s'. %s", ns_int, res)
|
||||||
|
|
||||||
|
# TODO get last successful txn id f.e. service
|
||||||
|
for service in services.values():
|
||||||
|
logger.info("Found application service: %s", service)
|
||||||
|
self.cache.services.append(ApplicationService(
|
||||||
|
token=service["token"],
|
||||||
|
url=service["url"],
|
||||||
|
namespaces=service["namespaces"],
|
||||||
|
hs_token=service["hs_token"],
|
||||||
|
sender=service["sender"]
|
||||||
|
))
|
34
synapse/storage/schema/application_services.sql
Normal file
34
synapse/storage/schema/application_services.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/* Copyright 2015 OpenMarket Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_services(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT,
|
||||||
|
token TEXT,
|
||||||
|
hs_token TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
UNIQUE(token) ON CONFLICT ROLLBACK
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_services_regex(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
as_id INTEGER NOT NULL,
|
||||||
|
namespace INTEGER, /* enum[room_id|room_alias|user_id] */
|
||||||
|
regex TEXT,
|
||||||
|
FOREIGN KEY(as_id) REFERENCES application_services(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
34
synapse/storage/schema/delta/v14.sql
Normal file
34
synapse/storage/schema/delta/v14.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/* Copyright 2015 OpenMarket Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_services(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT,
|
||||||
|
token TEXT,
|
||||||
|
hs_token TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
UNIQUE(token) ON CONFLICT ROLLBACK
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_services_regex(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
as_id INTEGER NOT NULL,
|
||||||
|
namespace INTEGER, /* enum[room_id|room_alias|user_id] */
|
||||||
|
regex TEXT,
|
||||||
|
FOREIGN KEY(as_id) REFERENCES application_services(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
139
tests/api/test_auth.py
Normal file
139
tests/api/test_auth.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from tests import unittest
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from mock import Mock
|
||||||
|
|
||||||
|
from synapse.api.auth import Auth
|
||||||
|
from synapse.api.errors import AuthError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthTestCase(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.state_handler = Mock()
|
||||||
|
self.store = Mock()
|
||||||
|
|
||||||
|
self.hs = Mock()
|
||||||
|
self.hs.get_datastore = Mock(return_value=self.store)
|
||||||
|
self.hs.get_state_handler = Mock(return_value=self.state_handler)
|
||||||
|
self.auth = Auth(self.hs)
|
||||||
|
|
||||||
|
self.test_user = "@foo:bar"
|
||||||
|
self.test_token = "_test_token_"
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_get_user_by_req_user_valid_token(self):
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
user_info = {
|
||||||
|
"name": self.test_user,
|
||||||
|
"device_id": "nothing",
|
||||||
|
"token_id": "ditto",
|
||||||
|
"admin": False
|
||||||
|
}
|
||||||
|
self.store.get_user_by_token = Mock(return_value=user_info)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
(user, info) = yield self.auth.get_user_by_req(request)
|
||||||
|
self.assertEquals(user.to_string(), self.test_user)
|
||||||
|
|
||||||
|
def test_get_user_by_req_user_bad_token(self):
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
d = self.auth.get_user_by_req(request)
|
||||||
|
self.failureResultOf(d, AuthError)
|
||||||
|
|
||||||
|
def test_get_user_by_req_user_missing_token(self):
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
user_info = {
|
||||||
|
"name": self.test_user,
|
||||||
|
"device_id": "nothing",
|
||||||
|
"token_id": "ditto",
|
||||||
|
"admin": False
|
||||||
|
}
|
||||||
|
self.store.get_user_by_token = Mock(return_value=user_info)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
d = self.auth.get_user_by_req(request)
|
||||||
|
self.failureResultOf(d, AuthError)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_get_user_by_req_appservice_valid_token(self):
|
||||||
|
app_service = Mock(token="foobar", url="a_url", sender=self.test_user)
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=app_service)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
(user, info) = yield self.auth.get_user_by_req(request)
|
||||||
|
self.assertEquals(user.to_string(), self.test_user)
|
||||||
|
|
||||||
|
def test_get_user_by_req_appservice_bad_token(self):
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
d = self.auth.get_user_by_req(request)
|
||||||
|
self.failureResultOf(d, AuthError)
|
||||||
|
|
||||||
|
def test_get_user_by_req_appservice_missing_token(self):
|
||||||
|
app_service = Mock(token="foobar", url="a_url", sender=self.test_user)
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=app_service)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
d = self.auth.get_user_by_req(request)
|
||||||
|
self.failureResultOf(d, AuthError)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_get_user_by_req_appservice_valid_token_valid_user_id(self):
|
||||||
|
masquerading_user_id = "@doppelganger:matrix.org"
|
||||||
|
app_service = Mock(token="foobar", url="a_url", sender=self.test_user)
|
||||||
|
app_service.is_interested_in_user = Mock(return_value=True)
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=app_service)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.args["user_id"] = [masquerading_user_id]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
(user, info) = yield self.auth.get_user_by_req(request)
|
||||||
|
self.assertEquals(user.to_string(), masquerading_user_id)
|
||||||
|
|
||||||
|
def test_get_user_by_req_appservice_valid_token_bad_user_id(self):
|
||||||
|
masquerading_user_id = "@doppelganger:matrix.org"
|
||||||
|
app_service = Mock(token="foobar", url="a_url", sender=self.test_user)
|
||||||
|
app_service.is_interested_in_user = Mock(return_value=False)
|
||||||
|
self.store.get_app_service_by_token = Mock(return_value=app_service)
|
||||||
|
self.store.get_user_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
|
request = Mock(args={})
|
||||||
|
request.args["access_token"] = [self.test_token]
|
||||||
|
request.args["user_id"] = [masquerading_user_id]
|
||||||
|
request.requestHeaders.getRawHeaders = Mock(return_value=[""])
|
||||||
|
d = self.auth.get_user_by_req(request)
|
||||||
|
self.failureResultOf(d, AuthError)
|
14
tests/appservice/__init__.py
Normal file
14
tests/appservice/__init__.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
170
tests/appservice/test_appservice.py
Normal file
170
tests/appservice/test_appservice.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from synapse.appservice import ApplicationService
|
||||||
|
|
||||||
|
from mock import Mock, PropertyMock
|
||||||
|
from tests import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationServiceTestCase(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.service = ApplicationService(
|
||||||
|
url="some_url",
|
||||||
|
token="some_token",
|
||||||
|
namespaces={
|
||||||
|
ApplicationService.NS_USERS: [],
|
||||||
|
ApplicationService.NS_ROOMS: [],
|
||||||
|
ApplicationService.NS_ALIASES: []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.event = Mock(
|
||||||
|
type="m.something", room_id="!foo:bar", sender="@someone:somewhere"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_regex_user_id_prefix_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@irc_foobar:matrix.org"
|
||||||
|
self.assertTrue(self.service.is_interested(self.event))
|
||||||
|
|
||||||
|
def test_regex_user_id_prefix_no_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@someone_else:matrix.org"
|
||||||
|
self.assertFalse(self.service.is_interested(self.event))
|
||||||
|
|
||||||
|
def test_regex_room_member_is_checked(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@someone_else:matrix.org"
|
||||||
|
self.event.type = "m.room.member"
|
||||||
|
self.event.state_key = "@irc_foobar:matrix.org"
|
||||||
|
self.assertTrue(self.service.is_interested(self.event))
|
||||||
|
|
||||||
|
def test_regex_room_id_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ROOMS].append(
|
||||||
|
"!some_prefix.*some_suffix:matrix.org"
|
||||||
|
)
|
||||||
|
self.event.room_id = "!some_prefixs0m3th1nGsome_suffix:matrix.org"
|
||||||
|
self.assertTrue(self.service.is_interested(self.event))
|
||||||
|
|
||||||
|
def test_regex_room_id_no_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ROOMS].append(
|
||||||
|
"!some_prefix.*some_suffix:matrix.org"
|
||||||
|
)
|
||||||
|
self.event.room_id = "!XqBunHwQIXUiqCaoxq:matrix.org"
|
||||||
|
self.assertFalse(self.service.is_interested(self.event))
|
||||||
|
|
||||||
|
def test_regex_alias_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ALIASES].append(
|
||||||
|
"#irc_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.assertTrue(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
aliases_for_event=["#irc_foobar:matrix.org", "#athing:matrix.org"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_regex_alias_no_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ALIASES].append(
|
||||||
|
"#irc_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.assertFalse(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
aliases_for_event=["#xmpp_foobar:matrix.org", "#athing:matrix.org"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_regex_multiple_matches(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ALIASES].append(
|
||||||
|
"#irc_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@irc_foobar:matrix.org"
|
||||||
|
self.assertTrue(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
aliases_for_event=["#irc_barfoo:matrix.org"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_restrict_to_rooms(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ROOMS].append(
|
||||||
|
"!flibble_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@irc_foobar:matrix.org"
|
||||||
|
self.event.room_id = "!wibblewoo:matrix.org"
|
||||||
|
self.assertFalse(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
restrict_to=ApplicationService.NS_ROOMS
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_restrict_to_aliases(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ALIASES].append(
|
||||||
|
"#xmpp_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@irc_foobar:matrix.org"
|
||||||
|
self.assertFalse(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
restrict_to=ApplicationService.NS_ALIASES,
|
||||||
|
aliases_for_event=["#irc_barfoo:matrix.org"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_restrict_to_senders(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_ALIASES].append(
|
||||||
|
"#xmpp_.*:matrix.org"
|
||||||
|
)
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
self.event.sender = "@xmpp_foobar:matrix.org"
|
||||||
|
self.assertFalse(self.service.is_interested(
|
||||||
|
self.event,
|
||||||
|
restrict_to=ApplicationService.NS_USERS,
|
||||||
|
aliases_for_event=["#xmpp_barfoo:matrix.org"]
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_member_list_match(self):
|
||||||
|
self.service.namespaces[ApplicationService.NS_USERS].append(
|
||||||
|
"@irc_.*"
|
||||||
|
)
|
||||||
|
join_list = [
|
||||||
|
Mock(
|
||||||
|
type="m.room.member", room_id="!foo:bar", sender="@alice:here",
|
||||||
|
state_key="@alice:here"
|
||||||
|
),
|
||||||
|
Mock(
|
||||||
|
type="m.room.member", room_id="!foo:bar", sender="@irc_fo:here",
|
||||||
|
state_key="@irc_fo:here" # AS user
|
||||||
|
),
|
||||||
|
Mock(
|
||||||
|
type="m.room.member", room_id="!foo:bar", sender="@bob:here",
|
||||||
|
state_key="@bob:here"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.event.sender = "@xmpp_foobar:matrix.org"
|
||||||
|
self.assertTrue(self.service.is_interested(
|
||||||
|
event=self.event,
|
||||||
|
member_list=join_list
|
||||||
|
))
|
93
tests/handlers/test_appservice.py
Normal file
93
tests/handlers/test_appservice.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from twisted.internet import defer
|
||||||
|
from .. import unittest
|
||||||
|
|
||||||
|
from synapse.handlers.appservice import ApplicationServicesHandler
|
||||||
|
|
||||||
|
from mock import Mock
|
||||||
|
|
||||||
|
|
||||||
|
class AppServiceHandlerTestCase(unittest.TestCase):
|
||||||
|
""" Tests the ApplicationServicesHandler. """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_store = Mock()
|
||||||
|
self.mock_as_api = Mock()
|
||||||
|
hs = Mock()
|
||||||
|
hs.get_datastore = Mock(return_value=self.mock_store)
|
||||||
|
self.handler = ApplicationServicesHandler(
|
||||||
|
hs, self.mock_as_api
|
||||||
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_notify_interested_services(self):
|
||||||
|
interested_service = self._mkservice(is_interested=True)
|
||||||
|
services = [
|
||||||
|
self._mkservice(is_interested=False),
|
||||||
|
interested_service,
|
||||||
|
self._mkservice(is_interested=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.mock_store.get_app_services = Mock(return_value=services)
|
||||||
|
self.mock_store.get_user_by_id = Mock(return_value=[])
|
||||||
|
|
||||||
|
event = Mock(
|
||||||
|
sender="@someone:anywhere",
|
||||||
|
type="m.room.message",
|
||||||
|
room_id="!foo:bar"
|
||||||
|
)
|
||||||
|
self.mock_as_api.push = Mock()
|
||||||
|
yield self.handler.notify_interested_services(event)
|
||||||
|
self.mock_as_api.push.assert_called_once_with(interested_service, event)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_query_room_alias_exists(self):
|
||||||
|
room_alias_str = "#foo:bar"
|
||||||
|
room_alias = Mock()
|
||||||
|
room_alias.to_string = Mock(return_value=room_alias_str)
|
||||||
|
|
||||||
|
room_id = "!alpha:bet"
|
||||||
|
servers = ["aperture"]
|
||||||
|
interested_service = self._mkservice(is_interested=True)
|
||||||
|
services = [
|
||||||
|
self._mkservice(is_interested=False),
|
||||||
|
interested_service,
|
||||||
|
self._mkservice(is_interested=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.mock_store.get_app_services = Mock(return_value=services)
|
||||||
|
self.mock_store.get_association_from_room_alias = Mock(
|
||||||
|
return_value=Mock(room_id=room_id, servers=servers)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = yield self.handler.query_room_alias_exists(room_alias)
|
||||||
|
|
||||||
|
self.mock_as_api.query_alias.assert_called_once_with(
|
||||||
|
interested_service,
|
||||||
|
room_alias_str
|
||||||
|
)
|
||||||
|
self.assertEquals(result.room_id, room_id)
|
||||||
|
self.assertEquals(result.servers, servers)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _mkservice(self, is_interested):
|
||||||
|
service = Mock()
|
||||||
|
service.is_interested = Mock(return_value=is_interested)
|
||||||
|
service.token = "mock_service_token"
|
||||||
|
service.url = "mock_service_url"
|
||||||
|
return service
|
@ -61,6 +61,7 @@ class PresenceStateTestCase(unittest.TestCase):
|
|||||||
hs.handlers = JustPresenceHandlers(hs)
|
hs.handlers = JustPresenceHandlers(hs)
|
||||||
|
|
||||||
self.datastore = hs.get_datastore()
|
self.datastore = hs.get_datastore()
|
||||||
|
self.datastore.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
def get_presence_list(*a, **kw):
|
def get_presence_list(*a, **kw):
|
||||||
return defer.succeed([])
|
return defer.succeed([])
|
||||||
@ -147,6 +148,7 @@ class PresenceListTestCase(unittest.TestCase):
|
|||||||
hs.handlers = JustPresenceHandlers(hs)
|
hs.handlers = JustPresenceHandlers(hs)
|
||||||
|
|
||||||
self.datastore = hs.get_datastore()
|
self.datastore = hs.get_datastore()
|
||||||
|
self.datastore.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
def has_presence_state(user_localpart):
|
def has_presence_state(user_localpart):
|
||||||
return defer.succeed(
|
return defer.succeed(
|
||||||
@ -292,6 +294,7 @@ class PresenceEventStreamTestCase(unittest.TestCase):
|
|||||||
hs.handlers.room_member_handler.get_rooms_for_user = get_rooms_for_user
|
hs.handlers.room_member_handler.get_rooms_for_user = get_rooms_for_user
|
||||||
|
|
||||||
self.mock_datastore = hs.get_datastore()
|
self.mock_datastore = hs.get_datastore()
|
||||||
|
self.mock_datastore.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
|
||||||
def get_profile_displayname(user_id):
|
def get_profile_displayname(user_id):
|
||||||
return defer.succeed("Frank")
|
return defer.succeed("Frank")
|
||||||
|
@ -56,6 +56,8 @@ class V2AlphaRestTestCase(unittest.TestCase):
|
|||||||
r.register_servlets(hs, self.mock_resource)
|
r.register_servlets(hs, self.mock_resource)
|
||||||
|
|
||||||
def make_datastore_mock(self):
|
def make_datastore_mock(self):
|
||||||
return Mock(spec=[
|
store = Mock(spec=[
|
||||||
"insert_client_ip",
|
"insert_client_ip",
|
||||||
])
|
])
|
||||||
|
store.get_app_service_by_token = Mock(return_value=None)
|
||||||
|
return store
|
||||||
|
110
tests/storage/test_appservice.py
Normal file
110
tests/storage/test_appservice.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2015 OpenMarket Ltd
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
from tests import unittest
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from synapse.appservice import ApplicationService
|
||||||
|
from synapse.server import HomeServer
|
||||||
|
from synapse.storage.appservice import ApplicationServiceStore
|
||||||
|
|
||||||
|
from mock import Mock
|
||||||
|
from tests.utils import SQLiteMemoryDbPool, MockClock
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationServiceStoreTestCase(unittest.TestCase):
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def setUp(self):
|
||||||
|
db_pool = SQLiteMemoryDbPool()
|
||||||
|
yield db_pool.prepare()
|
||||||
|
hs = HomeServer(
|
||||||
|
"test", db_pool=db_pool, clock=MockClock(), config=Mock()
|
||||||
|
)
|
||||||
|
self.as_token = "token1"
|
||||||
|
db_pool.runQuery(
|
||||||
|
"INSERT INTO application_services(token) VALUES(?)",
|
||||||
|
(self.as_token,)
|
||||||
|
)
|
||||||
|
db_pool.runQuery(
|
||||||
|
"INSERT INTO application_services(token) VALUES(?)", ("token2",)
|
||||||
|
)
|
||||||
|
db_pool.runQuery(
|
||||||
|
"INSERT INTO application_services(token) VALUES(?)", ("token3",)
|
||||||
|
)
|
||||||
|
# must be done after inserts
|
||||||
|
self.store = ApplicationServiceStore(hs)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_update_and_retrieval_of_service(self):
|
||||||
|
url = "https://matrix.org/appservices/foobar"
|
||||||
|
hs_token = "hstok"
|
||||||
|
user_regex = ["@foobar_.*:matrix.org"]
|
||||||
|
alias_regex = ["#foobar_.*:matrix.org"]
|
||||||
|
room_regex = []
|
||||||
|
service = ApplicationService(
|
||||||
|
url=url, hs_token=hs_token, token=self.as_token, namespaces={
|
||||||
|
ApplicationService.NS_USERS: user_regex,
|
||||||
|
ApplicationService.NS_ALIASES: alias_regex,
|
||||||
|
ApplicationService.NS_ROOMS: room_regex
|
||||||
|
})
|
||||||
|
yield self.store.update_app_service(service)
|
||||||
|
|
||||||
|
stored_service = yield self.store.get_app_service_by_token(
|
||||||
|
self.as_token
|
||||||
|
)
|
||||||
|
self.assertEquals(stored_service.token, self.as_token)
|
||||||
|
self.assertEquals(stored_service.url, url)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_ALIASES],
|
||||||
|
alias_regex
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_ROOMS],
|
||||||
|
room_regex
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_USERS],
|
||||||
|
user_regex
|
||||||
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_retrieve_unknown_service_token(self):
|
||||||
|
service = yield self.store.get_app_service_by_token("invalid_token")
|
||||||
|
self.assertEquals(service, None)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_retrieval_of_service(self):
|
||||||
|
stored_service = yield self.store.get_app_service_by_token(
|
||||||
|
self.as_token
|
||||||
|
)
|
||||||
|
self.assertEquals(stored_service.token, self.as_token)
|
||||||
|
self.assertEquals(stored_service.url, None)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_ALIASES],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_ROOMS],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
stored_service.namespaces[ApplicationService.NS_USERS],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_retrieval_of_all_services(self):
|
||||||
|
services = yield self.store.get_app_services()
|
||||||
|
self.assertEquals(len(services), 3)
|
Loading…
Reference in New Issue
Block a user