summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.rst9
-rw-r--r--graph/graph2.py30
-rwxr-xr-xscripts/copyrighter-sql.pl33
-rw-r--r--synapse/__init__.py2
-rwxr-xr-xsynapse/app/homeserver.py8
-rw-r--r--synapse/config/logger.py5
-rw-r--r--synapse/config/server.py8
-rw-r--r--synapse/events/utils.py10
-rw-r--r--synapse/handlers/events.py7
-rw-r--r--synapse/handlers/federation.py6
-rw-r--r--synapse/handlers/message.py15
-rw-r--r--synapse/handlers/room.py17
-rw-r--r--synapse/handlers/typing.py8
-rw-r--r--synapse/media/v1/__init__.py6
-rw-r--r--synapse/media/v1/thumbnailer.py2
-rw-r--r--synapse/python_dependencies.py80
-rw-r--r--synapse/rest/events.py5
-rw-r--r--synapse/rest/initial_sync.py5
-rw-r--r--synapse/rest/room.py5
-rw-r--r--synapse/server.py4
-rw-r--r--synapse/storage/stream.py8
-rw-r--r--tests/handlers/test_typing.py26
-rw-r--r--tests/rest/test_rooms.py2
-rw-r--r--tests/rest/test_typing.py51
-rw-r--r--tests/unittest.py2
25 files changed, 319 insertions, 35 deletions
diff --git a/README.rst b/README.rst
index 92b94bcd7d..768da3df64 100644
--- a/README.rst
+++ b/README.rst
@@ -108,6 +108,15 @@ To install the synapse homeserver run::
 This installs synapse, along with the libraries it uses, into
 ``$HOME/.local/lib/`` on Linux or ``$HOME/Library/Python/2.7/lib/`` on OSX.
 
+Your python may not give priority to locally installed libraries over system
+libraries, in which case you must add your local packages to your python path::
+
+    $ # on Linux:
+    $ export PYTHONPATH=$HOME/.local/lib/python2.7/site-packages:$PYTHONPATH
+
+    $ # on OSX:
+    $ export PYTHONPATH=$HOME/Library/Python/2.7/lib/python/site-packages:$PYTHONPATH
+
 For reliable VoIP calls to be routed via this homeserver, you MUST configure
 a TURN server.  See docs/turn-howto.rst for details.
 
diff --git a/graph/graph2.py b/graph/graph2.py
index b9b8a562a0..6b551d42e5 100644
--- a/graph/graph2.py
+++ b/graph/graph2.py
@@ -23,14 +23,27 @@ import argparse
 from synapse.events import FrozenEvent
 
 
-def make_graph(db_name, room_id, file_prefix):
+def make_graph(db_name, room_id, file_prefix, limit):
     conn = sqlite3.connect(db_name)
 
-    c = conn.execute(
-        "SELECT json FROM event_json where room_id = ?",
-        (room_id,)
+    sql = (
+        "SELECT json FROM event_json as j "
+        "INNER JOIN events as e ON e.event_id = j.event_id "
+        "WHERE j.room_id = ?"
     )
 
+    args = [room_id]
+
+    if limit:
+        sql += (
+            " ORDER BY topological_ordering DESC, stream_ordering DESC "
+            "LIMIT ?"
+        )
+
+        args.append(limit)
+
+    c = conn.execute(sql, args)
+
     events = [FrozenEvent(json.loads(e[0])) for e in c.fetchall()]
 
     events.sort(key=lambda e: e.depth)
@@ -128,11 +141,16 @@ if __name__ == "__main__":
     )
     parser.add_argument(
         "-p", "--prefix", dest="prefix",
-        help="String to prefix output files with"
+        help="String to prefix output files with",
+        default="graph_output"
+    )
+    parser.add_argument(
+        "-l", "--limit",
+        help="Only retrieve the last N events.",
     )
     parser.add_argument('db')
     parser.add_argument('room')
 
     args = parser.parse_args()
 
