summary refs log tree commit diff
path: root/synapse/rest
diff options
context:
space:
mode:
Diffstat (limited to 'synapse/rest')
-rw-r--r--synapse/rest/__init__.py35
-rw-r--r--synapse/rest/base.py80
-rw-r--r--synapse/rest/client/__init__.py14
-rw-r--r--synapse/rest/client/v1/__init__.py44
-rw-r--r--synapse/rest/client/v1/admin.py (renamed from synapse/rest/admin.py)10
-rw-r--r--synapse/rest/client/v1/base.py52
-rw-r--r--synapse/rest/client/v1/directory.py (renamed from synapse/rest/directory.py)15
-rw-r--r--synapse/rest/client/v1/events.py (renamed from synapse/rest/events.py)18
-rw-r--r--synapse/rest/client/v1/initial_sync.py (renamed from synapse/rest/initial_sync.py)6
-rw-r--r--synapse/rest/client/v1/login.py (renamed from synapse/rest/login.py)8
-rw-r--r--synapse/rest/client/v1/presence.py (renamed from synapse/rest/presence.py)27
-rw-r--r--synapse/rest/client/v1/profile.py (renamed from synapse/rest/profile.py)23
-rw-r--r--synapse/rest/client/v1/push_rule.py401
-rw-r--r--synapse/rest/client/v1/pusher.py89
-rw-r--r--synapse/rest/client/v1/register.py (renamed from synapse/rest/register.py)4
-rw-r--r--synapse/rest/client/v1/room.py (renamed from synapse/rest/room.py)110
-rw-r--r--synapse/rest/client/v1/transactions.py (renamed from synapse/rest/transactions.py)0
-rw-r--r--synapse/rest/client/v1/voip.py (renamed from synapse/rest/voip.py)6
-rw-r--r--synapse/rest/client/v2_alpha/__init__.py29
-rw-r--r--synapse/rest/client/v2_alpha/_base.py38
-rw-r--r--synapse/rest/media/__init__.py0
-rw-r--r--synapse/rest/media/v0/__init__.py0
-rw-r--r--synapse/rest/media/v0/content_repository.py212
-rw-r--r--synapse/rest/media/v1/__init__.py45
-rw-r--r--synapse/rest/media/v1/base_resource.py378
-rw-r--r--synapse/rest/media/v1/download_resource.py74
-rw-r--r--synapse/rest/media/v1/filepath.py67
-rw-r--r--synapse/rest/media/v1/media_repository.py77
-rw-r--r--synapse/rest/media/v1/thumbnail_resource.py193
-rw-r--r--synapse/rest/media/v1/thumbnailer.py89
-rw-r--r--synapse/rest/media/v1/upload_resource.py113
31 files changed, 2045 insertions, 212 deletions
diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py
index 88ec9cd27d..1a84d94cd9 100644
--- a/synapse/rest/__init__.py
+++ b/synapse/rest/__init__.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# Copyright 2014, 2015 OpenMarket Ltd
+# 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.
@@ -12,36 +12,3 @@
 # 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 (
-    room, events, register, login, profile, presence, initial_sync, directory,
-    voip, admin,
-)
-
-
-class RestServletFactory(object):
-
-    """ A factory for creating REST servlets.
-
-    These REST servlets represent the entire client-server REST API. Generally
-    speaking, they serve as wrappers around events and the handlers that
-    process them.
-
-    See synapse.events for information on synapse events.
-    """
-
-    def __init__(self, hs):
-        client_resource = hs.get_resource_for_client()
-
-        # TODO(erikj): There *must* be a better way of doing this.
-        room.register_servlets(hs, client_resource)
-        events.register_servlets(hs, client_resource)
-        register.register_servlets(hs, client_resource)
-        login.register_servlets(hs, client_resource)
-        profile.register_servlets(hs, client_resource)
-        presence.register_servlets(hs, client_resource)
-        initial_sync.register_servlets(hs, client_resource)
-        directory.register_servlets(hs, client_resource)
-        voip.register_servlets(hs, client_resource)
-        admin.register_servlets(hs, client_resource)
diff --git a/synapse/rest/base.py b/synapse/rest/base.py
deleted file mode 100644
index c583945527..0000000000
--- a/synapse/rest/base.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2014, 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 REST servlets. """
-from synapse.api.urls import CLIENT_PREFIX
-from synapse.rest.transactions import HttpTransactionStore
-import re
-
-import logging
-
-
-logger = logging.getLogger(__name__)
-
-
-def client_path_pattern(path_regex):
-    """Creates a regex compiled client path with the correct client 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("^" + CLIENT_PREFIX + path_regex)
-
-
-class RestServlet(object):
-
-    """ A Synapse REST Servlet.
-
-    An implementing class can either provide its own custom 'register' method,
-    or use the automatic pattern handling provided by the base class.
-
-    To use this latter, the implementing class instead provides a `PATTERN`
-    class attribute containing a pre-compiled regular expression. The automatic
-    register method will then use this method to register any of the following
-    instance methods associated with the corresponding HTTP method:
-
-      on_GET
-      on_PUT
-      on_POST
-      on_DELETE
-      on_OPTIONS
-
-    Automatically handles turning CodeMessageExceptions thrown by these methods
-    into the appropriate HTTP response.
-    """
-
-    def __init__(self, hs):
-        self.hs = hs
-
-        self.handlers = hs.get_handlers()
-        self.builder_factory = hs.get_event_builder_factory()
-        self.auth = hs.get_auth()
-        self.txns = HttpTransactionStore()
-
-    def register(self, http_server):
-        """ Register this servlet with the given HTTP server. """
-        if hasattr(self, "PATTERN"):
-            pattern = self.PATTERN
-
-            for method in ("GET", "PUT", "POST", "OPTIONS", "DELETE"):
-                if hasattr(self, "on_%s" % (method)):
-                    method_handler = getattr(self, "on_%s" % (method))
-                    http_server.register_path(method, pattern, method_handler)
-        else:
-            raise NotImplementedError("RestServlet must register something.")
diff --git a/synapse/rest/client/__init__.py b/synapse/rest/client/__init__.py
new file mode 100644
index 0000000000..1a84d94cd9
--- /dev/null
+++ b/synapse/rest/client/__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/client/v1/__init__.py b/synapse/rest/client/v1/__init__.py
new file mode 100644
index 0000000000..d8d01cdd16
--- /dev/null
+++ b/synapse/rest/client/v1/__init__.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 (
+    room, events, register, login, profile, presence, initial_sync, directory,
+    voip, admin, pusher, push_rule
+)
+
+from synapse.http.server import JsonResource
+
+
+class ClientV1RestResource(JsonResource):
+    """A resource for version 1 of the matrix client API."""
+
+    def __init__(self, hs):
+        JsonResource.__init__(self)
+        self.register_servlets(self, hs)
+
+    @staticmethod
+    def register_servlets(client_resource, hs):
+        room.register_servlets(hs, client_resource)
+        events.register_servlets(hs, client_resource)
+        register.register_servlets(hs, client_resource)
+        login.register_servlets(hs, client_resource)
+        profile.register_servlets(hs, client_resource)
+        presence.register_servlets(hs, client_resource)
+        initial_sync.register_servlets(hs, client_resource)
+        directory.register_servlets(hs, client_resource)
+        voip.register_servlets(hs, client_resource)
+        admin.register_servlets(hs, client_resource)
+        pusher.register_servlets(hs, client_resource)
+        push_rule.register_servlets(hs, client_resource)
diff --git a/synapse/rest/admin.py b/synapse/rest/client/v1/admin.py
index 0aa83514c8..2ce754b028 100644
--- a/synapse/rest/admin.py
+++ b/synapse/rest/client/v1/admin.py
@@ -16,20 +16,22 @@
 from twisted.internet import defer
 
 from synapse.api.errors import AuthError, SynapseError
-from base import RestServlet, client_path_pattern
+from synapse.types import UserID
+
+from base import ClientV1RestServlet, client_path_pattern
 
 import logging
 
 logger = logging.getLogger(__name__)
 
 
