summary refs log tree commit diff
path: root/synapse
diff options
context:
space:
mode:
Diffstat (limited to 'synapse')
-rw-r--r--synapse/__init__.py2
-rw-r--r--synapse/api/constants.py2
-rw-r--r--synapse/api/errors.py1
-rw-r--r--synapse/config/repository.py59
-rw-r--r--synapse/handlers/_base.py18
-rw-r--r--synapse/handlers/auth.py2
-rw-r--r--synapse/handlers/events.py10
-rw-r--r--synapse/handlers/identity.py25
-rw-r--r--synapse/handlers/room.py14
-rw-r--r--synapse/handlers/sync.py24
-rw-r--r--synapse/push/__init__.py16
-rw-r--r--synapse/rest/client/v1/login.py17
-rw-r--r--synapse/rest/client/v2_alpha/account.py2
-rw-r--r--synapse/rest/client/v2_alpha/register.py28
-rw-r--r--synapse/rest/media/v1/base_resource.py82
-rw-r--r--synapse/rest/media/v1/thumbnail_resource.py106
-rw-r--r--synapse/storage/schema/delta/22/user_threepids_unique.sql19
17 files changed, 379 insertions, 48 deletions
diff --git a/synapse/__init__.py b/synapse/__init__.py
index 96e37308d6..5853165a21 100644
--- a/synapse/__init__.py
+++ b/synapse/__init__.py
@@ -16,4 +16,4 @@
 """ This is a reference implementation of a Matrix home server.
 """
 
-__version__ = "0.9.3"
+__version__ = "0.10.0-rc1"
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index 7156ee4e7d..1423986c1e 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -76,6 +76,8 @@ class EventTypes(object):
     Feedback = "m.room.message.feedback"
 
     RoomHistoryVisibility = "m.room.history_visibility"
+    CanonicalAlias = "m.room.canonical_alias"
+    RoomAvatar = "m.room.avatar"
 
     # These are used for validation
     Message = "m.room.message"
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index 0b3320e62c..c3b4d971a8 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -40,6 +40,7 @@ class Codes(object):
     TOO_LARGE = "M_TOO_LARGE"
     EXCLUSIVE = "M_EXCLUSIVE"
     THREEPID_AUTH_FAILED = "M_THREEPID_AUTH_FAILED"
+    THREEPID_IN_USE = "THREEPID_IN_USE"
 
 
 class CodeMessageException(RuntimeError):
diff --git a/synapse/config/repository.py b/synapse/config/repository.py
index 6891abd71d..64644b9a7a 100644
--- a/synapse/config/repository.py
+++ b/synapse/config/repository.py
@@ -14,6 +14,39 @@
 # limitations under the License.
 
 from ._base import Config
+from collections import namedtuple
+
+ThumbnailRequirement = namedtuple(
+    "ThumbnailRequirement", ["width", "height", "method", "media_type"]
+)
+
+
+def parse_thumbnail_requirements(thumbnail_sizes):
+    """ Takes a list of dictionaries with "width", "height", and "method" keys
+    and creates a map from image media types to the thumbnail size, thumnailing
+    method, and thumbnail media type to precalculate
+
+    Args:
+        thumbnail_sizes(list): List of dicts with "width", "height", and
+            "method" keys
+    Returns:
+        Dictionary mapping from media type string to list of
+        ThumbnailRequirement tuples.
+    """
+    requirements = {}
+    for size in thumbnail_sizes:
+        width = size["width"]
+        height = size["height"]
+        method = size["method"]
+        jpeg_thumbnail = ThumbnailRequirement(width, height, method, "image/jpeg")
+        png_thumbnail = ThumbnailRequirement(width, height, method, "image/png")
+        requirements.setdefault("image/jpeg", []).append(jpeg_thumbnail)
+        requirements.setdefault("image/gif", []).append(png_thumbnail)
+        requirements.setdefault("image/png", []).append(png_thumbnail)
+    return {
+        media_type: tuple(thumbnails)
+        for media_type, thumbnails in requirements.items()
+    }
 
 
 class ContentRepositoryConfig(Config):