-    make_graph(args.db, args.room, args.prefix)
+    make_graph(args.db, args.room, args.prefix, args.limit)
diff --git a/scripts/copyrighter-sql.pl b/scripts/copyrighter-sql.pl
new file mode 100755
index 0000000000..890e51e587
--- /dev/null
+++ b/scripts/copyrighter-sql.pl
@@ -0,0 +1,33 @@
+#!/usr/bin/perl -pi
+# 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.
+
+$copyright = <<EOT;
+/* 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.
+ */
+EOT
+
+s/^(# -\*- coding: utf-8 -\*-\n)?/$1$copyright/ if ($. == 1);
diff --git a/synapse/__init__.py b/synapse/__init__.py
index e7e27b06ec..895a0766d2 100644
--- a/synapse/__init__.py
+++ b/synapse/__init__.py
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-""" This is a reference implementation of a synapse home server.
+""" This is a reference implementation of a Matrix home server.
 """
 
 __version__ = "0.6.1b"
diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py
index 5fec8da7ca..43b5c26144 100755
--- a/synapse/app/homeserver.py
+++ b/synapse/app/homeserver.py
@@ -18,6 +18,8 @@ from synapse.storage import prepare_database, UpgradeDatabaseException
 
 from synapse.server import HomeServer
 
+from synapse.python_dependencies import check_requirements
+
 from twisted.internet import reactor
 from twisted.enterprise import adbapi
 from twisted.web.resource import Resource
@@ -39,6 +41,8 @@ from synapse.util.logcontext import LoggingContext
 from daemonize import Daemonize
 import twisted.manhole.telnet
 
+import synapse
+
 import logging
 import os
 import re
@@ -198,7 +202,10 @@ def setup():
 
     config.setup_logging()
 
+    check_requirements()
+
     logger.info("Server hostname: %s", config.server_name)
+    logger.info("Server version: %s", synapse.__version__)
 
     if re.search(":[0-9]+$", config.server_name):
         domain_with_port = config.server_name
@@ -277,6 +284,7 @@ def run():
 
 def main():
     with LoggingContext("main"):
+        check_requirements()
         setup()
 
 
diff --git a/synapse/config/logger.py b/synapse/config/logger.py
index 15383b3184..f9568ebd21 100644
--- a/synapse/config/logger.py
+++ b/synapse/config/logger.py
@@ -66,7 +66,10 @@ class LoggingConfig(Config):
 
             formatter = logging.Formatter(log_format)
             if self.log_file:
-                handler = logging.FileHandler(self.log_file)
+                # TODO: Customisable file size / backup count
+                handler = logging.handlers.RotatingFileHandler(
+                    self.log_file, maxBytes=(1000 * 1000 * 100), backupCount=3
+                )
             else:
                 handler = logging.StreamHandler()
             handler.setFormatter(formatter)
diff --git a/synapse/config/server.py b/synapse/config/server.py
index 4f73c85466..31e44cc857 100644
--- a/synapse/config/server.py
+++ b/synapse/config/server.py
@@ -47,8 +47,12 @@ class ServerConfig(Config):
     def add_arguments(cls, parser):
         super(ServerConfig, cls).add_arguments(parser)
         server_group = parser.add_argument_group("server")
-        server_group.add_argument("-H", "--server-name", default="localhost",
-                                  help="The name of the server")
+        server_group.add_argument(
+            "-H", "--server-name", default="localhost",
+            help="The domain name of the server, with optional explicit port. "
+                 "This is used by remote servers to connect to this server, "
+                 "e.g. matrix.org, localhost:8080, etc."
+        )
         server_group.add_argument("--signing-key-path",
                                   help="The signing key to sign messages with")
         server_group.add_argument("-p", "--bind-port", metavar="PORT",
diff --git a/synapse/events/utils.py b/synapse/events/utils.py
index 4ad37188ba..bcb5457278 100644
--- a/synapse/events/utils.py
+++ b/synapse/events/utils.py
@@ -89,13 +89,21 @@ def prune_event(event):
     return type(event)(allowed_fields)
 
 
-def serialize_event(hs, e):
+def serialize_event(hs, e, client_event=True):
     # FIXME(erikj): To handle the case of presence events and the like
     if not isinstance(e, EventBase):
         return e
 
     # Should this strip out None's?
     d = {k: v for k, v in e.get_dict().items()}
+
+    if not client_event:
+        # set the age and keep all other keys
+        if "age_ts" in d["unsigned"]:
+            now = int(hs.get_clock().time_msec())
+            d["unsigned"]["age"] = now - d["unsigned"]["age_ts"]
+        return d
+
     if "age_ts" in d["unsigned"]:
         now = int(hs.get_clock().time_msec())
         d["age"] = now - d["unsigned"]["age_ts"]
diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py
index 808219bd10..c9ade253dd 100644
--- a/synapse/handlers/events.py
+++ b/synapse/handlers/events.py
@@ -46,7 +46,8 @@ class EventStreamHandler(BaseHandler):
 
     @defer.inlineCallbacks
     @log_function
-    def get_stream(self, auth_user_id, pagin_config, timeout=0):
+    def get_stream(self, auth_user_id, pagin_config, timeout=0,
+                   as_client_event=True):
         auth_user = self.hs.parse_userid(auth_user_id)
 
         try:
@@ -78,7 +79,9 @@ class EventStreamHandler(BaseHandler):
                     auth_user, room_ids, pagin_config, timeout
                 )
 
-            chunks = [self.hs.serialize_event(e) for e in events]
+            chunks = [
+                self.hs.serialize_event(e, as_client_event) for e in events
+            ]
 
             chunk = {
                 "chunk": chunks,
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index d26975a88a..195f7c618a 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -617,13 +617,13 @@ class FederationHandler(BaseHandler):
 
     @defer.inlineCallbacks
     @log_function
-    def on_backfill_request(self, origin, context, pdu_list, limit):
-        in_room = yield self.auth.check_host_in_room(context, origin)
+    def on_backfill_request(self, origin, room_id, pdu_list, limit):
+        in_room = yield self.auth.check_host_in_room(room_id, origin)
         if not in_room:
             raise AuthError(403, "Host not in room.")
 
         events = yield self.store.get_backfill_events(
-            context,
+            room_id,
             pdu_list,
             limit
         )
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 7195de98b5..f2a2f16933 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -67,7 +67,7 @@ class MessageHandler(BaseHandler):
 
     @defer.inlineCallbacks
     def get_messages(self, user_id=None, room_id=None, pagin_config=None,
-                     feedback=False):
+                     feedback=False, as_client_event=True):
         """Get messages in a room.
 
         Args:
@@ -76,6 +76,7 @@ class MessageHandler(BaseHandler):
             pagin_config (synapse.api.streams.PaginationConfig): The pagination
             config rules to apply, if any.
             feedback (bool): True to get compressed feedback with the messages
+            as_client_event (bool): True to get events in client-server format.
         Returns:
             dict: Pagination API results
         """
@@ -99,7 +100,9 @@ class MessageHandler(BaseHandler):
         )
 
         chunk = {
-            "chunk": [self.hs.serialize_event(e) for e in events],
+            "chunk": [
+                self.hs.serialize_event(e, as_client_event) for e in events
+            ],
             "start": pagin_config.from_token.to_string(),
             "end": next_token.to_string(),
         }
@@ -211,7 +214,7 @@ class MessageHandler(BaseHandler):
 
     @defer.inlineCallbacks
     def snapshot_all_rooms(self, user_id=None, pagin_config=None,
-                           feedback=False):
+                           feedback=False, as_client_event=True):
         """Retrieve a snapshot of all rooms the user is invited or has joined.
 
         This snapshot may include messages for all rooms where the user is
@@ -222,6 +225,7 @@ class MessageHandler(BaseHandler):
             pagin_config (synapse.api.streams.PaginationConfig): The pagination
             config used to determine how many messages *PER ROOM* to return.
             feedback (bool): True to get feedback along with these messages.
+            as_client_event (bool): True to get events in client-server format.
         Returns:
             A list of dicts with "room_id" and "membership" keys for all rooms
             the user is currently invited or joined in on. Rooms where the user
@@ -280,7 +284,10 @@ class MessageHandler(BaseHandler):
                 end_token = now_token.copy_and_replace("room_key", token[1])
 
                 d["messages"] = {
-                    "chunk": [self.hs.serialize_event(m) for m in messages],
+                    "chunk": [
+                        self.hs.serialize_event(m, as_client_event)
+                        for m in messages
+                    ],
                     "start": start_token.to_string(),
                     "end": end_token.to_string(),
                 }
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 59719a1fae..6d0db18e51 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -425,10 +425,22 @@ class RoomMemberHandler(BaseHandler):
             event.room_id,
             self.hs.hostname
         )