-class WhoisRestServlet(RestServlet):
+class WhoisRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/admin/whois/(?P<user_id>[^/]*)")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        target_user = self.hs.parse_userid(user_id)
-        auth_user = yield self.auth.get_user_by_req(request)
+        target_user = UserID.from_string(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
         is_admin = yield self.auth.is_server_admin(auth_user)
 
         if not is_admin and target_user != auth_user:
diff --git a/synapse/rest/client/v1/base.py b/synapse/rest/client/v1/base.py
new file mode 100644
index 0000000000..72332bdb10
--- /dev/null
+++ b/synapse/rest/client/v1/base.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 CLIENT_PREFIX
+from .transactions import HttpTransactionStore
+import re
+
+import logging
+
+
+logger = logging.getLogger(__name__)
+
+
+def client_path_pattern(path_regex):
+    """Creates a regex compiled client path with the correct client 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("^" + CLIENT_PREFIX + path_regex)
+
+
+class ClientV1RestServlet(RestServlet):
+    """A base Synapse REST Servlet for the client version 1 API.
+    """
+
+    def __init__(self, hs):
+        self.hs = hs
+        self.handlers = hs.get_handlers()
+        self.builder_factory = hs.get_event_builder_factory()
+        self.auth = hs.get_auth()
+        self.txns = HttpTransactionStore()
diff --git a/synapse/rest/directory.py b/synapse/rest/client/v1/directory.py
index 7ff44fdd9e..8f65efec5f 100644
--- a/synapse/rest/directory.py
+++ b/synapse/rest/client/v1/directory.py
@@ -17,7 +17,8 @@
 from twisted.internet import defer
 
 from synapse.api.errors import AuthError, SynapseError, Codes
-from base import RestServlet, client_path_pattern
+from synapse.types import RoomAlias
+from .base import ClientV1RestServlet, client_path_pattern
 
 import json
 import logging
@@ -30,12 +31,12 @@ def register_servlets(hs, http_server):
     ClientDirectoryServer(hs).register(http_server)
 
 
-class ClientDirectoryServer(RestServlet):
+class ClientDirectoryServer(ClientV1RestServlet):
     PATTERN = client_path_pattern("/directory/room/(?P<room_alias>[^/]*)$")
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_alias):
-        room_alias = self.hs.parse_roomalias(room_alias)
+        room_alias = RoomAlias.from_string(room_alias)
 
         dir_handler = self.handlers.directory_handler
         res = yield dir_handler.get_association(room_alias)
@@ -44,7 +45,7 @@ class ClientDirectoryServer(RestServlet):
 
     @defer.inlineCallbacks
     def on_PUT(self, request, room_alias):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
 
         content = _parse_json(request)
         if not "room_id" in content:
@@ -53,7 +54,7 @@ class ClientDirectoryServer(RestServlet):
 
         logger.debug("Got content: %s", content)
 
-        room_alias = self.hs.parse_roomalias(room_alias)
+        room_alias = RoomAlias.from_string(room_alias)
 
         logger.debug("Got room name: %s", room_alias.to_string())
 
@@ -84,7 +85,7 @@ class ClientDirectoryServer(RestServlet):
 
     @defer.inlineCallbacks
     def on_DELETE(self, request, room_alias):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
 
         is_admin = yield self.auth.is_server_admin(user)
         if not is_admin:
@@ -92,7 +93,7 @@ class ClientDirectoryServer(RestServlet):
 
         dir_handler = self.handlers.directory_handler
 
-        room_alias = self.hs.parse_roomalias(room_alias)
+        room_alias = RoomAlias.from_string(room_alias)
 
         yield dir_handler.delete_association(
             user.to_string(), room_alias
diff --git a/synapse/rest/events.py b/synapse/rest/client/v1/events.py
index bedcb2bcc6..77b7c25a03 100644
--- a/synapse/rest/events.py
+++ b/synapse/rest/client/v1/events.py
@@ -18,7 +18,8 @@ from twisted.internet import defer
 
 from synapse.api.errors import SynapseError
 from synapse.streams.config import PaginationConfig
-from synapse.rest.base import RestServlet, client_path_pattern
+from .base import ClientV1RestServlet, client_path_pattern
+from synapse.events.utils import serialize_event
 
 import logging
 
@@ -26,14 +27,14 @@ import logging
 logger = logging.getLogger(__name__)
 
 
-class EventStreamRestServlet(RestServlet):
+class EventStreamRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/events$")
 
     DEFAULT_LONGPOLL_TIME_MS = 30000
 
     @defer.inlineCallbacks
     def on_GET(self, request):
-        auth_user = yield self.auth.get_user_by_req(request)
+        auth_user, client = yield self.auth.get_user_by_req(request)
         try:
             handler = self.handlers.event_stream_handler
             pagin_config = PaginationConfig.from_request(request)
@@ -61,17 +62,22 @@ class EventStreamRestServlet(RestServlet):
 
 
 # TODO: Unit test gets, with and without auth, with different kinds of events.
-class EventRestServlet(RestServlet):
+class EventRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/events/(?P<event_id>[^/]*)$")
 
+    def __init__(self, hs):
+        super(EventRestServlet, self).__init__(hs)
+        self.clock = hs.get_clock()
+
     @defer.inlineCallbacks
     def on_GET(self, request, event_id):
-        auth_user = yield self.auth.get_user_by_req(request)
+        auth_user, client = yield self.auth.get_user_by_req(request)
         handler = self.handlers.event_handler
         event = yield handler.get_event(auth_user, event_id)
 
+        time_now = self.clock.time_msec()
         if event:
-            defer.returnValue((200, self.hs.serialize_event(event)))
+            defer.returnValue((200, serialize_event(event, time_now)))
         else:
             defer.returnValue((404, "Event not found."))
 
diff --git a/synapse/rest/initial_sync.py b/synapse/rest/client/v1/initial_sync.py
index b13d56b286..4a259bba64 100644
--- a/synapse/rest/initial_sync.py
+++ b/synapse/rest/client/v1/initial_sync.py
@@ -16,16 +16,16 @@
 from twisted.internet import defer
 
 from synapse.streams.config import PaginationConfig
-from base import RestServlet, client_path_pattern
+from base import ClientV1RestServlet, client_path_pattern
 
 
 # TODO: Needs unit testing
-class InitialSyncRestServlet(RestServlet):
+class InitialSyncRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/initialSync$")
 
     @defer.inlineCallbacks
     def on_GET(self, request):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = 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)
diff --git a/synapse/rest/login.py b/synapse/rest/client/v1/login.py
index 6b8deff67b..7116ac98e8 100644
--- a/synapse/rest/login.py
+++ b/synapse/rest/client/v1/login.py
@@ -17,12 +17,12 @@ from twisted.internet import defer
 
 from synapse.api.errors import SynapseError
 from synapse.types import UserID
-from base import RestServlet, client_path_pattern
+from base import ClientV1RestServlet, client_path_pattern
 
 import json
 
 
-class LoginRestServlet(RestServlet):
+class LoginRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/login$")
     PASS_TYPE = "m.login.password"
 
@@ -64,7 +64,7 @@ class LoginRestServlet(RestServlet):
         defer.returnValue((200, result))
 
 
-class LoginFallbackRestServlet(RestServlet):
+class LoginFallbackRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/login/fallback$")
 
     def on_GET(self, request):
@@ -73,7 +73,7 @@ class LoginFallbackRestServlet(RestServlet):
         return (200, {})
 
 
-class PasswordResetRestServlet(RestServlet):
+class PasswordResetRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/login/reset")
 
     @defer.inlineCallbacks
diff --git a/synapse/rest/presence.py b/synapse/rest/client/v1/presence.py
index ca4d2d21f0..7feb4aadb1 100644
--- a/synapse/rest/presence.py
+++ b/synapse/rest/client/v1/presence.py
@@ -18,7 +18,8 @@
 from twisted.internet import defer
 
 from synapse.api.errors import SynapseError
-from base import RestServlet, client_path_pattern
+from synapse.types import UserID
+from .base import ClientV1RestServlet, client_path_pattern
 
 import json
 import logging
@@ -26,13 +27,13 @@ import logging
 logger = logging.getLogger(__name__)
 
 
-class PresenceStatusRestServlet(RestServlet):
+class PresenceStatusRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/presence/(?P<user_id>[^/]*)/status")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         state = yield self.handlers.presence_handler.get_state(
             target_user=user, auth_user=auth_user)
@@ -41,8 +42,8 @@ class PresenceStatusRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_PUT(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         state = {}
         try:
@@ -71,13 +72,13 @@ class PresenceStatusRestServlet(RestServlet):
         return (200, {})
 
 
-class PresenceListRestServlet(RestServlet):
+class PresenceListRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/presence/list/(?P<user_id>[^/]*)")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         if not self.hs.is_mine(user):
             raise SynapseError(400, "User not hosted on this Home Server")
@@ -96,8 +97,8 @@ class PresenceListRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_POST(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         if not self.hs.is_mine(user):
             raise SynapseError(400, "User not hosted on this Home Server")
@@ -118,7 +119,7 @@ class PresenceListRestServlet(RestServlet):
                     raise SynapseError(400, "Bad invite value.")
                 if len(u) == 0:
                     continue
-                invited_user = self.hs.parse_userid(u)
+                invited_user = UserID.from_string(u)
                 yield self.handlers.presence_handler.send_invite(
                     observer_user=user, observed_user=invited_user
                 )
@@ -129,7 +130,7 @@ class PresenceListRestServlet(RestServlet):
                     raise SynapseError(400, "Bad drop value.")
                 if len(u) == 0:
                     continue
-                dropped_user = self.hs.parse_userid(u)
+                dropped_user = UserID.from_string(u)
                 yield self.handlers.presence_handler.drop(
                     observer_user=user, observed_user=dropped_user
                 )
diff --git a/synapse/rest/profile.py b/synapse/rest/client/v1/profile.py
index dc6eb424b0..15d6f3fc6c 100644
--- a/synapse/rest/profile.py
+++ b/synapse/rest/client/v1/profile.py
@@ -16,17 +16,18 @@
 """ This module contains REST servlets to do with profile: /profile/<paths> """
 from twisted.internet import defer
 
-from base import RestServlet, client_path_pattern
+from .base import ClientV1RestServlet, client_path_pattern
+from synapse.types import UserID
 
 import json
 
 
-class ProfileDisplaynameRestServlet(RestServlet):
+class ProfileDisplaynameRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/profile/(?P<user_id>[^/]*)/displayname")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        user = self.hs.parse_userid(user_id)
+        user = UserID.from_string(user_id)
 
         displayname = yield self.handlers.profile_handler.get_displayname(
             user,
@@ -36,8 +37,8 @@ class ProfileDisplaynameRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_PUT(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         try:
             content = json.loads(request.content.read())
@@ -54,12 +55,12 @@ class ProfileDisplaynameRestServlet(RestServlet):
         return (200, {})
 
 
-class ProfileAvatarURLRestServlet(RestServlet):
+class ProfileAvatarURLRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/profile/(?P<user_id>[^/]*)/avatar_url")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        user = self.hs.parse_userid(user_id)
+        user = UserID.from_string(user_id)
 
         avatar_url = yield self.handlers.profile_handler.get_avatar_url(
             user,
@@ -69,8 +70,8 @@ class ProfileAvatarURLRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_PUT(self, request, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
-        user = self.hs.parse_userid(user_id)
+        auth_user, client = yield self.auth.get_user_by_req(request)
+        user = UserID.from_string(user_id)
 
         try:
             content = json.loads(request.content.read())
@@ -87,12 +88,12 @@ class ProfileAvatarURLRestServlet(RestServlet):
         return (200, {})
 
 
-class ProfileRestServlet(RestServlet):
+class ProfileRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/profile/(?P<user_id>[^/]*)")
 
     @defer.inlineCallbacks
     def on_GET(self, request, user_id):
-        user = self.hs.parse_userid(user_id)
+        user = UserID.from_string(user_id)
 
         displayname = yield self.handlers.profile_handler.get_displayname(
             user,
diff --git a/synapse/rest/client/v1/push_rule.py b/synapse/rest/client/v1/push_rule.py
new file mode 100644
index 0000000000..0f78fa667c
--- /dev/null
+++ b/synapse/rest/client/v1/push_rule.py
@@ -0,0 +1,401 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 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 SynapseError, Codes, UnrecognizedRequestError, NotFoundError, \
+    StoreError
+from .base import ClientV1RestServlet, client_path_pattern
+from synapse.storage.push_rule import InconsistentRuleException, RuleNotFoundException
+
+import json
+
+
+class PushRuleRestServlet(ClientV1RestServlet):
+    PATTERN = client_path_pattern("/pushrules/.*$")
+    PRIORITY_CLASS_MAP = {
+        'underride': 1,
+        'sender': 2,
+        'room': 3,
+        'content': 4,
+        'override': 5,
+    }
+    PRIORITY_CLASS_INVERSE_MAP = {v: k for k, v in PRIORITY_CLASS_MAP.items()}
+    SLIGHTLY_PEDANTIC_TRAILING_SLASH_ERROR = (
+        "Unrecognised request: You probably wanted a trailing slash")
+
+    def rule_spec_from_path(self, path):
+        if len(path) < 2:
+            raise UnrecognizedRequestError()
+        if path[0] != 'pushrules':
+            raise UnrecognizedRequestError()
+
+        scope = path[1]
+        path = path[2:]
+        if scope not in ['global', 'device']:
+            raise UnrecognizedRequestError()
+
+        device = None
+        if scope == 'device':
+            if len(path) == 0:
+                raise UnrecognizedRequestError()
+            device = path[0]
+            path = path[1:]
+
+        if len(path) == 0:
+            raise UnrecognizedRequestError()
+
+        template = path[0]
+        path = path[1:]
+
+        if len(path) == 0:
+            raise UnrecognizedRequestError()
+
+        rule_id = path[0]
+
+        spec = {
+            'scope': scope,
+            'template': template,
+            'rule_id': rule_id
+        }
+        if device:
+            spec['device'] = device
+        return spec
+
+    def rule_tuple_from_request_object(self, rule_template, rule_id, req_obj, device=None):
+        if rule_template in ['override', 'underride']:
+            if 'conditions' not in req_obj:
+                raise InvalidRuleException("Missing 'conditions'")
+            conditions = req_obj['conditions']
+            for c in conditions:
+                if 'kind' not in c:
+                    raise InvalidRuleException("Condition without 'kind'")
+        elif rule_template == 'room':
+            conditions = [{
+                'kind': 'event_match',
+                'key': 'room_id',
+                'pattern': rule_id
+            }]
+        elif rule_template == 'sender':
+            conditions = [{
+                'kind': 'event_match',
+                'key': 'user_id',
+                'pattern': rule_id
+            }]
+        elif rule_template == 'content':
+            if 'pattern' not in req_obj:
+                raise InvalidRuleException("Content rule missing 'pattern'")
+            pat = req_obj['pattern']
+            if pat.strip("*?[]") == pat:
+                # no special glob characters so we assume the user means
+                # 'contains this string' rather than 'is this string'
+                pat = "*%s*" % (pat,)
+            conditions = [{
+                'kind': 'event_match',
+                'key': 'content.body',
+                'pattern': pat
+            }]
+        else:
+            raise InvalidRuleException("Unknown rule template: %s" % (rule_template,))
+
+        if device:
+            conditions.append({
+                'kind': 'device',
+                'instance_handle': device
+            })
+
+        if 'actions' not in req_obj:
+            raise InvalidRuleException("No actions found")
+        actions = req_obj['actions']
+
+        for a in actions:
+            if a in ['notify', 'dont_notify', 'coalesce']:
+                pass
+            elif isinstance(a, dict) and 'set_sound' in a:
+                pass
+            else:
+                raise InvalidRuleException("Unrecognised action")
+
+        return conditions, actions
+
+    @defer.inlineCallbacks
+    def on_PUT(self, request):
+        spec = self.rule_spec_from_path(request.postpath)
+        try:
+            priority_class = _priority_class_from_spec(spec)
+        except InvalidRuleException as e:
+            raise SynapseError(400, e.message)
+
+        user, _ = yield self.auth.get_user_by_req(request)
+
+        content = _parse_json(request)
+
+        try:
+            (conditions, actions) = self.rule_tuple_from_request_object(
+                spec['template'],
+                spec['rule_id'],
+                content,
+                device=spec['device'] if 'device' in spec else None
+            )
+        except InvalidRuleException as e:
+            raise SynapseError(400, e.message)
+
+        before = request.args.get("before", None)
+        if before and len(before):
+            before = before[0]
+        after = request.args.get("after", None)
+        if after and len(after):
+            after = after[0]
+
+        try:
+            yield self.hs.get_datastore().add_push_rule(
+                user_name=user.to_string(),
+                rule_id=spec['rule_id'],
+                priority_class=priority_class,
+                conditions=conditions,
+                actions=actions,
+                before=before,
+                after=after
+            )
+        except InconsistentRuleException as e:
+            raise SynapseError(400, e.message)
+        except RuleNotFoundException as e:
+            raise SynapseError(400, e.message)
+
+        defer.returnValue((200, {}))
+
+    @defer.inlineCallbacks
+    def on_DELETE(self, request):
+        spec = self.rule_spec_from_path(request.postpath)
+        try:
+            priority_class = _priority_class_from_spec(spec)
+        except InvalidRuleException as e:
+            raise SynapseError(400, e.message)
+
+        user, _ = yield self.auth.get_user_by_req(request)
+
+        if 'device' in spec:
+            rules = yield self.hs.get_datastore().get_push_rules_for_user_name(
+                user.to_string()
+            )
+
+            for r in rules:
+                conditions = json.loads(r['conditions'])
+                ih = _instance_handle_from_conditions(conditions)
+                if ih == spec['device'] and r['priority_class'] == priority_class:
+                    yield self.hs.get_datastore().delete_push_rule(
+                        user.to_string(), spec['rule_id']
+                    )
+                    defer.returnValue((200, {}))
+            raise NotFoundError()
+        else:
+            try:
+                yield self.hs.get_datastore().delete_push_rule(
+                    user.to_string(), spec['rule_id'],
+                    priority_class=priority_class
+                )
+                defer.returnValue((200, {}))
+            except StoreError as e:
+                if e.code == 404:
+                    raise NotFoundError()
+                else:
+                    raise
+
+    @defer.inlineCallbacks
+    def on_GET(self, request):
+        user, _ = yield self.auth.get_user_by_req(request)
+
+        # we build up the full structure and then decide which bits of it
+        # to send which means doing unnecessary work sometimes but is
+        # is probably not going to make a whole lot of difference
+        rawrules = yield self.hs.get_datastore().get_push_rules_for_user_name(user.to_string())
+
+        rules = {'global': {}, 'device': {}}
+
+        rules['global'] = _add_empty_priority_class_arrays(rules['global'])
+
+        for r in rawrules:
+            rulearray = None
+
+            r["conditions"] = json.loads(r["conditions"])
+            r["actions"] = json.loads(r["actions"])
+
+            template_name = _priority_class_to_template_name(r['priority_class'])
+
+            if r['priority_class'] > PushRuleRestServlet.PRIORITY_CLASS_MAP['override']:
+                # per-device rule
+                instance_handle = _instance_handle_from_conditions(r["conditions"])
+                r = _strip_device_condition(r)
+                if not instance_handle:
+                    continue
+                if instance_handle not in rules['device']:
+                    rules['device'][instance_handle] = {}
+                    rules['device'][instance_handle] = (
+                        _add_empty_priority_class_arrays(
+                            rules['device'][instance_handle]
+                        )
+                    )
+
+                rulearray = rules['device'][instance_handle][template_name]
+            else:
+                rulearray = rules['global'][template_name]
+
+            template_rule = _rule_to_template(r)
+            if template_rule:
+                rulearray.append(template_rule)
+
+        path = request.postpath[1:]
+
+        if path == []:
+            # we're a reference impl: pedantry is our job.
+            raise UnrecognizedRequestError(
+                PushRuleRestServlet.SLIGHTLY_PEDANTIC_TRAILING_SLASH_ERROR
+            )
+
+        if path[0] == '':
+            defer.returnValue((200, rules))
+        elif path[0] == 'global':
+            path = path[1:]
+            result = _filter_ruleset_with_path(rules['global'], path)
+            defer.returnValue((200, result))
+        elif path[0] == 'device':
+            path = path[1:]
+            if path == []:
+                raise UnrecognizedRequestError(
+                    PushRuleRestServlet.SLIGHTLY_PEDANTIC_TRAILING_SLASH_ERROR
+                )
+            if path[0] == '':
+                defer.returnValue((200, rules['device']))
+
+            instance_handle = path[0]
+            path = path[1:]
+            if instance_handle not in rules['device']:
+                ret = {}
+                ret = _add_empty_priority_class_arrays(ret)
+                defer.returnValue((200, ret))
+            ruleset = rules['device'][instance_handle]
+            result = _filter_ruleset_with_path(ruleset, path)
+            defer.returnValue((200, result))
+        else:
+            raise UnrecognizedRequestError()
+
+    def on_OPTIONS(self, _):
+        return 200, {}
+
+
+def _add_empty_priority_class_arrays(d):
+    for pc in PushRuleRestServlet.PRIORITY_CLASS_MAP.keys():
+        d[pc] = []
+    return d
+
+
+def _instance_handle_from_conditions(conditions):
+    """
+    Given a list of conditions, return the instance handle of the
+    device rule if there is one
+    """
+    for c in conditions:
+        if c['kind'] == 'device':
+            return c['instance_handle']
+    return None
+
+
+def _filter_ruleset_with_path(ruleset, path):
+    if path == []:
+        raise UnrecognizedRequestError(
+            PushRuleRestServlet.SLIGHTLY_PEDANTIC_TRAILING_SLASH_ERROR
+        )
+
+    if path[0] == '':
+        return ruleset
+    template_kind = path[0]
+    if template_kind not in ruleset:
+        raise UnrecognizedRequestError()
+    path = path[1:]
+    if path == []:
+        raise UnrecognizedRequestError(
+            PushRuleRestServlet.SLIGHTLY_PEDANTIC_TRAILING_SLASH_ERROR
+        )
+    if path[0] == '':
+        return ruleset[template_kind]
+    rule_id = path[0]
+    for r in ruleset[template_kind]:
+        if r['rule_id'] == rule_id:
+            return r
+    raise NotFoundError
+
+
+def _priority_class_from_spec(spec):
+    if spec['template'] not in PushRuleRestServlet.PRIORITY_CLASS_MAP.keys():
+        raise InvalidRuleException("Unknown template: %s" % (spec['kind']))
+    pc = PushRuleRestServlet.PRIORITY_CLASS_MAP[spec['template']]
+
+    if spec['scope'] == 'device':
+        pc += len(PushRuleRestServlet.PRIORITY_CLASS_MAP)
+
+    return pc
+
+
+def _priority_class_to_template_name(pc):
+    if pc > PushRuleRestServlet.PRIORITY_CLASS_MAP['override']:
+        # per-device
+        prio_class_index = pc - len(PushRuleRestServlet.PRIORITY_CLASS_MAP)
+        return PushRuleRestServlet.PRIORITY_CLASS_INVERSE_MAP[prio_class_index]
+    else:
+        return PushRuleRestServlet.PRIORITY_CLASS_INVERSE_MAP[pc]
+
+
+def _rule_to_template(rule):
+    template_name = _priority_class_to_template_name(rule['priority_class'])
+    if template_name in ['override', 'underride']:
+        return {k: rule[k] for k in ["rule_id", "conditions", "actions"]}
+    elif template_name in ["sender", "room"]:
+        return {k: rule[k] for k in ["rule_id", "actions"]}
+    elif template_name == 'content':
+        if len(rule["conditions"]) != 1:
+            return None
+        thecond = rule["conditions"][0]
+        if "pattern" not in thecond:
+            return None
+        ret = {k: rule[k] for k in ["rule_id", "actions"]}
+        ret["pattern"] = thecond["pattern"]
+        return ret
+
+
+def _strip_device_condition(rule):
+    for i, c in enumerate(rule['conditions']):
+        if c['kind'] == 'device':
+            del rule['conditions'][i]
+    return rule
+
+
+class InvalidRuleException(Exception):
+    pass
+
+
+# XXX: C+ped from rest/room.py - surely this should be common?
+def _parse_json(request):
+    try:
+        content = json.loads(request.content.read())
+        if type(content) != dict:
+            raise SynapseError(400, "Content must be a JSON object.",
+                               errcode=Codes.NOT_JSON)
+        return content
+    except ValueError:
+        raise SynapseError(400, "Content not JSON.", errcode=Codes.NOT_JSON)
+
+
+def register_servlets(hs, http_server):
+    PushRuleRestServlet(hs).register(http_server)
diff --git a/synapse/rest/client/v1/pusher.py b/synapse/rest/client/v1/pusher.py
new file mode 100644
index 0000000000..353a4a6589
--- /dev/null
+++ b/synapse/rest/client/v1/pusher.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 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 SynapseError, Codes
+from synapse.push import PusherConfigException
+from .base import ClientV1RestServlet, client_path_pattern
+
+import json
+
+
+class PusherRestServlet(ClientV1RestServlet):
+    PATTERN = client_path_pattern("/pushers/set$")
+
+    @defer.inlineCallbacks
+    def on_POST(self, request):
+        user, _ = yield self.auth.get_user_by_req(request)
+
+        content = _parse_json(request)
+
+        pusher_pool = self.hs.get_pusherpool()
+
+        if ('pushkey' in content and 'app_id' in content
+                    and 'kind' in content and
+                    content['kind'] is None):
+            yield pusher_pool.remove_pusher(
+                content['app_id'], content['pushkey']
+            )
+            defer.returnValue((200, {}))
+
+        reqd = ['instance_handle', 'kind', 'app_id', 'app_display_name',
+                'device_display_name', 'pushkey', 'lang', 'data']
+        missing = []
+        for i in reqd:
+            if i not in content:
+                missing.append(i)
+        if len(missing):
+            raise SynapseError(400, "Missing parameters: "+','.join(missing),
+                               errcode=Codes.MISSING_PARAM)
+
+        try:
+            yield pusher_pool.add_pusher(
+                user_name=user.to_string(),
+                instance_handle=content['instance_handle'],
+                kind=content['kind'],
+                app_id=content['app_id'],
+                app_display_name=content['app_display_name'],
+                device_display_name=content['device_display_name'],
+                pushkey=content['pushkey'],
+                lang=content['lang'],
+                data=content['data']
+            )
+        except PusherConfigException as pce:
+            raise SynapseError(400, "Config Error: "+pce.message,
+                               errcode=Codes.MISSING_PARAM)
+
+        defer.returnValue((200, {}))
+
+    def on_OPTIONS(self, _):
+        return 200, {}
+
+
+# XXX: C+ped from rest/room.py - surely this should be common?
+def _parse_json(request):
+    try:
+        content = json.loads(request.content.read())
+        if type(content) != dict:
+            raise SynapseError(400, "Content must be a JSON object.",
+                               errcode=Codes.NOT_JSON)
+        return content
+    except ValueError:
+        raise SynapseError(400, "Content not JSON.", errcode=Codes.NOT_JSON)
+
+
+def register_servlets(hs, http_server):
+    PusherRestServlet(hs).register(http_server)
diff --git a/synapse/rest/register.py b/synapse/rest/client/v1/register.py
index e3b26902d9..c0423c2d45 100644
--- a/synapse/rest/register.py
+++ b/synapse/rest/client/v1/register.py
@@ -18,7 +18,7 @@ from twisted.internet import defer
 
 from synapse.api.errors import SynapseError, Codes
 from synapse.api.constants import LoginType
-from base import RestServlet, client_path_pattern
+from base import ClientV1RestServlet, client_path_pattern
 import synapse.util.stringutils as stringutils
 
 from synapse.util.async import run_on_reactor
@@ -42,7 +42,7 @@ else:
     compare_digest = lambda a, b: a == b
 
 
-class RegisterRestServlet(RestServlet):
+class RegisterRestServlet(ClientV1RestServlet):
     """Handles registration with the home server.
 
     This servlet is in control of the registration flow; the registration
diff --git a/synapse/rest/room.py b/synapse/rest/client/v1/room.py
index 48bba2a5f3..410f19ccf6 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/client/v1/room.py
@@ -16,10 +16,12 @@
 """ This module contains REST servlets to do with rooms: /rooms/<paths> """
 from twisted.internet import defer
 
-from base import RestServlet, client_path_pattern
+from base import ClientV1RestServlet, client_path_pattern
 from synapse.api.errors import SynapseError, Codes
 from synapse.streams.config import PaginationConfig
 from synapse.api.constants import EventTypes, Membership
+from synapse.types import UserID, RoomID, RoomAlias
+from synapse.events.utils import serialize_event
 
 import json
 import logging
@@ -29,7 +31,7 @@ import urllib
 logger = logging.getLogger(__name__)
 
 
-class RoomCreateRestServlet(RestServlet):
+class RoomCreateRestServlet(ClientV1RestServlet):
     # No PATTERN; we have custom dispatch rules here
 
     def register(self, http_server):
@@ -60,7 +62,7 @@ class RoomCreateRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_POST(self, request):
-        auth_user = yield self.auth.get_user_by_req(request)
+        auth_user, client = yield self.auth.get_user_by_req(request)
 
         room_config = self.get_room_config(request)
         info = yield self.make_room(room_config, auth_user, None)
@@ -93,7 +95,7 @@ class RoomCreateRestServlet(RestServlet):
 
 
 # TODO: Needs unit testing for generic events
-class RoomStateEventRestServlet(RestServlet):
+class RoomStateEventRestServlet(ClientV1RestServlet):
     def register(self, http_server):
         # /room/$roomid/state/$eventtype
         no_state_key = "/rooms/(?P<room_id>[^/]*)/state/(?P<event_type>[^/]*)$"
@@ -123,7 +125,7 @@ class RoomStateEventRestServlet(RestServlet):
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_id, event_type, state_key):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
 
         msg_handler = self.handlers.message_handler
         data = yield msg_handler.get_room_data(
@@ -140,8 +142,8 @@ class RoomStateEventRestServlet(RestServlet):
         defer.returnValue((200, data.get_dict()["content"]))
 
     @defer.inlineCallbacks
-    def on_PUT(self, request, room_id, event_type, state_key):
-        user = yield self.auth.get_user_by_req(request)
+    def on_PUT(self, request, room_id, event_type, state_key, txn_id=None):
+        user, client = yield self.auth.get_user_by_req(request)
 
         content = _parse_json(request)
 
@@ -156,13 +158,15 @@ class RoomStateEventRestServlet(RestServlet):
             event_dict["state_key"] = state_key
 
         msg_handler = self.handlers.message_handler
-        yield msg_handler.create_and_send_event(event_dict)
+        yield msg_handler.create_and_send_event(
+            event_dict, client=client, txn_id=txn_id,
+        )
 
         defer.returnValue((200, {}))
 
 
 # TODO: Needs unit testing for generic events + feedback
-class RoomSendEventRestServlet(RestServlet):
+class RoomSendEventRestServlet(ClientV1RestServlet):
 
     def register(self, http_server):
         # /rooms/$roomid/send/$event_type[/$txn_id]
@@ -170,8 +174,8 @@ class RoomSendEventRestServlet(RestServlet):
         register_txn_path(self, PATTERN, http_server, with_get=True)
 
     @defer.inlineCallbacks
-    def on_POST(self, request, room_id, event_type):
-        user = yield self.auth.get_user_by_req(request)
+    def on_POST(self, request, room_id, event_type, txn_id=None):
+        user, client = yield self.auth.get_user_by_req(request)
         content = _parse_json(request)
 
         msg_handler = self.handlers.message_handler
@@ -181,7 +185,9 @@ class RoomSendEventRestServlet(RestServlet):
                 "content": content,
                 "room_id": room_id,
                 "sender": user.to_string(),
-            }
+            },
+            client=client,
+            txn_id=txn_id,
         )
 
         defer.returnValue((200, {"event_id": event.event_id}))