@@ -22,6 +55,10 @@ class ContentRepositoryConfig(Config):
         self.max_image_pixels = self.parse_size(config["max_image_pixels"])
         self.media_store_path = self.ensure_directory(config["media_store_path"])
         self.uploads_path = self.ensure_directory(config["uploads_path"])
+        self.dynamic_thumbnails = config["dynamic_thumbnails"]
+        self.thumbnail_requirements = parse_thumbnail_requirements(
+            config["thumbnail_sizes"]
+        )
 
     def default_config(self, config_dir_path, server_name):
         media_store = self.default_path("media_store")
@@ -38,4 +75,26 @@ class ContentRepositoryConfig(Config):
 
         # Maximum number of pixels that will be thumbnailed
         max_image_pixels: "32M"
+
+        # Whether to generate new thumbnails on the fly to precisely match
+        # the resolution requested by the client. If true then whenever
+        # a new resolution is requested by the client the server will
+        # generate a new thumbnail. If false the server will pick a thumbnail
+        # from a precalcualted list.
+        dynamic_thumbnails: false
+
+        # List of thumbnail to precalculate when an image is uploaded.
+        thumbnail_sizes:
+        - width: 32
+          height: 32
+          method: crop
+        - width: 96
+          height: 96
+          method: crop
+        - width: 320
+          height: 240
+          method: scale
+        - width: 640
+          height: 480
+          method: scale
         """ % locals()
diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py
index d6c064b398..e91f1129db 100644
--- a/synapse/handlers/_base.py
+++ b/synapse/handlers/_base.py
@@ -18,7 +18,7 @@ from twisted.internet import defer
 from synapse.api.errors import LimitExceededError, SynapseError
 from synapse.crypto.event_signing import add_hashes_and_signatures
 from synapse.api.constants import Membership, EventTypes
-from synapse.types import UserID
+from synapse.types import UserID, RoomAlias
 
 from synapse.util.logcontext import PreserveLoggingContext
 
@@ -130,6 +130,22 @@ class BaseHandler(object):
                         returned_invite.signatures
                     )
 
+        if event.type == EventTypes.CanonicalAlias:
+            # Check the alias is acually valid (at this time at least)
+            room_alias_str = event.content.get("alias", None)
+            if room_alias_str:
+                room_alias = RoomAlias.from_string(room_alias_str)
+                directory_handler = self.hs.get_handlers().directory_handler
+                mapping = yield directory_handler.get_association(room_alias)
+
+                if mapping["room_id"] != event.room_id:
+                    raise SynapseError(
+                        400,
+                        "Room alias %s does not point to the room" % (
+                            room_alias_str,
+                        )
+                    )
+
         destinations = set(extra_destinations)
         for k, s in context.current_state.items():
             try:
diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
index be2baeaece..ff2c66f442 100644
--- a/synapse/handlers/auth.py
+++ b/synapse/handlers/auth.py
@@ -162,7 +162,7 @@ class AuthHandler(BaseHandler):
         if not user_id.startswith('@'):
             user_id = UserID.create(user_id, self.hs.hostname).to_string()
 
-        self._check_password(user_id, password)
+        yield self._check_password(user_id, password)
         defer.returnValue(user_id)
 
     @defer.inlineCallbacks
diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py
index 993d33ba47..f9ca2f8634 100644
--- a/synapse/handlers/events.py
+++ b/synapse/handlers/events.py
@@ -70,7 +70,15 @@ class EventStreamHandler(BaseHandler):
                 self._streams_per_user[auth_user] += 1
 
             rm_handler = self.hs.get_handlers().room_member_handler
-            room_ids = yield rm_handler.get_joined_rooms_for_user(auth_user)
+
+            app_service = yield self.store.get_app_service_by_user_id(
+                auth_user.to_string()
+            )
+            if app_service:
+                rooms = yield self.store.get_app_service_rooms(app_service)
+                room_ids = set(r.room_id for r in rooms)
+            else:
+                room_ids = yield rm_handler.get_joined_rooms_for_user(auth_user)
 
             if timeout:
                 # If they've set a timeout set a minimum limit.
diff --git a/synapse/handlers/identity.py b/synapse/handlers/identity.py
index c1095708a0..2a99921d5f 100644
--- a/synapse/handlers/identity.py
+++ b/synapse/handlers/identity.py
@@ -117,3 +117,28 @@ class IdentityHandler(BaseHandler):
         except CodeMessageException as e:
             data = json.loads(e.msg)
         defer.returnValue(data)
+
+    @defer.inlineCallbacks
+    def requestEmailToken(self, id_server, email, client_secret, send_attempt, **kwargs):
+        yield run_on_reactor()
+        http_client = SimpleHttpClient(self.hs)
+
+        params = {
+            'email': email,
+            'client_secret': client_secret,
+            'send_attempt': send_attempt,
+        }
+        params.update(kwargs)
+
+        try:
+            data = yield http_client.post_urlencoded_get_json(
+                "https://%s%s" % (
+                    id_server,
+                    "/_matrix/identity/api/v1/validate/email/requestToken"
+                ),
+                params
+            )
+            defer.returnValue(data)
+        except CodeMessageException as e:
+            logger.info("Proxied requestToken failed: %r", e)
+            raise e
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 7511d294f3..c5d1001b50 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -247,9 +247,11 @@ class RoomCreationHandler(BaseHandler):
                 },
                 "users_default": 0,
                 "events": {
-                    EventTypes.Name: 100,
+                    EventTypes.Name: 50,
                     EventTypes.PowerLevels: 100,
                     EventTypes.RoomHistoryVisibility: 100,
+                    EventTypes.CanonicalAlias: 50,
+                    EventTypes.RoomAvatar: 50,
                 },
                 "events_default": 0,
                 "state_default": 50,
@@ -557,15 +559,9 @@ class RoomMemberHandler(BaseHandler):
         """Returns a list of roomids that the user has any of the given
         membership states in."""
 
-        app_service = yield self.store.get_app_service_by_user_id(
-            user.to_string()
+        rooms = yield self.store.get_rooms_for_user(
+            user.to_string(),
         )
-        if app_service:
-            rooms = yield self.store.get_app_service_rooms(app_service)
-        else:
-            rooms = yield self.store.get_rooms_for_user(
-                user.to_string(),
-            )
 
         # For some reason the list of events contains duplicates
         # TODO(paul): work out why because I really don't think it should
diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py
index 7206ae23d7..353a416054 100644
--- a/synapse/handlers/sync.py
+++ b/synapse/handlers/sync.py
@@ -96,9 +96,18 @@ class SyncHandler(BaseHandler):
                 return self.current_sync_for_user(sync_config, since_token)
 
             rm_handler = self.hs.get_handlers().room_member_handler
-            room_ids = yield rm_handler.get_joined_rooms_for_user(
-                sync_config.user
+
+            app_service = yield self.store.get_app_service_by_user_id(
+                sync_config.user.to_string()
             )
+            if app_service:
+                rooms = yield self.store.get_app_service_rooms(app_service)
+                room_ids = set(r.room_id for r in rooms)
+            else:
+                room_ids = yield rm_handler.get_joined_rooms_for_user(
+                    sync_config.user
+                )
+
             result = yield self.notifier.wait_for_events(
                 sync_config.user, room_ids,
                 sync_config.filter, timeout, current_sync_callback
@@ -229,7 +238,16 @@ class SyncHandler(BaseHandler):
         logger.debug("Typing %r", typing_by_room)
 
         rm_handler = self.hs.get_handlers().room_member_handler
-        room_ids = yield rm_handler.get_joined_rooms_for_user(sync_config.user)
+        app_service = yield self.store.get_app_service_by_user_id(
+            sync_config.user.to_string()
+        )
+        if app_service:
+            rooms = yield self.store.get_app_service_rooms(app_service)
+            room_ids = set(r.room_id for r in rooms)
+        else:
+            room_ids = yield rm_handler.get_joined_rooms_for_user(
+                sync_config.user
+            )
 
         # TODO (mjark): Does public mean "published"?
         published_rooms = yield self.store.get_rooms(is_public=True)
diff --git a/synapse/push/__init__.py b/synapse/push/__init__.py
index 36f450c31d..13002e0db4 100644
--- a/synapse/push/__init__.py
+++ b/synapse/push/__init__.py
@@ -294,6 +294,12 @@ class Pusher(object):
         if not single_event:
             self.last_token = chunk['end']
             logger.debug("Event stream timeout for pushkey %s", self.pushkey)
+            yield self.store.update_pusher_last_token(
+                self.app_id,
+                self.pushkey,
+                self.user_name,
+                self.last_token
+            )
             return
 
         if not self.alive:
@@ -345,7 +351,7 @@ class Pusher(object):
         if processed:
             self.backoff_delay = Pusher.INITIAL_BACKOFF
             self.last_token = chunk['end']
-            self.store.update_pusher_last_token_and_success(
+            yield self.store.update_pusher_last_token_and_success(
                 self.app_id,
                 self.pushkey,
                 self.user_name,
@@ -354,7 +360,7 @@ class Pusher(object):
             )
             if self.failing_since:
                 self.failing_since = None
-                self.store.update_pusher_failing_since(
+                yield self.store.update_pusher_failing_since(
                     self.app_id,
                     self.pushkey,
                     self.user_name,
@@ -362,7 +368,7 @@ class Pusher(object):
         else:
             if not self.failing_since:
                 self.failing_since = self.clock.time_msec()
-                self.store.update_pusher_failing_since(
+                yield self.store.update_pusher_failing_since(
                     self.app_id,
                     self.pushkey,
                     self.user_name,
@@ -380,7 +386,7 @@ class Pusher(object):
                             self.user_name, self.pushkey)
                 self.backoff_delay = Pusher.INITIAL_BACKOFF
                 self.last_token = chunk['end']
-                self.store.update_pusher_last_token(
+                yield self.store.update_pusher_last_token(
                     self.app_id,
                     self.pushkey,
                     self.user_name,
@@ -388,7 +394,7 @@ class Pusher(object):
                 )
 
                 self.failing_since = None
-                self.store.update_pusher_failing_since(
+                yield self.store.update_pusher_failing_since(
                     self.app_id,
                     self.pushkey,
                     self.user_name,
diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py
index 694072693d..0d5eafd0fa 100644
--- a/synapse/rest/client/v1/login.py
+++ b/synapse/rest/client/v1/login.py
@@ -74,16 +74,23 @@ class LoginRestServlet(ClientV1RestServlet):
 
     @defer.inlineCallbacks
     def do_password_login(self, login_submission):
-        if not login_submission["user"].startswith('@'):
-            login_submission["user"] = UserID.create(
-                login_submission["user"], self.hs.hostname).to_string()
+        if 'medium' in login_submission and 'address' in login_submission:
+            user_id = yield self.hs.get_datastore().get_user_id_by_threepid(
+                login_submission['medium'], login_submission['address']
+            )
+        else:
+            user_id = login_submission['user']
+
+        if not user_id.startswith('@'):
+            user_id = UserID.create(
+                user_id, self.hs.hostname).to_string()
 
         token = yield self.handlers.auth_handler.login_with_password(
-            user_id=login_submission["user"],
+            user_id=user_id,
             password=login_submission["password"])
 
         result = {
-            "user_id": login_submission["user"],  # may have changed
+            "user_id": user_id,  # may have changed
             "access_token": token,
             "home_server": self.hs.hostname,
         }
diff --git a/synapse/rest/client/v2_alpha/account.py b/synapse/rest/client/v2_alpha/account.py
index 897c54b539..522a312c9e 100644
--- a/synapse/rest/client/v2_alpha/account.py
+++ b/synapse/rest/client/v2_alpha/account.py
@@ -79,7 +79,7 @@ class PasswordRestServlet(RestServlet):
         new_password = params['new_password']
 
         yield self.auth_handler.set_password(
-            user_id, new_password, None
+            user_id, new_password
         )
 
         defer.returnValue((200, {}))
diff --git a/synapse/rest/client/v2_alpha/register.py b/synapse/rest/client/v2_alpha/register.py
index 012c447e88..1ba2f29711 100644
--- a/synapse/rest/client/v2_alpha/register.py
+++ b/synapse/rest/client/v2_alpha/register.py
@@ -54,6 +54,11 @@ class RegisterRestServlet(RestServlet):
     @defer.inlineCallbacks
     def on_POST(self, request):
         yield run_on_reactor()
+
+        if '/register/email/requestToken' in request.path:
+            ret = yield self.onEmailTokenRequest(request)
+            defer.returnValue(ret)
+
         body = parse_json_dict_from_request(request)
 
         # we do basic sanity checks here because the auth layer will store these
@@ -208,6 +213,29 @@ class RegisterRestServlet(RestServlet):
             "home_server": self.hs.hostname,
         }
 
+    @defer.inlineCallbacks
+    def onEmailTokenRequest(self, request):
+        body = parse_json_dict_from_request(request)
+
+        required = ['id_server', 'client_secret', 'email', 'send_attempt']
+        absent = []
+        for k in required:
+            if k not in body:
+                absent.append(k)
+
+        if len(absent) > 0:
+            raise SynapseError(400, "Missing params: %r" % absent, Codes.MISSING_PARAM)
+
+        existingUid = yield self.hs.get_datastore().get_user_id_by_threepid(
+            'email', body['email']
+        )
+
+        if existingUid is not None:
+            raise SynapseError(400, "Email is already in use", Codes.THREEPID_IN_USE)
+
+        ret = yield self.identity_handler.requestEmailToken(**body)
+        defer.returnValue((200, ret))
+
 
 def register_servlets(hs, http_server):
     RegisterRestServlet(hs).register(http_server)
diff --git a/synapse/rest/media/v1/base_resource.py b/synapse/rest/media/v1/base_resource.py
index 84e1961a21..4e21527c3d 100644
--- a/synapse/rest/media/v1/base_resource.py
+++ b/synapse/rest/media/v1/base_resource.py
@@ -69,6 +69,8 @@ class BaseMediaResource(Resource):
         self.filepaths = filepaths
         self.version_string = hs.version_string
         self.downloads = {}
+        self.dynamic_thumbnails = hs.config.dynamic_thumbnails
+        self.thumbnail_requirements = hs.config.thumbnail_requirements
 
     def _respond_404(self, request):
         respond_with_json(
@@ -208,22 +210,74 @@ class BaseMediaResource(Resource):
             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"),
+        return self.thumbnail_requirements.get(media_type, ())
+
+    def _generate_thumbnail(self, input_path, t_path, t_width, t_height,
+                            t_method, t_type):
+        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
+
+        if t_method == "crop":
+            t_len = thumbnailer.crop(t_path, t_width, t_height, t_type)
+        elif t_method == "scale":
+            t_len = thumbnailer.scale(t_path, t_width, t_height, t_type)
         else:
-            return ()
+            t_len = None
+
+        return t_len
+
+    @defer.inlineCallbacks
+    def _generate_local_exact_thumbnail(self, media_id, t_width, t_height,
+                                        t_method, t_type):
+        input_path = self.filepaths.local_media_filepath(media_id)
+
+        t_path = self.filepaths.local_media_thumbnail(
+            media_id, t_width, t_height, t_type, t_method
+        )
+        self._makedirs(t_path)
+
+        t_len = yield threads.deferToThread(
+            self._generate_thumbnail,
+            input_path, t_path, t_width, t_height, t_method, t_type
+        )
+
+        if t_len:
+            yield self.store.store_local_thumbnail(
+                media_id, t_width, t_height, t_type, t_method, t_len
+            )
+
+            defer.returnValue(t_path)
+
+    @defer.inlineCallbacks
+    def _generate_remote_exact_thumbnail(self, server_name, file_id, media_id,
+                                         t_width, t_height, t_method, t_type):
+        input_path = self.filepaths.remote_media_filepath(server_name, file_id)
+
+        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 = yield threads.deferToThread(
+            self._generate_thumbnail,
+            input_path, t_path, t_width, t_height, t_method, t_type
+        )
+
+        if t_len:
+            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(t_path)
 
     @defer.inlineCallbacks
     def _generate_local_thumbnails(self, media_id, media_info):
diff --git a/synapse/rest/media/v1/thumbnail_resource.py b/synapse/rest/media/v1/thumbnail_resource.py
index 61f88e486e..e506dad934 100644
--- a/synapse/rest/media/v1/thumbnail_resource.py
+++ b/synapse/rest/media/v1/thumbnail_resource.py
@@ -43,14 +43,25 @@ class ThumbnailResource(BaseMediaResource):
         m_type = 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
-            )
+            if self.dynamic_thumbnails:
+                yield self._select_or_generate_local_thumbnail(
+                    request, media_id, width, height, method, m_type
+                )
+            else:
+                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
-            )
+            if self.dynamic_thumbnails:
+                yield self._select_or_generate_remote_thumbnail(
+                    request, server_name, 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,
@@ -83,6 +94,87 @@ class ThumbnailResource(BaseMediaResource):
             )
 
     @defer.inlineCallbacks
+    def _select_or_generate_local_thumbnail(self, request, media_id, desired_width,
+                                            desired_height, desired_method,
+                                            desired_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)
+        for info in thumbnail_infos:
+            t_w = info["thumbnail_width"] == desired_width
+            t_h = info["thumbnail_height"] == desired_height
+            t_method = info["thumbnail_method"] == desired_method
+            t_type = info["thumbnail_type"] == desired_type
+
+            if t_w and t_h and t_method and t_type:
+                file_path = self.filepaths.local_media_thumbnail(
+                    media_id, desired_width, desired_height, desired_type, desired_method,
+                )
+                yield self._respond_with_file(request, desired_type, file_path)
+                return
+
+        logger.debug("We don't have a local thumbnail of that size. Generating")
+
+        # Okay, so we generate one.
+        file_path = yield self._generate_local_exact_thumbnail(
+            media_id, desired_width, desired_height, desired_method, desired_type
+        )
+
+        if file_path:
+            yield self._respond_with_file(request, desired_type, file_path)
+        else:
+            yield self._respond_default_thumbnail(
+                request, media_info, desired_width, desired_height,
+                desired_method, desired_type,
+            )
+
+    @defer.inlineCallbacks
+    def _select_or_generate_remote_thumbnail(self, request, server_name, media_id,
+                                             desired_width, desired_height,
+                                             desired_method, desired_type):
+        media_info = yield self._get_remote_media(server_name, media_id)
+
+        thumbnail_infos = yield self.store.get_remote_media_thumbnails(
+            server_name, media_id,
+        )
+
+        file_id = media_info["filesystem_id"]
+
+        for info in thumbnail_infos:
+            t_w = info["thumbnail_width"] == desired_width
+            t_h = info["thumbnail_height"] == desired_height
+            t_method = info["thumbnail_method"] == desired_method
+            t_type = info["thumbnail_type"] == desired_type
+
+            if t_w and t_h and t_method and t_type:
+                file_path = self.filepaths.remote_media_thumbnail(
+                    server_name, file_id, desired_width, desired_height,
+                    desired_type, desired_method,
+                )
+                yield self._respond_with_file(request, desired_type, file_path)
+                return
+
+        logger.debug("We don't have a local thumbnail of that size. Generating")
+
+        # Okay, so we generate one.
+        file_path = yield self._generate_remote_exact_thumbnail(
+            server_name, file_id, media_id, desired_width,
+            desired_height, desired_method, desired_type
+        )
+
+        if file_path:
+            yield self._respond_with_file(request, desired_type, file_path)
+        else:
+            yield self._respond_default_thumbnail(
+                request, media_info, desired_width, desired_height,
+                desired_method, desired_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
diff --git a/synapse/storage/schema/delta/22/user_threepids_unique.sql b/synapse/storage/schema/delta/22/user_threepids_unique.sql
new file mode 100644
index 0000000000..87edfa454c
--- /dev/null
+++ b/synapse/storage/schema/delta/22/user_threepids_unique.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS user_threepids2 (
+    user_id TEXT NOT NULL,
+    medium TEXT NOT NULL,
+    address TEXT NOT NULL,
+    validated_at BIGINT NOT NULL,
+    added_at BIGINT NOT NULL,
+    CONSTRAINT medium_address UNIQUE (medium, address)
+);
+
+INSERT INTO user_threepids2
+	SELECT * FROM user_threepids WHERE added_at IN (
+		SELECT max(added_at) FROM user_threepids GROUP BY medium, address
+	)
+;
+
+DROP TABLE user_threepids;
+ALTER TABLE user_threepids2 RENAME TO user_threepids;
+
+CREATE INDEX user_threepids_user_id ON user_threepids(user_id);