+        if not is_host_in_room:
+            # is *anyone* in the room?
+            room_member_keys = [
+                v for (k, v) in context.current_state.keys() if (
+                    k == "m.room.member"
+                )
+            ]
+            if len(room_member_keys) == 0:
+                # has the room been created so we can join it?
+                create_event = context.current_state.get(("m.room.create", ""))
+                if create_event:
+                    is_host_in_room = True
 
         if is_host_in_room:
             should_do_dance = False
-        elif room_host:
+        elif room_host:  # TODO: Shouldn't this be remote_room_host?
             should_do_dance = True
         else:
             # TODO(markjh): get prev_state from snapshot
@@ -442,7 +454,8 @@ class RoomMemberHandler(BaseHandler):
                 should_do_dance = not self.hs.is_mine(inviter)
                 room_host = inviter.domain
             else:
-                should_do_dance = False
+                # return the same error as join_room_alias does
+                raise SynapseError(404, "No known servers")
 
         if should_do_dance:
             handler = self.hs.get_handlers().federation_handler
diff --git a/synapse/handlers/typing.py b/synapse/handlers/typing.py
index ab698b36e1..22ce7873d0 100644
--- a/synapse/handlers/typing.py
+++ b/synapse/handlers/typing.py
@@ -83,9 +83,15 @@ class TypingNotificationHandler(BaseHandler):
         if member in self._member_typing_timer:
             self.clock.cancel_call_later(self._member_typing_timer[member])
 