@@ -198,14 +204,14 @@ class RoomSendEventRestServlet(RestServlet):
         except KeyError:
             pass
 
-        response = yield self.on_POST(request, room_id, event_type)
+        response = yield self.on_POST(request, room_id, event_type, txn_id)
 
         self.txns.store_client_transaction(request, txn_id, response)
         defer.returnValue(response)
 
 
 # TODO: Needs unit testing for room ID + alias joins
-class JoinRoomAliasServlet(RestServlet):
+class JoinRoomAliasServlet(ClientV1RestServlet):
 
     def register(self, http_server):
         # /join/$room_identifier[/$txn_id]
@@ -213,8 +219,8 @@ class JoinRoomAliasServlet(RestServlet):
         register_txn_path(self, PATTERN, http_server)
 
     @defer.inlineCallbacks
-    def on_POST(self, request, room_identifier):
-        user = yield self.auth.get_user_by_req(request)
+    def on_POST(self, request, room_identifier, txn_id=None):
+        user, client = yield self.auth.get_user_by_req(request)
 
         # the identifier could be a room alias or a room id. Try one then the
         # other if it fails to parse, without swallowing other valid
@@ -223,10 +229,10 @@ class JoinRoomAliasServlet(RestServlet):
         identifier = None
         is_room_alias = False
         try:
