From 586e0df62d859d6e811b745d48c2153a8c25ec09 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 8 Sep 2014 11:07:52 -0700 Subject: [PATCH 01/40] Updated spec and api docs to desired new format. --- .../swagger_matrix/api-docs-registration | 97 +++++++++++++------ docs/specification.rst | 15 ++- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/docs/client-server/swagger_matrix/api-docs-registration b/docs/client-server/swagger_matrix/api-docs-registration index f4669ea2f..11c170c3e 100644 --- a/docs/client-server/swagger_matrix/api-docs-registration +++ b/docs/client-server/swagger_matrix/api-docs-registration @@ -3,35 +3,38 @@ "apis": [ { "operations": [ + { + "method": "GET", + "nickname": "get_registration_info", + "notes": "All login stages MUST be mentioned if there is >1 login type.", + "summary": "Get the login mechanism to use when registering.", + "type": "RegistrationFlows" + }, { "method": "POST", - "nickname": "register", - "notes": "Volatile: This API is likely to change.", + "nickname": "submit_registration", + "notes": "If this is part of a multi-stage registration, there MUST be a 'session' key.", "parameters": [ { - "description": "A registration request", + "description": "A registration submission", "name": "body", "paramType": "body", "required": true, - "type": "RegistrationRequest" + "type": "RegistrationSubmission" } ], "responseMessages": [ { "code": 400, - "message": "No JSON object." + "message": "Bad login type" }, { "code": 400, - "message": "User ID must only contain characters which do not require url encoding." - }, - { - "code": 400, - "message": "User ID already taken." + "message": "Missing JSON keys" } ], - "summary": "Register with the home server.", - "type": "RegistrationResponse" + "summary": "Submit a registration action.", + "type": "RegistrationResult" } ], "path": "/register" @@ -42,30 +45,68 @@ "application/json" ], "models": { - "RegistrationResponse": { - "id": "RegistrationResponse", + "RegistrationFlows": { + "id": "RegistrationFlows", "properties": { - "access_token": { - "description": "The access token for this user.", - "type": "string" + "flows": { + "description": "A list of valid registration flows.", + "type": "array", + "items": { + "$ref": "RegistrationInfo" + } + } + } + }, + "RegistrationInfo": { + "id": "RegistrationInfo", + "properties": { + "stages": { + "description": "Multi-stage registration only: An array of all the login types required to registration.", + "items": { + "$ref": "string" + }, + "type": "array" }, - "user_id": { - "description": "The fully-qualified user ID.", - "type": "string" - }, - "home_server": { - "description": "The name of the home server.", + "type": { + "description": "The first login type that must be used when logging in.", "type": "string" } } }, - "RegistrationRequest": { - "id": "RegistrationRequest", + "RegistrationResult": { + "id": "RegistrationResult", "properties": { + "access_token": { + "description": "The access token for this user's registration if this is the final stage of the registration process.", + "type": "string" + }, "user_id": { - "description": "The desired user ID. If not specified, a random user ID will be allocated.", - "type": "string", - "required": false + "description": "The user's fully-qualified user ID.", + "type": "string" + }, + "next": { + "description": "Multi-stage registration only: The next registration type to submit.", + "type": "string" + }, + "session": { + "description": "Multi-stage registration only: The session token to send when submitting the next registration type.", + "type": "string" + } + } + }, + "RegistrationSubmission": { + "id": "RegistrationSubmission", + "properties": { + "type": { + "description": "The type of registration being submitted.", + "type": "string" + }, + "session": { + "description": "Multi-stage registration only: The session token from an earlier registration stage.", + "type": "string" + }, + "_registration_type_defined_keys_": { + "description": "Keys as defined by the specified registration type, e.g. \"user\", \"password\"" } } } diff --git a/docs/specification.rst b/docs/specification.rst index b15792c00..acfe47605 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -1279,12 +1279,6 @@ display name other than it being a valid unicode string. Registration and login ====================== -.. WARNING:: - The registration API is likely to change. - -.. TODO - - TODO Kegan : Make registration like login (just omit the "user" key on the - initial request?) Clients must register with a home server in order to use Matrix. After registering, the client will be given an access token which must be used in ALL @@ -1297,9 +1291,11 @@ a token sent to their email address, etc. This specification does not define how home servers should authorise their users who want to login to their existing accounts, but instead defines the standard interface which implementations should follow so that ANY client can login to ANY home server. Clients login -using the |login|_ API. +using the |login|_ API. Clients register using the |register|_ API. Registration +follows the same procedure as login, but the path requests are sent to are +different. -The login process breaks down into the following: +The registration/login process breaks down into the following: 1. Determine the requirements for logging in. 2. Submit the login stage credentials. 3. Get credentials or be told the next stage in the login process and repeat @@ -2216,6 +2212,9 @@ Transaction: .. |login| replace:: ``/login`` .. _login: /docs/api/client-server/#!/-login +.. |register| replace:: ``/register`` +.. _register: /docs/api/client-server/#!/-registration + .. |/rooms//messages| replace:: ``/rooms//messages`` .. _/rooms//messages: /docs/api/client-server/#!/-rooms/get_messages From 34878bc26a2ed4b796412830a4e1bf9edddc0089 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 10:23:20 +0100 Subject: [PATCH 02/40] Added LoginType constants. Created general structure for processing registrations. --- synapse/api/constants.py | 9 ++++ synapse/rest/register.py | 93 ++++++++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index fcef062fc..618d3d757 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -50,3 +50,12 @@ class JoinRules(object): KNOCK = u"knock" INVITE = u"invite" PRIVATE = u"private" + + +class LoginType(object): + PASSWORD = u"m.login.password" + OAUTH = u"m.login.oauth2" + EMAIL_CODE = u"m.login.email.code" + EMAIL_URL = u"m.login.email.url" + EMAIL_IDENTITY = u"m.login.email.identity" + RECAPTCHA = u"m.login.recaptcha" \ No newline at end of file diff --git a/synapse/rest/register.py b/synapse/rest/register.py index 48d3c6eca..8faa26572 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -17,6 +17,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 import json @@ -26,31 +27,64 @@ import urllib class RegisterRestServlet(RestServlet): PATTERN = client_path_pattern("/register$") + def on_GET(self, request): + return (200, { + "flows": [ + { + "type": LoginType.RECAPTCHA, + "stages": ([LoginType.RECAPTCHA, LoginType.EMAIL_IDENTITY, + LoginType.PASSWORD]) + }, + { + "type": LoginType.RECAPTCHA, + "stages": [LoginType.RECAPTCHA, LoginType.PASSWORD] + }, + ] + }) + @defer.inlineCallbacks def on_POST(self, request): + register_json = _parse_json(request) + + session = (register_json["session"] if "session" in register_json + else None) + try: + login_type = register_json["type"] + stages = { + LoginType.RECAPTCHA: self._do_recaptcha, + LoginType.PASSWORD: self._do_password, + LoginType.EMAIL_IDENTITY: self._do_email_identity + } + + session_info = None + if session: + session_info = self._get_session_info(session) + + response = yield stages[login_type](register_json, session_info) + defer.returnValue((200, response)) + except KeyError: + raise SynapseError(400, "Bad login type.") + + desired_user_id = None password = None - try: - register_json = json.loads(request.content.read()) - if "password" in register_json: - password = register_json["password"].encode("utf-8") - if type(register_json["user_id"]) == unicode: - desired_user_id = register_json["user_id"].encode("utf-8") - if urllib.quote(desired_user_id) != desired_user_id: - raise SynapseError( - 400, - "User ID must only contain characters which do not " + - "require URL encoding.") - except ValueError: - defer.returnValue((400, "No JSON object.")) - except KeyError: - pass # user_id is optional + if "password" in register_json: + password = register_json["password"].encode("utf-8") + + if ("user_id" in register_json and + type(register_json["user_id"]) == unicode): + desired_user_id = register_json["user_id"].encode("utf-8") + if urllib.quote(desired_user_id) != desired_user_id: + raise SynapseError( + 400, + "User ID must only contain characters which do not " + + "require URL encoding.") threepidCreds = None if 'threepidCreds' in register_json: threepidCreds = register_json['threepidCreds'] - + captcha = {} if self.hs.config.enable_registration_captcha: challenge = None @@ -65,7 +99,7 @@ class RegisterRestServlet(RestServlet): except KeyError: raise SynapseError(400, "Captcha response is required", errcode=Codes.CAPTCHA_NEEDED) - + # TODO determine the source IP : May be an X-Forwarding-For header depending on config ip_addr = request.getClientIP() if self.hs.config.captcha_ip_origin_is_x_forwarded: @@ -73,14 +107,14 @@ class RegisterRestServlet(RestServlet): if request.requestHeaders.hasHeader("X-Forwarded-For"): ip_addr = request.requestHeaders.getRawHeaders( "X-Forwarded-For")[0] - + captcha = { "ip": ip_addr, "private_key": self.hs.config.recaptcha_private_key, "challenge": challenge, "response": user_response } - + handler = self.handlers.registration_handler (user_id, token) = yield handler.register( @@ -101,6 +135,27 @@ class RegisterRestServlet(RestServlet): def on_OPTIONS(self, request): return (200, {}) + def _get_session_info(self, session_id): + pass + + def _do_recaptcha(self, register_json, session): + pass + + def _do_email_identity(self, register_json, session): + pass + + def _do_password(self, register_json, session): + pass + + +def _parse_json(request): + try: + content = json.loads(request.content.read()) + if type(content) != dict: + raise SynapseError(400, "Content must be a JSON object.") + return content + except ValueError: + raise SynapseError(400, "Content not JSON.") def register_servlets(hs, http_server): RegisterRestServlet(hs).register(http_server) From 285ecaacd0308f8088e38c64d49cd2e56b514d3d Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 12:42:36 +0100 Subject: [PATCH 03/40] Split out password/captcha/email logic. --- synapse/handlers/register.py | 120 +++++++++-------- synapse/rest/register.py | 243 ++++++++++++++++++++++------------- 2 files changed, 220 insertions(+), 143 deletions(-) diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index 0b841d6d3..a019d770d 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -40,8 +40,7 @@ class RegistrationHandler(BaseHandler): self.distributor.declare("registered_user") @defer.inlineCallbacks - def register(self, localpart=None, password=None, threepidCreds=None, - captcha_info={}): + def register(self, localpart=None, password=None): """Registers a new client on the server. Args: @@ -54,37 +53,6 @@ class RegistrationHandler(BaseHandler): Raises: RegistrationError if there was a problem registering. """ - if captcha_info: - captcha_response = yield self._validate_captcha( - captcha_info["ip"], - captcha_info["private_key"], - captcha_info["challenge"], - captcha_info["response"] - ) - if not captcha_response["valid"]: - logger.info("Invalid captcha entered from %s. Error: %s", - captcha_info["ip"], captcha_response["error_url"]) - raise InvalidCaptchaError( - error_url=captcha_response["error_url"] - ) - else: - logger.info("Valid captcha entered from %s", captcha_info["ip"]) - - if threepidCreds: - for c in threepidCreds: - logger.info("validating theeepidcred sid %s on id server %s", - c['sid'], c['idServer']) - try: - threepid = yield self._threepid_from_creds(c) - except: - logger.err() - raise RegistrationError(400, "Couldn't validate 3pid") - - if not threepid: - raise RegistrationError(400, "Couldn't validate 3pid") - logger.info("got threepid medium %s address %s", - threepid['medium'], threepid['address']) - password_hash = None if password: password_hash = bcrypt.hashpw(password, bcrypt.gensalt()) @@ -126,15 +94,54 @@ class RegistrationHandler(BaseHandler): raise RegistrationError( 500, "Cannot generate user ID.") - # Now we have a matrix ID, bind it to the threepids we were given - if threepidCreds: - for c in threepidCreds: - # XXX: This should be a deferred list, shouldn't it? - yield self._bind_threepid(c, user_id) - - defer.returnValue((user_id, token)) + @defer.inlineCallbacks + def check_recaptcha(self, ip, private_key, challenge, response): + """Checks a recaptcha is correct.""" + + captcha_response = yield self._validate_captcha( + ip, + private_key, + challenge, + response + ) + if not captcha_response["valid"]: + logger.info("Invalid captcha entered from %s. Error: %s", + ip, captcha_response["error_url"]) + raise InvalidCaptchaError( + error_url=captcha_response["error_url"] + ) + else: + logger.info("Valid captcha entered from %s", ip) + + @defer.inlineCallbacks + def register_email(self, threepidCreds): + """Registers emails with an identity server.""" + + for c in threepidCreds: + logger.info("validating theeepidcred sid %s on id server %s", + c['sid'], c['idServer']) + try: + threepid = yield self._threepid_from_creds(c) + except: + logger.err() + raise RegistrationError(400, "Couldn't validate 3pid") + + if not threepid: + raise RegistrationError(400, "Couldn't validate 3pid") + logger.info("got threepid medium %s address %s", + threepid['medium'], threepid['address']) + + @defer.inlineCallbacks + def bind_emails(self, user_id, threepidCreds): + """Links emails with a user ID and informs an identity server.""" + + # Now we have a matrix ID, bind it to the threepids we were given + for c in threepidCreds: + # XXX: This should be a deferred list, shouldn't it? + yield self._bind_threepid(c, user_id) + def _generate_token(self, user_id): # urlsafe variant uses _ and - so use . as the separator and replace # all =s with .s so http clients don't quote =s when it is used as @@ -149,17 +156,17 @@ class RegistrationHandler(BaseHandler): def _threepid_from_creds(self, creds): httpCli = PlainHttpClient(self.hs) # XXX: make this configurable! - trustedIdServers = [ 'matrix.org:8090' ] + trustedIdServers = ['matrix.org:8090'] if not creds['idServer'] in trustedIdServers: - logger.warn('%s is not a trusted ID server: rejecting 3pid '+ + logger.warn('%s is not a trusted ID server: rejecting 3pid ' + 'credentials', creds['idServer']) defer.returnValue(None) data = yield httpCli.get_json( creds['idServer'], "/_matrix/identity/api/v1/3pid/getValidated3pid", - { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'] } + {'sid': creds['sid'], 'clientSecret': creds['clientSecret']} ) - + if 'medium' in data: defer.returnValue(data) defer.returnValue(None) @@ -170,44 +177,45 @@ class RegistrationHandler(BaseHandler): data = yield httpCli.post_urlencoded_get_json( creds['idServer'], "/_matrix/identity/api/v1/3pid/bind", - { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'], - 'mxid':mxid } + {'sid': creds['sid'], 'clientSecret': creds['clientSecret'], + 'mxid': mxid} ) defer.returnValue(data) - + @defer.inlineCallbacks def _validate_captcha(self, ip_addr, private_key, challenge, response): """Validates the captcha provided. - + Returns: dict: Containing 'valid'(bool) and 'error_url'(str) if invalid. - + """ - response = yield self._submit_captcha(ip_addr, private_key, challenge, + response = yield self._submit_captcha(ip_addr, private_key, challenge, response) # parse Google's response. Lovely format.. lines = response.split('\n') json = { "valid": lines[0] == 'true', - "error_url": "http://www.google.com/recaptcha/api/challenge?"+ + "error_url": "http://www.google.com/recaptcha/api/challenge?" + "error=%s" % lines[1] } defer.returnValue(json) - + @defer.inlineCallbacks def _submit_captcha(self, ip_addr, private_key, challenge, response): client = PlainHttpClient(self.hs) data = yield client.post_urlencoded_get_raw( "www.google.com:80", "/recaptcha/api/verify", - accept_partial=True, # twisted dislikes google's response, no content length. - args={ - 'privatekey': private_key, + # twisted dislikes google's response, no content length. + accept_partial=True, + args={ + 'privatekey': private_key, 'remoteip': ip_addr, 'challenge': challenge, 'response': response } ) defer.returnValue(data) - + diff --git a/synapse/rest/register.py b/synapse/rest/register.py index 8faa26572..8036c3c40 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -19,28 +19,62 @@ 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 +import synapse.util.stringutils as stringutils import json +import logging import urllib +logger = logging.getLogger(__name__) + class RegisterRestServlet(RestServlet): + """Handles registration with the home server. + + This servlet is in control of the registration flow; the registration + handler doesn't have a concept of multi-stages or sessions. + """ + PATTERN = client_path_pattern("/register$") + def __init__(self, hs): + super(RegisterRestServlet, self).__init__(hs) + # sessions are stored as: + # self.sessions = { + # "session_id" : { __session_dict__ } + # } + # TODO: persistent storage + self.sessions = {} + def on_GET(self, request): - return (200, { - "flows": [ - { - "type": LoginType.RECAPTCHA, - "stages": ([LoginType.RECAPTCHA, LoginType.EMAIL_IDENTITY, - LoginType.PASSWORD]) - }, - { - "type": LoginType.RECAPTCHA, - "stages": [LoginType.RECAPTCHA, LoginType.PASSWORD] - }, - ] - }) + if self.hs.config.enable_registration_captcha: + return (200, { + "flows": [ + { + "type": LoginType.RECAPTCHA, + "stages": ([LoginType.RECAPTCHA, + LoginType.EMAIL_IDENTITY, + LoginType.PASSWORD]) + }, + { + "type": LoginType.RECAPTCHA, + "stages": [LoginType.RECAPTCHA, LoginType.PASSWORD] + } + ] + }) + else: + return (200, { + "flows": [ + { + "type": LoginType.EMAIL_IDENTITY, + "stages": ([LoginType.EMAIL_IDENTITY, + LoginType.PASSWORD]) + }, + { + "type": LoginType.PASSWORD + } + ] + }) @defer.inlineCallbacks def on_POST(self, request): @@ -56,96 +90,130 @@ class RegisterRestServlet(RestServlet): LoginType.EMAIL_IDENTITY: self._do_email_identity } - session_info = None - if session: - session_info = self._get_session_info(session) + session_info = self._get_session_info(request, session) + logger.debug("%s : session info %s request info %s", + login_type, session_info, register_json) + response = yield stages[login_type]( + request, + register_json, + session_info + ) + + if "access_token" not in response: + # isn't a final response + response["session"] = session_info["id"] - response = yield stages[login_type](register_json, session_info) defer.returnValue((200, response)) - except KeyError: - raise SynapseError(400, "Bad login type.") + except KeyError as e: + logger.exception(e) + raise SynapseError(400, "Missing JSON keys or bad login type.") + def on_OPTIONS(self, request): + return (200, {}) - desired_user_id = None - password = None - - if "password" in register_json: - password = register_json["password"].encode("utf-8") - - if ("user_id" in register_json and - type(register_json["user_id"]) == unicode): - desired_user_id = register_json["user_id"].encode("utf-8") - if urllib.quote(desired_user_id) != desired_user_id: - raise SynapseError( - 400, - "User ID must only contain characters which do not " + - "require URL encoding.") - - threepidCreds = None - if 'threepidCreds' in register_json: - threepidCreds = register_json['threepidCreds'] - - captcha = {} - if self.hs.config.enable_registration_captcha: - challenge = None - user_response = None - try: - captcha_type = register_json["captcha"]["type"] - if captcha_type != "m.login.recaptcha": - raise SynapseError(400, "Sorry, only m.login.recaptcha " + - "requests are supported.") - challenge = register_json["captcha"]["challenge"] - user_response = register_json["captcha"]["response"] - except KeyError: - raise SynapseError(400, "Captcha response is required", - errcode=Codes.CAPTCHA_NEEDED) - - # TODO determine the source IP : May be an X-Forwarding-For header depending on config - ip_addr = request.getClientIP() - if self.hs.config.captcha_ip_origin_is_x_forwarded: - # use the header - if request.requestHeaders.hasHeader("X-Forwarded-For"): - ip_addr = request.requestHeaders.getRawHeaders( - "X-Forwarded-For")[0] - - captcha = { - "ip": ip_addr, - "private_key": self.hs.config.recaptcha_private_key, - "challenge": challenge, - "response": user_response + def _get_session_info(self, request, session_id): + if not session_id: + # create a new session + while session_id is None or session_id in self.sessions: + session_id = stringutils.random_string(24) + self.sessions[session_id] = { + "id": session_id, + LoginType.EMAIL_IDENTITY: False, + LoginType.RECAPTCHA: False } + return self.sessions[session_id] + def _save_session(self, session): + # TODO: Persistent storage + logger.debug("Saving session %s", session) + self.sessions[session["id"]] = session + + def _remove_session(self, session): + logger.debug("Removing session %s", session) + self.sessions.pop(session["id"]) + + def _do_recaptcha(self, request, register_json, session): + if not self.hs.config.enable_registration_captcha: + raise SynapseError(400, "Captcha not required.") + + challenge = None + user_response = None + try: + challenge = register_json["challenge"] + user_response = register_json["response"] + except KeyError: + raise SynapseError(400, "Captcha response is required", + errcode=Codes.CAPTCHA_NEEDED) + + # May be an X-Forwarding-For header depending on config + ip_addr = request.getClientIP() + if self.hs.config.captcha_ip_origin_is_x_forwarded: + # use the header + if request.requestHeaders.hasHeader("X-Forwarded-For"): + ip_addr = request.requestHeaders.getRawHeaders( + "X-Forwarded-For")[0] + + handler = self.handlers.registration_handler + yield handler.check_recaptcha( + ip_addr, + self.hs.config.recaptcha_private_key, + challenge, + user_response + ) + session[LoginType.RECAPTCHA] = True # mark captcha as done + self._save_session(session) + defer.returnValue({ + "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY] + }) + + @defer.inlineCallbacks + def _do_email_identity(self, request, register_json, session): + if (self.hs.config.enable_registration_captcha and + not session[LoginType.RECAPTCHA]): + raise SynapseError(400, "Captcha is required.") + + threepidCreds = register_json['threepidCreds'] + handler = self.handlers.registration_handler + yield handler.register_email(threepidCreds) + session["threepidCreds"] = threepidCreds # store creds for next stage + session[LoginType.EMAIL_IDENTITY] = True # mark email as done + self._save_session(session) + defer.returnValue({ + "next": LoginType.PASSWORD + }) + + @defer.inlineCallbacks + def _do_password(self, request, register_json, session): + if (self.hs.config.enable_registration_captcha and + not session[LoginType.RECAPTCHA]): + # captcha should've been done by this stage! + raise SynapseError(400, "Captcha is required.") + + password = register_json["password"].encode("utf-8") + desired_user_id = (register_json["user_id"].encode("utf-8") if "user_id" + in register_json else None) + if desired_user_id and urllib.quote(desired_user_id) != desired_user_id: + raise SynapseError( + 400, + "User ID must only contain characters which do not " + + "require URL encoding.") handler = self.handlers.registration_handler (user_id, token) = yield handler.register( localpart=desired_user_id, - password=password, - threepidCreds=threepidCreds, - captcha_info=captcha) + password=password + ) + + if session[LoginType.EMAIL_IDENTITY]: + yield handler.bind_emails(user_id, session["threepidCreds"]) result = { "user_id": user_id, "access_token": token, "home_server": self.hs.hostname, } - defer.returnValue( - (200, result) - ) - - def on_OPTIONS(self, request): - return (200, {}) - - def _get_session_info(self, session_id): - pass - - def _do_recaptcha(self, register_json, session): - pass - - def _do_email_identity(self, register_json, session): - pass - - def _do_password(self, register_json, session): - pass + self._remove_session(session) + defer.returnValue(result) def _parse_json(request): @@ -157,5 +225,6 @@ def _parse_json(request): except ValueError: raise SynapseError(400, "Content not JSON.") + def register_servlets(hs, http_server): RegisterRestServlet(hs).register(http_server) From d821755b49e5387879a63a01e0f8916e360bfc5b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 14:31:53 +0100 Subject: [PATCH 04/40] Updated webclient to support the new registration logic. --- webclient/components/matrix/matrix-service.js | 143 +++++++++++++++--- 1 file changed, 120 insertions(+), 23 deletions(-) diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 68ef16800..d7d278a7f 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -81,38 +81,135 @@ angular.module('matrixService', []) return $http(request); }; + + var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) { + var data = {}; + if (loginType === "m.login.recaptcha") { + var challengeToken = Recaptcha.get_challenge(); + var captchaEntry = Recaptcha.get_response(); + data = { + type: "m.login.recaptcha", + challenge: challengeToken, + response: captchaEntry + }; + } + else if (loginType === "m.login.email.identity") { + data = { + threepidCreds: threepidCreds + }; + } + else if (loginType === "m.login.password") { + data = { + user_id: userName, + password: password + }; + } + + if (sessionId) { + data.session = sessionId; + } + data.type = loginType; + console.log("doRegisterLogin >>> " + loginType); + return doRequest("POST", path, undefined, data); + }; return { /****** Home server API ******/ prefix: prefixPath, // Register an user - register: function(user_name, password, threepidCreds, useCaptcha) { - // The REST path spec + register: function(user_name, password, threepidCreds, useCaptcha) { + // registration is composed of multiple requests, to check you can + // register, then to actually register. This deferred will fire when + // all the requests are done, along with the final response. + var deferred = $q.defer(); var path = "/register"; - var data = { - user_id: user_name, - password: password, - threepidCreds: threepidCreds - }; + // check we can actually register with this HS. + doRequest("GET", path, undefined, undefined).then( + function(response) { + console.log("/register [1] : "+JSON.stringify(response)); + var flows = response.data.flows; + var knownTypes = [ + "m.login.password", + "m.login.recaptcha", + "m.login.email.identity" + ]; + // if they entered 3pid creds, we want to use a flow which uses it. + var useThreePidFlow = threepidCreds != undefined; + var flowIndex = 0; + var firstRegType = undefined; + + for (var i=0; i Date: Mon, 15 Sep 2014 14:52:39 +0100 Subject: [PATCH 05/40] Make captcha work again with the new registration logic. --- synapse/rest/register.py | 1 + webclient/components/matrix/matrix-service.js | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/synapse/rest/register.py b/synapse/rest/register.py index 8036c3c40..fe8f0ed23 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -133,6 +133,7 @@ class RegisterRestServlet(RestServlet): logger.debug("Removing session %s", session) self.sessions.pop(session["id"]) + @defer.inlineCallbacks def _do_recaptcha(self, request, register_json, session): if not self.hs.config.enable_registration_captcha: raise SynapseError(400, "Captcha not required.") diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index d7d278a7f..35ebca961 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -154,6 +154,13 @@ angular.module('matrixService', []) } if (!useCaptcha && regType == "m.login.recaptcha") { console.error("Web client setup to not use captcha, but HS demands a captcha."); + deferred.reject({ + data: { + errcode: "M_CAPTCHA_NEEDED", + error: "Home server requires a captcha." + } + }); + return; } } } @@ -183,7 +190,20 @@ angular.module('matrixService', []) deferred.resolve(response); } else if (response.data.next) { - return doRegisterLogin(path, response.data.next, sessionId, user_name, password, threepidCreds).then( + var nextType = response.data.next; + if (response.data.next instanceof Array) { + for (var i=0; i Date: Mon, 15 Sep 2014 15:09:21 +0100 Subject: [PATCH 06/40] Updated cmdclient to use new registration logic. --- cmdclient/console.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/cmdclient/console.py b/cmdclient/console.py index 2e6b02676..5a9d4c3c4 100755 --- a/cmdclient/console.py +++ b/cmdclient/console.py @@ -145,35 +145,50 @@ class SynapseCmd(cmd.Cmd): : Do not automatically clobber config values. """ args = self._parse(line, ["userid", "noupdate"]) - path = "/register" password = None pwd = None pwd2 = "_" while pwd != pwd2: - pwd = getpass.getpass("(Optional) Type a password for this user: ") - if len(pwd) == 0: - print "Not using a password for this user." - break + pwd = getpass.getpass("Type a password for this user: ") pwd2 = getpass.getpass("Retype the password: ") - if pwd != pwd2: + if pwd != pwd2 or len(pwd) == 0: print "Password mismatch." + pwd = None else: password = pwd - body = {} + body = { + "type": "m.login.password" + } if "userid" in args: body["user_id"] = args["userid"] if password: body["password"] = password - reactor.callFromThread(self._do_register, "POST", path, body, + reactor.callFromThread(self._do_register, body, "noupdate" not in args) @defer.inlineCallbacks - def _do_register(self, method, path, data, update_config): - url = self._url() + path - json_res = yield self.http_client.do_request(method, url, data=data) + def _do_register(self, data, update_config): + # check the registration flows + url = self._url() + "/register" + json_res = yield self.http_client.do_request("GET", url) + print json.dumps(json_res, indent=4) + + passwordFlow = None + for flow in json_res["flows"]: + if flow["type"] == "m.login.recaptcha" or ("stages" in flow and "m.login.recaptcha" in flow["stages"]): + print "Unable to register: Home server requires captcha." + return + if flow["type"] == "m.login.password" and "stages" not in flow: + passwordFlow = flow + break + + if not passwordFlow: + return + + json_res = yield self.http_client.do_request("POST", url, data=data) print json.dumps(json_res, indent=4) if update_config and "user_id" in json_res: self.config["user"] = json_res["user_id"] From c04caff55c47364634b4148e9a4b328a250a31ab Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 15:14:19 +0100 Subject: [PATCH 07/40] Fix unit tests. --- tests/rest/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/rest/utils.py b/tests/rest/utils.py index ce2e8fd98..25ed1388c 100644 --- a/tests/rest/utils.py +++ b/tests/rest/utils.py @@ -95,8 +95,14 @@ class RestTestCase(unittest.TestCase): @defer.inlineCallbacks def register(self, user_id): - (code, response) = yield self.mock_resource.trigger("POST", "/register", - '{"user_id":"%s"}' % user_id) + (code, response) = yield self.mock_resource.trigger( + "POST", + "/register", + json.dumps({ + "user_id": user_id, + "password": "test", + "type": "m.login.password" + })) self.assertEquals(200, code) defer.returnValue(response) From 0b8a3bc3b91db723d431ca0d2d8978524b0a411f Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 15:27:17 +0100 Subject: [PATCH 08/40] Update spec to include m.login.email.identity --- docs/specification.rst | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/specification.rst b/docs/specification.rst index b06f14f8c..a2e348fa2 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -1379,7 +1379,7 @@ This specification defines the following login types: - ``m.login.oauth2`` - ``m.login.email.code`` - ``m.login.email.url`` - + - ``m.login.email.identity`` Password-based -------------- @@ -1527,6 +1527,31 @@ If the link has not been visited yet, a standard error response with an errcode ``M_LOGIN_EMAIL_URL_NOT_YET`` should be returned. +Email-based (identity server) +----------------------------- +:Type: + ``m.login.email.identity`` +:Description: + Login is supported by authorising an email address with an identity server. + +Prior to submitting this, the client should authenticate with an identity server. +After authenticating, the session information should be submitted to the home server. + +To respond to this type, reply with:: + + { + "type": "m.login.email.identity", + "threepidCreds": [ + { + "sid": "", + "clientSecret": "", + "idServer": "" + } + ] + } + + + N-Factor Authentication ----------------------- Multiple login stages can be combined to create N-factor authentication during login. From 42f5b0a6b855d76812f3772b07d1b9fae4987c34 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 15 Sep 2014 16:31:59 +0200 Subject: [PATCH 09/40] Recents uses data directly from $rootscope.events --- .../matrix/event-handler-service.js | 43 ++++-- webclient/recents/recents-controller.js | 127 +----------------- webclient/recents/recents-filter.js | 30 +++-- webclient/recents/recents.html | 66 ++++----- 4 files changed, 95 insertions(+), 171 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 4604ff619..a2c807b3f 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -63,13 +63,14 @@ angular.module('eventHandlerService', []) var initRoom = function(room_id) { if (!(room_id in $rootScope.events.rooms)) { console.log("Creating new handler entry for " + room_id); - $rootScope.events.rooms[room_id] = {}; - $rootScope.events.rooms[room_id].messages = []; - $rootScope.events.rooms[room_id].members = {}; - - // Pagination information - $rootScope.events.rooms[room_id].pagination = { - earliest_token: "END" // how far back we've paginated + $rootScope.events.rooms[room_id] = { + room_id: room_id, + messages: [], + members: {}, + // Pagination information + pagination: { + earliest_token: "END" // how far back we've paginated + } }; } }; @@ -257,7 +258,9 @@ angular.module('eventHandlerService', []) // FIXME: /initialSync on a particular room is not yet available // So initRoom on a new room is not called. Make sure the room data is initialised here - initRoom(event.room_id); + if (event.room_id) { + initRoom(event.room_id); + } // Avoid duplicated events // Needed for rooms where initialSync has not been done. @@ -347,6 +350,30 @@ angular.module('eventHandlerService', []) resetRoomMessages: function(room_id) { resetRoomMessages(room_id); + }, + + /** + * Compute the room users number, ie the number of members who has joined the room. + * @param {String} room_id the room id + * @returns {undefined | Number} the room users number if available + */ + getUsersCountInRoom: function(room_id) { + var memberCount; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + memberCount = 0; + + for (var i in room.members) { + var member = room.members[i]; + + if ("join" === member.membership) { + memberCount = memberCount + 1; + } + } + } + + return memberCount; } }; }]); diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index a0db0538f..2006f13a5 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -16,134 +16,13 @@ 'use strict'; -angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) -.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', - function($rootScope, $scope, matrixService, eventHandlerService) { - - // FIXME: Angularjs reloads the controller (and resets its $scope) each time - // the page URL changes, use $rootScope to avoid to have to reload data - $rootScope.rooms; +angular.module('RecentsController', ['matrixService', 'matrixFilter']) +.controller('RecentsController', ['$rootScope', + function($rootScope) { // $rootScope of the parent where the recents component is included can override this value // in order to highlight a specific room in the list $rootScope.recentsSelectedRoomID; - - var listenToEventStream = function() { - // Refresh the list on matrix invitation and message event - $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - if (!$rootScope.rooms[event.room_id]) { - // The user has joined a new room, which we do not have data yet. The reason is that - // the room has appeared in the scope of the user rooms after the global initialSync - // FIXME: an initialSync on this specific room should be done - $rootScope.rooms[event.room_id] = { - room_id:event.room_id - }; - } - else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) { - // The user has been kicked or banned from the room, remove this room from the recents - delete $rootScope.rooms[event.room_id]; - } - - if ($rootScope.rooms[event.room_id]) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - - // Update room users count - $rootScope.rooms[event.room_id].numUsersInRoom = getUsersCountInRoom(event.room_id); - } - }); - $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id] = event; - } - }); - $rootScope.$on(eventHandlerService.NAME_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - }; - - /** - * Compute the room users number, ie the number of members who has joined the room. - * @param {String} room_id the room id - * @returns {undefined | Number} the room users number if available - */ - var getUsersCountInRoom = function(room_id) { - var memberCount; - - var room = $rootScope.events.rooms[room_id]; - if (room) { - memberCount = 0; - - for (var i in room.members) { - var member = room.members[i]; - - if ("join" === member.membership) { - memberCount = memberCount + 1; - } - } - } - - return memberCount; - }; - $scope.onInit = function() { - // Init recents list only once - if ($rootScope.rooms) { - return; - } - - $rootScope.rooms = {}; - - // Use initialSync data to init the recents list - eventHandlerService.waitForInitialSyncCompletion().then( - function(initialSyncData) { - - var rooms = initialSyncData.data.rooms; - for (var i=0; i +
- + ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}"> @@ -25,67 +29,67 @@ {{ room.inviter | mUserDisplayName: room.room_id }} invited you -
+
- - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined + + {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined - - - {{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left + + + {{lastMsg.state_key | mUserDisplayName: room.room_id }} left - - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} - {{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }} - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} + + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }} + {{ lastMsg.state_key | mUserDisplayName: room.room_id }} - - : {{ room.lastMsg.content.reason }} + + : {{ lastMsg.content.reason }} - - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} - {{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }} - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} - - : {{ room.lastMsg.content.reason }} + + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }} + {{ lastMsg.state_key | mUserDisplayName: room.room_id }} + + : {{ lastMsg.content.reason }}
-
+
- {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} : - + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} : +
- {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
- +
- {{ room.lastMsg.content }} + {{ lastMsg.content }}
- {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }} + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ lastMsg.content.topic }}
- {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }} + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ lastMsg.content.name }}
-
+
Call
From 2c00e1ecd9774463d687559d5df38b2a76340b32 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 15:38:29 +0100 Subject: [PATCH 10/40] Be consistent when associating keys with login types for registration/login. --- cmdclient/console.py | 2 +- synapse/rest/register.py | 2 +- tests/rest/utils.py | 2 +- webclient/components/matrix/matrix-service.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmdclient/console.py b/cmdclient/console.py index 5a9d4c3c4..d9c6ec6a7 100755 --- a/cmdclient/console.py +++ b/cmdclient/console.py @@ -162,7 +162,7 @@ class SynapseCmd(cmd.Cmd): "type": "m.login.password" } if "userid" in args: - body["user_id"] = args["userid"] + body["user"] = args["userid"] if password: body["password"] = password diff --git a/synapse/rest/register.py b/synapse/rest/register.py index fe8f0ed23..c2c80e70c 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -192,7 +192,7 @@ class RegisterRestServlet(RestServlet): raise SynapseError(400, "Captcha is required.") password = register_json["password"].encode("utf-8") - desired_user_id = (register_json["user_id"].encode("utf-8") if "user_id" + desired_user_id = (register_json["user"].encode("utf-8") if "user" in register_json else None) if desired_user_id and urllib.quote(desired_user_id) != desired_user_id: raise SynapseError( diff --git a/tests/rest/utils.py b/tests/rest/utils.py index 25ed1388c..579441fb4 100644 --- a/tests/rest/utils.py +++ b/tests/rest/utils.py @@ -99,7 +99,7 @@ class RestTestCase(unittest.TestCase): "POST", "/register", json.dumps({ - "user_id": user_id, + "user": user_id, "password": "test", "type": "m.login.password" })) diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 35ebca961..069e02e93 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -100,7 +100,7 @@ angular.module('matrixService', []) } else if (loginType === "m.login.password") { data = { - user_id: userName, + user: userName, password: password }; } From 688c37ebf4357064f9d9bac01797800cade991a5 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 15:53:05 +0100 Subject: [PATCH 11/40] Updated CHANGES and UPGRADE to reflect registration API changes. --- CHANGES.rst | 11 +++++++++++ UPGRADE.rst | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d3beea3ed..0853c0312 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,14 @@ +Latest +====== + +Registration API: + * The registration API has been overhauled to function like the login API. In + practice, this means registration requests must now include the following: + 'type':'m.login.password'. See UPGRADE for more information on this. + * The 'user_id' key has been renamed to 'user' to better match the login API. + * There is an additional login type: 'm.login.email.identity'. + * The command client and web client have been updated to reflect these changes. + Changes in synapse 0.2.3 (2014-09-12) ===================================== diff --git a/UPGRADE.rst b/UPGRADE.rst index da2a7a0a2..44c0af728 100644 --- a/UPGRADE.rst +++ b/UPGRADE.rst @@ -1,3 +1,26 @@ +Upgrading to Latest +=================== + +This registration API now closely matches the login API. This introduces a bit +more backwards and forwards between the HS and the client, but this improves +the overall flexibility of the API. You can now GET on /register to retrieve a list +of valid registration flows. Upon choosing one, they are submitted in the same +way as login, e.g:: + + { + type: m.login.password, + user: foo, + password: bar + } + +The default HS supports 2 flows, with and without Identity Server email +authentication. Enabling captcha on the HS will add in an extra step to all +flows: ``m.login.recaptcha`` which must be completed before you can transition +to the next stage. There is a new login type: ``m.login.email.identity`` which +contains the ``threepidCreds`` key which were previously sent in the original +register request. For more information on this, see the specification. + + Upgrading to v0.2.0 =================== From 34d7896b06ba72c4a7ea28d5c42124a35df121bd Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 15 Sep 2014 16:05:51 +0100 Subject: [PATCH 12/40] More helpful 400 error messages. --- synapse/rest/register.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/synapse/rest/register.py b/synapse/rest/register.py index c2c80e70c..af528a44f 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -82,6 +82,10 @@ class RegisterRestServlet(RestServlet): session = (register_json["session"] if "session" in register_json else None) + login_type = None + if "type" not in register_json: + raise SynapseError(400, "Missing 'type' key.") + try: login_type = register_json["type"] stages = { @@ -106,7 +110,7 @@ class RegisterRestServlet(RestServlet): defer.returnValue((200, response)) except KeyError as e: logger.exception(e) - raise SynapseError(400, "Missing JSON keys or bad login type.") + raise SynapseError(400, "Missing JSON keys for login type %s." % login_type) def on_OPTIONS(self, request): return (200, {}) From 6ac0b4ade86d1bdb59c01ff8edff6b149cf1981e Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 14:54:25 +0100 Subject: [PATCH 13/40] Fix 'age' key to update on retries --- synapse/federation/replication.py | 19 ++++++++++++++++--- synapse/federation/transport.py | 17 +++++++++++++++-- synapse/http/client.py | 13 ++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/synapse/federation/replication.py b/synapse/federation/replication.py index c79ce4468..a48a7ac15 100644 --- a/synapse/federation/replication.py +++ b/synapse/federation/replication.py @@ -292,8 +292,8 @@ class ReplicationLayer(object): transaction = Transaction(**transaction_data) for p in transaction.pdus: - if "age" in p: - p["age_ts"] = int(self.clock.time_msec()) - int(p["age"]) + if "age_ts" in p: + p["age"] = int(self._clock.time_msec()) - int(p["age_ts"]) pdu_list = [Pdu(**p) for p in transaction.pdus] @@ -602,8 +602,21 @@ class _TransactionQueue(object): logger.debug("TX [%s] Sending transaction...", destination) # Actually send the transaction + + # FIXME (erikj): This is a bit of a hack to make the Pdu age + # keys work + def cb(transaction): + now = int(self._clock.time_msec()) + if "pdus" in transaction: + for p in transaction["pdus"]: + if "age_ts" in p: + p["age"] = now - int(p["age_ts"]) + + return transaction + code, response = yield self.transport_layer.send_transaction( - transaction + transaction, + on_send_callback=cb, ) logger.debug("TX [%s] Sent transaction", destination) diff --git a/synapse/federation/transport.py b/synapse/federation/transport.py index 6e62ae7c7..afc777ec9 100644 --- a/synapse/federation/transport.py +++ b/synapse/federation/transport.py @@ -144,7 +144,7 @@ class TransportLayer(object): @defer.inlineCallbacks @log_function - def send_transaction(self, transaction): + def send_transaction(self, transaction, on_send_callback=None): """ Sends the given Transaction to it's destination Args: @@ -165,10 +165,23 @@ class TransportLayer(object): data = transaction.get_dict() + # FIXME (erikj): This is a bit of a hack to make the Pdu age + # keys work + def cb(destination, method, path_bytes, producer): + if not on_send_callback: + return + + transaction = json.loads(producer.body) + + new_transaction = on_send_callback(transaction) + + producer.reset(new_transaction) + code, response = yield self.client.put_json( transaction.destination, path=PREFIX + "/send/%s/" % transaction.transaction_id, - data=data + data=data, + on_send_callback=cb, ) logger.debug( diff --git a/synapse/http/client.py b/synapse/http/client.py index ece6318e0..eb11bfd4d 100644 --- a/synapse/http/client.py +++ b/synapse/http/client.py @@ -122,7 +122,7 @@ class TwistedHttpClient(HttpClient): self.hs = hs @defer.inlineCallbacks - def put_json(self, destination, path, data): + def put_json(self, destination, path, data, on_send_callback=None): if destination in _destination_mappings: destination = _destination_mappings[destination] @@ -131,7 +131,8 @@ class TwistedHttpClient(HttpClient): "PUT", path.encode("ascii"), producer=_JsonProducer(data), - headers_dict={"Content-Type": ["application/json"]} + headers_dict={"Content-Type": ["application/json"]}, + on_send_callback=on_send_callback, ) logger.debug("Getting resp body") @@ -218,7 +219,7 @@ class TwistedHttpClient(HttpClient): @defer.inlineCallbacks def _create_request(self, destination, method, path_bytes, param_bytes=b"", query_bytes=b"", producer=None, headers_dict={}, - retry_on_dns_fail=True): + retry_on_dns_fail=True, on_send_callback=None): """ Creates and sends a request to the given url """ headers_dict[b"User-Agent"] = [b"Synapse"] @@ -242,6 +243,9 @@ class TwistedHttpClient(HttpClient): endpoint = self._getEndpoint(reactor, destination); while True: + if on_send_callback: + on_send_callback(destination, method, path_bytes, producer) + try: response = yield self.agent.request( destination, @@ -310,6 +314,9 @@ class _JsonProducer(object): """ Used by the twisted http client to create the HTTP body from json """ def __init__(self, jsn): + self.reset(jsn) + + def reset(self, jsn): self.body = encode_canonical_json(jsn) self.length = len(self.body) From 0897a09f49ea3e259acebe5ec630a06a6acfb08d Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 15:05:12 +0100 Subject: [PATCH 14/40] Fix unit tests after adding extra argument on put_json --- tests/federation/test_federation.py | 9 ++++++--- tests/handlers/test_presence.py | 23 ++++++++++++++++++----- tests/handlers/test_typing.py | 6 ++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/federation/test_federation.py b/tests/federation/test_federation.py index 954ccac2a..bb17e9aaf 100644 --- a/tests/federation/test_federation.py +++ b/tests/federation/test_federation.py @@ -17,7 +17,7 @@ from twisted.internet import defer from tests import unittest # python imports -from mock import Mock +from mock import Mock, ANY from ..utils import MockHttpResource, MockClock @@ -181,7 +181,8 @@ class FederationTestCase(unittest.TestCase): "depth": 1, }, ] - } + }, + on_send_callback=ANY, ) @defer.inlineCallbacks @@ -212,7 +213,9 @@ class FederationTestCase(unittest.TestCase): "content": {"testing": "content here"}, } ], - }) + }, + on_send_callback=ANY, + ) @defer.inlineCallbacks def test_recv_edu(self): diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py index 06f5f9c2b..0cb4dfba3 100644 --- a/tests/handlers/test_presence.py +++ b/tests/handlers/test_presence.py @@ -319,7 +319,8 @@ class PresenceInvitesTestCase(unittest.TestCase): "observer_user": "@apple:test", "observed_user": "@cabbage:elsewhere", } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -345,7 +346,8 @@ class PresenceInvitesTestCase(unittest.TestCase): "observer_user": "@cabbage:elsewhere", "observed_user": "@apple:test", } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -376,7 +378,8 @@ class PresenceInvitesTestCase(unittest.TestCase): "observer_user": "@cabbage:elsewhere", "observed_user": "@durian:test", } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -765,7 +768,8 @@ class PresencePushTestCase(unittest.TestCase): "last_active_ago": 0}, ], } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -780,7 +784,8 @@ class PresencePushTestCase(unittest.TestCase): "last_active_ago": 0}, ], } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -906,6 +911,7 @@ class PresencePushTestCase(unittest.TestCase): ], } ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -920,6 +926,7 @@ class PresencePushTestCase(unittest.TestCase): ], } ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -949,6 +956,7 @@ class PresencePushTestCase(unittest.TestCase): ], } ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -1145,6 +1153,7 @@ class PresencePollingTestCase(unittest.TestCase): "poll": [ "@potato:remote" ], }, ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -1157,6 +1166,7 @@ class PresencePollingTestCase(unittest.TestCase): "push": [ {"user_id": "@clementine:test" }], }, ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -1185,6 +1195,7 @@ class PresencePollingTestCase(unittest.TestCase): "push": [ {"user_id": "@fig:test" }], }, ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -1217,6 +1228,7 @@ class PresencePollingTestCase(unittest.TestCase): "unpoll": [ "@potato:remote" ], }, ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -1248,6 +1260,7 @@ class PresencePollingTestCase(unittest.TestCase): ], }, ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) diff --git a/tests/handlers/test_typing.py b/tests/handlers/test_typing.py index ab908cdfc..a66f208ab 100644 --- a/tests/handlers/test_typing.py +++ b/tests/handlers/test_typing.py @@ -169,7 +169,8 @@ class TypingNotificationsTestCase(unittest.TestCase): "user_id": self.u_apple.to_string(), "typing": True, } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) @@ -219,7 +220,8 @@ class TypingNotificationsTestCase(unittest.TestCase): "user_id": self.u_apple.to_string(), "typing": False, } - ) + ), + on_send_callback=ANY, ), defer.succeed((200, "OK")) ) From e639a3516d271c395862bcd0c6facfd8c5c9ff58 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 15:18:51 +0100 Subject: [PATCH 15/40] Improve logging in federation handler. --- synapse/handlers/federation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 59cbf71d7..5187bcb5b 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -93,6 +93,8 @@ class FederationHandler(BaseHandler): """ event = self.pdu_codec.event_from_pdu(pdu) + logger.debug("Got event: %s", event.event_id) + with (yield self.lock_manager.lock(pdu.context)): if event.is_state and not backfilled: is_new_state = yield self.state_handler.handle_new_state( @@ -106,7 +108,7 @@ class FederationHandler(BaseHandler): # respond to PDU. if hasattr(event, "state_key") and not is_new_state: - logger.debug("Ignoring old state.") + logger.debug("Ignoring old state: %s", event.event_id) return target_is_mine = False From 8aa4b7bf7fdc31b3a146fe8fdc07922a4bfb1f78 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 15 Sep 2014 17:31:07 +0200 Subject: [PATCH 16/40] Recents must not show temporary fake messages --- .../matrix/event-handler-service.js | 24 +++++++++++++++++++ webclient/recents/recents-controller.js | 7 ++++-- webclient/recents/recents-filter.js | 9 ++----- webclient/recents/recents.html | 2 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index a2c807b3f..6fd77c4f2 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -352,6 +352,30 @@ angular.module('eventHandlerService', []) resetRoomMessages(room_id); }, + /** + * Return the last message event of a room + * @param {String} room_id the room id + * @param {Boolean} filterFake true to not take into account fake messages + * @returns {undefined | Event} the last message event if available + */ + getLastMessage: function(room_id, filterEcho) { + var lastMessage; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + for (var i = room.messages.length - 1; i >= 0; i--) { + var message = room.messages[i]; + + if (!filterEcho || undefined === message.echo_msg_state) { + lastMessage = message; + break; + } + } + } + + return lastMessage; + }, + /** * Compute the room users number, ie the number of members who has joined the room. * @param {String} room_id the room id diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 2006f13a5..ee8a41c36 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -17,8 +17,11 @@ 'use strict'; angular.module('RecentsController', ['matrixService', 'matrixFilter']) -.controller('RecentsController', ['$rootScope', - function($rootScope) { +.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService', + function($rootScope, $scope, eventHandlerService) { + + // Expose the service to the view + $scope.eventHandlerService = eventHandlerService; // $rootScope of the parent where the recents component is included can override this value // in order to highlight a specific room in the list diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js index e8a706a5d..67fe49d4b 100644 --- a/webclient/recents/recents-filter.js +++ b/webclient/recents/recents-filter.js @@ -35,14 +35,9 @@ angular.module('RecentsController') // And time sort them // The room with the lastest message at first filtered.sort(function (roomA, roomB) { - var lastMsgRoomA, lastMsgRoomB; - if (roomA.messages && 0 < roomA.messages.length) { - lastMsgRoomA = roomA.messages[roomA.messages.length - 1]; - } - if (roomB.messages && 0 < roomB.messages.length) { - lastMsgRoomB = roomB.messages[roomB.messages.length - 1]; - } + var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true); + var lastMsgRoomB = eventHandlerService.getLastMessage(roomB.room_id, true); // Invite message does not have a body message nor ts // Puth them at the top of the list diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index eb9e269a4..789ffc9d2 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -16,7 +16,7 @@
From 59516a8bb1cd8040bd07420f84b856bd8904d6c8 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 16:40:44 +0100 Subject: [PATCH 17/40] Correctly handle receiving 'missing' Pdus from federation, rather than just discarding them. --- synapse/handlers/federation.py | 12 +++++------- synapse/storage/__init__.py | 15 ++++++++++----- tests/handlers/test_federation.py | 4 +++- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 5187bcb5b..001c6c110 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -100,17 +100,11 @@ class FederationHandler(BaseHandler): is_new_state = yield self.state_handler.handle_new_state( pdu ) - if not is_new_state: - return else: is_new_state = False # TODO: Implement something in federation that allows us to # respond to PDU. - if hasattr(event, "state_key") and not is_new_state: - logger.debug("Ignoring old state: %s", event.event_id) - return - target_is_mine = False if hasattr(event, "target_host"): target_is_mine = event.target_host == self.hs.hostname @@ -141,7 +135,11 @@ class FederationHandler(BaseHandler): else: with (yield self.room_lock.lock(event.room_id)): - yield self.store.persist_event(event, backfilled) + yield self.store.persist_event( + event, + backfilled, + is_new_state=is_new_state + ) room = yield self.store.get_room(event.room_id) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index 9201a377b..1cede2809 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -68,7 +68,8 @@ class DataStore(RoomMemberStore, RoomStore, @defer.inlineCallbacks @log_function - def persist_event(self, event=None, backfilled=False, pdu=None): + def persist_event(self, event=None, backfilled=False, pdu=None, + is_new_state=True): stream_ordering = None if backfilled: if not self.min_token_deferred.called: @@ -83,6 +84,7 @@ class DataStore(RoomMemberStore, RoomStore, event=event, backfilled=backfilled, stream_ordering=stream_ordering, + is_new_state=is_new_state, ) except _RollbackButIsFineException as e: pass @@ -109,12 +111,14 @@ class DataStore(RoomMemberStore, RoomStore, defer.returnValue(event) def _persist_pdu_event_txn(self, txn, pdu=None, event=None, - backfilled=False, stream_ordering=None): + backfilled=False, stream_ordering=None, + is_new_state=True): if pdu is not None: self._persist_event_pdu_txn(txn, pdu) if event is not None: return self._persist_event_txn( - txn, event, backfilled, stream_ordering + txn, event, backfilled, stream_ordering, + is_new_state=is_new_state, ) def _persist_event_pdu_txn(self, txn, pdu): @@ -141,7 +145,8 @@ class DataStore(RoomMemberStore, RoomStore, self._update_min_depth_for_context_txn(txn, pdu.context, pdu.depth) @log_function - def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None): + def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None, + is_new_state=True): if event.type == RoomMemberEvent.TYPE: self._store_room_member_txn(txn, event) elif event.type == FeedbackEvent.TYPE: @@ -195,7 +200,7 @@ class DataStore(RoomMemberStore, RoomStore, ) raise _RollbackButIsFineException("_persist_event") - if not backfilled and hasattr(event, "state_key"): + if is_new_state and hasattr(event, "state_key"): vals = { "event_id": event.event_id, "room_id": event.room_id, diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py index f0308a29d..eb6b7c22e 100644 --- a/tests/handlers/test_federation.py +++ b/tests/handlers/test_federation.py @@ -74,7 +74,9 @@ class FederationTestCase(unittest.TestCase): yield self.handlers.federation_handler.on_receive_pdu(pdu, False) - self.datastore.persist_event.assert_called_once_with(ANY, False) + self.datastore.persist_event.assert_called_once_with( + ANY, False, is_new_state=False + ) self.notifier.on_new_room_event.assert_called_once_with(ANY) @defer.inlineCallbacks From 40d2f38abe604525fb03622995c377904f1ea3dd Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 16:55:39 +0100 Subject: [PATCH 18/40] Fix bug where we incorrectly calculated 'age_ts' from 'age' key rather than the reverse. Don't transmit age_ts to clients for now. --- synapse/api/events/__init__.py | 1 + synapse/federation/replication.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py index 72c493db5..add81ec3e 100644 --- a/synapse/api/events/__init__.py +++ b/synapse/api/events/__init__.py @@ -25,6 +25,7 @@ def serialize_event(hs, e): d = e.get_dict() if "age_ts" in d: d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"] + del d["age_ts"] return d diff --git a/synapse/federation/replication.py b/synapse/federation/replication.py index a48a7ac15..96b82f00c 100644 --- a/synapse/federation/replication.py +++ b/synapse/federation/replication.py @@ -292,8 +292,9 @@ class ReplicationLayer(object): transaction = Transaction(**transaction_data) for p in transaction.pdus: - if "age_ts" in p: - p["age"] = int(self._clock.time_msec()) - int(p["age_ts"]) + if "age" in p: + p["age_ts"] = int(self._clock.time_msec()) - int(p["age"]) + del p["age"] pdu_list = [Pdu(**p) for p in transaction.pdus] From b0483cd47d72ea73760c8301f5729d840ceb7683 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Mon, 15 Sep 2014 18:22:38 +0200 Subject: [PATCH 19/40] Filter room where the user has been banned --- .../components/matrix/event-handler-service.js | 16 ++++++++++++++++ webclient/recents/recents-filter.js | 18 +++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 6fd77c4f2..4b0566fe3 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -398,6 +398,22 @@ angular.module('eventHandlerService', []) } return memberCount; + }, + + /** + * Get the member object of a room member + * @param {String} room_id the room id + * @param {String} user_id the id of the user + * @returns {undefined | Object} the member object of this user in this room if he is part of the room + */ + getMember: function(room_id, user_id) { + var member; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + member = room.members[user_id]; + } + return member; } }; }]); diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js index 67fe49d4b..2fd4dbe98 100644 --- a/webclient/recents/recents-filter.js +++ b/webclient/recents/recents-filter.js @@ -17,19 +17,27 @@ 'use strict'; angular.module('RecentsController') -.filter('orderRecents', ["eventHandlerService", function(eventHandlerService) { +.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) { return function(rooms) { + var user_id = matrixService.config().user_id; + // Transform the dict into an array // The key, room_id, is already in value objects var filtered = []; angular.forEach(rooms, function(room, room_id) { - // Count users here - // TODO: Compute it directly in eventHandlerService - room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id); + // Show the room only if the user has joined it or has been invited + // (ie, do not show it if he has been banned) + var member = eventHandlerService.getMember(room_id, user_id); + if (member && ("invite" === member.membership || "join" === member.membership)) { + + // Count users here + // TODO: Compute it directly in eventHandlerService + room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id); - filtered.push(room); + filtered.push(room); + } }); // And time sort them From 1e4b971f95ac953e9fbd4a8e4cc0d0d2edc5e5ea Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Mon, 15 Sep 2014 17:43:46 +0100 Subject: [PATCH 20/40] Fix bug where we didn't always get 'prev_content' key --- synapse/api/events/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py index add81ec3e..a9991e9c9 100644 --- a/synapse/api/events/__init__.py +++ b/synapse/api/events/__init__.py @@ -57,6 +57,7 @@ class SynapseEvent(JsonEncodedObject): "state_key", "required_power_level", "age_ts", + "prev_content", ] internal_keys = [ @@ -172,10 +173,6 @@ class SynapseEvent(JsonEncodedObject): class SynapseStateEvent(SynapseEvent): - valid_keys = SynapseEvent.valid_keys + [ - "prev_content", - ] - def __init__(self, **kwargs): if "state_key" not in kwargs: kwargs["state_key"] = "" From 5f30a69a9e617028c39ea3851b9a5de43d42a299 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 11:22:40 +0100 Subject: [PATCH 21/40] Added PasswordResetRestServlet. Hit the IS to confirm the email/user. Need to send email. --- synapse/handlers/login.py | 29 ++++++++++++++++++++++++++++- synapse/rest/login.py | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/login.py b/synapse/handlers/login.py index 6ee7ce5a2..101b9a81a 100644 --- a/synapse/handlers/login.py +++ b/synapse/handlers/login.py @@ -17,9 +17,11 @@ from twisted.internet import defer from ._base import BaseHandler from synapse.api.errors import LoginError, Codes +from synapse.http.client import PlainHttpClient import bcrypt import logging +import urllib logger = logging.getLogger(__name__) @@ -62,4 +64,29 @@ class LoginHandler(BaseHandler): defer.returnValue(token) else: logger.warn("Failed password login for user %s", user) - raise LoginError(403, "", errcode=Codes.FORBIDDEN) \ No newline at end of file + raise LoginError(403, "", errcode=Codes.FORBIDDEN) + + @defer.inlineCallbacks + def reset_password(self, user_id, email): + is_valid = yield self._check_valid_association(user_id, email) + logger.info("reset_password user=%s email=%s valid=%s", user_id, email, + is_valid) + + @defer.inlineCallbacks + def _check_valid_association(self, user_id, email): + identity = yield self._query_email(email) + if identity and "mxid" in identity: + if identity["mxid"] == user_id: + defer.returnValue(True) + return + defer.returnValue(False) + + @defer.inlineCallbacks + def _query_email(self, email): + httpCli = PlainHttpClient(self.hs) + data = yield httpCli.get_json( + 'matrix.org:8090', # TODO FIXME This should be configurable. + "/_matrix/identity/api/v1/lookup?medium=email&address=" + + "%s" % urllib.quote(email) + ) + defer.returnValue(data) \ No newline at end of file diff --git a/synapse/rest/login.py b/synapse/rest/login.py index ba49afcaa..7ab9cb51e 100644 --- a/synapse/rest/login.py +++ b/synapse/rest/login.py @@ -73,6 +73,27 @@ class LoginFallbackRestServlet(RestServlet): return (200, {}) +class PasswordResetRestServlet(RestServlet): + PATTERN = client_path_pattern("/login/reset") + + @defer.inlineCallbacks + def on_POST(self, request): + reset_info = _parse_json(request) + try: + email = reset_info["email"] + user_id = reset_info["user_id"] + handler = self.handlers.login_handler + yield handler.reset_password(user_id, email) + # purposefully give no feedback to avoid people hammering different + # combinations. + defer.returnValue((200, {})) + except KeyError: + raise SynapseError( + 400, + "Missing keys. Requires 'email' and 'user_id'." + ) + + def _parse_json(request): try: content = json.loads(request.content.read()) @@ -85,3 +106,4 @@ def _parse_json(request): def register_servlets(hs, http_server): LoginRestServlet(hs).register(http_server) + PasswordResetRestServlet(hs).register(http_server) From cc83b06cd19f8fc52f86700c1663185a2b1a7cac Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 12:36:39 +0100 Subject: [PATCH 22/40] Added support for the HS to send emails. Use it to send password resets. Added email_smtp_server and email_from_address config args. Added emailutils. --- synapse/config/email.py | 39 ++++++++++++++++++++ synapse/config/homeserver.py | 8 +++- synapse/handlers/login.py | 14 +++++++ synapse/util/emailutils.py | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 synapse/config/email.py create mode 100644 synapse/util/emailutils.py diff --git a/synapse/config/email.py b/synapse/config/email.py new file mode 100644 index 000000000..9bcc5a8fe --- /dev/null +++ b/synapse/config/email.py @@ -0,0 +1,39 @@ +# -*- 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 ._base import Config + + +class EmailConfig(Config): + + def __init__(self, args): + super(EmailConfig, self).__init__(args) + self.email_from_address = args.email_from_address + self.email_smtp_server = args.email_smtp_server + + @classmethod + def add_arguments(cls, parser): + super(EmailConfig, cls).add_arguments(parser) + email_group = parser.add_argument_group("email") + email_group.add_argument( + "--email-from-address", + default="FROM@EXAMPLE.COM", + help="The address to send emails from (e.g. for password resets)." + ) + email_group.add_argument( + "--email-smtp-server", + default="", + help="The SMTP server to send emails from (e.g. for password resets)." + ) \ No newline at end of file diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index e16f2c733..4b810a230 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -20,11 +20,15 @@ from .database import DatabaseConfig from .ratelimiting import RatelimitConfig from .repository import ContentRepositoryConfig from .captcha import CaptchaConfig +from .email import EmailConfig + class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, - RatelimitConfig, ContentRepositoryConfig, CaptchaConfig): + RatelimitConfig, ContentRepositoryConfig, CaptchaConfig, + EmailConfig): pass -if __name__=='__main__': + +if __name__ == '__main__': import sys HomeServerConfig.load_config("Generate config", sys.argv[1:], "HomeServer") diff --git a/synapse/handlers/login.py b/synapse/handlers/login.py index 101b9a81a..80ffdd272 100644 --- a/synapse/handlers/login.py +++ b/synapse/handlers/login.py @@ -18,6 +18,8 @@ from twisted.internet import defer from ._base import BaseHandler from synapse.api.errors import LoginError, Codes from synapse.http.client import PlainHttpClient +from synapse.util.emailutils import EmailException +import synapse.util.emailutils as emailutils import bcrypt import logging @@ -71,6 +73,18 @@ class LoginHandler(BaseHandler): is_valid = yield self._check_valid_association(user_id, email) logger.info("reset_password user=%s email=%s valid=%s", user_id, email, is_valid) + if is_valid: + try: + # send an email out + emailutils.send_email( + smtp_server=self.hs.config.email_smtp_server, + from_addr=self.hs.config.email_from_address, + to_addr=email, + subject="Password Reset", + body="TODO." + ) + except EmailException as e: + logger.exception(e) @defer.inlineCallbacks def _check_valid_association(self, user_id, email): diff --git a/synapse/util/emailutils.py b/synapse/util/emailutils.py new file mode 100644 index 000000000..cdb0abd7e --- /dev/null +++ b/synapse/util/emailutils.py @@ -0,0 +1,71 @@ +# -*- 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. +""" This module allows you to send out emails. +""" +import email.utils +import smtplib +import twisted.python.log +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +import logging + +logger = logging.getLogger(__name__) + + +class EmailException(Exception): + pass + + +def send_email(smtp_server, from_addr, to_addr, subject, body): + """Sends an email. + + Args: + smtp_server(str): The SMTP server to use. + from_addr(str): The address to send from. + to_addr(str): The address to send to. + subject(str): The subject of the email. + body(str): The plain text body of the email. + Raises: + EmailException if there was a problem sending the mail. + """ + if not smtp_server or not from_addr or not to_addr: + raise EmailException("Need SMTP server, from and to addresses. Check " + + "the config to set these.") + + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = from_addr + msg['To'] = to_addr + plain_part = MIMEText(body) + msg.attach(plain_part) + + raw_from = email.utils.parseaddr(from_addr)[1] + raw_to = email.utils.parseaddr(to_addr)[1] + if not raw_from or not raw_to: + raise EmailException("Couldn't parse from/to address.") + + logger.info("Sending email to %s on server %s with subject %s", + to_addr, smtp_server, subject) + + try: + smtp = smtplib.SMTP(smtp_server) + smtp.sendmail(raw_from, raw_to, msg.as_string()) + smtp.quit() + except Exception as origException: + twisted.python.log.err() + ese = EmailException() + ese.cause = origException + raise ese \ No newline at end of file From c099b36af3b7d842c86ea56edccacbf1082f25bb Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 13:32:33 +0100 Subject: [PATCH 23/40] Comment out password reset for now, until the mechanism is fully discussed (IS token auth vs HS auth) --- synapse/rest/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/rest/login.py b/synapse/rest/login.py index 7ab9cb51e..ad71f6c61 100644 --- a/synapse/rest/login.py +++ b/synapse/rest/login.py @@ -106,4 +106,4 @@ def _parse_json(request): def register_servlets(hs, http_server): LoginRestServlet(hs).register(http_server) - PasswordResetRestServlet(hs).register(http_server) + # TODO PasswordResetRestServlet(hs).register(http_server) From dd2b933a0d92f24421a56ec350ae0f80e32d2d3e Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 16 Sep 2014 14:46:13 +0100 Subject: [PATCH 24/40] Use event age to recognise which calls are current and which aren't and hence support answering calls that were placed before we loaded the page. --- webclient/app-controller.js | 4 ++ webclient/components/matrix/matrix-call.js | 26 ++++++-- .../components/matrix/matrix-phone-service.js | 62 ++++++++++++++++--- webclient/index.html | 3 +- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 6c3759878..633862448 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -130,6 +130,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even angular.element('#ringAudio')[0].pause(); angular.element('#ringbackAudio')[0].pause(); angular.element('#busyAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'local' && $rootScope.currentCall.hangupReason == 'invite_timeout') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#busyAudio')[0].play(); } else if (oldVal == 'invite_sent') { angular.element('#ringbackAudio')[0].pause(); } else if (oldVal == 'ringing') { diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index fd21198d2..650c415c8 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -53,6 +53,8 @@ angular.module('MatrixCall', []) this.candidateSendTries = 0; } + MatrixCall.CALL_TIMEOUT = 60000; + MatrixCall.prototype.createPeerConnection = function() { var stunServer = 'stun:stun.l.google.com:19302'; var pc; @@ -86,6 +88,14 @@ angular.module('MatrixCall', []) this.direction = 'inbound'; }; + // perverse as it may seem, sometimes we want to instantiate a call with a hangup message + // (because when getting the state of the room on load, events come in reverse order and + // we want to remember that a call has been hung up) + MatrixCall.prototype.initWithHangup = function(msg) { + this.msg = msg; + this.state = 'ended'; + }; + MatrixCall.prototype.answer = function() { console.log("Answering call "+this.call_id); var self = this; @@ -188,14 +198,12 @@ angular.module('MatrixCall', []) console.log("Ignoring remote ICE candidate because call has ended"); return; } - var candidateObject = new RTCIceCandidate({ - sdpMLineIndex: cand.label, - candidate: cand.candidate - }); - this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {}); + this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {}); }; MatrixCall.prototype.receivedAnswer = function(msg) { + if (this.state == 'ended') return; + this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'connecting'; }; @@ -213,11 +221,17 @@ angular.module('MatrixCall', []) var content = { version: 0, call_id: this.call_id, - offer: description + offer: description, + lifetime: MatrixCall.CALL_TIMEOUT }; this.sendEventWithRetry('m.call.invite', content); var self = this; + $timeout(function() { + self.hangupReason = 'invite_timeout'; + self.hangup(); + }, MatrixCall.CALL_TIMEOUT); + $rootScope.$apply(function() { self.state = 'invite_sent'; }); diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js index 2d0732a8d..3e99a7d11 100644 --- a/webclient/components/matrix/matrix-phone-service.js +++ b/webclient/components/matrix/matrix-phone-service.js @@ -24,22 +24,52 @@ angular.module('matrixPhoneService', []) matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT"; matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT"; matrixPhoneService.allCalls = {}; + // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards) + matrixPhoneService.candidatesByCall = {}; matrixPhoneService.callPlaced = function(call) { matrixPhoneService.allCalls[call.call_id] = call; }; $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { - if (!isLive) return; // until matrix supports expiring messages if (event.user_id == matrixService.config().user_id) return; + var msg = event.content; + if (event.type == 'm.call.invite') { + if (event.age == undefined || msg.lifetime == undefined) { + // if the event doesn't have either an age (the HS is too old) or a lifetime + // (the sending client was too old when it sent it) then fall back to old behaviour + if (!isLive) return; // until matrix supports expiring messages + } + + if (event.age > msg.lifetime) { + console.log("Ignoring expired call event of type "+event.type); + return; + } + + var call = undefined; + if (!isLive) { + // if this event wasn't live then this call may already be over + call = matrixPhoneService.allCalls[msg.call_id]; + if (call && call.state == 'ended') { + return; + } + } + var MatrixCall = $injector.get('MatrixCall'); var call = new MatrixCall(event.room_id); call.call_id = msg.call_id; call.initWithInvite(msg); matrixPhoneService.allCalls[call.call_id] = call; + // if we stashed candidate events for that call ID, play them back now + if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) { + for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) { + call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]); + } + } + // Were we trying to call that user (room)? var existingCall; var callIds = Object.keys(matrixPhoneService.allCalls); @@ -79,21 +109,35 @@ angular.module('matrixPhoneService', []) call.receivedAnswer(msg); } else if (event.type == 'm.call.candidates') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { + if (!call && isLive) { console.log("Got candidates for unknown call ID "+msg.call_id); return; - } - for (var i = 0; i < msg.candidates.length; ++i) { - call.gotRemoteIceCandidate(msg.candidates[i]); + } else if (!call) { + if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) { + matrixPhoneService.candidatesByCall[msg.call_id] = []; + } + matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates); + } else { + for (var i = 0; i < msg.candidates.length; ++i) { + call.gotRemoteIceCandidate(msg.candidates[i]); + } } } else if (event.type == 'm.call.hangup') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { + if (!call && isLive) { console.log("Got hangup for unknown call ID "+msg.call_id); - return; + } else if (!call) { + // if not live, store the fact that the call has ended because we're probably getting events backwards so + // the hangup will come before the invite + var MatrixCall = $injector.get('MatrixCall'); + var call = new MatrixCall(event.room_id); + call.call_id = msg.call_id; + call.initWithHangup(msg); + matrixPhoneService.allCalls[msg.call_id] = call; + } else { + call.onHangupReceived(); + delete(matrixPhoneService.allCalls[msg.call_id]); } - call.onHangupReceived(); - delete(matrixPhoneService.allCalls[msg.call_id]); } }); diff --git a/webclient/index.html b/webclient/index.html index 9eea08215..7e4dcb834 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -62,7 +62,8 @@ Call Connecting... Call Connected Call Rejected - Call Canceled + Call Canceled + User Not Responding Call Ended Call Canceled Call Ended From f4094c5eb33884050a7de2a4de233673ea4a2af7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 16 Sep 2014 14:54:52 +0100 Subject: [PATCH 25/40] Update spec with the lifetime field. --- docs/specification.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/specification.rst b/docs/specification.rst index a2e348fa2..370e238e0 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -1169,8 +1169,14 @@ This event is sent by the caller when they wish to establish a call. Required keys: - ``call_id`` : "string" - A unique identifier for the call - ``offer`` : "offer object" - The session description - - ``version`` : "integer" - The version of the VoIP specification this message - adheres to. This specification is version 0. + - ``version`` : "integer" - The version of the VoIP specification this + message adheres to. This specification is + version 0. + - ``lifetime`` : "integer" - The time in milliseconds that the invite is + valid for. Once the invite age exceeds this + value, clients should discard it. They + should also no longer show the call as + awaiting an answer in the UI. Optional keys: None. From 45592ccdfde4c0b9d2452d6ecec8daec903c7df4 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 16 Sep 2014 15:03:07 +0200 Subject: [PATCH 26/40] WEB-29: Improve room page content loading InitialSync: load the 30 last messages of each room so that a full page of messages can be displayed without additionnal request --- .../matrix/event-handler-service.js | 26 +++++++++++++++---- .../components/matrix/event-stream-service.js | 6 +++-- webclient/room/room-controller.js | 2 +- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 4b0566fe3..fc5a81617 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -329,13 +329,29 @@ angular.module('eventHandlerService', []) }, // Handle messages from /initialSync or /messages - handleRoomMessages: function(room_id, messages, isLiveEvents) { + handleRoomMessages: function(room_id, messages, isLiveEvents, dir) { initRoom(room_id); - this.handleEvents(messages.chunk, isLiveEvents); - // Store how far back we've paginated - // This assumes the paginations requests are contiguous and in reverse chronological order - $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + var events = messages.chunk; + + // Handles messages according to their time order + if (dir && 'b' === dir) { + // paginateBackMessages requests messages to be in reverse chronological order + for (var i=0; i=0; i--) { + this.handleEvent(events[i], isLiveEvents, isLiveEvents); + } + // Store where to start pagination + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.start; + } }, handleInitialSyncDone: function(initialSyncData) { diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 03b805213..6f9233224 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -104,8 +104,10 @@ angular.module('eventStreamService', []) settings.isActive = true; var deferred = $q.defer(); - // Initial sync: get all information and the last message of all rooms of the user - matrixService.initialSync(1, false).then( + // Initial sync: get all information and the last 30 messages of all rooms of the user + // 30 messages should be enough to display a full page of messages in a room + // without requiring to make an additional request + matrixService.initialSync(30, false).then( function(response) { var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 50d902ae4..8cea0511c 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -235,7 +235,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then( function(response) { - eventHandlerService.handleRoomMessages($scope.room_id, response.data, false); + eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b'); if (response.data.chunk.length < MESSAGES_PER_PAGINATION) { // no more messages to paginate. this currently never gets turned true again, as we never // expire paginated contents in the current implementation. From a284de73e621e7f67212982046130416c042b386 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 16 Sep 2014 15:42:31 +0200 Subject: [PATCH 27/40] If an initialSync has been already done on a room, we do not need to paginate back to get more messages --- webclient/room/room-controller.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 8cea0511c..d3888cae8 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -684,6 +684,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // The room members is available in the data fetched by initialSync if ($rootScope.events.rooms[$scope.room_id]) { + + // There is no need to do a 1st pagination (initialSync provided enough to fill a page) + $scope.state.first_pagination = false; + var members = $rootScope.events.rooms[$scope.room_id].members; // Update the member list @@ -743,9 +747,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Arm list timing update timer updateMemberListPresenceAge(); - // Start pagination + // Allow pagination $scope.state.can_paginate = true; - paginate(MESSAGES_PER_PAGINATION); + + // Do a first pagination only if it is required + // FIXME: Should be no more require when initialSync/{room_id} will be available + if ($scope.state.first_pagination) { + paginate(MESSAGES_PER_PAGINATION); + } + else { + // There are already messages, go to the last message + scrollToBottom(true); + } }, function(error) { $scope.feedback = "Failed get member list: " + error.data.error; From 890178cf25491716289e5c54c045478b1be55d29 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 16 Sep 2014 16:13:24 +0200 Subject: [PATCH 28/40] Fixed scroll flickering when opening the room --- webclient/room/room-controller.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index d3888cae8..2c9a3836e 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -676,6 +676,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var onInit2 = function() { console.log("onInit2"); + // Scroll down as soon as possible so that we point to the last message + // if it already exists in memory + scrollToBottom(true); + // Make sure the initialSync has been before going further eventHandlerService.waitForInitialSyncCompletion().then( function() { From 84372cef4a512ae4603767c97c8055c218ac1557 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 16 Sep 2014 15:25:51 +0100 Subject: [PATCH 29/40] Time out calls from both ends properly. --- webclient/components/matrix/matrix-call.js | 24 ++++++++++++++----- .../components/matrix/matrix-phone-service.js | 4 ++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 650c415c8..bf1e61ad7 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -80,19 +80,29 @@ angular.module('MatrixCall', []) this.config = config; }; - MatrixCall.prototype.initWithInvite = function(msg) { - this.msg = msg; + MatrixCall.prototype.initWithInvite = function(event) { + this.msg = event.content; this.peerConn = this.createPeerConnection(); this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'ringing'; this.direction = 'inbound'; + var self = this; + $timeout(function() { + if (self.state == 'ringing') { + self.state = 'ended'; + self.hangupParty = 'remote'; // effectively + self.stopAllMedia(); + if (self.peerConn.signalingState != 'closed') self.peerConn.close(); + if (self.onHangup) self.onHangup(self); + } + }, this.msg.lifetime - event.age); }; // perverse as it may seem, sometimes we want to instantiate a call with a hangup message // (because when getting the state of the room on load, events come in reverse order and // we want to remember that a call has been hung up) - MatrixCall.prototype.initWithHangup = function(msg) { - this.msg = msg; + MatrixCall.prototype.initWithHangup = function(event) { + this.msg = event.content; this.state = 'ended'; }; @@ -228,8 +238,10 @@ angular.module('MatrixCall', []) var self = this; $timeout(function() { - self.hangupReason = 'invite_timeout'; - self.hangup(); + if (self.state == 'invite_sent') { + self.hangupReason = 'invite_timeout'; + self.hangup(); + } }, MatrixCall.CALL_TIMEOUT); $rootScope.$apply(function() { diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js index 3e99a7d11..d05eecf72 100644 --- a/webclient/components/matrix/matrix-phone-service.js +++ b/webclient/components/matrix/matrix-phone-service.js @@ -60,7 +60,7 @@ angular.module('matrixPhoneService', []) var MatrixCall = $injector.get('MatrixCall'); var call = new MatrixCall(event.room_id); call.call_id = msg.call_id; - call.initWithInvite(msg); + call.initWithInvite(event); matrixPhoneService.allCalls[call.call_id] = call; // if we stashed candidate events for that call ID, play them back now @@ -132,7 +132,7 @@ angular.module('matrixPhoneService', []) var MatrixCall = $injector.get('MatrixCall'); var call = new MatrixCall(event.room_id); call.call_id = msg.call_id; - call.initWithHangup(msg); + call.initWithHangup(event); matrixPhoneService.allCalls[msg.call_id] = call; } else { call.onHangupReceived(); From b170fe921e327d8d1be9768d30305ba953ccae9f Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 14:20:26 +0100 Subject: [PATCH 30/40] Added a section on bing words if you enable desktop notifications. --- webclient/room/room-controller.js | 2 +- webclient/settings/settings-controller.js | 11 ++++++++++- webclient/settings/settings.html | 8 ++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 2c9a3836e..4a91d298b 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -139,7 +139,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) if (isLive && event.room_id === $scope.room_id) { scrollToBottom(); - + if (window.Notification) { // Show notification when the window is hidden, or the user is idle if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js index 8c877a24e..9cdace704 100644 --- a/webclient/settings/settings-controller.js +++ b/webclient/settings/settings-controller.js @@ -194,7 +194,16 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu /*** Desktop notifications section ***/ $scope.settings = { - notifications: undefined + notifications: undefined, + bingWords: matrixService.config().bingWords + }; + + $scope.saveBingWords = function() { + console.log("Saving words: "+JSON.stringify($scope.settings.bingWords)); + var config = matrixService.config(); + config.bingWords = $scope.settings.bingWords; + matrixService.setConfig(config); + matrixService.saveConfig(); }; // If the browser supports it, check the desktop notification state diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html index c358a6e9d..3b3dd3dad 100644 --- a/webclient/settings/settings.html +++ b/webclient/settings/settings.html @@ -52,6 +52,14 @@
Notifications are enabled. +
+

Words to alert on:

+ +
    +
  • {{word}}
  • +
+
You have denied permission for notifications.
From 660364d6a7a4ad69bbb951990c20fc38d168e588 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 14:32:50 +0100 Subject: [PATCH 31/40] Move the notification logic out of an individual room controller and into the general event handler, so we can notify for >1 room. --- .../matrix/event-handler-service.js | 20 ++++++++++++++++++- webclient/room/room-controller.js | 16 --------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index fc5a81617..8783b9b1e 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners if typically all the $on method would do is update its own $scope. */ angular.module('eventHandlerService', []) -.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) { +.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', +function(matrixService, $rootScope, $q, $timeout, mPresence) { var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT"; var MSG_EVENT = "MSG_EVENT"; var MEMBER_EVENT = "MEMBER_EVENT"; @@ -137,6 +138,23 @@ angular.module('eventHandlerService', []) else { $rootScope.events.rooms[event.room_id].messages.push(event); } + + if (window.Notification) { + // Show notification when the window is hidden, or the user is idle + if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { + console.log("Displaying notification for "+JSON.stringify(event)); + var notification = new window.Notification( + ($rootScope.events.rooms[event.room_id].members[event.user_id].displayname || event.user_id) + + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here + { + "body": event.content.body, + "icon": $rootScope.events.rooms[event.room_id].members[event.user_id].avatar_url + }); + $timeout(function() { + notification.close(); + }, 5 * 1000); + } + } } else { $rootScope.events.rooms[event.room_id].messages.unshift(event); diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 4a91d298b..4a1dfd6aa 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -139,22 +139,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) if (isLive && event.room_id === $scope.room_id) { scrollToBottom(); - - if (window.Notification) { - // Show notification when the window is hidden, or the user is idle - if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { - var notification = new window.Notification( - ($scope.members[event.user_id].displayname || event.user_id) + - " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here - { - "body": event.content.body, - "icon": $scope.members[event.user_id].avatar_url - }); - $timeout(function() { - notification.close(); - }, 5 * 1000); - } - } } }); From a402e0c5e6e432a175b48279c972bc9ae7e944bc Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 15:15:19 +0100 Subject: [PATCH 32/40] Added bing detection logic. Persist the display name of the user in localstorage for use when binging. --- .../matrix/event-handler-service.js | 37 ++++++++++++++++++- webclient/home/home-controller.js | 4 ++ webclient/room/room-controller.js | 4 +- webclient/settings/settings.html | 6 +-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 8783b9b1e..16d5763ed 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -140,8 +140,41 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { } if (window.Notification) { - // Show notification when the window is hidden, or the user is idle - if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { + var bingWords = matrixService.config().bingWords; + var content = event.content.body; + var shouldBing = false; + + // case-insensitive name check for user_id OR display_name if they exist + var myUserId = matrixService.config().user_id; + if (myUserId) { + myUserId = myUserId.toLocaleLowerCase(); + } + var myDisplayName = matrixService.config().display_name; + if (myDisplayName) { + myDisplayName = myDisplayName.toLocaleLowerCase(); + } + if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) || + (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) { + shouldBing = true; + } + + // bing word list check + if (bingWords && !shouldBing) { + for (var i=0; iDesktop notifications
- Notifications are enabled. + Notifications are enabled. You will be alerted when a message contains your user ID or display name.
-

Words to alert on:

- Additional words to alert on: +
  • {{word}}
  • From b36a0c71d1da1fd23a154389c692f6644a3e7ac2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 15:31:18 +0100 Subject: [PATCH 33/40] Added utility function containsBingWord and hook up some css to it. --- webclient/app.css | 4 ++ .../matrix/event-handler-service.js | 71 +++++++++++-------- webclient/room/room.html | 2 +- 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/webclient/app.css b/webclient/app.css index 4a4ba7b8f..b947d8b66 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -538,6 +538,10 @@ a:active { color: #000; } color: #F00; } +.messageBing { + color: #00F; +} + #room-fullscreen-image { position: absolute; top: 0px; diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 16d5763ed..389eee547 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -45,6 +45,46 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { var eventMap = {}; $rootScope.presence = {}; + + // TODO: This is attached to the rootScope so .html can just go containsBingWord + // for determining classes so it is easy to highlight bing messages. It seems a + // bit strange to put the impl in this service though, but I can't think of a better + // file to put it in. + $rootScope.containsBingWord = function(content) { + if (!content) { + return false; + } + var bingWords = matrixService.config().bingWords; + var shouldBing = false; + + // case-insensitive name check for user_id OR display_name if they exist + var myUserId = matrixService.config().user_id; + if (myUserId) { + myUserId = myUserId.toLocaleLowerCase(); + } + var myDisplayName = matrixService.config().display_name; + if (myDisplayName) { + myDisplayName = myDisplayName.toLocaleLowerCase(); + } + if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) || + (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) { + shouldBing = true; + } + + // bing word list check + if (bingWords && !shouldBing) { + for (var i=0; i Outgoing Call From 5aaa3c09c1066333f6fa51effb771432f1c63bf1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 15:34:33 +0100 Subject: [PATCH 34/40] hidden/minimise/focus disaster disclaimer with the TODO --- webclient/components/matrix/event-handler-service.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 389eee547..d61a15046 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -183,6 +183,10 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { var shouldBing = $rootScope.containsBingWord(event.content.body); // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly? + // Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus; + // true when you swap tabs though. However, for the case where the chat screen is OPEN and there is + // another window on top, we want to be notifying for those events. This DOES mean that there will be + // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo. var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState()); if (shouldBing) { From 3395a3305f9477e950849b18c2caacfef70c118b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 15:47:29 +0100 Subject: [PATCH 35/40] Bing on all the things if there are 0 bing words. --- webclient/components/matrix/event-handler-service.js | 6 ++++++ webclient/settings/settings.html | 1 + 2 files changed, 7 insertions(+) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index d61a15046..72859eae3 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -189,6 +189,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo. var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState()); + // always bing if there are 0 bing words... apparently. + var bingWords = matrixService.config().bingWords; + if (bingWords && bingWords.length === 0) { + shouldBing = true; + } + if (shouldBing) { console.log("Displaying notification for "+JSON.stringify(event)); var notification = new window.Notification( diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html index 1a42ae435..b251cce56 100644 --- a/webclient/settings/settings.html +++ b/webclient/settings/settings.html @@ -54,6 +54,7 @@ Notifications are enabled. You will be alerted when a message contains your user ID or display name.

    Additional words to alert on:

    +

    Leave blank to alert on all messages.

      From 06dfbdf7c8eb7e810f9ad56621ce709ee66b210a Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Tue, 16 Sep 2014 17:07:47 +0200 Subject: [PATCH 36/40] WEB-27: We don't need to show the user-count in Recents in the room sidepanel - takes up too much room --- webclient/app.css | 7 ++++++- webclient/recents/recents.html | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/webclient/app.css b/webclient/app.css index b947d8b66..704cd8394 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -603,7 +603,7 @@ a:active { color: #000; } width: auto; } -.recentsRoomSummaryTS { +.recentsRoomSummaryUsersCount, .recentsRoomSummaryTS { color: #888; font-size: 12px; width: 7em; @@ -616,6 +616,11 @@ a:active { color: #000; } padding-bottom: 5px; } +/* Do not show users count in the recents fragment displayed on the room page */ +#roomPage .recentsRoomSummaryUsersCount { + width: 0em; +} + /*** Recents in the room page ***/ #roomRecentsTableWrapper { diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index 789ffc9d2..e783d3a6b 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -8,7 +8,7 @@
-
{{ room.room_id | mRoomName }} @@ -14,7 +14,11 @@ - {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} + + {{lastMsg = room.messages[room.messages.length - 1];""}} + + {{ (lastMsg.ts) | date:'MMM d HH:mm' }}
- {{lastMsg = room.messages[room.messages.length - 1];""}} + {{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }} {{ (lastMsg.ts) | date:'MMM d HH:mm' }} {{ room.room_id | mRoomName }} + {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} From d7b206cc9325e4cc7b460bf5bfc218ad2b304fc1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 16:01:38 +0100 Subject: [PATCH 37/40] Added basic RegExp support. --- webclient/components/matrix/event-handler-service.js | 6 ++---- webclient/settings/settings.html | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 72859eae3..0c7c0d169 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -74,10 +74,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { // bing word list check if (bingWords && !shouldBing) { for (var i=0; i

Additional words to alert on:

Leave blank to alert on all messages.

-
  • {{word}}
  • From 95e171e19a323bd92da5f20d651a52517b47ce03 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 16:23:20 +0100 Subject: [PATCH 38/40] Don't bing for sent messages. Handle cases where the member is unknown rather than erroring out. --- webclient/components/matrix/event-handler-service.js | 11 ++++++++--- webclient/room/room.html | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 0c7c0d169..55e8480a1 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -177,7 +177,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { $rootScope.events.rooms[event.room_id].messages.push(event); } - if (window.Notification) { + if (window.Notification && event.user_id != matrixService.config().user_id) { var shouldBing = $rootScope.containsBingWord(event.content.body); // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly? @@ -195,12 +195,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { if (shouldBing) { console.log("Displaying notification for "+JSON.stringify(event)); + var member = $rootScope.events.rooms[event.room_id].members[event.user_id]; + var displayname = undefined; + if (member) { + displayname = member.displayname; + } var notification = new window.Notification( - ($rootScope.events.rooms[event.room_id].members[event.user_id].displayname || event.user_id) + + (displayname || event.user_id) + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here { "body": event.content.body, - "icon": $rootScope.events.rooms[event.room_id].members[event.user_id].avatar_url + "icon": member ? member.avatar_url : undefined }); $timeout(function() { notification.close(); diff --git a/webclient/room/room.html b/webclient/room/room.html index 86ec2e51c..886c2afe6 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -105,7 +105,7 @@ Outgoing Call From d6c0cff3bdf5de6229d10503520535076c8aae5d Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 16 Sep 2014 16:31:16 +0100 Subject: [PATCH 39/40] Bugfix when content isn't a string. --- webclient/components/matrix/event-handler-service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 55e8480a1..258de9a31 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -51,7 +51,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) { // bit strange to put the impl in this service though, but I can't think of a better // file to put it in. $rootScope.containsBingWord = function(content) { - if (!content) { + if (!content || $.type(content) != "string") { return false; } var bingWords = matrixService.config().bingWords; From f9bb000ccf9cb6bfa5be3af68ec319f346b313f1 Mon Sep 17 00:00:00 2001 From: Emmanuel ROHEE Date: Wed, 17 Sep 2014 09:41:21 +0200 Subject: [PATCH 40/40] WEB-35: joins/parts should trigger desktop notifications --- webclient/room/room-controller.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index f82f4aed2..6e1d83a23 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -143,7 +143,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) }); $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - if (isLive) { + if (isLive && event.room_id === $scope.room_id) { if ($scope.state.waiting_for_joined_event) { // The user has successfully joined the room, we can getting data for this room $scope.state.waiting_for_joined_event = false; @@ -161,19 +161,33 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) else { user = event.user_id; } - if ("ban" === event.membership) { $scope.state.permission_denied = "You have been banned by " + user; } else { $scope.state.permission_denied = "You have been kicked by " + user; - } - + } } else { scrollToBottom(); updateMemberList(event); + + // Notify when a user joins + if ((document.hidden || matrixService.presence.unavailable === mPresence.getState()) + && event.state_key !== $scope.state.user_id && "join" === event.membership) { + debugger; + var notification = new window.Notification( + event.content.displayname + + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here + { + "body": event.content.displayname + " joined", + "icon": event.content.avatar_url ? event.content.avatar_url : undefined + }); + $timeout(function() { + notification.close(); + }, 5 * 1000); + } } } });