+        def _cb():
+            logger.debug(
+                "%s has timed out in %s", target_user.to_string(), room_id
+            )
+            self._stopped_typing(member)
+
         self._member_typing_until[member] = until
         self._member_typing_timer[member] = self.clock.call_later(
-            timeout / 1000, lambda: self._stopped_typing(member)
+            timeout / 1000.0, _cb
         )
 
         if was_present:
diff --git a/synapse/media/v1/__init__.py b/synapse/media/v1/__init__.py
index 619999d268..d6c6690577 100644
--- a/synapse/media/v1/__init__.py
+++ b/synapse/media/v1/__init__.py
@@ -22,7 +22,8 @@ except IOError as e:
     if str(e).startswith("decoder jpeg not available"):
         raise Exception(
             "FATAL: jpeg codec not supported. Install pillow correctly! "
-            " 'sudo apt-get install libjpeg-dev' then 'pip install -I pillow'"
+            " 'sudo apt-get install libjpeg-dev' then 'pip uninstall pillow &&"
+            " pip install pillow --user'"
         )
 except Exception:
     # any other exception is fine
@@ -36,7 +37,8 @@ except IOError as e:
     if str(e).startswith("decoder zip not available"):
         raise Exception(
             "FATAL: zip codec not supported. Install pillow correctly! "
-            " 'sudo apt-get install libjpeg-dev' then 'pip install -I pillow'"
+            " 'sudo apt-get install libjpeg-dev' then 'pip uninstall pillow &&"
+            " pip install pillow --user'"
         )
 except Exception:
     # any other exception is fine
diff --git a/synapse/media/v1/thumbnailer.py b/synapse/media/v1/thumbnailer.py
index bc86efea8f..28404f2b7b 100644
--- a/synapse/media/v1/thumbnailer.py
+++ b/synapse/media/v1/thumbnailer.py
@@ -82,7 +82,7 @@ class Thumbnailer(object):
 
     def save_image(self, output_image, output_type, output_path):
         output_bytes_io = BytesIO()