-            identifier = self.hs.parse_roomalias(room_identifier)
+            identifier = RoomAlias.from_string(room_identifier)
             is_room_alias = True
         except SynapseError:
-            identifier = self.hs.parse_roomid(room_identifier)
+            identifier = RoomID.from_string(room_identifier)
 
         # TODO: Support for specifying the home server to join with?
 
@@ -243,7 +249,9 @@ class JoinRoomAliasServlet(RestServlet):
                     "room_id": identifier.to_string(),
                     "sender": user.to_string(),
                     "state_key": user.to_string(),
-                }
+                },
+                client=client,
+                txn_id=txn_id,
             )
 
             defer.returnValue((200, {"room_id": identifier.to_string()}))
@@ -257,14 +265,14 @@ class JoinRoomAliasServlet(RestServlet):
         except KeyError:
             pass
 
-        response = yield self.on_POST(request, room_identifier)
+        response = yield self.on_POST(request, room_identifier, txn_id)
 
         self.txns.store_client_transaction(request, txn_id, response)
         defer.returnValue(response)
 
 
 # TODO: Needs unit testing
-class PublicRoomListRestServlet(RestServlet):
+class PublicRoomListRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/publicRooms$")
 
     @defer.inlineCallbacks
@@ -275,13 +283,13 @@ class PublicRoomListRestServlet(RestServlet):
 
 
 # TODO: Needs unit testing
