From 3a3fadcece2f90171d82aee1b44956bb4b383549 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 10:58:53 +0100 Subject: [PATCH 1/7] Make API docs not lie on registration/login. --- .../swagger_matrix/api-docs-login | 22 +++++++++++++++++-- .../swagger_matrix/api-docs-registration | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/client-server/swagger_matrix/api-docs-login b/docs/client-server/swagger_matrix/api-docs-login index 77a588a68e..d6f8d84f29 100644 --- a/docs/client-server/swagger_matrix/api-docs-login +++ b/docs/client-server/swagger_matrix/api-docs-login @@ -8,7 +8,7 @@ "nickname": "get_login_info", "notes": "All login stages MUST be mentioned if there is >1 login type.", "summary": "Get the login mechanism to use when logging in.", - "type": "LoginInfo" + "type": "LoginFlows" }, { "method": "POST", @@ -45,12 +45,26 @@ "application/json" ], "models": { + "LoginFlows": { + "id": "LoginFlows", + "properties": { + "flows": { + "description": "A list of valid login flows.", + "type": "array", + "items": { + "$ref": "LoginInfo" + } + } + } + }, "LoginInfo": { "id": "LoginInfo", "properties": { "stages": { "description": "Multi-stage login only: An array of all the login types required to login.", - "format": "string", + "items": { + "$ref": "string" + }, "type": "array" }, "type": { @@ -65,6 +79,10 @@ "access_token": { "description": "The access token for this user's login if this is the final stage of the login process.", "type": "string" + }, + "user_id": { + "description": "The user's fully-qualified user ID.", + "type": "string" }, "next": { "description": "Multi-stage login only: The next login type to submit.", diff --git a/docs/client-server/swagger_matrix/api-docs-registration b/docs/client-server/swagger_matrix/api-docs-registration index a3dbc01b36..f4669ea2f0 100644 --- a/docs/client-server/swagger_matrix/api-docs-registration +++ b/docs/client-server/swagger_matrix/api-docs-registration @@ -52,6 +52,10 @@ "user_id": { "description": "The fully-qualified user ID.", "type": "string" + }, + "home_server": { + "description": "The name of the home server.", + "type": "string" } } }, From 2385d396c303a8ba5c4f0d1ce31ee8a31393f4af Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 11:05:29 +0100 Subject: [PATCH 2/7] URL decode user IDs for /profile REST path segments. --- synapse/rest/profile.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/synapse/rest/profile.py b/synapse/rest/profile.py index 06076667c7..c4a0a8d260 100644 --- a/synapse/rest/profile.py +++ b/synapse/rest/profile.py @@ -19,6 +19,7 @@ from twisted.internet import defer from base import RestServlet, client_path_pattern import json +import urllib class ProfileDisplaynameRestServlet(RestServlet): @@ -26,6 +27,7 @@ class ProfileDisplaynameRestServlet(RestServlet): @defer.inlineCallbacks def on_GET(self, request, user_id): + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) displayname = yield self.handlers.profile_handler.get_displayname( @@ -37,6 +39,7 @@ class ProfileDisplaynameRestServlet(RestServlet): @defer.inlineCallbacks def on_PUT(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) try: @@ -59,6 +62,7 @@ class ProfileAvatarURLRestServlet(RestServlet): @defer.inlineCallbacks def on_GET(self, request, user_id): + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) avatar_url = yield self.handlers.profile_handler.get_avatar_url( @@ -70,6 +74,7 @@ class ProfileAvatarURLRestServlet(RestServlet): @defer.inlineCallbacks def on_PUT(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) try: @@ -92,6 +97,7 @@ class ProfileRestServlet(RestServlet): @defer.inlineCallbacks def on_GET(self, request, user_id): + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) displayname = yield self.handlers.profile_handler.get_displayname( From bf10cf5f1a16eaf741257b8b2670491c4d7caf25 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Wed, 3 Sep 2014 11:10:29 +0100 Subject: [PATCH 3/7] move contentrepo class to it's own file --- synapse/app/homeserver.py | 3 +- synapse/http/content_repository.py | 191 +++++++++++++++++++++++++++++ synapse/http/server.py | 163 +----------------------- 3 files changed, 194 insertions(+), 163 deletions(-) create mode 100644 synapse/http/content_repository.py diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py index 8a7cd07fec..5e2a91b1da 100755 --- a/synapse/app/homeserver.py +++ b/synapse/app/homeserver.py @@ -23,7 +23,8 @@ from twisted.enterprise import adbapi from twisted.web.resource import Resource from twisted.web.static import File from twisted.web.server import Site -from synapse.http.server import JsonResource, RootRedirect, ContentRepoResource +from synapse.http.server import JsonResource, RootRedirect +from synapse.http.content_repository import ContentRepoResource from synapse.http.client import TwistedHttpClient from synapse.api.urls import ( CLIENT_PREFIX, FEDERATION_PREFIX, WEB_CLIENT_PREFIX, CONTENT_REPO_PREFIX diff --git a/synapse/http/content_repository.py b/synapse/http/content_repository.py new file mode 100644 index 0000000000..9b1ab3fc3e --- /dev/null +++ b/synapse/http/content_repository.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# 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 .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): + resource.Resource.__init__(self) + self.hs = hs + self.directory = directory + self.auth = auth + + 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 = 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) + 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: + fname = yield self.map_request_to_name(request) + + # TODO I have a suspcious 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 public mounted path of the repo 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 + # server it via the non-SSL listener... + url = "https://%s/_matrix/content/%s" % ( + self.hs.domain_with_port, 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/http/server.py b/synapse/http/server.py index 74c220e869..5440a7fdda 100644 --- a/synapse/http/server.py +++ b/synapse/http/server.py @@ -18,22 +18,16 @@ from syutil.jsonutil import ( encode_canonical_json, encode_pretty_printed_json ) from synapse.api.errors import ( - cs_exception, SynapseError, CodeMessageException, Codes, cs_error + cs_exception, SynapseError, CodeMessageException ) -from synapse.util.stringutils import random_string from twisted.internet import defer, reactor -from twisted.protocols.basic import FileSender from twisted.web import server, resource from twisted.web.server import NOT_DONE_YET from twisted.web.util import redirectTo -import base64 import collections -import json import logging -import os -import re logger = logging.getLogger(__name__) @@ -198,161 +192,6 @@ class RootRedirect(resource.Resource): return resource.Resource.getChild(self, name, request) -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): - resource.Resource.__init__(self) - self.hs = hs - self.directory = directory - self.auth = auth - - 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 = 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) - 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: - fname = yield self.map_request_to_name(request) - - # TODO I have a suspcious 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 public mounted path of the repo 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 - # server it via the non-SSL listener... - url = "https://%s/_matrix/content/%s" % ( - self.hs.domain_with_port, 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) - - def respond_with_json_bytes(request, code, json_bytes, send_cors=False, response_code_message=None): """Sends encoded JSON in response to the given request. From 65693e9e150341ac0df236817e1afc54ceb5acb5 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 11:24:37 +0100 Subject: [PATCH 4/7] Fixed GET /events/$id to be not broken. --- synapse/handlers/events.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py index 980a169b25..3e9efabd2c 100644 --- a/synapse/handlers/events.py +++ b/synapse/handlers/events.py @@ -126,5 +126,7 @@ class EventHandler(BaseHandler): defer.returnValue(None) return - yield self.auth.check(event, raises=True) + if hasattr(event, "room_id"): + yield self.auth.check_joined_room(event.room_id, user.to_string()) + defer.returnValue(event) From acfabfff9c7cda10b5be085732e80bb7a2af50a6 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 11:31:48 +0100 Subject: [PATCH 5/7] Fixed /presence APIs to urldecode user IDs. --- synapse/rest/presence.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/synapse/rest/presence.py b/synapse/rest/presence.py index bce3943542..967bb7d34f 100644 --- a/synapse/rest/presence.py +++ b/synapse/rest/presence.py @@ -21,7 +21,7 @@ from base import RestServlet, client_path_pattern import json import logging - +import urllib logger = logging.getLogger(__name__) @@ -32,6 +32,7 @@ class PresenceStatusRestServlet(RestServlet): @defer.inlineCallbacks def on_GET(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) state = yield self.handlers.presence_handler.get_state( @@ -42,6 +43,7 @@ class PresenceStatusRestServlet(RestServlet): @defer.inlineCallbacks def on_PUT(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) state = {} @@ -77,6 +79,7 @@ class PresenceListRestServlet(RestServlet): @defer.inlineCallbacks def on_GET(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) if not user.is_mine: @@ -97,6 +100,7 @@ class PresenceListRestServlet(RestServlet): @defer.inlineCallbacks def on_POST(self, request, user_id): auth_user = yield self.auth.get_user_by_req(request) + user_id = urllib.unquote(user_id) user = self.hs.parse_userid(user_id) if not user.is_mine: From c54d8df5040140c95b8ca31ce2ac023379f8cfdc Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 11:45:30 +0100 Subject: [PATCH 6/7] Update API docs to use 'presence' key not 'state'. Fixed error messages when setting presence. --- docs/client-server/swagger_matrix/api-docs-presence | 2 +- synapse/handlers/presence.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/client-server/swagger_matrix/api-docs-presence b/docs/client-server/swagger_matrix/api-docs-presence index 99630d2056..5c9f160508 100644 --- a/docs/client-server/swagger_matrix/api-docs-presence +++ b/docs/client-server/swagger_matrix/api-docs-presence @@ -106,7 +106,7 @@ "PresenceUpdate": { "id": "PresenceUpdate", "properties": { - "state": { + "presence": { "type": "string", "description": "Enum: The presence state.", "enum": [ diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index 48549b03e5..beb5aa3a6a 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -207,7 +207,7 @@ class PresenceHandler(BaseHandler): raise SynapseError(400, "User is not hosted on this Home Server") if target_user != auth_user: - raise AuthError(400, "Cannot set another user's displayname") + raise AuthError(400, "Cannot set another user's presence") if "status_msg" not in state: state["status_msg"] = None From 10afd895c4318240d2afca19f34cd93440f43dd0 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 3 Sep 2014 11:56:47 +0100 Subject: [PATCH 7/7] Edited /presence REST servlet to raise SynapseErrors to return a standard error response, rather than a string. --- synapse/rest/presence.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/synapse/rest/presence.py b/synapse/rest/presence.py index 967bb7d34f..69be6fe989 100644 --- a/synapse/rest/presence.py +++ b/synapse/rest/presence.py @@ -17,6 +17,7 @@ """ from twisted.internet import defer +from synapse.api.errors import SynapseError from base import RestServlet, client_path_pattern import json @@ -58,11 +59,15 @@ class PresenceStatusRestServlet(RestServlet): if "status_msg" in content: state["status_msg"] = content.pop("status_msg") + if not isinstance(state["status_msg"], basestring): + raise SynapseError(400, "status_msg must be a string.") if content: raise KeyError() + except SynapseError as e: + raise e except: - defer.returnValue((400, "Unable to parse state")) + raise SynapseError(400, "Unable to parse state") yield self.handlers.presence_handler.set_state( target_user=user, auth_user=auth_user, state=state) @@ -83,10 +88,10 @@ class PresenceListRestServlet(RestServlet): user = self.hs.parse_userid(user_id) if not user.is_mine: - defer.returnValue((400, "User not hosted on this Home Server")) + raise SynapseError(400, "User not hosted on this Home Server") if auth_user != user: - defer.returnValue((400, "Cannot get another user's presence list")) + raise SynapseError(400, "Cannot get another user's presence list") presence = yield self.handlers.presence_handler.get_presence_list( observer_user=user, accepted=True) @@ -104,17 +109,17 @@ class PresenceListRestServlet(RestServlet): user = self.hs.parse_userid(user_id) if not user.is_mine: - defer.returnValue((400, "User not hosted on this Home Server")) + raise SynapseError(400, "User not hosted on this Home Server") if auth_user != user: - defer.returnValue(( - 400, "Cannot modify another user's presence list")) + raise SynapseError( + 400, "Cannot modify another user's presence list") try: content = json.loads(request.content.read()) except: logger.exception("JSON parse error") - defer.returnValue((400, "Unable to parse content")) + raise SynapseError(400, "Unable to parse content") deferreds = []