-        output_image.save(output_bytes_io, self.FORMATS[output_type])
+        output_image.save(output_bytes_io, self.FORMATS[output_type], quality=70)
         output_bytes = output_bytes_io.getvalue()
         with open(output_path, "wb") as output_file:
             output_file.write(output_bytes)
diff --git a/synapse/python_dependencies.py b/synapse/python_dependencies.py
new file mode 100644
index 0000000000..b1fae991e0
--- /dev/null
+++ b/synapse/python_dependencies.py
@@ -0,0 +1,80 @@
+import logging
+from distutils.version import LooseVersion
+
+logger = logging.getLogger(__name__)
+
+REQUIREMENTS = {
+    "syutil==0.0.2": ["syutil"],
+    "matrix_angular_sdk==0.6.0": ["syweb==0.6.0"],
+    "Twisted>=14.0.0": ["twisted>=14.0.0"],
+    "service_identity>=1.0.0": ["service_identity>=1.0.0"],
+    "pyopenssl>=0.14": ["OpenSSL>=0.14"],
+    "pyyaml": ["yaml"],
+    "pyasn1": ["pyasn1"],
+    "pynacl": ["nacl"],
+    "daemonize": ["daemonize"],
+    "py-bcrypt": ["bcrypt"],
+    "frozendict>=0.4": ["frozendict"],
+    "pillow": ["PIL"],
+}
+
+
+class MissingRequirementError(Exception):
+    pass
+
+
+def check_requirements():
+    """Checks that all the modules needed by synapse have been correctly
+    installed and are at the correct version"""
+    for dependency, module_requirements in REQUIREMENTS.items():
+        for module_requirement in module_requirements:
+            if ">=" in module_requirement:
+                module_name, required_version = module_requirement.split(">=")
+                version_test = ">="
+            elif "==" in module_requirement:
+                module_name, required_version = module_requirement.split("==")
+                version_test = "=="
+            else:
+                module_name = module_requirement
+                version_test = None
+
+            try:
+                module = __import__(module_name)
+            except ImportError:
+                logging.exception(
+                    "Can't import %r which is part of %r",
+                    module_name, dependency
+                )
+                raise MissingRequirementError(
+                    "Can't import %r which is part of %r"
+                    % (module_name, dependency)
+                )
+            version = getattr(module, "__version__", None)
+            file_path = getattr(module, "__file__", None)
+            logger.info(
+                "Using %r version %r from %r to satisfy %r",
+                module_name, version, file_path, dependency
+            )
+
+            if version_test == ">=":
+                if version is None:
+                    raise MissingRequirementError(
+                        "Version of %r isn't set as __version__ of module %r"
+                        % (dependency, module_name)
+                    )
+                if LooseVersion(version) < LooseVersion(required_version):
+                    raise MissingRequirementError(
+                        "Version of %r in %r is too old. %r < %r"
+                        % (dependency, file_path, version, required_version)
+                    )
+            elif version_test == "==":
+                if version is None:
+                    raise MissingRequirementError(
+                        "Version of %r isn't set as __version__ of module %r"
+                        % (dependency, module_name)
+                    )
+                if LooseVersion(version) != LooseVersion(required_version):
+                    raise MissingRequirementError(
+                        "Unexpected version of %r in %r. %r != %r"
+                        % (dependency, file_path, version, required_version)
+                    )
diff --git a/synapse/rest/events.py b/synapse/rest/events.py
index cf6d13f817..bedcb2bcc6 100644
--- a/synapse/rest/events.py
+++ b/synapse/rest/events.py
@@ -44,8 +44,11 @@ class EventStreamRestServlet(RestServlet):
                 except ValueError:
                     raise SynapseError(400, "timeout must be in milliseconds.")
 
+            as_client_event = "raw" not in request.args
+
             chunk = yield handler.get_stream(
-                auth_user.to_string(), pagin_config, timeout=timeout
+                auth_user.to_string(), pagin_config, timeout=timeout,
+                as_client_event=as_client_event
             )
         except:
             logger.exception("Event stream failed")