-class RoomMemberListRestServlet(RestServlet):
+class RoomMemberListRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/members$")
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
         # TODO support Pagination stream API (limit/tokens)
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
         handler = self.handlers.room_member_handler
         members = yield handler.get_room_members_as_pagination_chunk(
             room_id=room_id,
@@ -289,7 +297,7 @@ class RoomMemberListRestServlet(RestServlet):
 
         for event in members["chunk"]:
             # FIXME: should probably be state_key here, not user_id
-            target_user = self.hs.parse_userid(event["user_id"])
+            target_user = UserID.from_string(event["user_id"])
             # Presence is an optional cache; don't fail if we can't fetch it
             try:
                 presence_handler = self.handlers.presence_handler
@@ -304,12 +312,12 @@ class RoomMemberListRestServlet(RestServlet):
 
 
 # TODO: Needs unit testing
-class RoomMessageListRestServlet(RestServlet):
+class RoomMessageListRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/messages$")
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
         pagination_config = PaginationConfig.from_request(
             request, default_limit=10,
         )
@@ -328,12 +336,12 @@ class RoomMessageListRestServlet(RestServlet):
 
 
 # TODO: Needs unit testing
-class RoomStateRestServlet(RestServlet):
+class RoomStateRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/state$")
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
         handler = self.handlers.message_handler
         # Get all the current state for this room
         events = yield handler.get_state_events(
@@ -344,12 +352,12 @@ class RoomStateRestServlet(RestServlet):
 
 
 # TODO: Needs unit testing
-class RoomInitialSyncRestServlet(RestServlet):
+class RoomInitialSyncRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/initialSync$")
 
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
-        user = yield self.auth.get_user_by_req(request)
+        user, client = yield self.auth.get_user_by_req(request)
         pagination_config = PaginationConfig.from_request(request)
         content = yield self.handlers.message_handler.room_initial_sync(
             room_id=room_id,
@@ -359,9 +367,13 @@ class RoomInitialSyncRestServlet(RestServlet):
         defer.returnValue((200, content))
 
 
-class RoomTriggerBackfill(RestServlet):
+class RoomTriggerBackfill(ClientV1RestServlet):
     PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/backfill$")
 
+    def __init__(self, hs):
+        super(RoomTriggerBackfill, self).__init__(hs)
+        self.clock = hs.get_clock()
+
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
         remote_server = urllib.unquote(
@@ -373,12 +385,14 @@ class RoomTriggerBackfill(RestServlet):
         handler = self.handlers.federation_handler
         events = yield handler.backfill(remote_server, room_id, limit)
 
-        res = [self.hs.serialize_event(event) for event in events]
+        time_now = self.clock.time_msec()
+
+        res = [serialize_event(event, time_now) for event in events]
         defer.returnValue((200, res))
 
 
 # TODO: Needs unit testing
-class RoomMembershipRestServlet(RestServlet):
+class RoomMembershipRestServlet(ClientV1RestServlet):
 
     def register(self, http_server):
         # /rooms/$roomid/[invite|join|leave]
@@ -387,8 +401,8 @@ class RoomMembershipRestServlet(RestServlet):
         register_txn_path(self, PATTERN, http_server)
 
     @defer.inlineCallbacks
-    def on_POST(self, request, room_id, membership_action):
-        user = yield self.auth.get_user_by_req(request)
+    def on_POST(self, request, room_id, membership_action, txn_id=None):
+        user, client = yield self.auth.get_user_by_req(request)
 
         content = _parse_json(request)
 
@@ -410,7 +424,9 @@ class RoomMembershipRestServlet(RestServlet):
                 "room_id": room_id,
                 "sender": user.to_string(),
                 "state_key": state_key,
-            }
+            },
+            client=client,
+            txn_id=txn_id,
         )
 
         defer.returnValue((200, {}))
@@ -424,20 +440,22 @@ class RoomMembershipRestServlet(RestServlet):
         except KeyError:
             pass
 
-        response = yield self.on_POST(request, room_id, membership_action)
+        response = yield self.on_POST(
+            request, room_id, membership_action, txn_id
+        )
 
         self.txns.store_client_transaction(request, txn_id, response)
         defer.returnValue(response)
 
 
-class RoomRedactEventRestServlet(RestServlet):
+class RoomRedactEventRestServlet(ClientV1RestServlet):
     def register(self, http_server):
         PATTERN = ("/rooms/(?P<room_id>[^/]*)/redact/(?P<event_id>[^/]*)")
         register_txn_path(self, PATTERN, http_server)
 
     @defer.inlineCallbacks
-    def on_POST(self, request, room_id, event_id):
-        user = yield self.auth.get_user_by_req(request)
+    def on_POST(self, request, room_id, event_id, txn_id=None):
+        user, client = yield self.auth.get_user_by_req(request)
         content = _parse_json(request)
 
         msg_handler = self.handlers.message_handler
@@ -448,7 +466,9 @@ class RoomRedactEventRestServlet(RestServlet):
                 "room_id": room_id,
                 "sender": user.to_string(),
                 "redacts": event_id,
-            }
+            },
+            client=client,
+            txn_id=txn_id,
         )
 
         defer.returnValue((200, {"event_id": event.event_id}))
@@ -462,23 +482,23 @@ class RoomRedactEventRestServlet(RestServlet):
         except KeyError:
             pass
 
-        response = yield self.on_POST(request, room_id, event_id)
+        response = yield self.on_POST(request, room_id, event_id, txn_id)
 
         self.txns.store_client_transaction(request, txn_id, response)
         defer.returnValue(response)
 
 
-class RoomTypingRestServlet(RestServlet):
+class RoomTypingRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern(
         "/rooms/(?P<room_id>[^/]*)/typing/(?P<user_id>[^/]*)$"
     )
 
     @defer.inlineCallbacks
     def on_PUT(self, request, room_id, user_id):
-        auth_user = yield self.auth.get_user_by_req(request)
+        auth_user, client = yield self.auth.get_user_by_req(request)
 
         room_id = urllib.unquote(room_id)
-        target_user = self.hs.parse_userid(urllib.unquote(user_id))
+        target_user = UserID.from_string(urllib.unquote(user_id))
 
         content = _parse_json(request)
 
diff --git a/synapse/rest/transactions.py b/synapse/rest/client/v1/transactions.py
index d933fea18a..d933fea18a 100644
--- a/synapse/rest/transactions.py
+++ b/synapse/rest/client/v1/transactions.py
diff --git a/synapse/rest/voip.py b/synapse/rest/client/v1/voip.py
index 011c35e69b..11d08fbced 100644
--- a/synapse/rest/voip.py
+++ b/synapse/rest/client/v1/voip.py
@@ -15,7 +15,7 @@
 
 from twisted.internet import defer
 
-from base import RestServlet, client_path_pattern
+from base import ClientV1RestServlet, client_path_pattern
 
 
 import hmac
@@ -23,12 +23,12 @@ import hashlib
 import base64
 
 
-class VoipRestServlet(RestServlet):
+class VoipRestServlet(ClientV1RestServlet):
     PATTERN = client_path_pattern("/voip/turnServer$")
 
     @defer.inlineCallbacks
     def on_GET(self, request):
-        auth_user = yield self.auth.get_user_by_req(request)
+        auth_user, client = yield self.auth.get_user_by_req(request)
 
         turnUris = self.hs.config.turn_uris
         turnSecret = self.hs.config.turn_shared_secret
