diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 0054745363..b176db8ce1 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -306,6 +306,34 @@ class Auth(object):
# Can optionally look elsewhere in the request (e.g. headers)
try:
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 = user_info["user"]
device_id = user_info["device_id"]
@@ -344,8 +372,7 @@ class Auth(object):
try:
ret = yield self.store.get_user_by_token(token=token)
if not ret:
- raise StoreError()
-
+ raise StoreError(400, "Unknown token")
user_info = {
"admin": bool(ret.get("admin", False)),
"device_id": ret.get("device_id"),
@@ -358,6 +385,18 @@ class Auth(object):
raise AuthError(403, "Unrecognised access 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):
return self.store.is_server_admin(user)
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index 0d3fc629af..420f963d91 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -59,6 +59,7 @@ class LoginType(object):
EMAIL_URL = u"m.login.email.url"
EMAIL_IDENTITY = u"m.login.email.identity"
RECAPTCHA = u"m.login.recaptcha"
+ APPLICATION_SERVICE = u"m.login.application_service"
class EventTypes(object):
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index 5041828f18..eddd889778 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -36,7 +36,8 @@ class Codes(object):
CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
MISSING_PARAM = "M_MISSING_PARAM",
- TOO_LARGE = "M_TOO_LARGE"
+ TOO_LARGE = "M_TOO_LARGE",
+ EXCLUSIVE = "M_EXCLUSIVE"
class CodeMessageException(RuntimeError):
diff --git a/synapse/api/urls.py b/synapse/api/urls.py
index 693c0efda6..9485719332 100644
--- a/synapse/api/urls.py
+++ b/synapse/api/urls.py
@@ -22,3 +22,4 @@ WEB_CLIENT_PREFIX = "/_matrix/client"
CONTENT_REPO_PREFIX = "/_matrix/content"
SERVER_KEY_PREFIX = "/_matrix/key/v1"
MEDIA_PREFIX = "/_matrix/media/v1"
+APP_SERVICE_PREFIX = "/_matrix/appservice/v1"
diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py
index f5681fac20..27b478a1c3 100755
--- a/synapse/app/homeserver.py
+++ b/synapse/app/homeserver.py
@@ -26,13 +26,14 @@ from twisted.web.resource import Resource
from twisted.web.static import File
from twisted.web.server import Site
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.v1.media_repository import MediaRepositoryResource
from synapse.http.server_key_resource import LocalKey
from synapse.http.matrixfederationclient import MatrixFederationHttpClient
from synapse.api.urls import (
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.crypto import context_factory
@@ -69,6 +70,9 @@ class SynapseHomeServer(HomeServer):
def build_resource_for_federation(self):
return JsonResource(self)
+ def build_resource_for_app_services(self):
+ return AppServiceRestResource(self)
+
def build_resource_for_web_client(self):
syweb_path = os.path.dirname(syweb.__file__)
webclient_path = os.path.join(syweb_path, "webclient")
@@ -90,7 +94,9 @@ class SynapseHomeServer(HomeServer):
"sqlite3", self.get_db_name(),
check_same_thread=False,
cp_min=1,
- cp_max=1
+ cp_max=1,
+ cp_openfun=prepare_database, # Prepare the database for each conn
+ # so that :memory: sqlite works
)
def create_resource_tree(self, web_client, redirect_root_to_web_client):
@@ -114,6 +120,7 @@ class SynapseHomeServer(HomeServer):
(CONTENT_REPO_PREFIX, self.get_resource_for_content_repo()),
(SERVER_KEY_PREFIX, self.get_resource_for_server_key()),
(MEDIA_PREFIX, self.get_resource_for_media_repository()),
+ (APP_SERVICE_PREFIX, self.get_resource_for_app_services()),
]
if web_client:
logger.info("Adding the web client.")
@@ -252,14 +259,6 @@ def setup():
logger.info("Database prepared in %s.", db_name)
- db_pool = hs.get_db_pool()
-
- if db_name == ":memory:":
- # Memory databases will need to be setup each time they are opened.
- reactor.callWhenRunning(
- db_pool.runWithConnection, prepare_database
- )
-
if config.manhole:
f = twisted.manhole.telnet.ShellFactory()
f.username = "matrix"
@@ -270,10 +269,10 @@ def setup():
bind_port = config.bind_port
if config.no_tls:
bind_port = None
+
hs.start_listening(bind_port, config.unsecure_port)
hs.get_pusherpool().start()
-
hs.get_state_handler().start_caching()
hs.get_datastore().start_profiling()
diff --git a/synapse/appservice/__init__.py b/synapse/appservice/__init__.py
new file mode 100644
index 0000000000..381b4cfc4a
--- /dev/null
+++ b/synapse/appservice/__init__.py
@@ -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__,)
diff --git a/synapse/appservice/api.py b/synapse/appservice/api.py
new file mode 100644
index 0000000000..c2179f8d55
--- /dev/null
+++ b/synapse/appservice/api.py
@@ -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
+ ]
diff --git a/synapse/handlers/__init__.py b/synapse/handlers/__init__.py
index a32eab9316..8d345bf936 100644
--- a/synapse/handlers/__init__.py
+++ b/synapse/handlers/__init__.py
@@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from synapse.appservice.api import ApplicationServiceApi
from .register import RegistrationHandler
from .room import (
RoomCreationHandler, RoomMemberHandler, RoomListHandler
@@ -26,6 +27,7 @@ from .presence import PresenceHandler
from .directory import DirectoryHandler
from .typing import TypingNotificationHandler
from .admin import AdminHandler
+from .appservice import ApplicationServicesHandler
from .sync import SyncHandler
@@ -52,4 +54,7 @@ class Handlers(object):
self.directory_handler = DirectoryHandler(hs)
self.typing_notification_handler = TypingNotificationHandler(hs)
self.admin_handler = AdminHandler(hs)
+ self.appservice_handler = ApplicationServicesHandler(
+ hs, ApplicationServiceApi(hs)
+ )
self.sync_handler = SyncHandler(hs)
diff --git a/synapse/handlers/appservice.py b/synapse/handlers/appservice.py
new file mode 100644
index 0000000000..2c488a46f6
--- /dev/null
+++ b/synapse/handlers/appservice.py
@@ -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)
diff --git a/synapse/handlers/directory.py b/synapse/handlers/directory.py
index 7b60921040..20ab9e269c 100644
--- a/synapse/handlers/directory.py
+++ b/synapse/handlers/directory.py
@@ -37,18 +37,15 @@ class DirectoryHandler(BaseHandler):
)
@defer.inlineCallbacks
- def create_association(self, user_id, room_alias, room_id, servers=None):
-
- # TODO(erikj): Do auth.
+ def _create_association(self, room_alias, room_id, servers=None):
+ # general association creation for both human users and app services
if not self.hs.is_mine(room_alias):
raise SynapseError(400, "Room alias must be local")
# TODO(erikj): Change this.
# TODO(erikj): Add transactions.
-
# TODO(erikj): Check if there is a current association.
-
if not servers:
servers = yield self.store.get_joined_hosts_for_room(room_id)
@@ -62,22 +59,77 @@ class DirectoryHandler(BaseHandler):
)
@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
def delete_association(self, user_id, room_alias):
+ # association deletion for human users
+
# 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):
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:
- yield self._update_room_alias_events(user_id, room_id)
+ # TODO - Looks like _update_room_alias_event has never been implemented
+ # if room_id:
+ # yield self._update_room_alias_events(user_id, room_id)
@defer.inlineCallbacks
def get_association(self, room_alias):
room_id = None
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
)
@@ -138,7 +190,7 @@ class DirectoryHandler(BaseHandler):
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
)
@@ -166,3 +218,27 @@ class DirectoryHandler(BaseHandler):
"sender": user_id,
"content": {"aliases": aliases},
}, 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)
diff --git a/synapse/handlers/login.py b/synapse/handlers/login.py
index d297d71c03..7447800460 100644
--- a/synapse/handlers/login.py
+++ b/synapse/handlers/login.py
@@ -16,12 +16,13 @@
from twisted.internet import defer
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.util.emailutils import EmailException
import synapse.util.emailutils as emailutils
import bcrypt
+import json
import logging
logger = logging.getLogger(__name__)
@@ -96,16 +97,20 @@ class LoginHandler(BaseHandler):
@defer.inlineCallbacks
def _query_email(self, email):
- httpCli = SimpleHttpClient(self.hs)
- data = yield httpCli.get_json(
- # TODO FIXME This should be configurable.
- # XXX: ID servers need to use HTTPS
- "http://%s%s" % (
- "matrix.org:8090", "/_matrix/identity/api/v1/lookup"
- ),
- {
- 'medium': 'email',
- 'address': email
- }
- )
- defer.returnValue(data)
+ http_client = SimpleHttpClient(self.hs)
+ try:
+ data = yield http_client.get_json(
+ # TODO FIXME This should be configurable.
+ # XXX: ID servers need to use HTTPS
+ "http://%s%s" % (
+ "matrix.org:8090", "/_matrix/identity/api/v1/lookup"
+ ),
+ {
+ 'medium': 'email',
+ 'address': email
+ }
+ )
+ defer.returnValue(data)
+ except CodeMessageException as e:
+ data = json.loads(e.msg)
+ defer.returnValue(data)
diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py
index 0247327eb9..516a936cee 100644
--- a/synapse/handlers/register.py
+++ b/synapse/handlers/register.py
@@ -18,7 +18,8 @@ from twisted.internet import defer
from synapse.types import UserID
from synapse.api.errors import (
- SynapseError, RegistrationError, InvalidCaptchaError
+ AuthError, Codes, SynapseError, RegistrationError, InvalidCaptchaError,
+ CodeMessageException
)
from ._base import BaseHandler
import synapse.util.stringutils as stringutils
@@ -28,6 +29,7 @@ from synapse.http.client import CaptchaServerHttpClient
import base64
import bcrypt
+import json
import logging
logger = logging.getLogger(__name__)
@@ -64,6 +66,8 @@ class RegistrationHandler(BaseHandler):
user = UserID(localpart, self.hs.hostname)
user_id = user.to_string()
+ yield self.check_user_id_is_valid(user_id)
+
token = self._generate_token(user_id)
yield self.store.register(
user_id=user_id,
@@ -82,6 +86,7 @@ class RegistrationHandler(BaseHandler):
localpart = self._generate_user_id()
user = UserID(localpart, self.hs.hostname)
user_id = user.to_string()
+ yield self.check_user_id_is_valid(user_id)
token = self._generate_token(user_id)
yield self.store.register(
@@ -122,6 +127,27 @@ class RegistrationHandler(BaseHandler):
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
def check_recaptcha(self, ip, private_key, challenge, response):
"""Checks a recaptcha is correct."""
@@ -167,6 +193,20 @@ class RegistrationHandler(BaseHandler):
# XXX: This should be a deferred list, shouldn't it?
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):
# 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
@@ -181,21 +221,26 @@ class RegistrationHandler(BaseHandler):
def _threepid_from_creds(self, creds):
# TODO: get this from the homeserver rather than creating a new one for
# each request
- httpCli = SimpleHttpClient(self.hs)
+ http_client = SimpleHttpClient(self.hs)
# XXX: make this configurable!
trustedIdServers = ['matrix.org:8090', 'matrix.org']
if not creds['idServer'] in trustedIdServers:
logger.warn('%s is not a trusted ID server: rejecting 3pid ' +
'credentials', creds['idServer'])
defer.returnValue(None)
- data = yield httpCli.get_json(
- # XXX: This should be HTTPS
- "http://%s%s" % (
- creds['idServer'],
- "/_matrix/identity/api/v1/3pid/getValidated3pid"
- ),
- {'sid': creds['sid'], 'clientSecret': creds['clientSecret']}
- )
+
+ data = {}
+ try:
+ data = yield http_client.get_json(
+ # XXX: This should be HTTPS
+ "http://%s%s" % (
+ 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:
defer.returnValue(data)
@@ -205,19 +250,23 @@ class RegistrationHandler(BaseHandler):
def _bind_threepid(self, creds, mxid):
yield
logger.debug("binding threepid")
- httpCli = SimpleHttpClient(self.hs)
- data = yield httpCli.post_urlencoded_get_json(
- # 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,
- }
- )
- logger.debug("bound threepid")
+ http_client = SimpleHttpClient(self.hs)
+ data = None
+ try:
+ data = yield http_client.post_urlencoded_get_json(
+ # 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,
+ }
+ )
+ logger.debug("bound threepid")
+ except CodeMessageException as e:
+ data = json.loads(e.msg)
defer.returnValue(data)
@defer.inlineCallbacks
diff --git a/synapse/http/client.py b/synapse/http/client.py
index 43b2329780..e46e7db146 100644
--- a/synapse/http/client.py
+++ b/synapse/http/client.py
@@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-
+from synapse.api.errors import CodeMessageException
from synapse.http.agent_name import AGENT_NAME
from syutil.jsonutil import encode_canonical_json
@@ -85,7 +85,7 @@ class SimpleHttpClient(object):
@defer.inlineCallbacks
def get_json(self, uri, args={}):
- """ Get's some json from the given host and path
+ """ Gets some json from the given URI.
Args:
uri (str): The URI to request, not including query parameters
@@ -93,15 +93,13 @@ class SimpleHttpClient(object):
None.
**Note**: The value of each key is assumed to be an iterable
and *not* a string.
-
Returns:
- Deferred: Succeeds when we get *any* HTTP response.
-
- The result of the deferred is a tuple of `(code, response)`,
- where `response` is a dict representing the decoded JSON body.
+ Deferred: Succeeds when we get *any* 2xx HTTP response, with the
+ HTTP body as JSON.
+ Raises:
+ On a non-2xx HTTP response. The response body will be used as the
+ error message.
"""
-
- yield
if len(args):
query_bytes = urllib.urlencode(args, True)
uri = "%s?%s" % (uri, query_bytes)
@@ -116,7 +114,56 @@ class SimpleHttpClient(object):
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):
diff --git a/synapse/notifier.py b/synapse/notifier.py
index e3b6ead620..c7f75ab801 100644
--- a/synapse/notifier.py
+++ b/synapse/notifier.py
@@ -99,6 +99,12 @@ class Notifier(object):
`extra_users` param.
"""
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_source = self.event_sources.sources["room"]
diff --git a/synapse/rest/appservice/__init__.py b/synapse/rest/appservice/__init__.py
new file mode 100644
index 0000000000..1a84d94cd9
--- /dev/null
+++ b/synapse/rest/appservice/__init__.py
@@ -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.
diff --git a/synapse/rest/appservice/v1/__init__.py b/synapse/rest/appservice/v1/__init__.py
new file mode 100644
index 0000000000..a7877609ad
--- /dev/null
+++ b/synapse/rest/appservice/v1/__init__.py
@@ -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)
diff --git a/synapse/rest/appservice/v1/base.py b/synapse/rest/appservice/v1/base.py
new file mode 100644
index 0000000000..65d5bcf9be
--- /dev/null
+++ b/synapse/rest/appservice/v1/base.py
@@ -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
diff --git a/synapse/rest/appservice/v1/register.py b/synapse/rest/appservice/v1/register.py
new file mode 100644
index 0000000000..3bd0c1220c
--- /dev/null
+++ b/synapse/rest/appservice/v1/register.py
@@ -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)
diff --git a/synapse/rest/client/v1/directory.py b/synapse/rest/client/v1/directory.py
index 420aa89f38..6758a888b3 100644
--- a/synapse/rest/client/v1/directory.py
+++ b/synapse/rest/client/v1/directory.py
@@ -45,8 +45,6 @@ class ClientDirectoryServer(ClientV1RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_alias):
- user, client = yield self.auth.get_user_by_req(request)
-
content = _parse_json(request)
if "room_id" not in content:
raise SynapseError(400, "Missing room_id key",
@@ -70,34 +68,70 @@ class ClientDirectoryServer(ClientV1RestServlet):
dir_handler = self.handlers.directory_handler
try:
- user_id = user.to_string()
- yield dir_handler.create_association(
- user_id, room_alias, room_id, servers
+ # try to auth as a user
+ user, client = yield self.auth.get_user_by_req(request)
+ 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.inlineCallbacks
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)
is_admin = yield self.auth.is_server_admin(user)
if not is_admin:
raise AuthError(403, "You need to be a server admin")
- dir_handler = self.handlers.directory_handler
-
room_alias = RoomAlias.from_string(room_alias)
yield dir_handler.delete_association(
user.to_string(), room_alias
)
+ logger.info(
+ "User %s deleted alias %s",
+ user.to_string(),
+ room_alias.to_string()
+ )
defer.returnValue((200, {}))
diff --git a/synapse/rest/client/v1/register.py b/synapse/rest/client/v1/register.py
index d3399c446b..8d2115082b 100644
--- a/synapse/rest/client/v1/register.py
+++ b/synapse/rest/client/v1/register.py
@@ -110,7 +110,8 @@ class RegisterRestServlet(ClientV1RestServlet):
stages = {
LoginType.RECAPTCHA: self._do_recaptcha,
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)
@@ -276,6 +277,27 @@ class RegisterRestServlet(ClientV1RestServlet):
self._remove_session(session)
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):
try:
diff --git a/synapse/server.py b/synapse/server.py
index 23bdad0c7c..ba2b2593f1 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -77,6 +77,7 @@ class BaseHomeServer(object):
'resource_for_content_repo',
'resource_for_server_key',
'resource_for_media_repository',
+ 'resource_for_app_services',
'event_sources',
'ratelimiter',
'keyring',
diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py
index 02b1f06854..c6e96b842f 100644
--- a/synapse/storage/__init__.py
+++ b/synapse/storage/__init__.py
@@ -18,6 +18,7 @@ from twisted.internet import defer
from synapse.util.logutils import log_function
from synapse.api.constants import EventTypes
+from .appservice import ApplicationServiceStore
from .directory import DirectoryStore
from .feedback import FeedbackStore
from .presence import PresenceStore
@@ -65,6 +66,7 @@ SCHEMAS = [
"event_signatures",
"pusher",
"media_repository",
+ "application_services",
"filtering",
"rejections",
]
@@ -86,6 +88,7 @@ class DataStore(RoomMemberStore, RoomStore,
RegistrationStore, StreamStore, ProfileStore, FeedbackStore,
PresenceStore, TransactionStore,
DirectoryStore, KeyStore, StateStore, SignatureStore,
+ ApplicationServiceStore,
EventFederationStore,
MediaRepositoryStore,
RejectionsStore,
@@ -637,10 +640,13 @@ def prepare_database(db_conn):
c.executescript(sql_script)
db_conn.commit()
+ else:
+ logger.info("Database is at version %r", user_version)
else:
sql_script = "BEGIN TRANSACTION;\n"
for sql_loc in SCHEMAS:
+ logger.debug("Applying schema %r", sql_loc)
sql_script += read_schema(sql_loc)
sql_script += "\n"
sql_script += "COMMIT TRANSACTION;"
diff --git a/synapse/storage/appservice.py b/synapse/storage/appservice.py
new file mode 100644
index 0000000000..d941b1f387
--- /dev/null
+++ b/synapse/storage/appservice.py
@@ -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"]
+ ))
diff --git a/synapse/storage/schema/application_services.sql b/synapse/storage/schema/application_services.sql
new file mode 100644
index 0000000000..e491ad5aec
--- /dev/null
+++ b/synapse/storage/schema/application_services.sql
@@ -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)
+);
+
+
+
diff --git a/synapse/storage/schema/delta/v14.sql b/synapse/storage/schema/delta/v14.sql
new file mode 100644
index 0000000000..e491ad5aec
--- /dev/null
+++ b/synapse/storage/schema/delta/v14.sql
@@ -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)
+);
+
+
+
diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py
new file mode 100644
index 0000000000..4f83db5e84
--- /dev/null
+++ b/tests/api/test_auth.py
@@ -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)
diff --git a/tests/appservice/__init__.py b/tests/appservice/__init__.py
new file mode 100644
index 0000000000..1a84d94cd9
--- /dev/null
+++ b/tests/appservice/__init__.py
@@ -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.
diff --git a/tests/appservice/test_appservice.py b/tests/appservice/test_appservice.py
new file mode 100644
index 0000000000..d12e4f2644
--- /dev/null
+++ b/tests/appservice/test_appservice.py
@@ -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
+ ))
diff --git a/tests/handlers/test_appservice.py b/tests/handlers/test_appservice.py
new file mode 100644
index 0000000000..a2c541317c
--- /dev/null
+++ b/tests/handlers/test_appservice.py
@@ -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
diff --git a/tests/rest/client/v1/test_presence.py b/tests/rest/client/v1/test_presence.py
index c1acba5a09..7c5df5c116 100644
--- a/tests/rest/client/v1/test_presence.py
+++ b/tests/rest/client/v1/test_presence.py
@@ -61,6 +61,7 @@ class PresenceStateTestCase(unittest.TestCase):
hs.handlers = JustPresenceHandlers(hs)
self.datastore = hs.get_datastore()
+ self.datastore.get_app_service_by_token = Mock(return_value=None)
def get_presence_list(*a, **kw):
return defer.succeed([])
@@ -147,6 +148,7 @@ class PresenceListTestCase(unittest.TestCase):
hs.handlers = JustPresenceHandlers(hs)
self.datastore = hs.get_datastore()
+ self.datastore.get_app_service_by_token = Mock(return_value=None)
def has_presence_state(user_localpart):
return defer.succeed(
@@ -292,6 +294,7 @@ class PresenceEventStreamTestCase(unittest.TestCase):
hs.handlers.room_member_handler.get_rooms_for_user = get_rooms_for_user
self.mock_datastore = hs.get_datastore()
+ self.mock_datastore.get_app_service_by_token = Mock(return_value=None)
def get_profile_displayname(user_id):
return defer.succeed("Frank")
diff --git a/tests/rest/client/v2_alpha/__init__.py b/tests/rest/client/v2_alpha/__init__.py
index f5a5f780b4..de5a917e6a 100644
--- a/tests/rest/client/v2_alpha/__init__.py
+++ b/tests/rest/client/v2_alpha/__init__.py
@@ -56,6 +56,8 @@ class V2AlphaRestTestCase(unittest.TestCase):
r.register_servlets(hs, self.mock_resource)
def make_datastore_mock(self):
- return Mock(spec=[
+ store = Mock(spec=[
"insert_client_ip",
])
+ store.get_app_service_by_token = Mock(return_value=None)
+ return store
diff --git a/tests/storage/test_appservice.py b/tests/storage/test_appservice.py
new file mode 100644
index 0000000000..fc733d4c79
--- /dev/null
+++ b/tests/storage/test_appservice.py
@@ -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)
|