diff --git a/synapse/rest/initial_sync.py b/synapse/rest/initial_sync.py
index a571589581..b13d56b286 100644
--- a/synapse/rest/initial_sync.py
+++ b/synapse/rest/initial_sync.py
@@ -27,12 +27,15 @@ class InitialSyncRestServlet(RestServlet):
     def on_GET(self, request):
         user = yield self.auth.get_user_by_req(request)
         with_feedback = "feedback" in request.args
+        as_client_event = "raw" not in request.args
         pagination_config = PaginationConfig.from_request(request)
         handler = self.handlers.message_handler
         content = yield handler.snapshot_all_rooms(
             user_id=user.to_string(),
             pagin_config=pagination_config,
-            feedback=with_feedback)
+            feedback=with_feedback,
+            as_client_event=as_client_event
+        )
 
         defer.returnValue((200, content))
 
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index e40773758a..caafa959e6 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -314,12 +314,15 @@ class RoomMessageListRestServlet(RestServlet):
             request, default_limit=10,
         )
         with_feedback = "feedback" in request.args
+        as_client_event = "raw" not in request.args
         handler = self.handlers.message_handler
         msgs = yield handler.get_messages(
             room_id=room_id,
             user_id=user.to_string(),
             pagin_config=pagination_config,
-            feedback=with_feedback)
+            feedback=with_feedback,
+            as_client_event=as_client_event
+        )
 
         defer.returnValue((200, msgs))
 