diff --git a/synapse/rest/client/v2_alpha/__init__.py b/synapse/rest/client/v2_alpha/__init__.py
new file mode 100644
index 0000000000..bb740e2803
--- /dev/null
+++ b/synapse/rest/client/v2_alpha/__init__.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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.http.server import JsonResource
+
+
+class ClientV2AlphaRestResource(JsonResource):
+    """A resource for version 2 alpha of the matrix client API."""
+
+    def __init__(self, hs):
+        JsonResource.__init__(self)
+        self.register_servlets(self, hs)
+
+    @staticmethod
+    def register_servlets(client_resource, hs):
+        pass
diff --git a/synapse/rest/client/v2_alpha/_base.py b/synapse/rest/client/v2_alpha/_base.py
new file mode 100644
index 0000000000..22dc5cb862
--- /dev/null
+++ b/synapse/rest/client/v2_alpha/_base.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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.api.urls import CLIENT_V2_ALPHA_PREFIX
+import re
+
+import logging
+
+
+logger = logging.getLogger(__name__)
+
+
+def client_v2_pattern(path_regex):
+    """Creates a regex compiled client path with the correct client 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("^" + CLIENT_V2_ALPHA_PREFIX + path_regex)
diff --git a/synapse/rest/media/__init__.py b/synapse/rest/media/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/synapse/rest/media/__init__.py
diff --git a/synapse/rest/media/v0/__init__.py b/synapse/rest/media/v0/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/synapse/rest/media/v0/__init__.py
diff --git a/synapse/rest/media/v0/content_repository.py b/synapse/rest/media/v0/content_repository.py
new file mode 100644
index 0000000000..22e26e3cd5
--- /dev/null
+++ b/synapse/rest/media/v0/content_repository.py
@@ -0,0 +1,212 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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.http.server import respond_with_json_bytes
+
+from synapse.util.stringutils import random_string
+from synapse.api.errors import (
+    cs_exception, SynapseError, CodeMessageException, Codes, cs_error
+)
+
+from twisted.protocols.basic import FileSender
+from twisted.web import server, resource
+from twisted.internet import defer
+
+import base64
+import json
+import logging
+import os
+import re
+
+logger = logging.getLogger(__name__)
+
+
+class ContentRepoResource(resource.Resource):
+    """Provides file uploading and downloading.
+
+    Uploads are POSTed to wherever this Resource is linked to. This resource
+    returns a "content token" which can be used to GET this content again. The
+    token is typically a path, but it may not be. Tokens can expire, be
+    one-time uses, etc.
+
+    In this case, the token is a path to the file and contains 3 interesting
+    sections:
+        - User ID base64d (for namespacing content to each user)
+        - random 24 char string
+        - Content type base64d (so we can return it when clients GET it)
+
+    """
+    isLeaf = True
+
+    def __init__(self, hs, directory, auth, external_addr):
+        resource.Resource.__init__(self)
+        self.hs = hs
+        self.directory = directory
+        self.auth = auth
+        self.external_addr = external_addr.rstrip('/')
+        self.max_upload_size = hs.config.max_upload_size
+
+        if not os.path.isdir(self.directory):
+            os.mkdir(self.directory)
+            logger.info("ContentRepoResource : Created %s directory.",
+                        self.directory)
+
+    @defer.inlineCallbacks
+    def map_request_to_name(self, request):
+        # auth the user
+        auth_user, client = yield self.auth.get_user_by_req(request)
+
+        # namespace all file uploads on the user
+        prefix = base64.urlsafe_b64encode(
+            auth_user.to_string()
+        ).replace('=', '')
+
+        # use a random string for the main portion
+        main_part = random_string(24)
+
+        # suffix with a file extension if we can make one. This is nice to
+        # provide a hint to clients on the file information. We will also reuse
+        # this info to spit back the content type to the client.
+        suffix = ""
+        if request.requestHeaders.hasHeader("Content-Type"):
+            content_type = request.requestHeaders.getRawHeaders(
+                "Content-Type")[0]
+            suffix = "." + base64.urlsafe_b64encode(content_type)
+            if (content_type.split("/")[0].lower() in
+                    ["image", "video", "audio"]):
+                file_ext = content_type.split("/")[-1]
+                # be a little paranoid and only allow a-z
+                file_ext = re.sub("[^a-z]", "", file_ext)
+                suffix += "." + file_ext
+
+        file_name = prefix + main_part + suffix
+        file_path = os.path.join(self.directory, file_name)
+        logger.info("User %s is uploading a file to path %s",
+                    auth_user.to_string(),
+                    file_path)
+
+        # keep trying to make a non-clashing file, with a sensible max attempts
+        attempts = 0
+        while os.path.exists(file_path):
+            main_part = random_string(24)
+            file_name = prefix + main_part + suffix
+            file_path = os.path.join(self.directory, file_name)
+            attempts += 1
+            if attempts > 25:  # really? Really?
+                raise SynapseError(500, "Unable to create file.")
+
+        defer.returnValue(file_path)
+
+    def render_GET(self, request):
+        # no auth here on purpose, to allow anyone to view, even across home
+        # servers.
+
+        # TODO: A little crude here, we could do this better.
+        filename = request.path.split('/')[-1]
+        # be paranoid
+        filename = re.sub("[^0-9A-z.-_]", "", filename)
+
+        file_path = self.directory + "/" + filename
+
+        logger.debug("Searching for %s", file_path)
+
+        if os.path.isfile(file_path):
+            # filename has the content type
+            base64_contentype = filename.split(".")[1]
+            content_type = base64.urlsafe_b64decode(base64_contentype)
+            logger.info("Sending file %s", file_path)
+            f = open(file_path, 'rb')
+            request.setHeader('Content-Type', content_type)
+
+            # cache for at least a day.
+            # XXX: we might want to turn this off for data we don't want to
+            # recommend caching as it's sensitive or private - or at least
+            # select private. don't bother setting Expires as all our matrix
+            # clients are smart enough to be happy with Cache-Control (right?)
+            request.setHeader(
+                "Cache-Control", "public,max-age=86400,s-maxage=86400"
+            )
+
+            d = FileSender().beginFileTransfer(f, request)
+
+            # after the file has been sent, clean up and finish the request
+            def cbFinished(ignored):
+                f.close()
+                request.finish()
+            d.addCallback(cbFinished)
+        else:
+            respond_with_json_bytes(
+                request,
+                404,
+                json.dumps(cs_error("Not found", code=Codes.NOT_FOUND)),
+                send_cors=True)
+
+        return server.NOT_DONE_YET
+
+    def render_POST(self, request):
+        self._async_render(request)
+        return server.NOT_DONE_YET
+
+    def render_OPTIONS(self, request):
+        respond_with_json_bytes(request, 200, {}, send_cors=True)
+        return server.NOT_DONE_YET
+
+    @defer.inlineCallbacks
+    def _async_render(self, request):
+        try:
+            # TODO: The checks here are a bit late. The content will have
+            # already been uploaded to a tmp file at this point
+            content_length = request.getHeader("Content-Length")
+            if content_length is None:
+                raise SynapseError(
+                    msg="Request must specify a Content-Length", code=400
+                )
+            if int(content_length) > self.max_upload_size:
+                raise SynapseError(
+                    msg="Upload request body is too large",
+                    code=413,
+                )
+
+            fname = yield self.map_request_to_name(request)
+
+            # TODO I have a suspicious feeling this is just going to block
+            with open(fname, "wb") as f:
+                f.write(request.content.read())
+
+            # FIXME (erikj): These should use constants.
+            file_name = os.path.basename(fname)
+            # FIXME: we can't assume what the repo's public mounted path is
+            # ...plus self-signed SSL won't work to remote clients anyway
+            # ...and we can't assume that it's SSL anyway, as we might want to
+            # serve it via the non-SSL listener...
+            url = "%s/_matrix/content/%s" % (
+                self.external_addr, file_name
+            )
+
+            respond_with_json_bytes(request, 200,
+                                    json.dumps({"content_token": url}),
+                                    send_cors=True)
+
+        except CodeMessageException as e:
+            logger.exception(e)
+            respond_with_json_bytes(request, e.code,
+                                    json.dumps(cs_exception(e)))
+        except Exception as e:
+            logger.error("Failed to store file: %s" % e)
+            respond_with_json_bytes(
+                request,
+                500,
+                json.dumps({"error": "Internal server error"}),
+                send_cors=True)
diff --git a/synapse/rest/media/v1/__init__.py b/synapse/rest/media/v1/__init__.py
new file mode 100644
index 0000000000..d6c6690577
--- /dev/null
+++ b/synapse/rest/media/v1/__init__.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 PIL.Image
+
+# check for JPEG support.
+try:
+    PIL.Image._getdecoder("rgb", "jpeg", None)
+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 uninstall pillow &&"
+            " pip install pillow --user'"
+        )
+except Exception:
+    # any other exception is fine
+    pass
+
+
+# check for PNG support.
+try:
+    PIL.Image._getdecoder("rgb", "zip", None)
+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 uninstall pillow &&"
+            " pip install pillow --user'"
+        )
+except Exception:
+    # any other exception is fine
+    pass
diff --git a/synapse/rest/media/v1/base_resource.py b/synapse/rest/media/v1/base_resource.py
new file mode 100644
index 0000000000..688e7376ad
--- /dev/null
+++ b/synapse/rest/media/v1/base_resource.py
@@ -0,0 +1,378 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 .thumbnailer import Thumbnailer
+
+from synapse.http.server import respond_with_json
+from synapse.util.stringutils import random_string
+from synapse.api.errors import (
+    cs_exception, CodeMessageException, cs_error, Codes, SynapseError
+)
+
+from twisted.internet import defer
+from twisted.web.resource import Resource
+from twisted.protocols.basic import FileSender
+
+import os
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class BaseMediaResource(Resource):
+    isLeaf = True
+
+    def __init__(self, hs, filepaths):
+        Resource.__init__(self)
+        self.auth = hs.get_auth()
+        self.client = hs.get_http_client()
+        self.clock = hs.get_clock()
+        self.server_name = hs.hostname
+        self.store = hs.get_datastore()
+        self.max_upload_size = hs.config.max_upload_size
+        self.max_image_pixels = hs.config.max_image_pixels
+        self.filepaths = filepaths
+        self.downloads = {}
+
+    @staticmethod
+    def catch_errors(request_handler):
+        @defer.inlineCallbacks
+        def wrapped_request_handler(self, request):
+            try:
+                yield request_handler(self, request)
+            except CodeMessageException as e:
+                logger.exception(e)
+                respond_with_json(
+                    request, e.code, cs_exception(e), send_cors=True
+                )
+            except:
+                logger.exception(
+                    "Failed handle request %s.%s on %r",
+                    request_handler.__module__,
+                    request_handler.__name__,
+                    self,
+                )
+                respond_with_json(
+                    request,
+                    500,
+                    {"error": "Internal server error"},
+                    send_cors=True
+                )
+        return wrapped_request_handler
+
+    @staticmethod
+    def _parse_media_id(request):
+        try:
+            server_name, media_id = request.postpath
+            return (server_name, media_id)
+        except:
+            raise SynapseError(
+                404,
+                "Invalid media id token %r" % (request.postpath,),
+                Codes.UNKKOWN,
+            )
+
+    @staticmethod
+    def _parse_integer(request, arg_name, default=None):
+        try:
+            if default is None:
+                return int(request.args[arg_name][0])
+            else:
+                return int(request.args.get(arg_name, [default])[0])
+        except:
+            raise SynapseError(
+                400,
+                "Missing integer argument %r" % (arg_name,),
+                Codes.UNKNOWN,
+            )
+
+    @staticmethod
+    def _parse_string(request, arg_name, default=None):
+        try:
+            if default is None:
+                return request.args[arg_name][0]
+            else:
+                return request.args.get(arg_name, [default])[0]
+        except:
+            raise SynapseError(
+                400,
+                "Missing string argument %r" % (arg_name,),
+                Codes.UNKNOWN,
+            )
+
+    def _respond_404(self, request):
+        respond_with_json(
+            request, 404,
+            cs_error(
+                "Not found %r" % (request.postpath,),
+                code=Codes.NOT_FOUND,
+            ),
+            send_cors=True
+        )
+
+    @staticmethod
+    def _makedirs(filepath):
+        dirname = os.path.dirname(filepath)
+        if not os.path.exists(dirname):
+            os.makedirs(dirname)
+
+    def _get_remote_media(self, server_name, media_id):
+        key = (server_name, media_id)
+        download = self.downloads.get(key)
+        if download is None:
+            download = self._get_remote_media_impl(server_name, media_id)
+            self.downloads[key] = download
+
+            @download.addBoth
+            def callback(media_info):
+                del self.downloads[key]
+                return media_info
+        return download
+
+    @defer.inlineCallbacks
+    def _get_remote_media_impl(self, server_name, media_id):
+        media_info = yield self.store.get_cached_remote_media(
+            server_name, media_id
+        )
+        if not media_info:
+            media_info = yield self._download_remote_file(
+                server_name, media_id
+            )
+        defer.returnValue(media_info)
+
+    @defer.inlineCallbacks
+    def _download_remote_file(self, server_name, media_id):
+        file_id = random_string(24)
+
+        fname = self.filepaths.remote_media_filepath(
+            server_name, file_id
+        )
+        self._makedirs(fname)
+
+        try:
+            with open(fname, "wb") as f:
+                request_path = "/".join((
+                    "/_matrix/media/v1/download", server_name, media_id,
+                ))
+                length, headers = yield self.client.get_file(
+                    server_name, request_path, output_stream=f,
+                    max_size=self.max_upload_size,
+                )
+            media_type = headers["Content-Type"][0]
+            time_now_ms = self.clock.time_msec()
+
+            yield self.store.store_cached_remote_media(
+                origin=server_name,
+                media_id=media_id,
+                media_type=media_type,
+                time_now_ms=self.clock.time_msec(),
+                upload_name=None,
+                media_length=length,
+                filesystem_id=file_id,
+            )
+        except:
+            os.remove(fname)
+            raise
+
+        media_info = {
+            "media_type": media_type,
+            "media_length": length,
+            "upload_name": None,
+            "created_ts": time_now_ms,
+            "filesystem_id": file_id,
+        }
+
+        yield self._generate_remote_thumbnails(
+            server_name, media_id, media_info
+        )
+
+        defer.returnValue(media_info)
+
+    @defer.inlineCallbacks
+    def _respond_with_file(self, request, media_type, file_path,
+                           file_size=None):
+        logger.debug("Responding with %r", file_path)
+
+        if os.path.isfile(file_path):
+            request.setHeader(b"Content-Type", media_type.encode("UTF-8"))
+
+            # cache for at least a day.
+            # XXX: we might want to turn this off for data we don't want to
+            # recommend caching as it's sensitive or private - or at least
+            # select private. don't bother setting Expires as all our
+            # clients are smart enough to be happy with Cache-Control
+            request.setHeader(
+                b"Cache-Control", b"public,max-age=86400,s-maxage=86400"
+            )
+            if file_size is None:
+                stat = os.stat(file_path)
+                file_size = stat.st_size
+
+            request.setHeader(
+                b"Content-Length", b"%d" % (file_size,)
+            )
+
+            with open(file_path, "rb") as f:
+                yield FileSender().beginFileTransfer(f, request)
+
+            request.finish()
+        else:
+            self._respond_404(request)
+
+    def _get_thumbnail_requirements(self, media_type):
+        if media_type == "image/jpeg":
+            return (
+                (32, 32, "crop", "image/jpeg"),
+                (96, 96, "crop", "image/jpeg"),
+                (320, 240, "scale", "image/jpeg"),
+                (640, 480, "scale", "image/jpeg"),
+            )
+        elif (media_type == "image/png") or (media_type == "image/gif"):
+            return (
+                (32, 32, "crop", "image/png"),
+                (96, 96, "crop", "image/png"),
+                (320, 240, "scale", "image/png"),
+                (640, 480, "scale", "image/png"),
+            )
+        else:
+            return ()
+
+    @defer.inlineCallbacks
+    def _generate_local_thumbnails(self, media_id, media_info):
+        media_type = media_info["media_type"]
+        requirements = self._get_thumbnail_requirements(media_type)
+        if not requirements:
+            return
+
+        input_path = self.filepaths.local_media_filepath(media_id)
+        thumbnailer = Thumbnailer(input_path)
+        m_width = thumbnailer.width
+        m_height = thumbnailer.height
+
+        if m_width * m_height >= self.max_image_pixels:
+            logger.info(
+                "Image too large to thumbnail %r x %r > %r",
+                m_width, m_height, self.max_image_pixels
+            )
+            return
+
+        scales = set()
+        crops = set()
+        for r_width, r_height, r_method, r_type in requirements:
+            if r_method == "scale":
+                t_width, t_height = thumbnailer.aspect(r_width, r_height)
+                scales.add((
+                    min(m_width, t_width), min(m_height, t_height), r_type,
+                ))
+            elif r_method == "crop":
+                crops.add((r_width, r_height, r_type))
+
+        for t_width, t_height, t_type in scales:
+            t_method = "scale"
+            t_path = self.filepaths.local_media_thumbnail(
+                media_id, t_width, t_height, t_type, t_method
+            )
+            self._makedirs(t_path)
+            t_len = thumbnailer.scale(t_path, t_width, t_height, t_type)
+            yield self.store.store_local_thumbnail(
+                media_id, t_width, t_height, t_type, t_method, t_len
+            )
+
+        for t_width, t_height, t_type in crops:
+            if (t_width, t_height, t_type) in scales:
+                # If the aspect ratio of the cropped thumbnail matches a purely
+                # scaled one then there is no point in calculating a separate
+                # thumbnail.
+                continue
+            t_method = "crop"
+            t_path = self.filepaths.local_media_thumbnail(
+                media_id, t_width, t_height, t_type, t_method
+            )
+            self._makedirs(t_path)
+            t_len = thumbnailer.crop(t_path, t_width, t_height, t_type)
+            yield self.store.store_local_thumbnail(
+                media_id, t_width, t_height, t_type, t_method, t_len
+            )
+
+        defer.returnValue({
+            "width": m_width,
+            "height": m_height,
+        })
+
+    @defer.inlineCallbacks
+    def _generate_remote_thumbnails(self, server_name, media_id, media_info):
+        media_type = media_info["media_type"]
+        file_id = media_info["filesystem_id"]
+        requirements = self._get_thumbnail_requirements(media_type)
+        if not requirements:
+            return
+
+        input_path = self.filepaths.remote_media_filepath(server_name, file_id)
+        thumbnailer = Thumbnailer(input_path)
+        m_width = thumbnailer.width
+        m_height = thumbnailer.height
+
+        if m_width * m_height >= self.max_image_pixels:
+            logger.info(
+                "Image too large to thumbnail %r x %r > %r",
+                m_width, m_height, self.max_image_pixels
+            )
+            return
+
+        scales = set()
+        crops = set()
+        for r_width, r_height, r_method, r_type in requirements:
+            if r_method == "scale":
+                t_width, t_height = thumbnailer.aspect(r_width, r_height)
+                scales.add((
+                    min(m_width, t_width), min(m_height, t_height), r_type,
+                ))
+            elif r_method == "crop":
+                crops.add((r_width, r_height, r_type))
+
+        for t_width, t_height, t_type in scales:
+            t_method = "scale"
+            t_path = self.filepaths.remote_media_thumbnail(
+                server_name, file_id, t_width, t_height, t_type, t_method
+            )
+            self._makedirs(t_path)
+            t_len = thumbnailer.scale(t_path, t_width, t_height, t_type)
+            yield self.store.store_remote_media_thumbnail(
+                server_name, media_id, file_id,
+                t_width, t_height, t_type, t_method, t_len
+            )
+
+        for t_width, t_height, t_type in crops:
+            if (t_width, t_height, t_type) in scales:
+                # If the aspect ratio of the cropped thumbnail matches a purely
+                # scaled one then there is no point in calculating a separate
+                # thumbnail.
+                continue
+            t_method = "crop"
+            t_path = self.filepaths.remote_media_thumbnail(
+                server_name, file_id, t_width, t_height, t_type, t_method
+            )
+            self._makedirs(t_path)
+            t_len = thumbnailer.crop(t_path, t_width, t_height, t_type)
+            yield self.store.store_remote_media_thumbnail(
+                server_name, media_id, file_id,
+                t_width, t_height, t_type, t_method, t_len
+            )
+
+        defer.returnValue({
+            "width": m_width,
+            "height": m_height,
+        })
diff --git a/synapse/rest/media/v1/download_resource.py b/synapse/rest/media/v1/download_resource.py
new file mode 100644
index 0000000000..c585bb11f7
--- /dev/null
+++ b/synapse/rest/media/v1/download_resource.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 .base_resource import BaseMediaResource
+
+from twisted.web.server import NOT_DONE_YET
+from twisted.internet import defer
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class DownloadResource(BaseMediaResource):
+    def render_GET(self, request):
+        self._async_render_GET(request)
+        return NOT_DONE_YET
+
+    @BaseMediaResource.catch_errors
+    @defer.inlineCallbacks
+    def _async_render_GET(self, request):
+        try:
+            server_name, media_id = request.postpath
+        except:
+            self._respond_404(request)
+            return
+
+        if server_name == self.server_name:
+            yield self._respond_local_file(request, media_id)
+        else:
+            yield self._respond_remote_file(request, server_name, media_id)
+
+    @defer.inlineCallbacks
+    def _respond_local_file(self, request, media_id):
+        media_info = yield self.store.get_local_media(media_id)
+        if not media_info:
+            self._respond_404(request)
+            return
+
+        media_type = media_info["media_type"]
+        media_length = media_info["media_length"]
+        file_path = self.filepaths.local_media_filepath(media_id)
+
+        yield self._respond_with_file(
+            request, media_type, file_path, media_length
+        )
+
+    @defer.inlineCallbacks
+    def _respond_remote_file(self, request, server_name, media_id):
+        media_info = yield self._get_remote_media(server_name, media_id)
+
+        media_type = media_info["media_type"]
+        media_length = media_info["media_length"]
+        filesystem_id = media_info["filesystem_id"]
+
+        file_path = self.filepaths.remote_media_filepath(
+            server_name, filesystem_id
+        )
+
+        yield self._respond_with_file(
+            request, media_type, file_path, media_length
+        )
diff --git a/synapse/rest/media/v1/filepath.py b/synapse/rest/media/v1/filepath.py
new file mode 100644
index 0000000000..ed9a58e9d9
--- /dev/null
+++ b/synapse/rest/media/v1/filepath.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 os
+
+
+class MediaFilePaths(object):
+
+    def __init__(self, base_path):
+        self.base_path = base_path
+
+    def default_thumbnail(self, default_top_level, default_sub_type, width,
+                          height, content_type, method):
+        top_level_type, sub_type = content_type.split("/")
+        file_name = "%i-%i-%s-%s-%s" % (
+            width, height, top_level_type, sub_type, method
+        )
+        return os.path.join(
+            self.base_path, "default_thumbnails", default_top_level,
+            default_sub_type, file_name
+        )
+
+    def local_media_filepath(self, media_id):
+        return os.path.join(
+            self.base_path, "local_content",
+            media_id[0:2], media_id[2:4], media_id[4:]
+        )
+
+    def local_media_thumbnail(self, media_id, width, height, content_type,
+                              method):
+        top_level_type, sub_type = content_type.split("/")
+        file_name = "%i-%i-%s-%s-%s" % (
+            width, height, top_level_type, sub_type, method
+        )
+        return os.path.join(
+            self.base_path, "local_thumbnails",
+            media_id[0:2], media_id[2:4], media_id[4:],
+            file_name
+        )
+
+    def remote_media_filepath(self, server_name, file_id):
+        return os.path.join(
+            self.base_path, "remote_content", server_name,
+            file_id[0:2], file_id[2:4], file_id[4:]
+        )
+
+    def remote_media_thumbnail(self, server_name, file_id, width, height,
+                               content_type, method):
+        top_level_type, sub_type = content_type.split("/")
+        file_name = "%i-%i-%s-%s" % (width, height, top_level_type, sub_type)
+        return os.path.join(
+            self.base_path, "remote_thumbnail", server_name,
+            file_id[0:2], file_id[2:4], file_id[4:],
+            file_name
+        )
diff --git a/synapse/rest/media/v1/media_repository.py b/synapse/rest/media/v1/media_repository.py
new file mode 100644
index 0000000000..461cc001f1
--- /dev/null
+++ b/synapse/rest/media/v1/media_repository.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 .upload_resource import UploadResource
+from .download_resource import DownloadResource
+from .thumbnail_resource import ThumbnailResource
+from .filepath import MediaFilePaths
+
+from twisted.web.resource import Resource
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class MediaRepositoryResource(Resource):
+    """File uploading and downloading.
+
+    Uploads are POSTed to a resource which returns a token which is used to GET
+    the download::
+
+        => POST /_matrix/media/v1/upload HTTP/1.1
+           Content-Type: <media-type>
+
+           <media>
+
+        <= HTTP/1.1 200 OK
+           Content-Type: application/json
+
+           { "content_uri": "mxc://<server-name>/<media-id>" }
+
+        => GET /_matrix/media/v1/download/<server-name>/<media-id> HTTP/1.1
+
+        <= HTTP/1.1 200 OK
+           Content-Type: <media-type>
+           Content-Disposition: attachment;filename=<upload-filename>
+
+           <media>
+
+    Clients can get thumbnails by supplying a desired width and height and
+    thumbnailing method::
+
+        => GET /_matrix/media/v1/thumbnail/<server_name>
+                /<media-id>?width=<w>&height=<h>&method=<m> HTTP/1.1
+
+        <= HTTP/1.1 200 OK
+           Content-Type: image/jpeg or image/png
+
+           <thumbnail>
+
+    The thumbnail methods are "crop" and "scale". "scale" trys to return an
+    image where either the width or the height is smaller than the requested
+    size. The client should then scale and letterbox the image if it needs to
+    fit within a given rectangle. "crop" trys to return an image where the
+    width and height are close to the requested size and the aspect matches
+    the requested size. The client should scale the image if it needs to fit
+    within a given rectangle.
+    """
+
+    def __init__(self, hs):
+        Resource.__init__(self)
+        filepaths = MediaFilePaths(hs.config.media_store_path)
+        self.putChild("upload", UploadResource(hs, filepaths))
+        self.putChild("download", DownloadResource(hs, filepaths))
+        self.putChild("thumbnail", ThumbnailResource(hs, filepaths))
diff --git a/synapse/rest/media/v1/thumbnail_resource.py b/synapse/rest/media/v1/thumbnail_resource.py
new file mode 100644
index 0000000000..84f5e3463c
--- /dev/null
+++ b/synapse/rest/media/v1/thumbnail_resource.py
@@ -0,0 +1,193 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 .base_resource import BaseMediaResource
+
+from twisted.web.server import NOT_DONE_YET
+from twisted.internet import defer
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ThumbnailResource(BaseMediaResource):
+    isLeaf = True
+
+    def render_GET(self, request):
+        self._async_render_GET(request)
+        return NOT_DONE_YET
+
+    @BaseMediaResource.catch_errors
+    @defer.inlineCallbacks
+    def _async_render_GET(self, request):
+        server_name, media_id = self._parse_media_id(request)
+        width = self._parse_integer(request, "width")
+        height = self._parse_integer(request, "height")
+        method = self._parse_string(request, "method", "scale")
+        m_type = self._parse_string(request, "type", "image/png")
+
+        if server_name == self.server_name:
+            yield self._respond_local_thumbnail(
+                request, media_id, width, height, method, m_type
+            )
+        else:
+            yield self._respond_remote_thumbnail(
+                request, server_name, media_id,
+                width, height, method, m_type
+            )
+
+    @defer.inlineCallbacks
+    def _respond_local_thumbnail(self, request, media_id, width, height,
+                                 method, m_type):
+        media_info = yield self.store.get_local_media(media_id)
+
+        if not media_info:
+            self._respond_404(request)
+            return
+
+        thumbnail_infos = yield self.store.get_local_media_thumbnails(media_id)
+
+        if thumbnail_infos:
+            thumbnail_info = self._select_thumbnail(
+                width, height, method, m_type, thumbnail_infos
+            )
+            t_width = thumbnail_info["thumbnail_width"]
+            t_height = thumbnail_info["thumbnail_height"]
+            t_type = thumbnail_info["thumbnail_type"]
+            t_method = thumbnail_info["thumbnail_method"]
+
+            file_path = self.filepaths.local_media_thumbnail(
+                media_id, t_width, t_height, t_type, t_method,
+            )
+            yield self._respond_with_file(request, t_type, file_path)
+
+        else:
+            yield self._respond_default_thumbnail(
+                request, media_info, width, height, method, m_type,
+            )
+
+    @defer.inlineCallbacks
+    def _respond_remote_thumbnail(self, request, server_name, media_id, width,
+                                  height, method, m_type):
+        # TODO: Don't download the whole remote file
+        # We should proxy the thumbnail from the remote server instead.
+        media_info = yield self._get_remote_media(server_name, media_id)
+
+        thumbnail_infos = yield self.store.get_remote_media_thumbnails(
+            server_name, media_id,
+        )
+
+        if thumbnail_infos:
+            thumbnail_info = self._select_thumbnail(
+                width, height, method, m_type, thumbnail_infos
+            )
+            t_width = thumbnail_info["thumbnail_width"]
+            t_height = thumbnail_info["thumbnail_height"]
+            t_type = thumbnail_info["thumbnail_type"]
+            t_method = thumbnail_info["thumbnail_method"]
+            file_id = thumbnail_info["filesystem_id"]
+            t_length = thumbnail_info["thumbnail_length"]
+
+            file_path = self.filepaths.remote_media_thumbnail(
+                server_name, file_id, t_width, t_height, t_type, t_method,
+            )
+            yield self._respond_with_file(request, t_type, file_path, t_length)
+        else:
+            yield self._respond_default_thumbnail(
+                request, media_info, width, height, method, m_type,
+            )
+
+    @defer.inlineCallbacks
+    def _respond_default_thumbnail(self, request, media_info, width, height,
+                                   method, m_type):
+        media_type = media_info["media_type"]
+        top_level_type = media_type.split("/")[0]
+        sub_type = media_type.split("/")[-1].split(";")[0]
+        thumbnail_infos = yield self.store.get_default_thumbnails(
+            top_level_type, sub_type,
+        )
+        if not thumbnail_infos:
+            thumbnail_infos = yield self.store.get_default_thumbnails(
+                top_level_type, "_default",
+            )
+        if not thumbnail_infos:
+            thumbnail_infos = yield self.store.get_default_thumbnails(
+                "_default", "_default",
+            )
+        if not thumbnail_infos:
+            self._respond_404(request)
+            return
+
+        thumbnail_info = self._select_thumbnail(
+            width, height, "crop", m_type, thumbnail_infos
+        )
+
+        t_width = thumbnail_info["thumbnail_width"]
+        t_height = thumbnail_info["thumbnail_height"]
+        t_type = thumbnail_info["thumbnail_type"]
+        t_method = thumbnail_info["thumbnail_method"]
+        t_length = thumbnail_info["thumbnail_length"]
+
+        file_path = self.filepaths.default_thumbnail(
+            top_level_type, sub_type, t_width, t_height, t_type, t_method,
+        )
+        yield self.respond_with_file(request, t_type, file_path, t_length)
+
+    def _select_thumbnail(self, desired_width, desired_height, desired_method,
+                          desired_type, thumbnail_infos):
+        d_w = desired_width
+        d_h = desired_height
+
+        if desired_method.lower() == "crop":
+            info_list = []
+            for info in thumbnail_infos:
+                t_w = info["thumbnail_width"]
+                t_h = info["thumbnail_height"]
+                t_method = info["thumbnail_method"]
+                if t_method == "scale" or t_method == "crop":
+                    aspect_quality = abs(d_w * t_h - d_h * t_w)
+                    size_quality = abs((d_w - t_w) * (d_h - t_h))
+                    type_quality = desired_type != info["thumbnail_type"]
+                    length_quality = info["thumbnail_length"]
+                    info_list.append((
+                        aspect_quality, size_quality, type_quality,
+                        length_quality, info
+                    ))
+            if info_list:
+                return min(info_list)[-1]
+        else:
+            info_list = []
+            info_list2 = []
+            for info in thumbnail_infos:
+                t_w = info["thumbnail_width"]
+                t_h = info["thumbnail_height"]
+                t_method = info["thumbnail_method"]
+                size_quality = abs((d_w - t_w) * (d_h - t_h))
+                type_quality = desired_type != info["thumbnail_type"]
+                length_quality = info["thumbnail_length"]
+                if t_method == "scale" and (t_w >= d_w or t_h >= d_h):
+                    info_list.append((
+                        size_quality, type_quality, length_quality, info
+                    ))
+                elif t_method == "scale":
+                    info_list2.append((
+                        size_quality, type_quality, length_quality, info
+                    ))
+            if info_list:
+                return min(info_list)[-1]
+            else:
+                return min(info_list2)[-1]
diff --git a/synapse/rest/media/v1/thumbnailer.py b/synapse/rest/media/v1/thumbnailer.py
new file mode 100644
index 0000000000..28404f2b7b
--- /dev/null
+++ b/synapse/rest/media/v1/thumbnailer.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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 PIL.Image as Image
+from io import BytesIO
+
+
+class Thumbnailer(object):
+
+    FORMATS = {
+        "image/jpeg": "JPEG",
+        "image/png": "PNG",
+    }
+
+    def __init__(self, input_path):
+        self.image = Image.open(input_path)
+        self.width, self.height = self.image.size
+
+    def aspect(self, max_width, max_height):
+        """Calculate the largest size that preserves aspect ratio which
+        fits within the given rectangle::
+
+            (w_in / h_in) = (w_out / h_out)
+            w_out = min(w_max, h_max * (w_in / h_in))
+            h_out = min(h_max, w_max * (h_in / w_in))
+
+        Args:
+            max_width: The largest possible width.
+            max_height: The larget possible height.
+        """
+
+        if max_width * self.height < max_height * self.width:
+            return (max_width, (max_width * self.height) // self.width)
+        else:
+            return ((max_height * self.width) // self.height, max_height)
+
+    def scale(self, output_path, width, height, output_type):
+        """Rescales the image to the given dimensions"""
+        scaled = self.image.resize((width, height), Image.ANTIALIAS)
+        return self.save_image(scaled, output_type, output_path)
+
+    def crop(self, output_path, width, height, output_type):
+        """Rescales and crops the image to the given dimensions preserving
+        aspect::
+            (w_in / h_in) = (w_scaled / h_scaled)
+            w_scaled = max(w_out, h_out * (w_in / h_in))
+            h_scaled = max(h_out, w_out * (h_in / w_in))
+
+        Args:
+            max_width: The largest possible width.
+            max_height: The larget possible height.
+        """
+        if width * self.height > height * self.width:
+            scaled_height = (width * self.height) // self.width
+            scaled_image = self.image.resize(
+                (width, scaled_height), Image.ANTIALIAS
+            )
+            crop_top = (scaled_height - height) // 2
+            crop_bottom = height + crop_top
+            cropped = scaled_image.crop((0, crop_top, width, crop_bottom))
+        else:
+            scaled_width = (height * self.width) // self.height
+            scaled_image = self.image.resize(
+                (scaled_width, height), Image.ANTIALIAS
+            )
+            crop_left = (scaled_width - width) // 2
+            crop_right = width + crop_left
+            cropped = scaled_image.crop((crop_left, 0, crop_right, height))
+        return self.save_image(cropped, output_type, output_path)
+
+    def save_image(self, output_image, output_type, output_path):
+        output_bytes_io = BytesIO()
+        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)
+        return len(output_bytes)
diff --git a/synapse/rest/media/v1/upload_resource.py b/synapse/rest/media/v1/upload_resource.py
new file mode 100644
index 0000000000..b939a30e19
--- /dev/null
+++ b/synapse/rest/media/v1/upload_resource.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014, 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.http.server import respond_with_json
+
+from synapse.util.stringutils import random_string
+from synapse.api.errors import (
+    cs_exception, SynapseError, CodeMessageException
+)
+
+from twisted.web.server import NOT_DONE_YET
+from twisted.internet import defer
+
+from .base_resource import BaseMediaResource
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class UploadResource(BaseMediaResource):
+    def render_POST(self, request):
+        self._async_render_POST(request)
+        return NOT_DONE_YET
+
+    def render_OPTIONS(self, request):
+        respond_with_json(request, 200, {}, send_cors=True)
+        return NOT_DONE_YET
+
+    @defer.inlineCallbacks
+    def _async_render_POST(self, request):
+        try:
+            auth_user, client = yield self.auth.get_user_by_req(request)
+            # TODO: The checks here are a bit late. The content will have
+            # already been uploaded to a tmp file at this point
+            content_length = request.getHeader("Content-Length")
+            if content_length is None:
+                raise SynapseError(
+                    msg="Request must specify a Content-Length", code=400
+                )
+            if int(content_length) > self.max_upload_size:
+                raise SynapseError(
+                    msg="Upload request body is too large",
+                    code=413,
+                )
+
+            headers = request.requestHeaders
+
+            if headers.hasHeader("Content-Type"):
+                media_type = headers.getRawHeaders("Content-Type")[0]
+            else:
+                raise SynapseError(
+                    msg="Upload request missing 'Content-Type'",
+                    code=400,
+                )
+
+            #if headers.hasHeader("Content-Disposition"):
+            #    disposition = headers.getRawHeaders("Content-Disposition")[0]
+            # TODO(markjh): parse content-dispostion
+
+            media_id = random_string(24)
+
+            fname = self.filepaths.local_media_filepath(media_id)
+            self._makedirs(fname)
+
+            # This shouldn't block for very long because the content will have
+            # already been uploaded at this point.
+            with open(fname, "wb") as f:
+                f.write(request.content.read())
+
+            yield self.store.store_local_media(
+                media_id=media_id,
+                media_type=media_type,
+                time_now_ms=self.clock.time_msec(),
+                upload_name=None,
+                media_length=content_length,
+                user_id=auth_user,
+            )
+            media_info = {
+                "media_type": media_type,
+                "media_length": content_length,
+            }
+
+            yield self._generate_local_thumbnails(media_id, media_info)
+
+            content_uri = "mxc://%s/%s" % (self.server_name, media_id)
+
+            respond_with_json(
+                request, 200, {"content_uri": content_uri}, send_cors=True
+            )
+        except CodeMessageException as e:
+            logger.exception(e)
+            respond_with_json(request, e.code, cs_exception(e), send_cors=True)
+        except:
+            logger.exception("Failed to store file")
+            respond_with_json(
+                request,
+                500,
+                {"error": "Internal server error"},
+                send_cors=True
+            )