diff --git a/.gitignore b/.gitignore index c214971e11d330a471f50a9ad9015b5cc3cc46cf..dfe8dfedbf8fb92f025b2e7e42a3fd6325260c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,12 @@ htmlcov demo/*.db demo/*.log demo/*.pid +demo/etc graph/*.svg graph/*.png graph/*.dot +webclient/config.js + uploads diff --git a/CHANGES.rst b/CHANGES.rst index 31eee891daeaf52f2bf2e8ac58f00bbe5b208378..b9b3e9d0eaaeb9d9f7fdf9fa1f7caca1e0ddd6d0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,23 @@ +Changes in synapse 0.2.2 (2014-09-06) +===================================== + +Homeserver: + * When the server returns state events it now also includes the previous + content. + * Add support for inviting people when creating a new room. + * Make the homeserver inform the room via `m.room.aliases` when a new alias + is added for a room. + * Validate `m.room.power_level` events. + +Webclient: + * Add support for captchas on registration. + * Handle `m.room.aliases` events. + * Asynchronously send messages and show a local echo. + * Inform the UI when a message failed to send. + * Only autoscroll on receiving a new message if the user was already at the + bottom of the screen. + * Add support for ban/kick reasons. + Changes in synapse 0.2.1 (2014-09-03) ===================================== diff --git a/VERSION b/VERSION index 0c62199f16ac1e2d7f7ae75b420c1231325dff4e..ee1372d33a29e27945406f0527f8af8e6ee119c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/docs/specification.rst b/docs/specification.rst index a329c0754267b35e6e0be5c83bc33408eb0c5fb3..b15792c00db79c0ea620d8bc98682577c4e76eb1 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -347,11 +347,12 @@ Receiving live updates on a client Clients can receive new events by long-polling the home server. This will hold open the HTTP connection for a short period of time waiting for new events, returning early if an event occurs. This is called the `Event Stream`_. All events which are visible to the -client and match the client's query will appear in the event stream. When the request +client will appear in the event stream. When the request returns, an ``end`` token is included in the response. This token can be used in the next request to continue where the client left off. .. TODO + How do we filter the event stream? Do we ever return multiple events in a single request? Don't we get lots of request setup RTT latency if we only do one event per request? Do we ever support streaming requests? Why not websockets? @@ -417,6 +418,16 @@ which can be set when creating a room: If this is included, an ``m.room.topic`` event will be sent into the room to indicate the topic for the room. See `Room Events`_ for more information on ``m.room.topic``. +``invite`` + Type: + List + Optional: + Yes + Value: + A list of user ids to invite. + Description: + This will tell the server to invite everyone in the list to the newly created room. + Example:: { @@ -473,7 +484,9 @@ action in a room a user must have a suitable power level. Power levels for users are defined in ``m.room.power_levels``, where both a default and specific users' power levels can be set. By default all users -have a power level of 0. +have a power level of 0, other than the room creator whose power level defaults to 100. +Power levels for users are tracked per-room even if the user is not present in +the room. State events may contain a ``required_power_level`` key, which indicates the minimum power a user must have before they can update that state key. The only @@ -483,11 +496,11 @@ To perform certain actions there are additional power level requirements defined in the following state events: - ``m.room.send_event_level`` defines the minimum level for sending non-state - events. Defaults to 5. + events. Defaults to 50. - ``m.room.add_state_level`` defines the minimum level for adding new state, - rather than updating existing state. Defaults to 5. + rather than updating existing state. Defaults to 50. - ``m.room.ops_level`` defines the minimum levels to ban and kick other users. - This defaults to a kick and ban levels of 5 each. + This defaults to a kick and ban levels of 50 each. Joining rooms @@ -908,6 +921,22 @@ prefixed with ``m.`` ``ban_level`` will be greater than or equal to ``kick_level`` since banning is more severe than kicking. +``m.room.aliases`` + Summary: + These state events are used to inform the room about what room aliases it has. + Type: + State event + JSON format: + ``{ "aliases": ["string", ...] }`` + Example: + ``{ "aliases": ["#foo:example.com"] }`` + Description: + A server `may` inform the room that it has added or removed an alias for + the room. This is purely for informational purposes and may become stale. + Clients `should` check that the room alias is still valid before using it. + The ``state_key`` of the event is the homeserver which owns the room + alias. + ``m.room.message`` Summary: A message. @@ -1124,19 +1153,104 @@ Typing notifications Voice over IP ============= -.. NOTE:: - This section is a work in progress. +Matrix can also be used to set up VoIP calls. This is part of the core specification, +although is still in a very early stage. Voice (and video) over Matrix is based on +the WebRTC standards. -.. TODO Dave - - what are the event types. - - what are the valid keys/values. What do they represent. Any gotchas? - - In what sequence should the events be sent? - - How do you accept / decline inbound calls? How do you make outbound calls? - Give examples. - - How does negotiation work? Give examples. - - How do you hang up? - - What does call log information look like e.g. duration of call? +Call events are sent to a room, like any other event. This means that clients +must only send call events to rooms with exactly two participants as currently +the WebRTC standard is based around two-party communication. + +Events +------ +``m.call.invite`` +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. + + Optional keys: + None. + Example: + ``{ "version" : 0, "call_id": "12345", "offer": { "type" : "offer", "sdp" : "v=0\r\no=- 6584580628695956864 2 IN IP4 127.0.0.1[...]" } }`` + +``Offer Object`` + Required keys: + - ``type`` : "string" - The type of session description, in this case 'offer' + - ``sdp`` : "string" - The SDP text of the session description + +``m.call.candidate`` +This event is sent by callers after sending an invite and by the callee after answering. +Its purpose is to give the other party an additional ICE candidate to try using to +communicate. + + Required keys: + - ``call_id`` : "string" - The ID of the call this event relates to + - ``version`` : "integer" - The version of the VoIP specification this messages + adheres to. his specification is version 0. + - ``candidate`` : "candidate object" - Object describing the candidate. + +``Candidate Object`` + Required Keys: + - ``sdpMid`` : "string" - The SDP media type this candidate is intended for. + - ``sdpMLineIndex`` : "integer" - The index of the SDP 'm' line this + candidate is intended for + - ``candidate`` : "string" - The SDP 'a' line of the candidate + +``m.call.answer`` + + Required keys: + - ``call_id`` : "string" - The ID of the call this event relates to + - ``version`` : "integer" - The version of the VoIP specification this messages + - ``answer`` : "answer object" - Object giving the SDK answer + +``Answer Object`` + + Required keys: + - ``type`` : "string" - The type of session description. 'answer' in this case. + - ``sdp`` : "string" - The SDP text of the session description + +``m.call.hangup`` +Sent by either party to signal their termination of the call. This can be sent either once +the call has has been established or before to abort the call. + + Required keys: + - ``call_id`` : "string" - The ID of the call this event relates to + - ``version`` : "integer" - The version of the VoIP specification this messages + +Message Exchange +---------------- +A call is set up with messages exchanged as follows: + +:: + + Caller Callee + m.call.invite -----------> + m.call.candidate --------> + [more candidates events] + User answers call + <------ m.call.answer + [...] + <------ m.call.hangup + +Or a rejected call: + +:: + + Caller Callee + m.call.invite -----------> + m.call.candidate --------> + [more candidates events] + User rejects call + <------- m.call.hangup + +Calls are negotiated according to the WebRTC specification. + + Profiles ======== .. NOTE:: @@ -1151,8 +1265,8 @@ Profiles - Display name changes also generates m.room.member with displayname key f.e. room the user is in. -Internally within Matrix users are referred to by their user ID, which is not a -human-friendly string. Profiles grant users the ability to see human-readable +Internally within Matrix users are referred to by their user ID, which is typically +a compact unique identifier. Profiles grant users the ability to see human-readable names for other users that are in some way meaningful to them. Additionally, profiles can publish additional information, such as the user's age or location. @@ -1466,17 +1580,19 @@ Federation is the term used to describe how to communicate between Matrix home servers. Federation is a mechanism by which two home servers can exchange Matrix event messages, both as a real-time push of current events, and as a historic fetching mechanism to synchronise past history for clients to view. It -uses HTTP connections between each pair of servers involved as the underlying +uses HTTPS connections between each pair of servers involved as the underlying transport. Messages are exchanged between servers in real-time by active pushing from each server's HTTP client into the server of the other. Queries to fetch historic data for the purpose of back-filling scrollback buffers and the like -can also be performed. +can also be performed. Currently routing of messages between homeservers is full +mesh (like email) - however, fan-out refinements to this design are currently +under consideration. There are three main kinds of communication that occur between home servers: :Queries: These are single request/response interactions between a given pair of - servers, initiated by one side sending an HTTP GET request to obtain some + servers, initiated by one side sending an HTTPS GET request to obtain some information, and responded by the other. They are not persisted and contain no long-term significant history. They simply request a snapshot state at the instant the query is made. @@ -1692,7 +1808,7 @@ by the same origin as the current one, or other origins. Because of the distributed nature of participants in a Matrix conversation, it is impossible to establish a globally-consistent total ordering on the events. However, by annotating each outbound PDU at its origin with IDs of other PDUs it -has received, a partial ordering can be constructed allowing causallity +has received, a partial ordering can be constructed allowing causality relationships to be preserved. A client can then display these messages to the end-user in some order consistent with their content and ensure that no message that is semantically in reply of an earlier one is ever displayed before it. @@ -1778,7 +1894,7 @@ Retrieves a sliding-window history of previous PDUs that occurred on the given context. Starting from the PDU ID(s) given in the "v" argument, the PDUs that preceeded it are retrieved, up to a total number given by the "limit" argument. These are then returned in a new Transaction containing all -off the PDUs. +of the PDUs. To stream events all the events:: @@ -1858,6 +1974,10 @@ victim would then include in their view of the chatroom history. Other servers in the chatroom would reject the invalid messages and potentially reject the victims messages as well since they depended on the invalid messages. +.. TODO + Track trustworthiness of HS or users based on if they try to pretend they + haven't seen recent events, and fake a splitbrain... --M + Threat: Block Network Traffic +++++++++++++++++++++++++++++ @@ -1963,6 +2083,9 @@ The ``retry_after_ms`` key SHOULD be included to tell the client how long they h in milliseconds before they can try again. .. TODO + - Surely we should recommend an algorithm for the rate limiting, rather than letting every + homeserver come up with their own idea, causing totally unpredictable performance over + federated rooms? - crypto (s-s auth) - E2E - Lawful intercept + Key Escrow @@ -1973,6 +2096,9 @@ Policy Servers .. NOTE:: This section is a work in progress. +.. TODO + We should mention them in the Architecture section at least... + Content repository ================== .. NOTE:: @@ -2071,6 +2197,9 @@ Transaction: A message which relates to the communication between a given pair of servers. A transaction contains possibly-empty lists of PDUs and EDUs. +.. TODO + This glossary contradicts the terms used above - especially on State Events v. "State" + and Non-State Events v. "Events". We need better consistent names. .. Links through the external API docs are below .. ============================================= @@ -2118,3 +2247,4 @@ Transaction: .. _/join/<room_alias_or_id>: /docs/api/client-server/#!/-rooms/join .. _`Event Stream`: /docs/api/client-server/#!/-events/get_event_stream + diff --git a/synapse/__init__.py b/synapse/__init__.py index 440e6339662c3504993bcd1ab90895c7ae3f568e..1ed9cdcdf3e06c23e96dd0f897d513e82a1df2db 100644 --- a/synapse/__init__.py +++ b/synapse/__init__.py @@ -16,4 +16,4 @@ """ This is a reference implementation of a synapse home server. """ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/synapse/api/auth.py b/synapse/api/auth.py index b4eda3df01ea0d10f1035495e0773c0ea6f8edd2..8f32191b57cf1f9041592b780a7634911900aa2d 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -18,8 +18,8 @@ from twisted.internet import defer from synapse.api.constants import Membership, JoinRules -from synapse.api.errors import AuthError, StoreError, Codes -from synapse.api.events.room import RoomMemberEvent +from synapse.api.errors import AuthError, StoreError, Codes, SynapseError +from synapse.api.events.room import RoomMemberEvent, RoomPowerLevelsEvent from synapse.util.logutils import log_function import logging @@ -67,6 +67,9 @@ class Auth(object): else: yield self._can_send_event(event) + if event.type == RoomPowerLevelsEvent.TYPE: + yield self._check_power_levels(event) + defer.returnValue(True) else: raise AuthError(500, "Unknown event: %s" % event) @@ -172,7 +175,7 @@ class Auth(object): if kick_level: kick_level = int(kick_level) else: - kick_level = 5 + kick_level = 50 if user_level < kick_level: raise AuthError( @@ -189,7 +192,7 @@ class Auth(object): if ban_level: ban_level = int(ban_level) else: - ban_level = 5 # FIXME (erikj): What should we do here? + ban_level = 50 # FIXME (erikj): What should we do here? if user_level < ban_level: raise AuthError(403, "You don't have permission to ban") @@ -305,7 +308,9 @@ class Auth(object): else: user_level = 0 - logger.debug("Checking power level for %s, %s", event.user_id, user_level) + logger.debug( + "Checking power level for %s, %s", event.user_id, user_level + ) if current_state and hasattr(current_state, "required_power_level"): req = current_state.required_power_level @@ -315,3 +320,101 @@ class Auth(object): 403, "You don't have permission to change that state" ) + + @defer.inlineCallbacks + def _check_power_levels(self, event): + for k, v in event.content.items(): + if k == "default": + continue + + # FIXME (erikj): We don't want hsob_Ts in content. + if k == "hsob_ts": + continue + + try: + self.hs.parse_userid(k) + except: + raise SynapseError(400, "Not a valid user_id: %s" % (k,)) + + try: + int(v) + except: + raise SynapseError(400, "Not a valid power level: %s" % (v,)) + + current_state = yield self.store.get_current_state( + event.room_id, + event.type, + event.state_key, + ) + + if not current_state: + return + else: + current_state = current_state[0] + + user_level = yield self.store.get_power_level( + event.room_id, + event.user_id, + ) + + if user_level: + user_level = int(user_level) + else: + user_level = 0 + + old_list = current_state.content + + # FIXME (erikj) + old_people = {k: v for k, v in old_list.items() if k.startswith("@")} + new_people = { + k: v for k, v in event.content.items() + if k.startswith("@") + } + + removed = set(old_people.keys()) - set(new_people.keys()) + added = set(old_people.keys()) - set(new_people.keys()) + same = set(old_people.keys()) & set(new_people.keys()) + + for r in removed: + if int(old_list.content[r]) > user_level: + raise AuthError( + 403, + "You don't have permission to remove user: %s" % (r, ) + ) + + for n in added: + if int(event.content[n]) > user_level: + raise AuthError( + 403, + "You don't have permission to add ops level greater " + "than your own" + ) + + for s in same: + if int(event.content[s]) != int(old_list[s]): + if int(event.content[s]) > user_level: + raise AuthError( + 403, + "You don't have permission to add ops level greater " + "than your own" + ) + + if "default" in old_list: + old_default = int(old_list["default"]) + + if old_default > user_level: + raise AuthError( + 403, + "You don't have permission to add ops level greater than " + "your own" + ) + + if "default" in event.content: + new_default = int(event.content["default"]) + + if new_default > user_level: + raise AuthError( + 403, + "You don't have permission to add ops level greater " + "than your own" + ) diff --git a/synapse/api/errors.py b/synapse/api/errors.py index 84afe4fa375eed68f62cfc4c579fb889759305a4..88175602c42168e2350adede0ef1c8e2b4d8b86e 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -29,6 +29,8 @@ class Codes(object): NOT_FOUND = "M_NOT_FOUND" UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN" LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED" + CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED" + CAPTCHA_INVALID = "M_CAPTCHA_INVALID" class CodeMessageException(Exception): @@ -101,6 +103,19 @@ class StoreError(SynapseError): pass +class InvalidCaptchaError(SynapseError): + def __init__(self, code=400, msg="Invalid captcha.", error_url=None, + errcode=Codes.CAPTCHA_INVALID): + super(InvalidCaptchaError, self).__init__(code, msg, errcode) + self.error_url = error_url + + def error_dict(self): + return cs_error( + self.msg, + self.errcode, + error_url=self.error_url, + ) + class LimitExceededError(SynapseError): """A client has sent too many requests and is being throttled. """ diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py index f95468fc65e8e6ab846717f34c1ab73d7b65383f..5f300de1086cf25d411e7cd0a1dd53cb2b067a67 100644 --- a/synapse/api/events/__init__.py +++ b/synapse/api/events/__init__.py @@ -157,7 +157,12 @@ class SynapseEvent(JsonEncodedObject): class SynapseStateEvent(SynapseEvent): - def __init__(self, **kwargs): + + valid_keys = SynapseEvent.valid_keys + [ + "prev_content", + ] + + def __init__(self, **kwargs): if "state_key" not in kwargs: kwargs["state_key"] = "" super(SynapseStateEvent, self).__init__(**kwargs) diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py index a3b293e024d37c1ae10c7cb710e71d509dbd8a94..5e38cdbc44bd66b81771422693ea1f425842c4d3 100644 --- a/synapse/api/events/factory.py +++ b/synapse/api/events/factory.py @@ -47,11 +47,14 @@ class EventFactory(object): self._event_list[event_class.TYPE] = event_class self.clock = hs.get_clock() + self.hs = hs def create_event(self, etype=None, **kwargs): kwargs["type"] = etype if "event_id" not in kwargs: - kwargs["event_id"] = random_string(10) + kwargs["event_id"] = "%s@%s" % ( + random_string(10), self.hs.hostname + ) if "ts" not in kwargs: kwargs["ts"] = int(self.clock.time_msec()) diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py index 33f0f0cb99f9f958e49d618d3ab61119c6dc54d9..3a4dbc58ce82267fe01c1c904b51a185f4672603 100644 --- a/synapse/api/events/room.py +++ b/synapse/api/events/room.py @@ -173,3 +173,10 @@ class RoomOpsPowerLevelsEvent(SynapseStateEvent): def get_content_template(self): return {} + + +class RoomAliasesEvent(SynapseStateEvent): + TYPE = "m.room.aliases" + + def get_content_template(self): + return {} diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py index 49cf928cc166cec974c73e326fc7565f805ba57b..d675d8c8f961461433adc90004123f493d3f20e0 100755 --- a/synapse/app/homeserver.py +++ b/synapse/app/homeserver.py @@ -57,7 +57,7 @@ SCHEMAS = [ # Remember to update this number every time an incompatible change is made to # database schema files, so the users will be informed on server restarts. -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 class SynapseHomeServer(HomeServer): diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py new file mode 100644 index 0000000000000000000000000000000000000000..a97a5bab1e4d33b0e7b8823e7cee53ba8b7a20f2 --- /dev/null +++ b/synapse/config/captcha.py @@ -0,0 +1,42 @@ +# 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 CaptchaConfig(Config): + + def __init__(self, args): + super(CaptchaConfig, self).__init__(args) + self.recaptcha_private_key = args.recaptcha_private_key + self.enable_registration_captcha = args.enable_registration_captcha + self.captcha_ip_origin_is_x_forwarded = args.captcha_ip_origin_is_x_forwarded + + @classmethod + def add_arguments(cls, parser): + super(CaptchaConfig, cls).add_arguments(parser) + group = parser.add_argument_group("recaptcha") + group.add_argument( + "--recaptcha-private-key", type=str, default="YOUR_PRIVATE_KEY", + help="The matching private key for the web client's public key." + ) + group.add_argument( + "--enable-registration-captcha", type=bool, default=False, + help="Enables ReCaptcha checks when registering, preventing signup "+ + "unless a captcha is answered. Requires a valid ReCaptcha public/private key." + ) + group.add_argument( + "--captcha_ip_origin_is_x_forwarded", type=bool, default=False, + help="When checking captchas, use the X-Forwarded-For (XFF) header as the client IP "+ + "and not the actual client IP." + ) \ No newline at end of file diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 76e2cdeddd62ef8cd07561a1cb6fafe06174feec..e16f2c733b99751ef342d6dcd700724599337e22 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -19,9 +19,10 @@ from .logger import LoggingConfig from .database import DatabaseConfig from .ratelimiting import RatelimitConfig from .repository import ContentRepositoryConfig +from .captcha import CaptchaConfig class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, - RatelimitConfig, ContentRepositoryConfig): + RatelimitConfig, ContentRepositoryConfig, CaptchaConfig): pass if __name__=='__main__': diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py index 9989fe86708a2fa87c3fb807c506d5ee10b29990..de4d23bbb3c98816d46f265b2c0bf074380756e3 100644 --- a/synapse/handlers/_base.py +++ b/synapse/handlers/_base.py @@ -42,9 +42,6 @@ class BaseHandler(object): retry_after_ms=int(1000*(time_allowed - time_now)), ) - -class BaseRoomHandler(BaseHandler): - @defer.inlineCallbacks def _on_new_room_event(self, event, snapshot, extra_destinations=[], extra_users=[]): diff --git a/synapse/handlers/directory.py b/synapse/handlers/directory.py index 1b9e831fc010d9bb77bd19460e8b3cfbc604eef0..4ab00a761abf58b2cfcb6c59ffd48c9ff4267fd0 100644 --- a/synapse/handlers/directory.py +++ b/synapse/handlers/directory.py @@ -19,8 +19,10 @@ from ._base import BaseHandler from synapse.api.errors import SynapseError from synapse.http.client import HttpClient +from synapse.api.events.room import RoomAliasesEvent import logging +import sqlite3 logger = logging.getLogger(__name__) @@ -37,7 +39,8 @@ class DirectoryHandler(BaseHandler): ) @defer.inlineCallbacks - def create_association(self, room_alias, room_id, servers=None): + def create_association(self, user_id, room_alias, room_id, servers=None): + # TODO(erikj): Do auth. if not room_alias.is_mine: @@ -54,12 +57,37 @@ class DirectoryHandler(BaseHandler): if not servers: raise SynapseError(400, "Failed to get server list") - yield self.store.create_room_alias_association( - room_alias, - room_id, - servers + + try: + yield self.store.create_room_alias_association( + room_alias, + room_id, + servers + ) + except sqlite3.IntegrityError: + defer.returnValue("Already exists") + + # TODO: Send the room event. + + aliases = yield self.store.get_aliases_for_room(room_id) + + event = self.event_factory.create_event( + etype=RoomAliasesEvent.TYPE, + state_key=self.hs.hostname, + room_id=room_id, + user_id=user_id, + content={"aliases": aliases}, + ) + + snapshot = yield self.store.snapshot_room( + room_id=room_id, + user_id=user_id, ) + yield self.state_handler.handle_new_event(event, snapshot) + yield self._on_new_room_event(event, snapshot, extra_users=[user_id]) + + @defer.inlineCallbacks def get_association(self, room_alias): room_id = None diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index dad2bbd1a4e38c08b2cb1118289027160e220c36..87fc04478b92d006e78707d01d4a31655547aa7b 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -19,7 +19,7 @@ from synapse.api.constants import Membership from synapse.api.events.room import RoomTopicEvent from synapse.api.errors import RoomError from synapse.streams.config import PaginationConfig -from ._base import BaseRoomHandler +from ._base import BaseHandler import logging @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -class MessageHandler(BaseRoomHandler): +class MessageHandler(BaseHandler): def __init__(self, hs): super(MessageHandler, self).__init__(hs) diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index c79bb6ff76fd3c8d6bd22945a185600dbf0326f9..b2af09f09029d150460840510be8dddd0c13d117 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -796,11 +796,12 @@ class PresenceEventSource(object): updates = [] # TODO(paul): use a DeferredList ? How to limit concurrency. for observed_user in cachemap.keys(): - if not (from_key < cachemap[observed_user].serial): + cached = cachemap[observed_user] + if not (from_key < cached.serial): continue if (yield self.is_visible(observer_user, observed_user)): - updates.append((observed_user, cachemap[observed_user])) + updates.append((observed_user, cached)) # TODO(paul): limit diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py index bee052274f148b7532ecf59c8fa355212f6835b2..0b841d6d3af35e8c77fbcfc326593cb313b25bd8 100644 --- a/synapse/handlers/register.py +++ b/synapse/handlers/register.py @@ -17,7 +17,9 @@ from twisted.internet import defer from synapse.types import UserID -from synapse.api.errors import SynapseError, RegistrationError +from synapse.api.errors import ( + SynapseError, RegistrationError, InvalidCaptchaError +) from ._base import BaseHandler import synapse.util.stringutils as stringutils from synapse.http.client import PlainHttpClient @@ -38,7 +40,8 @@ class RegistrationHandler(BaseHandler): self.distributor.declare("registered_user") @defer.inlineCallbacks - def register(self, localpart=None, password=None, threepidCreds=None): + def register(self, localpart=None, password=None, threepidCreds=None, + captcha_info={}): """Registers a new client on the server. Args: @@ -51,10 +54,26 @@ 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']) + logger.info("validating theeepidcred sid %s on id server %s", + c['sid'], c['idServer']) try: threepid = yield self._threepid_from_creds(c) except: @@ -63,7 +82,8 @@ class RegistrationHandler(BaseHandler): if not threepid: raise RegistrationError(400, "Couldn't validate 3pid") - logger.info("got threepid medium %s address %s", threepid['medium'], threepid['address']) + logger.info("got threepid medium %s address %s", + threepid['medium'], threepid['address']) password_hash = None if password: @@ -131,7 +151,8 @@ class RegistrationHandler(BaseHandler): # XXX: make this configurable! trustedIdServers = [ 'matrix.org:8090' ] if not creds['idServer'] in trustedIdServers: - logger.warn('%s is not a trusted ID server: rejecting 3pid credentials', creds['idServer']) + logger.warn('%s is not a trusted ID server: rejecting 3pid '+ + 'credentials', creds['idServer']) defer.returnValue(None) data = yield httpCli.get_json( creds['idServer'], @@ -149,9 +170,44 @@ 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) + # 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=%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, + 'remoteip': ip_addr, + 'challenge': challenge, + 'response': response + } + ) + defer.returnValue(data) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 8171e9eb45eb48cc894c6f2efc3adb11c1344d7e..a0d0f2af162600054dd8b88d31cfaa672dd0a3e3 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -25,14 +25,14 @@ from synapse.api.events.room import ( RoomSendEventLevelEvent, RoomOpsPowerLevelsEvent, RoomNameEvent, ) from synapse.util import stringutils -from ._base import BaseRoomHandler +from ._base import BaseHandler import logging logger = logging.getLogger(__name__) -class RoomCreationHandler(BaseRoomHandler): +class RoomCreationHandler(BaseHandler): @defer.inlineCallbacks def create_room(self, user_id, room_id, config): @@ -65,6 +65,13 @@ class RoomCreationHandler(BaseRoomHandler): else: room_alias = None + invite_list = config.get("invite", []) + for i in invite_list: + try: + self.hs.parse_userid(i) + except: + raise SynapseError(400, "Invalid user_id: %s" % (i,)) + is_public = config.get("visibility", None) == "public" if room_id: @@ -105,7 +112,9 @@ class RoomCreationHandler(BaseRoomHandler): ) if room_alias: - yield self.store.create_room_alias_association( + directory_handler = self.hs.get_handlers().directory_handler + yield directory_handler.create_association( + user_id=user_id, room_id=room_id, room_alias=room_alias, servers=[self.hs.hostname], @@ -132,7 +141,7 @@ class RoomCreationHandler(BaseRoomHandler): etype=RoomNameEvent.TYPE, room_id=room_id, user_id=user_id, - required_power_level=5, + required_power_level=50, content={"name": name}, ) @@ -143,7 +152,7 @@ class RoomCreationHandler(BaseRoomHandler): etype=RoomNameEvent.TYPE, room_id=room_id, user_id=user_id, - required_power_level=5, + required_power_level=50, content={"name": name}, ) @@ -155,7 +164,7 @@ class RoomCreationHandler(BaseRoomHandler): etype=RoomTopicEvent.TYPE, room_id=room_id, user_id=user_id, - required_power_level=5, + required_power_level=50, content={"topic": topic}, ) @@ -176,6 +185,25 @@ class RoomCreationHandler(BaseRoomHandler): do_auth=False ) + content = {"membership": Membership.INVITE} + for invitee in invite_list: + invite_event = self.event_factory.create_event( + etype=RoomMemberEvent.TYPE, + state_key=invitee, + room_id=room_id, + user_id=user_id, + content=content + ) + + yield self.hs.get_handlers().room_member_handler.change_membership( + invite_event, + do_auth=False + ) + + yield self.hs.get_handlers().room_member_handler.change_membership( + join_event, + do_auth=False + ) result = {"room_id": room_id} if room_alias: result["room_alias"] = room_alias.to_string() @@ -186,7 +214,7 @@ class RoomCreationHandler(BaseRoomHandler): event_keys = { "room_id": room_id, "user_id": creator.to_string(), - "required_power_level": 10, + "required_power_level": 100, } def create(etype, **content): @@ -203,7 +231,7 @@ class RoomCreationHandler(BaseRoomHandler): power_levels_event = self.event_factory.create_event( etype=RoomPowerLevelsEvent.TYPE, - content={creator.to_string(): 10, "default": 0}, + content={creator.to_string(): 100, "default": 0}, **event_keys ) @@ -215,7 +243,7 @@ class RoomCreationHandler(BaseRoomHandler): add_state_event = create( etype=RoomAddStateLevelEvent.TYPE, - level=10, + level=100, ) send_event = create( @@ -225,8 +253,8 @@ class RoomCreationHandler(BaseRoomHandler): ops = create( etype=RoomOpsPowerLevelsEvent.TYPE, - ban_level=5, - kick_level=5, + ban_level=50, + kick_level=50, ) return [ @@ -239,7 +267,7 @@ class RoomCreationHandler(BaseRoomHandler): ] -class RoomMemberHandler(BaseRoomHandler): +class RoomMemberHandler(BaseHandler): # TODO(paul): This handler currently contains a messy conflation of # low-level API that works on UserID objects and so on, and REST-level # API that takes ID strings and returns pagination chunks. These concerns @@ -560,7 +588,7 @@ class RoomMemberHandler(BaseRoomHandler): extra_users=[target_user] ) -class RoomListHandler(BaseRoomHandler): +class RoomListHandler(BaseHandler): @defer.inlineCallbacks def get_public_room_list(self): diff --git a/synapse/http/client.py b/synapse/http/client.py index ebf1aa47c4c7957168477afc0b510dd0699253a6..ece6318e006073807881526025c1078636dc7c93 100644 --- a/synapse/http/client.py +++ b/synapse/http/client.py @@ -16,7 +16,7 @@ from twisted.internet import defer, reactor from twisted.internet.error import DNSLookupError -from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer +from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer, PartialDownloadError from twisted.web.http_headers import Headers from synapse.http.endpoint import matrix_endpoint @@ -188,6 +188,32 @@ class TwistedHttpClient(HttpClient): body = yield readBody(response) defer.returnValue(json.loads(body)) + + # XXX FIXME : I'm so sorry. + @defer.inlineCallbacks + def post_urlencoded_get_raw(self, destination, path, accept_partial=False, args={}): + if destination in _destination_mappings: + destination = _destination_mappings[destination] + + query_bytes = urllib.urlencode(args, True) + + response = yield self._create_request( + destination.encode("ascii"), + "POST", + path.encode("ascii"), + producer=FileBodyProducer(StringIO(urllib.urlencode(args))), + headers_dict={"Content-Type": ["application/x-www-form-urlencoded"]} + ) + + try: + body = yield readBody(response) + defer.returnValue(body) + except PartialDownloadError as e: + if accept_partial: + defer.returnValue(e.response) + else: + raise e + @defer.inlineCallbacks def _create_request(self, destination, method, path_bytes, param_bytes=b"", diff --git a/synapse/rest/directory.py b/synapse/rest/directory.py index 18df7c8d8bceceeda12c06c9c9c950232cf38af8..31849246a1284d3484f0b0ca173868efcc43c587 100644 --- a/synapse/rest/directory.py +++ b/synapse/rest/directory.py @@ -45,6 +45,8 @@ class ClientDirectoryServer(RestServlet): @defer.inlineCallbacks def on_PUT(self, request, room_alias): + user = yield self.auth.get_user_by_req(request) + content = _parse_json(request) if not "room_id" in content: raise SynapseError(400, "Missing room_id key", @@ -69,12 +71,13 @@ class ClientDirectoryServer(RestServlet): try: yield dir_handler.create_association( - room_alias, room_id, servers + user.to_string(), room_alias, room_id, servers ) except SynapseError as e: raise e except: logger.exception("Failed to create association") + raise defer.returnValue((200, {})) diff --git a/synapse/rest/login.py b/synapse/rest/login.py index c7bf901c8e54139ce36db50e9b59178a00673c50..ba49afcaa7b35888cdf100d0cc0d7582929fbded 100644 --- a/synapse/rest/login.py +++ b/synapse/rest/login.py @@ -70,7 +70,7 @@ class LoginFallbackRestServlet(RestServlet): def on_GET(self, request): # TODO(kegan): This should be returning some HTML which is capable of # hitting LoginRestServlet - return (200, "") + return (200, {}) def _parse_json(request): diff --git a/synapse/rest/profile.py b/synapse/rest/profile.py index 2e17f87fa13e64fc4c33d068e029bf787419b5c7..dad5a208c78704f229087d9dcc86f1271d78c6dc 100644 --- a/synapse/rest/profile.py +++ b/synapse/rest/profile.py @@ -51,7 +51,7 @@ class ProfileDisplaynameRestServlet(RestServlet): yield self.handlers.profile_handler.set_displayname( user, auth_user, new_name) - defer.returnValue((200, "")) + defer.returnValue((200, {})) def on_OPTIONS(self, request, user_id): return (200, {}) @@ -86,7 +86,7 @@ class ProfileAvatarURLRestServlet(RestServlet): yield self.handlers.profile_handler.set_avatar_url( user, auth_user, new_name) - defer.returnValue((200, "")) + defer.returnValue((200, {})) def on_OPTIONS(self, request, user_id): return (200, {}) diff --git a/synapse/rest/register.py b/synapse/rest/register.py index b8de3b250d7f9378060552063c377e840ba5b929..48d3c6eca087926f6f209f8ad95d23c50ff46fe1 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -16,7 +16,7 @@ """This module contains REST servlets to do with registration: /register""" from twisted.internet import defer -from synapse.api.errors import SynapseError +from synapse.api.errors import SynapseError, Codes from base import RestServlet, client_path_pattern import json @@ -50,12 +50,44 @@ class RegisterRestServlet(RestServlet): 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 + } + handler = self.handlers.registration_handler (user_id, token) = yield handler.register( localpart=desired_user_id, password=password, - threepidCreds=threepidCreds) + threepidCreds=threepidCreds, + captcha_info=captcha) result = { "user_id": user_id, diff --git a/synapse/rest/room.py b/synapse/rest/room.py index 308b4470905183fc89ccb58eeb1b25a44272f720..cef700c81c6d6fe4b0a5e2974ea982ff48e26d9c 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -154,14 +154,14 @@ class RoomStateEventRestServlet(RestServlet): # membership events are special handler = self.handlers.room_member_handler yield handler.change_membership(event) - defer.returnValue((200, "")) + defer.returnValue((200, {})) else: # store random bits of state msg_handler = self.handlers.message_handler yield msg_handler.store_room_data( event=event ) - defer.returnValue((200, "")) + defer.returnValue((200, {})) # TODO: Needs unit testing for generic events + feedback @@ -249,7 +249,7 @@ class JoinRoomAliasServlet(RestServlet): ) handler = self.handlers.room_member_handler yield handler.change_membership(event) - defer.returnValue((200, "")) + defer.returnValue((200, {})) @defer.inlineCallbacks def on_PUT(self, request, room_identifier, txn_id): @@ -416,7 +416,7 @@ class RoomMembershipRestServlet(RestServlet): ) handler = self.handlers.room_member_handler yield handler.change_membership(event) - defer.returnValue((200, "")) + defer.returnValue((200, {})) @defer.inlineCallbacks def on_PUT(self, request, room_id, membership_action, txn_id): diff --git a/synapse/state.py b/synapse/state.py index 36d8210eb5d17270d978a55cff107d16aaff559d..5dcff273676ddf8343793dbf37302ff1f18b3ec3 100644 --- a/synapse/state.py +++ b/synapse/state.py @@ -16,7 +16,7 @@ from twisted.internet import defer -from synapse.federation.pdu_codec import encode_event_id +from synapse.federation.pdu_codec import encode_event_id, decode_event_id from synapse.util.logutils import log_function from collections import namedtuple @@ -87,9 +87,11 @@ class StateHandler(object): # than the power level of the user # power_level = self._get_power_level_for_event(event) + pdu_id, origin = decode_event_id(event.event_id, self.server_name) + yield self.store.update_current_state( - pdu_id=event.event_id, - origin=self.server_name, + pdu_id=pdu_id, + origin=origin, context=key.context, pdu_type=key.type, state_key=key.state_key diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index d97014f4da336d9b161767b1be5ccfb614994f45..81c3c94b2e3297d79346dff6f461995bb8323502 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -81,7 +81,7 @@ class DataStore(RoomMemberStore, RoomStore, defer.returnValue(latest) @defer.inlineCallbacks - def get_event(self, event_id): + def get_event(self, event_id, allow_none=False): events_dict = yield self._simple_select_one( "events", {"event_id": event_id}, @@ -92,8 +92,12 @@ class DataStore(RoomMemberStore, RoomStore, "content", "unrecognized_keys" ], + allow_none=allow_none, ) + if not events_dict: + defer.returnValue(None) + event = self._parse_event_from_row(events_dict) defer.returnValue(event) @@ -220,7 +224,8 @@ class DataStore(RoomMemberStore, RoomStore, results = yield self._execute_and_decode(sql, *args) - defer.returnValue([self._parse_event_from_row(r) for r in results]) + events = yield self._parse_events(results) + defer.returnValue(events) @defer.inlineCallbacks def _get_min_token(self): diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py index bae50e7d1f065842b678f95fba3bdef781ea3ece..8037225079608133e02d39ed779c988eae449534 100644 --- a/synapse/storage/_base.py +++ b/synapse/storage/_base.py @@ -312,6 +312,25 @@ class SQLBaseStore(object): **d ) + def _parse_events(self, rows): + return self._db_pool.runInteraction(self._parse_events_txn, rows) + + def _parse_events_txn(self, txn, rows): + events = [self._parse_event_from_row(r) for r in rows] + + sql = "SELECT * FROM events WHERE event_id = ?" + + for ev in events: + if hasattr(ev, "prev_state"): + # Load previous state_content. + # TODO: Should we be pulling this out above? + cursor = txn.execute(sql, (ev.prev_state,)) + prevs = self.cursor_to_dict(cursor) + if prevs: + prev = self._parse_event_from_row(prevs[0]) + ev.prev_content = prev.content + + return events class Table(object): """ A base class used to store information about a particular table. diff --git a/synapse/storage/directory.py b/synapse/storage/directory.py index bf55449253145edfa52983f75117ade5cd1ee9c3..540eb4c2c4f38a682f418f20fa117f8122351516 100644 --- a/synapse/storage/directory.py +++ b/synapse/storage/directory.py @@ -92,3 +92,10 @@ class DirectoryStore(SQLBaseStore): "server": server, } ) + + def get_aliases_for_room(self, room_id): + return self._simple_select_onecol( + "room_aliases", + {"room_id": room_id}, + "room_alias", + ) diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py index 75c9a60101020ccb354a4056e58aad573ede5c0e..9a393e2568bb22d604298a5b3c6bd1a7bc971dfc 100644 --- a/synapse/storage/roommember.py +++ b/synapse/storage/roommember.py @@ -88,7 +88,7 @@ class RoomMemberStore(SQLBaseStore): txn.execute(sql, (user_id, room_id)) rows = self.cursor_to_dict(txn) if rows: - return self._parse_event_from_row(rows[0]) + return self._parse_events_txn(txn, rows)[0] else: return None @@ -161,7 +161,7 @@ class RoomMemberStore(SQLBaseStore): # logger.debug("_get_members_query Got rows %s", rows) - results = [self._parse_event_from_row(r) for r in rows] + results = yield self._parse_events(rows) defer.returnValue(results) @defer.inlineCallbacks diff --git a/synapse/storage/schema/delta/v3.sql b/synapse/storage/schema/delta/v3.sql new file mode 100644 index 0000000000000000000000000000000000000000..cade295989a4409f5a95bfa0790fd666d4006137 --- /dev/null +++ b/synapse/storage/schema/delta/v3.sql @@ -0,0 +1,27 @@ +/* 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. + */ + + +CREATE INDEX IF NOT EXISTS room_aliases_alias ON room_aliases(room_alias); +CREATE INDEX IF NOT EXISTS room_aliases_id ON room_aliases(room_id); + + +CREATE INDEX IF NOT EXISTS room_alias_servers_alias ON room_alias_servers(room_alias); + +DELETE FROM room_aliases WHERE rowid NOT IN (SELECT max(rowid) FROM room_aliases GROUP BY room_alias, room_id); + +CREATE UNIQUE INDEX IF NOT EXISTS room_aliases_uniq ON room_aliases(room_alias, room_id); + +PRAGMA user_version = 3; diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py index 2cb0067a674840dece792999d59044bed08efde4..aff6dc985541064504640b0dc99f5c508b220266 100644 --- a/synapse/storage/stream.py +++ b/synapse/storage/stream.py @@ -188,7 +188,7 @@ class StreamStore(SQLBaseStore): user_id, user_id, from_id, to_id ) - ret = [self._parse_event_from_row(r) for r in rows] + ret = yield self._parse_events(rows) if rows: key = "s%d" % max([r["stream_ordering"] for r in rows]) @@ -243,9 +243,11 @@ class StreamStore(SQLBaseStore): # TODO (erikj): We should work out what to do here instead. next_token = to_key if to_key else from_key + events = yield self._parse_events(rows) + defer.returnValue( ( - [self._parse_event_from_row(r) for r in rows], + events, next_token ) ) @@ -277,12 +279,11 @@ class StreamStore(SQLBaseStore): else: token = (end_token, end_token) - defer.returnValue( - ( - [self._parse_event_from_row(r) for r in rows], - token - ) - ) + events = yield self._parse_events(rows) + + ret = (events, token) + + defer.returnValue(ret) def get_room_events_max_id(self): return self._db_pool.runInteraction(self._get_room_events_max_id_txn) diff --git a/tests/rest/test_events.py b/tests/rest/test_events.py index 1dccf4c50330de404d0acc0197ef0fcdec5e1c99..fd2224f55fa2bf5457ce85eb1e8073a9732e576f 100644 --- a/tests/rest/test_events.py +++ b/tests/rest/test_events.py @@ -145,6 +145,7 @@ class EventStreamPermissionsTestCase(RestTestCase): ) self.ratelimiter = hs.get_ratelimiter() self.ratelimiter.send_message.return_value = (True, 0) + hs.config.enable_registration_captcha = False hs.get_handlers().federation_handler = Mock() diff --git a/tests/test_state.py b/tests/test_state.py index a1f5ee869bb543b16144ffbac628133b43aa6495..b01496c40ff0ff23c8e0894ed32e064f9df71a96 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -240,6 +240,7 @@ class StateTestCase(unittest.TestCase): @defer.inlineCallbacks def test_new_event(self): event = Mock() + event.event_id = "12123123@test" state_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20) diff --git a/webclient/CAPTCHA_SETUP b/webclient/CAPTCHA_SETUP new file mode 100644 index 0000000000000000000000000000000000000000..ebc8a5f3b010e7afeb72fdb41a7edf9741b5297a --- /dev/null +++ b/webclient/CAPTCHA_SETUP @@ -0,0 +1,46 @@ +Captcha can be enabled for this web client / home server. This file explains how to do that. +The captcha mechanism used is Google's ReCaptcha. This requires API keys from Google. + +Getting keys +------------ +Requires a public/private key pair from: + +https://developers.google.com/recaptcha/ + + +Setting Private ReCaptcha Key +----------------------------- +The private key is a config option on the home server config. If it is not +visible, you can generate it via --generate-config. Set the following value: + + recaptcha_private_key: YOUR_PRIVATE_KEY + +In addition, you MUST enable captchas via: + + enable_registration_captcha: true + +Setting Public ReCaptcha Key +---------------------------- +The web client will look for the global variable webClientConfig for config +options. You should put your ReCaptcha public key there like so: + +webClientConfig = { + useCaptcha: true, + recaptcha_public_key: "YOUR_PUBLIC_KEY" +} + +This should be put in webclient/config.js which is already .gitignored, rather +than in the web client source files. You MUST set useCaptcha to true else a +ReCaptcha widget will not be generated. + +Configuring IP used for auth +---------------------------- +The ReCaptcha API requires that the IP address of the user who solved the +captcha is sent. If the client is connecting through a proxy or load balancer, +it may be required to use the X-Forwarded-For (XFF) header instead of the origin +IP address. This can be configured as an option on the home server like so: + + captcha_ip_origin_is_x_forwarded: true + + + diff --git a/webclient/README b/webclient/README index 0f893b1712b7ec139c26440330bdb4a15f55e870..13224c3d071ea87a88b511e78525fe0d2dd1c5d6 100644 --- a/webclient/README +++ b/webclient/README @@ -1,12 +1,13 @@ Basic Usage ----------- -The Synapse web client needs to be hosted by a basic HTTP server. - -You can use the Python simple HTTP server:: +The web client should automatically run when running the home server. Alternatively, you can run +it stand-alone: $ python -m SimpleHTTPServer Then, open this URL in a WEB browser:: http://127.0.0.1:8000/ + + diff --git a/webclient/app-controller.js b/webclient/app-controller.js index ea48cbb011d3cf504a43156fd87665f03f5d71b7..064bde3ab2e2598e03da2f9eac82568502bafbbe 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -21,8 +21,8 @@ limitations under the License. 'use strict'; angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'eventStreamService']) -.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventStreamService', 'matrixPhoneService', - function($scope, $location, $rootScope, matrixService, mPresence, eventStreamService, matrixPhoneService) { +.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', '$animate', 'matrixService', 'mPresence', 'eventStreamService', 'matrixPhoneService', + function($scope, $location, $rootScope, $timeout, $animate, matrixService, mPresence, eventStreamService, matrixPhoneService) { // Check current URL to avoid to display the logout button on the login page $scope.location = $location.path(); @@ -89,6 +89,23 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even $scope.user_id = matrixService.config().user_id; }; + $rootScope.$watch('currentCall', function(newVal, oldVal) { + if (!$rootScope.currentCall) return; + + var roomMembers = angular.copy($rootScope.events.rooms[$rootScope.currentCall.room_id].members); + delete roomMembers[matrixService.config().user_id]; + + $rootScope.currentCall.user_id = Object.keys(roomMembers)[0]; + matrixService.getProfile($rootScope.currentCall.user_id).then( + function(response) { + $rootScope.currentCall.userProfile = response.data; + }, + function(error) { + $scope.feedback = "Can't load user profile"; + } + ); + }); + $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) { console.trace("incoming call"); call.onError = $scope.onCallError; @@ -97,12 +114,19 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even }); $scope.answerCall = function() { - $scope.currentCall.answer(); + $rootScope.currentCall.answer(); }; $scope.hangupCall = function() { - $scope.currentCall.hangup(); - $scope.currentCall = undefined; + $rootScope.currentCall.hangup(); + + $timeout(function() { + var icon = angular.element('#callEndedIcon'); + $animate.addClass(icon, 'callIconRotate'); + $timeout(function(){ + $rootScope.currentCall = undefined; + }, 2000); + }, 100); }; $rootScope.onCallError = function(errStr) { @@ -110,5 +134,12 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even } $rootScope.onCallHangup = function() { + $timeout(function() { + var icon = angular.element('#callEndedIcon'); + $animate.addClass(icon, 'callIconRotate'); + $timeout(function(){ + $rootScope.currentCall = undefined; + }, 2000); + }, 100); } }]); diff --git a/webclient/app-filter.js b/webclient/app-filter.js index 27f435674f76e4b2ef1894db5ee3aa5d286a2a70..ee9374668bad5ff73790645b46f14ed6b0e544a0 100644 --- a/webclient/app-filter.js +++ b/webclient/app-filter.js @@ -79,85 +79,4 @@ angular.module('matrixWebClient') return function(text) { return $sce.trustAsHtml(text); }; -}]) - -// Compute the room name according to information we have -.filter('roomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) { - return function(room_id) { - var roomName; - - // If there is an alias, use it - // TODO: only one alias is managed for now - var alias = matrixService.getRoomIdToAliasMapping(room_id); - if (alias) { - roomName = alias; - } - - if (undefined === roomName) { - // Else, build the name from its users - var room = $rootScope.events.rooms[room_id]; - if (room) { - var room_name_event = room["m.room.name"]; - - if (room_name_event) { - roomName = room_name_event.content.name; - } - else if (room.members) { - // Limit the room renaming to 1:1 room - if (2 === Object.keys(room.members).length) { - for (var i in room.members) { - var member = room.members[i]; - if (member.state_key !== matrixService.config().user_id) { - - if (member.state_key in $rootScope.presence) { - // If the user is available in presence, use the displayname there - // as it is the most uptodate - roomName = $rootScope.presence[member.state_key].content.displayname; - } - else if (member.content.displayname) { - roomName = member.content.displayname; - } - else { - roomName = member.state_key; - } - } - } - } - else if (1 === Object.keys(room.members).length) { - // The other member may be in the invite list, get all invited users - var invitedUserIDs = []; - for (var i in room.messages) { - var message = room.messages[i]; - if ("m.room.member" === message.type && "invite" === message.membership) { - // Make sure there is no duplicate user - if (-1 === invitedUserIDs.indexOf(message.state_key)) { - invitedUserIDs.push(message.state_key); - } - } - } - - // For now, only 1:1 room needs to be renamed. It means only 1 invited user - if (1 === invitedUserIDs.length) { - var userID = invitedUserIDs[0]; - - // Try to resolve his displayname in presence global data - if (userID in $rootScope.presence) { - roomName = $rootScope.presence[userID].content.displayname; - } - else { - roomName = userID; - } - } - } - } - } - } - - if (undefined === roomName) { - // By default, use the room ID - roomName = room_id; - } - - return roomName; - }; -}]); +}]); \ No newline at end of file diff --git a/webclient/app.css b/webclient/app.css index 425d5bb11a68e1d644d53c1b1b356f767b0263a1..7698cb4fda4618175e10c39c636f657e791f482b 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -44,7 +44,49 @@ a:active { color: #000; } } #callBar { - float: left; + float: left; + height: 32px; + margin: auto; + text-align: right; + line-height: 16px; +} + +.callIcon { + margin-left: 4px; + margin-right: 4px; + margin-top: 8px; + -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + -moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + -o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; + transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; +} + +.callIconRotate { + -webkit-transform: rotateZ(45deg); + -moz-transform: rotateZ(45deg); + -ms-transform: rotateZ(45deg); + -o-transform: rotateZ(45deg); + transform: rotateZ(45deg); +} + +#callPeerImage { + width: 32px; + height: 32px; + border: none; + float: left; +} + +#callPeerNameAndState { + float: left; + margin-left: 4px; +} + +#callState { + font-size: 60%; +} + +#callPeerName { + font-size: 80%; } #headerContent { @@ -105,6 +147,10 @@ a:active { color: #000; } text-align: center; } +#recaptcha_area { + margin: auto +} + #loginForm { text-align: left; padding: 1em; @@ -251,12 +297,14 @@ a:active { color: #000; } .userAvatar .userAvatarImage { position: absolute; top: 0px; - object-fit: cover; + object-fit: cover; + width: 100%; } .userAvatar .userAvatarGradient { position: absolute; bottom: 20px; + width: 100%; } .userAvatar .userName { @@ -417,6 +465,13 @@ a:active { color: #000; } text-align: left ! important; } +.bubble .messagePending { + opacity: 0.3 +} +.messageUnSent { + color: #F00; +} + #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 ee478d2eb0fbd1cd2043a5bda8cc0706a16ec2b6..d2bb31053fc321e39a437199c011d17f5da59d14 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -41,6 +41,11 @@ angular.module('eventHandlerService', []) $rootScope.events = { rooms: {} // will contain roomId: { messages:[], members:{userid1: event} } }; + + // used for dedupping events - could be expanded in future... + // FIXME: means that we leak memory over time (along with lots of the rest + // of the app, given we never try to reap memory yet) + var eventMap = {}; $rootScope.presence = {}; @@ -66,11 +71,22 @@ angular.module('eventHandlerService', []) $rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent); }; + var handleRoomAliases = function(event, isLiveEvent) { + matrixService.createRoomIdToAliasMapping(event.room_id, event.content.aliases[0]); + }; + var handleMessage = function(event, isLiveEvent) { initRoom(event.room_id); if (isLiveEvent) { - $rootScope.events.rooms[event.room_id].messages.push(event); + if (event.user_id === matrixService.config().user_id && + (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) { + // assume we've already echoed it + // FIXME: track events by ID and ungrey the right message to show it's been delivered + } + else { + $rootScope.events.rooms[event.room_id].messages.push(event); + } } else { $rootScope.events.rooms[event.room_id].messages.unshift(event); @@ -87,6 +103,14 @@ angular.module('eventHandlerService', []) var handleRoomMember = function(event, isLiveEvent) { initRoom(event.room_id); + // if the server is stupidly re-relaying a no-op join, discard it. + if (event.prev_content && + event.content.membership === "join" && + event.content.membership === event.prev_content.membership) + { + return; + } + // add membership changes as if they were a room message if something interesting changed if (event.content.prev !== event.content.membership) { if (isLiveEvent) { @@ -137,40 +161,55 @@ angular.module('eventHandlerService', []) POWERLEVEL_EVENT: POWERLEVEL_EVENT, CALL_EVENT: CALL_EVENT, NAME_EVENT: NAME_EVENT, - handleEvent: function(event, isLiveEvent) { - switch(event.type) { - case "m.room.create": - handleRoomCreate(event, isLiveEvent); - break; - case "m.room.message": - handleMessage(event, isLiveEvent); - break; - case "m.room.member": - handleRoomMember(event, isLiveEvent); - break; - case "m.presence": - handlePresence(event, isLiveEvent); - break; - case 'm.room.ops_levels': - case 'm.room.send_event_level': - case 'm.room.add_state_level': - case 'm.room.join_rules': - case 'm.room.power_levels': - handlePowerLevels(event, isLiveEvent); - break; - case 'm.room.name': - handleRoomName(event, isLiveEvent); - break; - default: - console.log("Unable to handle event type " + event.type); - console.log(JSON.stringify(event, undefined, 4)); - break; + // FIXME: event duplication suppression is all broken as the code currently expect to handles + // events multiple times to get their side-effects... +/* + if (eventMap[event.event_id]) { + console.log("discarding duplicate event: " + JSON.stringify(event)); + return; } + else { + eventMap[event.event_id] = 1; + } +*/ if (event.type.indexOf('m.call.') === 0) { handleCallEvent(event, isLiveEvent); } + else { + switch(event.type) { + case "m.room.create": + handleRoomCreate(event, isLiveEvent); + break; + case "m.room.aliases": + handleRoomAliases(event, isLiveEvent); + break; + case "m.room.message": + handleMessage(event, isLiveEvent); + break; + case "m.room.member": + handleRoomMember(event, isLiveEvent); + break; + case "m.presence": + handlePresence(event, isLiveEvent); + break; + case 'm.room.ops_levels': + case 'm.room.send_event_level': + case 'm.room.add_state_level': + case 'm.room.join_rules': + case 'm.room.power_levels': + handlePowerLevels(event, isLiveEvent); + break; + case 'm.room.name': + handleRoomName(event, isLiveEvent); + break; + default: + console.log("Unable to handle event type " + event.type); + console.log(JSON.stringify(event, undefined, 4)); + break; + } + } }, // isLiveEvents determines whether notifications should be shown, whether diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 1c0f7712b46e3b298c6f66e3ae79173eb5348c19..ed4f3b2ffc0aee2c3c8e97d246c24fc3e136e1c3 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -110,6 +110,7 @@ angular.module('eventStreamService', []) var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { var room = rooms[i]; + // console.log("got room: " + room.room_id); if ("state" in room) { eventHandlerService.handleEvents(room.state, false); } diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 3e13e4e81fd6eb11a90185adcaa63e8fa23feb3b..3cb5e8b693d480f32a108d339767f35838c0df56 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -41,6 +41,7 @@ angular.module('MatrixCall', []) this.room_id = room_id; this.call_id = "c" + new Date().getTime(); this.state = 'fledgling'; + this.didConnect = false; } navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; @@ -52,6 +53,7 @@ angular.module('MatrixCall', []) matrixPhoneService.callPlaced(this); navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); }); self.state = 'wait_local_media'; + this.direction = 'outbound'; }; MatrixCall.prototype.initWithInvite = function(msg) { @@ -64,6 +66,7 @@ angular.module('MatrixCall', []) this.peerConn.onaddstream = function(s) { self.onAddStream(s); }; this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError); this.state = 'ringing'; + this.direction = 'inbound'; }; MatrixCall.prototype.answer = function() { @@ -204,10 +207,12 @@ angular.module('MatrixCall', []) }; MatrixCall.prototype.onIceConnectionStateChanged = function() { + if (this.state == 'ended') return; // because ICE can still complete as we're ending the call console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState); // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') { this.state = 'connected'; + this.didConnect = true; $rootScope.$apply(); } }; diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js new file mode 100644 index 0000000000000000000000000000000000000000..260e0827df05f2c1551e163ff86996d5f484e964 --- /dev/null +++ b/webclient/components/matrix/matrix-filter.js @@ -0,0 +1,135 @@ +/* + 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. + */ + +'use strict'; + +angular.module('matrixFilter', []) + +// Compute the room name according to information we have +.filter('mRoomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) { + return function(room_id) { + var roomName; + + // If there is an alias, use it + // TODO: only one alias is managed for now + var alias = matrixService.getRoomIdToAliasMapping(room_id); + if (alias) { + roomName = alias; + } + + if (undefined === roomName) { + // Else, build the name from its users + var room = $rootScope.events.rooms[room_id]; + if (room) { + var room_name_event = room["m.room.name"]; + + if (room_name_event) { + roomName = room_name_event.content.name; + } + else if (room.members) { + // Limit the room renaming to 1:1 room + if (2 === Object.keys(room.members).length) { + for (var i in room.members) { + var member = room.members[i]; + if (member.state_key !== matrixService.config().user_id) { + + if (member.state_key in $rootScope.presence) { + // If the user is available in presence, use the displayname there + // as it is the most uptodate + roomName = $rootScope.presence[member.state_key].content.displayname; + } + else if (member.content.displayname) { + roomName = member.content.displayname; + } + else { + roomName = member.state_key; + } + } + } + } + else if (1 === Object.keys(room.members).length) { + // The other member may be in the invite list, get all invited users + var invitedUserIDs = []; + for (var i in room.messages) { + var message = room.messages[i]; + if ("m.room.member" === message.type && "invite" === message.membership) { + // Make sure there is no duplicate user + if (-1 === invitedUserIDs.indexOf(message.state_key)) { + invitedUserIDs.push(message.state_key); + } + } + } + + // For now, only 1:1 room needs to be renamed. It means only 1 invited user + if (1 === invitedUserIDs.length) { + var userID = invitedUserIDs[0]; + + // Try to resolve his displayname in presence global data + if (userID in $rootScope.presence) { + roomName = $rootScope.presence[userID].content.displayname; + } + else { + roomName = userID; + } + } + } + } + } + } + + if (undefined === roomName) { + // By default, use the room ID + roomName = room_id; + } + + return roomName; + }; +}]) + +// Compute the user display name in a room according to the data already downloaded +.filter('mUserDisplayName', ['$rootScope', function($rootScope) { + return function(user_id, room_id) { + var displayName; + + // Try to find the user name among presence data + // Warning: that means we have received before a presence event for this + // user which cannot be guaranted. + // However, if we get the info by this way, we are sure this is the latest user display name + // See FIXME comment below + if (user_id in $rootScope.presence) { + displayName = $rootScope.presence[user_id].content.displayname; + } + + // FIXME: Would like to use the display name as defined in room members of the room. + // But this information is the display name of the user when he has joined the room. + // It does not take into account user display name update + if (room_id) { + var room = $rootScope.events.rooms[room_id]; + if (room && (user_id in room.members)) { + var member = room.members[user_id]; + if (member.content.displayname) { + displayName = member.content.displayname; + } + } + } + + if (undefined === displayName) { + // By default, use the user ID + displayName = user_id; + } + return displayName; + }; +}]); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 25222a9e9e34a30b81ffb8a92ffc9b746a676921..3c28c52fbe3fb5d99421276b4b8757b58d5e4df0 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -36,6 +36,9 @@ angular.module('matrixService', []) */ var config; + var roomIdToAlias = {}; + var aliasToRoomId = {}; + // Current version of permanent storage var configVersion = 0; var prefixPath = "/_matrix/client/api/v1"; @@ -84,15 +87,32 @@ angular.module('matrixService', []) prefix: prefixPath, // Register an user - register: function(user_name, password, threepidCreds) { + register: function(user_name, password, threepidCreds, useCaptcha) { // The REST path spec var path = "/register"; - - return doRequest("POST", path, undefined, { + + var data = { user_id: user_name, password: password, threepidCreds: threepidCreds - }); + }; + + if (useCaptcha) { + // Not all home servers will require captcha on signup, but if this flag is checked, + // send captcha information. + // TODO: Might be nice to make this a bit more flexible.. + var challengeToken = Recaptcha.get_challenge(); + var captchaEntry = Recaptcha.get_response(); + var captchaType = "m.login.recaptcha"; + + data.captcha = { + type: captchaType, + challenge: challengeToken, + response: captchaEntry + }; + } + + return doRequest("POST", path, undefined, data); }, // Create a room @@ -168,18 +188,20 @@ angular.module('matrixService', []) }, // Change the membership of an another user - setMembership: function(room_id, user_id, membershipValue) { + setMembership: function(room_id, user_id, membershipValue, reason) { + // The REST path spec var path = "/rooms/$room_id/state/m.room.member/$user_id"; path = path.replace("$room_id", encodeURIComponent(room_id)); path = path.replace("$user_id", user_id); return doRequest("PUT", path, undefined, { - membership: membershipValue + membership : membershipValue, + reason: reason }); }, - // Bans a user from from a room + // Bans a user from a room ban: function(room_id, user_id, reason) { var path = "/rooms/$room_id/ban"; path = path.replace("$room_id", encodeURIComponent(room_id)); @@ -189,7 +211,20 @@ angular.module('matrixService', []) reason: reason }); }, - + + // Unbans a user in a room + unban: function(room_id, user_id) { + // FIXME: To update when there will be homeserver API for unban + // For now, do an unban by resetting the user membership to "leave" + return this.setMembership(room_id, user_id, "leave"); + }, + + // Kicks a user from a room + kick: function(room_id, user_id, reason) { + // Set the user membership to "leave" to kick him + return this.setMembership(room_id, user_id, "leave", reason); + }, + // Retrieves the room ID corresponding to a room alias resolveRoomAlias:function(room_alias) { var path = "/_matrix/client/api/v1/directory/room/$room_alias"; @@ -280,6 +315,11 @@ angular.module('matrixService', []) return doRequest("GET", path); }, + // get a user's profile + getProfile: function(userId) { + return this.getProfileInfo(userId); + }, + // get a display name for this user ID getDisplayName: function(userId) { return this.getProfileInfo(userId, "displayname"); @@ -313,8 +353,8 @@ angular.module('matrixService', []) }, getProfileInfo: function(userId, info_segment) { - var path = "/profile/$user_id/" + info_segment; - path = path.replace("$user_id", userId); + var path = "/profile/"+userId + if (info_segment) path += '/' + info_segment; return doRequest("GET", path); }, @@ -485,18 +525,20 @@ angular.module('matrixService', []) room_alias: undefined, room_display_name: undefined }; - var alias = this.getRoomIdToAliasMapping(room.room_id); if (alias) { // use the existing alias from storage result.room_alias = alias; result.room_display_name = alias; } + // XXX: this only lets us learn aliases from our local HS - we should + // make the client stop returning this if we can trust m.room.aliases state events else if (room.aliases && room.aliases[0]) { // save the mapping // TODO: select the smarter alias from the array this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]); result.room_display_name = room.aliases[0]; + result.room_alias = room.aliases[0]; } else if (room.membership === "invite" && "inviter" in room) { result.room_display_name = room.inviter + "'s room"; @@ -509,13 +551,22 @@ angular.module('matrixService', []) }, createRoomIdToAliasMapping: function(roomId, alias) { - localStorage.setItem(MAPPING_PREFIX+roomId, alias); + roomIdToAlias[roomId] = alias; + aliasToRoomId[alias] = roomId; + // localStorage.setItem(MAPPING_PREFIX+roomId, alias); }, getRoomIdToAliasMapping: function(roomId) { - return localStorage.getItem(MAPPING_PREFIX+roomId); + var alias = roomIdToAlias[roomId]; // was localStorage.getItem(MAPPING_PREFIX+roomId) + //console.log("looking for alias for " + roomId + "; found: " + alias); + return alias; }, + getAliasToRoomIdMapping: function(alias) { + var roomId = aliasToRoomId[alias]; + //console.log("looking for roomId for " + alias + "; found: " + roomId); + return roomId; + }, /****** Power levels management ******/ diff --git a/webclient/home/home.html b/webclient/home/home.html index c1f964383991b4a02709d7181371816c35fa4ba0..7240e79f867facf898e4abe0c939ab85f1b5628a 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -26,7 +26,7 @@ <div class="public_rooms" ng-repeat="room in public_rooms"> <div> - <a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_alias }}</a> + <a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_display_name }}</a> </div> </div> <br/> diff --git a/webclient/img/green_phone.png b/webclient/img/green_phone.png new file mode 100644 index 0000000000000000000000000000000000000000..28807c749b91fd7a9a12ee95b53874cd5baedd0e Binary files /dev/null and b/webclient/img/green_phone.png differ diff --git a/webclient/img/red_phone.png b/webclient/img/red_phone.png new file mode 100644 index 0000000000000000000000000000000000000000..11fc44940cb35654bc24874c4cd3c2377dbb353f Binary files /dev/null and b/webclient/img/red_phone.png differ diff --git a/webclient/index.html b/webclient/index.html index f016dbb8776a3a45bdcad975058190c9642a4b54..81c7c7d06c0a93949d796bdb1652dbc025cb9570 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -10,12 +10,14 @@ <meta name="viewport" content="width=device-width"> - <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script> + <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script> + <script type="text/javascript" src="https://www.google.com/recaptcha/api/js/recaptcha_ajax.js"></script> <script src="js/angular.min.js"></script> <script src="js/angular-route.min.js"></script> <script src="js/angular-sanitize.min.js"></script> <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script> <script src="app.js"></script> + <script src="config.js"></script> <script src="app-controller.js"></script> <script src="app-directive.js"></script> <script src="app-filter.js"></script> @@ -29,6 +31,7 @@ <script src="settings/settings-controller.js"></script> <script src="user/user-controller.js"></script> <script src="components/matrix/matrix-service.js"></script> + <script src="components/matrix/matrix-filter.js"></script> <script src="components/matrix/matrix-call.js"></script> <script src="components/matrix/matrix-phone-service.js"></script> <script src="components/matrix/event-stream-service.js"></script> @@ -44,18 +47,29 @@ <div id="header"> <!-- Do not show buttons on the login page --> <div id="headerContent" ng-hide="'/login' == location || '/register' == location"> - <div id="callBar"> - <div ng-show="currentCall.state == 'ringing'"> - Incoming call from {{ currentCall.user_id }} - <button ng-click="answerCall()">Answer</button> - <button ng-click="hangupCall()">Reject</button> + <div id="callBar" ng-show="currentCall"> + <img id="callPeerImage" ng-show="currentCall.userProfile.avatar_url" ngSrc="{{ currentCall.userProfile.avatar_url }}" /> + <img class="callIcon" src="img/green_phone.png" ng-show="currentCall.state != 'ended'" /> + <img class="callIcon" id="callEndedIcon" src="img/red_phone.png" ng-show="currentCall.state == 'ended'" /> + <div id="callPeerNameAndState"> + <span id="callPeerName">{{ currentCall.userProfile.displayname }}</span> + <br /> + <span id="callState"> + <span ng-show="currentCall.state == 'invite_sent'">Calling...</span> + <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span> + <span ng-show="currentCall.state == 'connected'">Call Connected</span> + <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound'">Call Rejected</span> + <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span> + <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span> + <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span> + <span ng-show="currentCall.state == 'wait_local_media'">Waiting for media permission...</span> + </span> </div> + <span ng-show="currentCall.state == 'ringing'"> + <button ng-click="answerCall()">Answer</button> + <button ng-click="hangupCall()">Reject</button> + </span> <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing' && currentCall.state != 'ended' && currentCall.state != 'fledgling'">Hang up</button> - <span ng-show="currentCall.state == 'invite_sent'">Calling...</span> - <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span> - <span ng-show="currentCall.state == 'connected'">Call Connected</span> - <span ng-show="currentCall.state == 'ended'">Call Ended</span> - <span style="display: none; ">{{ currentCall.state }}</span> </div> <a href id="headerUserId" ng-click='goToUserPage(user_id)'>{{ user_id }}</a> diff --git a/webclient/login/login.html b/webclient/login/login.html index 18e7a02815d4286ca4cd38e0988d183dd7b085cf..6297ec4d42ec4b0fc4bab1f4f5166a6540dec980 100644 --- a/webclient/login/login.html +++ b/webclient/login/login.html @@ -39,8 +39,8 @@ Only http://matrix.org:8090 currently exists.</div> <br/> <br/> - <a href="#/register" style="padding-right: 3em">Create account</a> - <a href="#/reset_password">Forgotten password?</a> + <a href="#/register" style="padding-right: 0em">Create account</a> + <a href="#/reset_password" style="display: none; ">Forgotten password?</a> </div> </div> </form> diff --git a/webclient/login/register-controller.js b/webclient/login/register-controller.js index 5a14964248e5e940e65a3f122a111310f3b8a44d..b3c0c2133537f8f2496efdf40a78772d42f084ce 100644 --- a/webclient/login/register-controller.js +++ b/webclient/login/register-controller.js @@ -19,6 +19,12 @@ angular.module('RegisterController', ['matrixService']) function($scope, $rootScope, $location, matrixService, eventStreamService) { 'use strict'; + var config = window.webClientConfig; + var useCaptcha = true; + if (config !== undefined) { + useCaptcha = config.useCaptcha; + } + // FIXME: factor out duplication with login-controller.js // Assume that this is hosted on the home server, in which case the URL @@ -87,9 +93,12 @@ angular.module('RegisterController', ['matrixService']) }; $scope.registerWithMxidAndPassword = function(mxid, password, threepidCreds) { - matrixService.register(mxid, password, threepidCreds).then( + matrixService.register(mxid, password, threepidCreds, useCaptcha).then( function(response) { $scope.feedback = "Success"; + if (useCaptcha) { + Recaptcha.destroy(); + } // Update the current config var config = matrixService.config(); angular.extend(config, { @@ -116,11 +125,21 @@ angular.module('RegisterController', ['matrixService']) }, function(error) { console.trace("Registration error: "+error); + if (useCaptcha) { + Recaptcha.reload(); + } if (error.data) { if (error.data.errcode === "M_USER_IN_USE") { $scope.feedback = "Username already taken."; $scope.reenter_username = true; } + else if (error.data.errcode == "M_CAPTCHA_INVALID") { + $scope.feedback = "Failed captcha."; + } + else if (error.data.errcode == "M_CAPTCHA_NEEDED") { + $scope.feedback = "Captcha is required on this home " + + "server."; + } } else if (error.status === 0) { $scope.feedback = "Unable to talk to the server."; @@ -142,6 +161,33 @@ angular.module('RegisterController', ['matrixService']) } ); }; + + var setupCaptcha = function() { + console.log("Setting up ReCaptcha") + var config = window.webClientConfig; + var public_key = undefined; + if (config === undefined) { + console.error("Couldn't find webClientConfig. Cannot get public key for captcha."); + } + else { + public_key = webClientConfig.recaptcha_public_key; + if (public_key === undefined) { + console.error("No public key defined for captcha!") + } + } + Recaptcha.create(public_key, + "regcaptcha", + { + theme: "red", + callback: Recaptcha.focus_response_field + }); + }; + $scope.init = function() { + if (useCaptcha) { + setupCaptcha(); + } + }; + }]); diff --git a/webclient/login/register.html b/webclient/login/register.html index 06a6526b70113392e56f380845f75b74a09f6e08..a27f9ad4e89139cc3fe3389b637dcefead9f0687 100644 --- a/webclient/login/register.html +++ b/webclient/login/register.html @@ -12,7 +12,6 @@ <div style="text-align: center"> <br/> - <input ng-show="!wait_3pid_code" id="email" size="32" type="text" ng-focus="true" ng-model="account.email" placeholder="Email address (optional)"/> <div ng-show="!wait_3pid_code" class="smallPrint">Specifying an email address lets other users find you on Matrix more easily,<br/> and will give you a way to reset your password in the future</div> @@ -26,7 +25,10 @@ <input ng-show="!wait_3pid_code" id="displayName" size="32" type="text" ng-model="account.displayName" placeholder="Display name (e.g. Bob Obson)"/> <br ng-show="!wait_3pid_code" /> <br ng-show="!wait_3pid_code" /> - + + + <div id="regcaptcha" ng-init="init()" /> + <button ng-show="!wait_3pid_code" ng-click="register()" ng-disabled="!account.desired_user_id || !account.homeserver || !account.pwd1 || !account.pwd2 || account.pwd1 !== account.pwd2">Sign up</button> <div ng-show="wait_3pid_code"> diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 3209f2cbdf3e366cca547322f86e03cc1548c536..0f27f7a66017be202e410a18b533424ec22c75a3 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -16,7 +16,7 @@ 'use strict'; -angular.module('RecentsController', ['matrixService', 'eventHandlerService']) +angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) .controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', function($scope, matrixService, eventHandlerService) { $scope.rooms = {}; @@ -28,13 +28,8 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService']) var listenToEventStream = function() { // Refresh the list on matrix invitation and message event $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - var config = matrixService.config(); - if (isLive && event.state_key === config.user_id && event.content.membership === "invite") { - console.log("Invited to room " + event.room_id); - // FIXME push membership to top level key to match /im/sync - event.membership = event.content.membership; - - $scope.rooms[event.room_id] = event; + if (isLive) { + $scope.rooms[event.room_id].lastMsg = event; } }); $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index 9978e08b13b550a6f033fbb645e61637a37cebac..280d0632abfc67f552da81b69a15361e32e83e15 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -6,7 +6,7 @@ ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}"> <tr> <td class="recentsRoomName"> - {{ room.room_id | roomName }} + {{ room.room_id | mRoomName }} </td> <td class="recentsRoomSummaryTS"> {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} @@ -16,27 +16,48 @@ <tr> <td colspan="2" class="recentsRoomSummary"> - <div ng-show="room.membership === 'invite'" > - {{ room.inviter }} invited you + <div ng-show="room.membership === 'invite'"> + {{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you </div> - - <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type" > - <div ng-switch-when="m.room.member"> - {{ room.lastMsg.user_id }} - {{ {"join": "joined", "leave": "left", "invite": "invited", "ban": "banned"}[msg.content.membership] }} - {{ (msg.content.membership === "invite" || msg.content.membership === "ban") ? (msg.state_key || '') : '' }} + + <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type"> + <div ng-switch-when="m.room.member"> + <span ng-if="'join' === room.lastMsg.content.membership"> + {{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined + </span> + <span ng-if="'leave' === room.lastMsg.content.membership"> + <span ng-if="room.lastMsg.user_id === room.lastMsg.state_key"> + {{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left + </span> + <span ng-if="room.lastMsg.user_id !== room.lastMsg.state_key"> + {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }} + {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} + </span> + <span ng-if="'join' === room.lastMsg.content.prev && room.lastMsg.content.reason"> + : {{ room.lastMsg.content.reason }} + </span> + </span> + <span ng-if="'invite' === room.lastMsg.content.membership || 'ban' === room.lastMsg.content.membership"> + {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }} + {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} + <span ng-if="'ban' === room.lastMsg.content.prev && room.lastMsg.content.reason"> + : {{ room.lastMsg.content.reason }} + </span> + </span> </div> <div ng-switch-when="m.room.message"> <div ng-switch="room.lastMsg.content.msgtype"> <div ng-switch-when="m.text"> - {{ room.lastMsg.user_id }} : + {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} : <span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'"> </span> </div> <div ng-switch-when="m.image"> - {{ room.lastMsg.user_id }} sent an image + {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image </div> <div ng-switch-when="m.emote"> @@ -51,7 +72,7 @@ </div> <div ng-switch-default> - <div ng-if="room.lastMsg.type.indexOf('m.call.') == 0"> + <div ng-if="room.lastMsg.type.indexOf('m.call.') === 0"> Call </div> </div> diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index c3f72c9d25834f1a5945608ae078053e06ceaf83..e69adb9b4654ebabfc60a7793e13e42f2f68f5b2 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -angular.module('RoomController', ['ngSanitize', 'mFileInput']) +angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) .controller('RoomController', ['$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall', function($scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) { 'use strict'; @@ -32,9 +32,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) first_pagination: true, // this is toggled off when the first pagination is done can_paginate: true, // this is toggled off when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents - stream_failure: undefined, // the response when the stream fails - // FIXME: sending has been disabled, as surely messages should be sent in the background rather than locking the UI synchronously --Matthew - sending: false // true when a message is being sent. It helps to disable the UI when a process is running + stream_failure: undefined // the response when the stream fails }; $scope.members = {}; $scope.autoCompleting = false; @@ -44,18 +42,25 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) $scope.imageURLToSend = ""; $scope.userIDToInvite = ""; - var scrollToBottom = function() { + var scrollToBottom = function(force) { console.log("Scrolling to bottom"); - $timeout(function() { - var objDiv = document.getElementById("messageTableWrapper"); - objDiv.scrollTop = objDiv.scrollHeight; - }, 0); + + // Do not autoscroll to the bottom to display the new event if the user is not at the bottom. + // Exception: in case where the event is from the user, we want to force scroll to the bottom + var objDiv = document.getElementById("messageTableWrapper"); + if ((objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) { + + $timeout(function() { + objDiv.scrollTop = objDiv.scrollHeight; + }, 0); + } }; $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { if (isLive && event.room_id === $scope.room_id) { - scrollToBottom(); + scrollToBottom(); + if (window.Notification) { // Show notification when the user is idle if (matrixService.presence.offline === mPresence.getState()) { @@ -76,6 +81,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { if (isLive) { + scrollToBottom(); updateMemberList(event); } }); @@ -169,16 +175,18 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) var updateMemberList = function(chunk) { if (chunk.room_id != $scope.room_id) return; - // Ignore banned and kicked (leave) people - if ("ban" === chunk.membership || "leave" === chunk.membership) { - return; - } // set target_user_id to keep things clear var target_user_id = chunk.state_key; var isNewMember = !(target_user_id in $scope.members); if (isNewMember) { + + // Ignore banned and kicked (leave) people + if ("ban" === chunk.membership || "leave" === chunk.membership) { + return; + } + // FIXME: why are we copying these fields around inside chunk? if ("presence" in chunk.content) { chunk.presence = chunk.content.presence; @@ -202,6 +210,13 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) } else { // selectively update membership and presence else it will nuke the picture and displayname too :/ + + // Remove banned and kicked (leave) people + if ("ban" === chunk.membership || "leave" === chunk.membership) { + delete $scope.members[target_user_id]; + return; + } + var member = $scope.members[target_user_id]; member.membership = chunk.content.membership; if ("presence" in chunk.content) { @@ -256,7 +271,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) normaliseMembersPowerLevels(); } - } + }; // Normalise users power levels so that the user with the higher power level // will have a bar covering 100% of the width of his avatar @@ -277,104 +292,225 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel; } } - } + }; $scope.send = function() { if ($scope.textInput === "") { return; } - - $scope.state.sending = true; + + scrollToBottom(true); var promise; + var cmd; + var args; + var echo = false; // Check for IRC style commands first - if ($scope.textInput.indexOf("/") === 0) { - var args = $scope.textInput.split(' '); - var cmd = args[0]; + var line = $scope.textInput; + + // trim any trailing whitespace, as it can confuse the parser for IRC-style commands + line = line.replace(/\s+$/, ""); + + if (line[0] === "/" && line[1] !== "/") { + var bits = line.match(/^(\S+?)( +(.*))?$/); + cmd = bits[1]; + args = bits[3]; + + console.log("cmd: " + cmd + ", args: " + args); switch (cmd) { case "/me": - var emoteMsg = args.slice(1).join(' '); - promise = matrixService.sendEmoteMessage($scope.room_id, emoteMsg); + promise = matrixService.sendEmoteMessage($scope.room_id, args); + echo = true; break; case "/nick": // Change user display name - if (2 === args.length) { - promise = matrixService.setDisplayName(args[1]); + if (args) { + promise = matrixService.setDisplayName(args); + } + else { + $scope.feedback = "Usage: /nick <display_name>"; + } + break; + + case "/join": + // Join a room + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + var room_alias = matches[1]; + if (room_alias.indexOf(':') == -1) { + // FIXME: actually track the :domain style name of our homeserver + // with or without port as is appropriate and append it at this point + } + + var room_id = matrixService.getAliasToRoomIdMapping(room_alias); + console.log("joining " + room_alias + " id=" + room_id); + if ($rootScope.events.rooms[room_id]) { + // don't send a join event for a room you're already in. + $location.url("room/" + room_alias); + } + else { + promise = matrixService.joinAlias(room_alias).then( + function(response) { + $location.url("room/" + room_alias); + }, + function(error) { + $scope.feedback = "Can't join room: " + JSON.stringify(error.data); + } + ); + } + } + } + else { + $scope.feedback = "Usage: /join <room_alias>"; } break; case "/kick": - // Kick a user from the room - if (2 === args.length) { - var user_id = args[1]; + // Kick a user from the room with an optional reason + if (args) { + var matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + promise = matrixService.kick($scope.room_id, matches[1], matches[3]); + } + } - // Set his state in the room as leave - promise = matrixService.setMembership($scope.room_id, user_id, "leave"); + if (!promise) { + $scope.feedback = "Usage: /kick <userId> [<reason>]"; } break; - + case "/ban": - // Ban a user from the room - if (2 <= args.length) { - // TODO: The user may have entered the display name - // Need display name -> user_id resolution. Pb: how to manage user with same display names? - var user_id = args[1]; - - // Does the user provide a reason? - if (3 <= args.length) { - var reason = args.slice(2).join(' '); + // Ban a user from the room with an optional reason + if (args) { + var matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + promise = matrixService.ban($scope.room_id, matches[1], matches[3]); } - promise = matrixService.ban($scope.room_id, user_id, reason); } - break; + if (!promise) { + $scope.feedback = "Usage: /ban <userId> [<reason>]"; + } + break; + case "/unban": // Unban a user from the room - if (2 === args.length) { - var user_id = args[1]; - - // Reset the user membership to leave to unban him - promise = matrixService.setMembership($scope.room_id, user_id, "leave"); + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + // Reset the user membership to "leave" to unban him + promise = matrixService.unban($scope.room_id, matches[1]); + } + } + + if (!promise) { + $scope.feedback = "Usage: /unban <userId>"; } break; case "/op": // Define the power level of a user - if (3 === args.length) { - var user_id = args[1]; - var powerLevel = parseInt(args[2]); - promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel); + if (args) { + var matches = args.match(/^(\S+?)( +(\d+))?$/); + var powerLevel = 50; // default power level for op + if (matches) { + var user_id = matches[1]; + if (matches.length === 4) { + powerLevel = parseInt(matches[3]); + } + if (powerLevel !== NaN) { + promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel); + } + } + } + + if (!promise) { + $scope.feedback = "Usage: /op <userId> [<power level>]"; } break; case "/deop": // Reset the power level of a user - if (2 === args.length) { - var user_id = args[1]; - promise = matrixService.setUserPowerLevel($scope.room_id, user_id, undefined); + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + promise = matrixService.setUserPowerLevel($scope.room_id, args, undefined); + } } + + if (!promise) { + $scope.feedback = "Usage: /deop <userId>"; + } + break; + + default: + $scope.feedback = ("Unrecognised IRC-style command: " + cmd); break; } } - if (!promise) { - // Send the text message - promise = matrixService.sendTextMessage($scope.room_id, $scope.textInput); + // By default send this as a message unless it's an IRC-style command + if (!promise && !cmd) { + // Make the request + promise = matrixService.sendTextMessage($scope.room_id, line); + echo = true; } - promise.then( - function() { - console.log("Request successfully sent"); - $scope.textInput = ""; - $scope.state.sending = false; - }, - function(error) { - $scope.feedback = "Request failed: " + error.data.error; - $scope.state.sending = false; - }); + if (echo) { + // Echo the message to the room + // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages + var echoMessage = { + content: { + body: (cmd === "/me" ? args : line), + hsob_ts: new Date().getTime(), // fake a timestamp + msgtype: (cmd === "/me" ? "m.emote" : "m.text"), + }, + room_id: $scope.room_id, + type: "m.room.message", + user_id: $scope.state.user_id, + // FIXME: re-enable echo_msg_state when we have a nice way to turn the field off again + // echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML + }; + + $scope.textInput = ""; + $rootScope.events.rooms[$scope.room_id].messages.push(echoMessage); + scrollToBottom(); + } + + if (promise) { + promise.then( + function() { + console.log("Request successfully sent"); + $scope.textInput = ""; +/* + if (echoMessage) { + // Remove the fake echo message from the room messages + // It will be replaced by the one acknowledged by the server + // ...except this causes a nasty flicker. So don't swap messages for now. --matthew + // var index = $rootScope.events.rooms[$scope.room_id].messages.indexOf(echoMessage); + // if (index > -1) { + // $rootScope.events.rooms[$scope.room_id].messages.splice(index, 1); + // } + } + else { + $scope.textInput = ""; + } +*/ + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + + if (echoMessage) { + // Mark the message as unsent for the rest of the page life + echoMessage.content.hsob_ts = "Unsent"; + echoMessage.echo_msg_state = "messageUnSent"; + } + }); + } }; $scope.onInit = function() { @@ -531,25 +667,20 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) }; $scope.sendImage = function(url, body) { - $scope.state.sending = true; - + scrollToBottom(true); + matrixService.sendImageMessage($scope.room_id, url, body).then( function() { console.log("Image sent"); - $scope.state.sending = false; }, function(error) { $scope.feedback = "Failed to send image: " + error.data.error; - $scope.state.sending = false; }); }; $scope.imageFileToSend; $scope.$watch("imageFileToSend", function(newValue, oldValue) { if ($scope.imageFileToSend) { - - $scope.state.sending = true; - // Upload this image with its thumbnail to Internet mFileUpload.uploadImageAndThumbnail($scope.imageFileToSend, THUMBNAIL_SIZE).then( function(imageMessage) { @@ -557,16 +688,13 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) matrixService.sendMessage($scope.room_id, undefined, imageMessage).then( function() { console.log("Image message sent"); - $scope.state.sending = false; }, function(error) { $scope.feedback = "Failed to send image message: " + error.data.error; - $scope.state.sending = false; }); }, function(error) { $scope.feedback = "Can't upload image"; - $scope.state.sending = false; } ); } @@ -582,6 +710,6 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput']) call.onHangup = $rootScope.onCallHangup; call.placeCall(); $rootScope.currentCall = call; - } + }; }]); diff --git a/webclient/room/room-directive.js b/webclient/room/room-directive.js index 659bcbc60ff70a2f2a3eff8fecd4d17a68271bee..e033b003e1ce4df1652316051cfe0ffec93ba512 100644 --- a/webclient/room/room-directive.js +++ b/webclient/room/room-directive.js @@ -48,6 +48,9 @@ angular.module('RoomController') var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); if (targetIndex === 0) { element[0].value = text; + + // Force angular to wake up and update the input ng-model by firing up input event + angular.element(element[0]).triggerHandler('input'); } else if (search && search[1]) { // console.log("search found: " + search); @@ -81,7 +84,10 @@ angular.module('RoomController') expansion += " "; element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion); // cancel blink - element[0].className = ""; + element[0].className = ""; + + // Force angular to wake up and update the input ng-model by firing up input event + angular.element(element[0]).triggerHandler('input'); } else { // console.log("wrapped!"); @@ -91,6 +97,9 @@ angular.module('RoomController') }, 150); element[0].value = text; scope.tabCompleteIndex = 0; + + // Force angular to wake up and update the input ng-model by firing up input event + angular.element(element[0]).triggerHandler('input'); } } else { diff --git a/webclient/room/room.html b/webclient/room/room.html index 6732a7b3ae3f4fb8a34468c072c6650fed9bfab1..5bd2cc92d5c78a1d940b3de1a0f9244e200011ef 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -3,7 +3,7 @@ <div id="roomHeader"> <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a> <div id="roomName"> - {{ room_id | roomName }} + {{ room_id | mRoomName }} </div> </div> @@ -40,7 +40,10 @@ ng-class="(events.rooms[room_id].messages[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item> <td class="leftBlock"> <div class="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id">{{ members[msg.user_id].displayname || msg.user_id }}</div> - <div class="timestamp">{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}</div> + <div class="timestamp" + ng-class="msg.echo_msg_state"> + {{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }} + </div> </td> <td class="avatar"> <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32" @@ -59,15 +62,24 @@ {{ members[msg.user_id].displayname || msg.user_id }} {{ {"join": "kicked", "ban": "unbanned"}[msg.content.prev] }} {{ members[msg.state_key].displayname || msg.state_key }} + <span ng-if="'join' === msg.content.prev && msg.content.reason"> + : {{ msg.content.reason }} + </span> </span> </span> <span ng-if="'invite' === msg.content.membership || 'ban' === msg.content.membership"> {{ members[msg.user_id].displayname || msg.user_id }} {{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }} {{ members[msg.state_key].displayname || msg.state_key }} - </span> + <span ng-if="'ban' === msg.content.prev && msg.content.reason"> + : {{ msg.content.reason }} + </span> + </span> + <span ng-show='msg.content.msgtype === "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/> - <span ng-show='msg.content.msgtype === "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/> + <span ng-show='msg.content.msgtype === "m.text"' + ng-class="msg.echo_msg_state" + ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/> <div ng-show='msg.content.msgtype === "m.image"'> <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}"> <img class="image" ng-src="{{ msg.content.url }}"/> diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js index 7a26367a1bc8c70662a19e0e908e5156a67af96e..8c877a24e9f331e05b59c0e980e2c30ed73a9e9e 100644 --- a/webclient/settings/settings-controller.js +++ b/webclient/settings/settings-controller.js @@ -19,6 +19,17 @@ limitations under the License. angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInput']) .controller('SettingsController', ['$scope', 'matrixService', 'mFileUpload', function($scope, matrixService, mFileUpload) { + // XXX: duplicated from register + var generateClientSecret = function() { + var ret = ""; + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (var i = 0; i < 32; i++) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return ret; + }; $scope.config = matrixService.config(); $scope.profile = { @@ -106,16 +117,22 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu $scope.linkedEmails = { linkNewEmail: "", // the email entry box emailBeingAuthed: undefined, // to populate verification text - authTokenId: undefined, // the token id from the IS + authSid: undefined, // the token id from the IS emailCode: "", // the code entry box linkedEmailList: matrixService.config().emailList // linked email list }; $scope.linkEmail = function(email) { - matrixService.linkEmail(email).then( + if (email != $scope.linkedEmails.emailBeingAuthed) { + $scope.linkedEmails.emailBeingAuthed = email; + $scope.clientSecret = generateClientSecret(); + $scope.sendAttempt = 0; + } + $scope.sendAttempt++; + matrixService.linkEmail(email, $scope.clientSecret, $scope.sendAttempt).then( function(response) { if (response.data.success === true) { - $scope.linkedEmails.authTokenId = response.data.tokenId; + $scope.linkedEmails.authSid = response.data.sid; $scope.emailFeedback = "You have been sent an email."; $scope.linkedEmails.emailBeingAuthed = email; } @@ -129,34 +146,44 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu ); }; - $scope.submitEmailCode = function(code) { - var tokenId = $scope.linkedEmails.authTokenId; + $scope.submitEmailCode = function() { + var tokenId = $scope.linkedEmails.authSid; if (tokenId === undefined) { $scope.emailFeedback = "You have not requested a code with this email."; return; } - matrixService.authEmail(matrixService.config().user_id, tokenId, code).then( + matrixService.authEmail($scope.clientSecret, $scope.linkedEmails.authSid, $scope.linkedEmails.emailCode).then( function(response) { - if ("success" in response.data && response.data.success === false) { + if ("errcode" in response.data) { $scope.emailFeedback = "Failed to authenticate email."; return; } - var config = matrixService.config(); - var emailList = {}; - if ("emailList" in config) { - emailList = config.emailList; - } - emailList[response.address] = response; - // save the new email list - config.emailList = emailList; - matrixService.setConfig(config); - matrixService.saveConfig(); - // invalidate the email being authed and update UI. - $scope.linkedEmails.emailBeingAuthed = undefined; - $scope.emailFeedback = ""; - $scope.linkedEmails.linkedEmailList = emailList; - $scope.linkedEmails.linkNewEmail = ""; - $scope.linkedEmails.emailCode = ""; + matrixService.bindEmail(matrixService.config().user_id, tokenId, $scope.clientSecret).then( + function(response) { + if ('errcode' in response.data) { + $scope.emailFeedback = "Failed to link email."; + return; + } + var config = matrixService.config(); + var emailList = {}; + if ("emailList" in config) { + emailList = config.emailList; + } + emailList[$scope.linkedEmails.emailBeingAuthed] = response; + // save the new email list + config.emailList = emailList; + matrixService.setConfig(config); + matrixService.saveConfig(); + // invalidate the email being authed and update UI. + $scope.linkedEmails.emailBeingAuthed = undefined; + $scope.emailFeedback = ""; + $scope.linkedEmails.linkedEmailList = emailList; + $scope.linkedEmails.linkNewEmail = ""; + $scope.linkedEmails.emailCode = ""; + }, function(reason) { + $scope.emailFeedback = "Failed to link email: " + reason; + } + ); }, function(reason) { $scope.emailFeedback = "Failed to auth email: " + reason; @@ -182,4 +209,4 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu $scope.settings.notifications = permission; }); }; -}]); \ No newline at end of file +}]); diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html index b7fd5dfb502b28bbad5faec4376daeb2f3c40cc4..924812e7ae4ca4e4ecb068a189cf5e9e719dc2ed 100644 --- a/webclient/settings/settings.html +++ b/webclient/settings/settings.html @@ -23,14 +23,14 @@ </div> <br/> - <h3 style="display: none; ">Linked emails</h3> - <div class="section" style="display: none; "> + <h3>Linked emails</h3> + <div class="section"> <form> <input size="40" ng-model="linkedEmails.linkNewEmail" ng-enter="linkEmail(linkedEmails.linkNewEmail)" /> <button ng-disabled="!linkedEmails.linkNewEmail" ng-click="linkEmail(linkedEmails.linkNewEmail)"> Link Email </button> - {{ emailFeedback }} + {{ emailFeedback }} </form> <form ng-hide="!linkedEmails.emailBeingAuthed"> Enter validation token for {{ linkedEmails.emailBeingAuthed }}: @@ -81,7 +81,7 @@ <ul> <li>/nick <display_name>: change your display name</li> <li>/me <action>: send the action you are doing. /me will be replaced by your display name</li> - <li>/kick <user_id>: kick the user</li> + <li>/kick <user_id> [<reason>]: kick the user</li> <li>/ban <user_id> [<reason>]: ban the user</li> <li>/unban <user_id>: unban the user</li> <li>/op <user_id> <power_level>: set user power level</li>