diff --git a/synapse/server.py b/synapse/server.py
index c3bf46abbf..d861efd2fd 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -149,8 +149,8 @@ class BaseHomeServer(object):
         object."""
         return EventID.from_string(s)
 
-    def serialize_event(self, e):
-        return serialize_event(self, e)
+    def serialize_event(self, e, as_client_event=True):
+        return serialize_event(self, e, as_client_event)
 
     def get_ip_from_request(self, request):
         # May be an X-Forwarding-For header depending on config
diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py
index bedc3c6c52..744c821dfe 100644
--- a/synapse/storage/stream.py
+++ b/synapse/storage/stream.py
@@ -284,8 +284,12 @@ class StreamStore(SQLBaseStore):
             rows.reverse()  # As we selected with reverse ordering
 
             if rows:
-                topo = rows[0]["topological_ordering"]
-                toke = rows[0]["stream_ordering"]
+                # XXX: Always subtract 1 since the start token always goes
+                # backwards (parity with paginate_room_events). It isn't
+                # obvious that this is correct; we should clarify the algorithm
+                # used here.
+                topo = rows[0]["topological_ordering"] - 1
+                toke = rows[0]["stream_ordering"] - 1
                 start_token = "t%s-%s" % (topo, toke)
 
                 token = (start_token, end_token)
diff --git a/tests/handlers/test_typing.py b/tests/handlers/test_typing.py
index 0d4b368a43..6a498b23a4 100644
--- a/tests/handlers/test_typing.py
+++ b/tests/handlers/test_typing.py
@@ -352,3 +352,29 @@ class TypingNotificationsTestCase(unittest.TestCase):
                 }},
             ]
         )
+
+        # SYN-230 - see if we can still set after timeout
+
+        yield self.handler.started_typing(
+            target_user=self.u_apple,
+            auth_user=self.u_apple,
+            room_id=self.room_id,
+            timeout=10000,
+        )
+
+        self.on_new_user_event.assert_has_calls([
+            call(rooms=[self.room_id]),
+        ])
+        self.on_new_user_event.reset_mock()
+
+        self.assertEquals(self.event_source.get_current_key(), 3)
+        self.assertEquals(
+            self.event_source.get_new_events_for_user(self.u_apple, 0, None)[0],
+            [
+                {"type": "m.typing",
+                 "room_id": self.room_id,
+                 "content": {
+                     "user_ids": [self.u_apple.to_string()],
+                }},
+            ]
+        )
diff --git a/tests/rest/test_rooms.py b/tests/rest/test_rooms.py
index 84fd730afc..8e65ff9a1c 100644
--- a/tests/rest/test_rooms.py
+++ b/tests/rest/test_rooms.py
@@ -294,7 +294,7 @@ class RoomPermissionsTestCase(RestTestCase):
         # set [invite/join/left] of self, set [invite/join/left] of other,
         # expect all 403s
         for usr in [self.user_id, self.rmcreator_id]:
-            yield self.join(room=room, user=usr, expect_code=403)
+            yield self.join(room=room, user=usr, expect_code=404)
             yield self.leave(room=room, user=usr, expect_code=403)
 
     @defer.inlineCallbacks
diff --git a/tests/rest/test_typing.py b/tests/rest/test_typing.py
index c550294d59..18138af1b5 100644
--- a/tests/rest/test_typing.py
+++ b/tests/rest/test_typing.py
@@ -21,7 +21,7 @@ from twisted.internet import defer
 import synapse.rest.room
 from synapse.server import HomeServer
 
-from ..utils import MockHttpResource, SQLiteMemoryDbPool, MockKey
+from ..utils import MockHttpResource, MockClock, SQLiteMemoryDbPool, MockKey
 from .utils import RestTestCase
 
 from mock import Mock, NonCallableMock
@@ -36,6 +36,8 @@ class RoomTypingTestCase(RestTestCase):
 
     @defer.inlineCallbacks
     def setUp(self):
+        self.clock = MockClock()
+
         self.mock_resource = MockHttpResource(prefix=PATH_PREFIX)
         self.auth_user_id = self.user_id
 
@@ -47,6 +49,7 @@ class RoomTypingTestCase(RestTestCase):
 
         hs = HomeServer(
             "red",
+            clock=self.clock,
             db_pool=db_pool,
             http_client=None,
             replication_layer=Mock(),
@@ -77,6 +80,30 @@ class RoomTypingTestCase(RestTestCase):
             return defer.succeed(None)
         hs.get_datastore().insert_client_ip = _insert_client_ip
 
+        def get_room_members(room_id):
+            if room_id == self.room_id:
+                return defer.succeed([hs.parse_userid(self.user_id)])
+            else:
+                return defer.succeed([])
+
+        @defer.inlineCallbacks
+        def fetch_room_distributions_into(room_id, localusers=None,
+                remotedomains=None, ignore_user=None):
+
+            members = yield get_room_members(room_id)
+            for member in members:
+                if ignore_user is not None and member == ignore_user:
+                    continue
+
+                if hs.is_mine(member):
+                    if localusers is not None:
+                        localusers.add(member)
+                else:
+                    if remotedomains is not None:
+                        remotedomains.add(member.domain)
+        hs.get_handlers().room_member_handler.fetch_room_distributions_into = (
+                fetch_room_distributions_into)
+
         synapse.rest.room.register_servlets(hs, self.mock_resource)
 
         self.room_id = yield self.create_room_as(self.user_id)
@@ -113,3 +140,25 @@ class RoomTypingTestCase(RestTestCase):
             '{"typing": false}'
         )
         self.assertEquals(200, code)
+
+    @defer.inlineCallbacks
+    def test_typing_timeout(self):
+        (code, _) = yield self.mock_resource.trigger("PUT",
+            "/rooms/%s/typing/%s" % (self.room_id, self.user_id),
+            '{"typing": true, "timeout": 30000}'
+        )
+        self.assertEquals(200, code)
+
+        self.assertEquals(self.event_source.get_current_key(), 1)
+
+        self.clock.advance_time(31);
+
+        self.assertEquals(self.event_source.get_current_key(), 2)
+
+        (code, _) = yield self.mock_resource.trigger("PUT",
+            "/rooms/%s/typing/%s" % (self.room_id, self.user_id),
+            '{"typing": true, "timeout": 30000}'
+        )
+        self.assertEquals(200, code)
+
+        self.assertEquals(self.event_source.get_current_key(), 3)
diff --git a/tests/unittest.py b/tests/unittest.py
index a9c0e05541..fe26b7574f 100644
--- a/tests/unittest.py
+++ b/tests/unittest.py
@@ -69,6 +69,8 @@ class TestCase(unittest.TestCase):
                     return ret
 
             logging.getLogger().setLevel(level)
+            # Don't set SQL logging
+            logging.getLogger("synapse.storage").setLevel(old_level)
             return orig()
 
     def assertObjectHasAttributes(self, attrs, obj):