0
0
Fork 1
mirror of https://mau.dev/maunium/synapse.git synced 2024-05-20 12:33:46 +02:00

Merge branch 'develop' into server2server_signing

Conflicts:
	synapse/storage/__init__.py
	tests/rest/test_presence.py
This commit is contained in:
Mark Haines 2014-09-30 17:55:06 +01:00
commit 9605593d11
64 changed files with 2402 additions and 933 deletions

View file

@ -1,3 +1,28 @@
Changes in synapse 0.3.4 (2014-09-25)
=====================================
This version adds support for using a TURN server. See docs/turn-howto.rst on
how to set one up.
Homeserver:
* Add support for redaction of messages.
* Fix bug where inviting a user on a remote home server could take up to
20-30s.
* Implement a get current room state API.
* Add support specifying and retrieving turn server configuration.
Webclient:
* Add button to send messages to users from the home page.
* Add support for using TURN for VoIP calls.
* Show display name change messages.
* Fix bug where the client didn't get the state of a newly joined room
until after it has been refreshed.
* Fix bugs with tab complete.
* Fix bug where holding down the down arrow caused chrome to chew 100% CPU.
* Fix bug where desktop notifications occasionally used "Undefined" as the
display name.
* Fix more places where we sometimes saw room IDs incorrectly.
* Fix bug which caused lag when entering text in the text box.
Changes in synapse 0.3.3 (2014-09-22)
=====================================

View file

@ -1 +1 @@
0.3.3
0.3.4

View file

@ -639,7 +639,7 @@
{
"method": "GET",
"summary": "Get a list of all the current state events for this room.",
"notes": "NOT YET IMPLEMENTED.",
"notes": "This is equivalent to the events returned under the 'state' key for this room in /initialSync.",
"type": "array",
"items": {
"$ref": "Event"

View file

@ -1 +0,0 @@
NCjcRSEG

File diff suppressed because it is too large Load diff

93
docs/turn-howto.rst Normal file
View file

@ -0,0 +1,93 @@
How to enable VoIP relaying on your Home Server with TURN
Overview
--------
The synapse Matrix Home Server supports integration with TURN server via the
TURN server REST API
(http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00). This allows
the Home Server to generate credentials that are valid for use on the TURN
server through the use of a secret shared between the Home Server and the
TURN server.
This document described how to install coturn
(https://code.google.com/p/coturn/) which also supports the TURN REST API,
and integrate it with synapse.
coturn Setup
============
1. Check out coturn::
svn checkout http://coturn.googlecode.com/svn/trunk/ coturn
cd coturn
2. Configure it::
./configure
You may need to install libevent2: if so, you should do so
in the way recommended by your operating system.
You can ignore warnings about lack of database support: a
database is unnecessary for this purpose.
3. Build and install it::
make
make install
4. Make a config file in /etc/turnserver.conf. You can customise
a config file from turnserver.conf.default. The relevant
lines, with example values, are::
lt-cred-mech
use-auth-secret
static-auth-secret=[your secret key here]
realm=turn.myserver.org
See turnserver.conf.default for explanations of the options.
One way to generate the static-auth-secret is with pwgen::
pwgen -s 64 1
5. Ensure youe firewall allows traffic into the TURN server on
the ports you've configured it to listen on (remember to allow
both TCP and UDP if you've enabled both).
6. If you've configured coturn to support TLS/DTLS, generate or
import your private key and certificate.
7. Start the turn server::
bin/turnserver -o
synapse Setup
=============
Your home server configuration file needs the following extra keys:
1. "turn_uris": This needs to be a yaml list
of public-facing URIs for your TURN server to be given out
to your clients. Add separate entries for each transport your
TURN server supports.
2. "turn_shared_secret": This is the secret shared between your Home
server and your TURN server, so you should set it to the same
string you used in turnserver.conf.
3. "turn_user_lifetime": This is the amount of time credentials
generated by your Home Server are valid for (in milliseconds).
Shorter times offer less potential for abuse at the expense
of increased traffic between web clients and your home server
to refresh credentials. The TURN REST API specification recommends
one day (86400000).
As an example, here is the relevant section of the config file for
matrix.org::
turn_uris: turn:turn.matrix.org:3478?transport=udp,turn:turn.matrix.org:3478?transport=tcp
turn_shared_secret: n0t4ctuAllymatr1Xd0TorgSshar3d5ecret4obvIousreAsons
turn_user_lifetime: 86400000
Now, restart synapse::
cd /where/you/run/synapse
./synctl restart
...and your Home Server now supports VoIP relaying!

View file

@ -16,4 +16,4 @@
""" This is a reference implementation of a synapse home server.
"""
__version__ = "0.3.3"
__version__ = "0.3.4"

View file

@ -19,7 +19,9 @@ from twisted.internet import defer
from synapse.api.constants import Membership, JoinRules
from synapse.api.errors import AuthError, StoreError, Codes, SynapseError
from synapse.api.events.room import RoomMemberEvent, RoomPowerLevelsEvent
from synapse.api.events.room import (
RoomMemberEvent, RoomPowerLevelsEvent, RoomRedactionEvent,
)
from synapse.util.logutils import log_function
import logging
@ -70,6 +72,9 @@ class Auth(object):
if event.type == RoomPowerLevelsEvent.TYPE:
yield self._check_power_levels(event)
if event.type == RoomRedactionEvent.TYPE:
yield self._check_redaction(event)
defer.returnValue(True)
else:
raise AuthError(500, "Unknown event: %s" % event)
@ -170,7 +175,7 @@ class Auth(object):
event.room_id,
event.user_id,
)
_, kick_level = yield self.store.get_ops_levels(event.room_id)
_, kick_level, _ = yield self.store.get_ops_levels(event.room_id)
if kick_level:
kick_level = int(kick_level)
@ -187,7 +192,7 @@ class Auth(object):
event.user_id,
)
ban_level, _ = yield self.store.get_ops_levels(event.room_id)
ban_level, _, _ = yield self.store.get_ops_levels(event.room_id)
if ban_level:
ban_level = int(ban_level)
@ -201,6 +206,7 @@ class Auth(object):
defer.returnValue(True)
@defer.inlineCallbacks
def get_user_by_req(self, request):
""" Get a registered user's ID.
@ -213,7 +219,25 @@ class Auth(object):
"""
# Can optionally look elsewhere in the request (e.g. headers)
try:
return self.get_user_by_token(request.args["access_token"][0])
access_token = request.args["access_token"][0]
user_info = yield self.get_user_by_token(access_token)
user = user_info["user"]
ip_addr = self.hs.get_ip_from_request(request)
user_agent = request.requestHeaders.getRawHeaders(
"User-Agent",
default=[""]
)[0]
if user and access_token and ip_addr:
self.store.insert_client_ip(
user=user,
access_token=access_token,
device_id=user_info["device_id"],
ip=ip_addr,
user_agent=user_agent
)
defer.returnValue(user)
except KeyError:
raise AuthError(403, "Missing access token.")
@ -222,21 +246,32 @@ class Auth(object):
""" Get a registered user's ID.
Args:
token (str)- The access token to get the user by.
token (str): The access token to get the user by.
Returns:
UserID : User ID object of the user who has that access token.
dict : dict that includes the user, device_id, and whether the
user is a server admin.
Raises:
AuthError if no user by that token exists or the token is invalid.
"""
try:
user_id = yield self.store.get_user_by_token(token=token)
if not user_id:
ret = yield self.store.get_user_by_token(token=token)
if not ret:
raise StoreError()
defer.returnValue(self.hs.parse_userid(user_id))
user_info = {
"admin": bool(ret.get("admin", False)),
"device_id": ret.get("device_id"),
"user": self.hs.parse_userid(ret.get("name")),
}
defer.returnValue(user_info)
except StoreError:
raise AuthError(403, "Unrecognised access token.",
errcode=Codes.UNKNOWN_TOKEN)
def is_server_admin(self, user):
return self.store.is_server_admin(user)
@defer.inlineCallbacks
@log_function
def _can_send_event(self, event):
@ -321,6 +356,29 @@ class Auth(object):
"You don't have permission to change that state"
)
@defer.inlineCallbacks
def _check_redaction(self, event):
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
_, _, redact_level = yield self.store.get_ops_levels(event.room_id)
if not redact_level:
redact_level = 50
if user_level < redact_level:
raise AuthError(
403,
"You don't have permission to redact events"
)
@defer.inlineCallbacks
def _check_power_levels(self, event):
for k, v in event.content.items():
@ -372,11 +430,11 @@ class Auth(object):
}
removed = set(old_people.keys()) - set(new_people.keys())
added = set(old_people.keys()) - set(new_people.keys())
added = set(new_people.keys()) - set(old_people.keys())
same = set(old_people.keys()) & set(new_people.keys())
for r in removed:
if int(old_list.content[r]) > user_level:
if int(old_list[r]) > user_level:
raise AuthError(
403,
"You don't have permission to remove user: %s" % (r, )

View file

@ -22,7 +22,8 @@ def serialize_event(hs, e):
if not isinstance(e, SynapseEvent):
return e
d = e.get_dict()
# Should this strip out None's?
d = {k: v for k, v in e.get_dict().items()}
if "age_ts" in d:
d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"]
del d["age_ts"]
@ -58,17 +59,19 @@ class SynapseEvent(JsonEncodedObject):
"required_power_level",
"age_ts",
"prev_content",
"prev_state",
"redacted_because",
]
internal_keys = [
"is_state",
"prev_events",
"prev_state",
"depth",
"destinations",
"origin",
"outlier",
"power_level",
"redacted",
]
required_keys = [

View file

@ -17,7 +17,8 @@ from synapse.api.events.room import (
RoomTopicEvent, MessageEvent, RoomMemberEvent, FeedbackEvent,
InviteJoinEvent, RoomConfigEvent, RoomNameEvent, GenericEvent,
RoomPowerLevelsEvent, RoomJoinRulesEvent, RoomOpsPowerLevelsEvent,
RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent
RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent,
RoomRedactionEvent,
)
from synapse.util.stringutils import random_string
@ -39,6 +40,7 @@ class EventFactory(object):
RoomAddStateLevelEvent,
RoomSendEventLevelEvent,
RoomOpsPowerLevelsEvent,
RoomRedactionEvent,
]
def __init__(self, hs):

View file

@ -180,3 +180,12 @@ class RoomAliasesEvent(SynapseStateEvent):
def get_content_template(self):
return {}
class RoomRedactionEvent(SynapseEvent):
TYPE = "m.room.redaction"
valid_keys = SynapseEvent.valid_keys + ["redacts"]
def get_content_template(self):
return {}

View file

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright 2014 OpenMarket Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .room import (
RoomMemberEvent, RoomJoinRulesEvent, RoomPowerLevelsEvent,
RoomAddStateLevelEvent, RoomSendEventLevelEvent, RoomOpsPowerLevelsEvent,
RoomAliasesEvent, RoomCreateEvent,
)
def prune_event(event):
""" Prunes the given event of all keys we don't know about or think could
potentially be dodgy.
This is used when we "redact" an event. We want to remove all fields that
the user has specified, but we do want to keep necessary information like
type, state_key etc.
"""
# Remove all extraneous fields.
event.unrecognized_keys = {}
new_content = {}
def add_fields(*fields):
for field in fields:
if field in event.content:
new_content[field] = event.content[field]
if event.type == RoomMemberEvent.TYPE:
add_fields("membership")
elif event.type == RoomCreateEvent.TYPE:
add_fields("creator")
elif event.type == RoomJoinRulesEvent.TYPE:
add_fields("join_rule")
elif event.type == RoomPowerLevelsEvent.TYPE:
# TODO: Actually check these are valid user_ids etc.
add_fields("default")
for k, v in event.content.items():
if k.startswith("@") and isinstance(v, (int, long)):
new_content[k] = v
elif event.type == RoomAddStateLevelEvent.TYPE:
add_fields("level")
elif event.type == RoomSendEventLevelEvent.TYPE:
add_fields("level")
elif event.type == RoomOpsPowerLevelsEvent.TYPE:
add_fields("kick_level", "ban_level", "redact_level")
elif event.type == RoomAliasesEvent.TYPE:
add_fields("aliases")
event.content = new_content
return event

View file

@ -24,6 +24,7 @@ class CaptchaConfig(Config):
self.captcha_ip_origin_is_x_forwarded = (
args.captcha_ip_origin_is_x_forwarded
)
self.captcha_bypass_secret = args.captcha_bypass_secret
@classmethod
def add_arguments(cls, parser):
@ -43,4 +44,8 @@ class CaptchaConfig(Config):
"--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."
)
)
group.add_argument(
"--captcha_bypass_secret", type=str,
help="A secret key used to bypass the captcha test entirely."
)

View file

@ -21,11 +21,12 @@ from .ratelimiting import RatelimitConfig
from .repository import ContentRepositoryConfig
from .captcha import CaptchaConfig
from .email import EmailConfig
from .voip import VoipConfig
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
EmailConfig):
EmailConfig, VoipConfig):
pass

View file

@ -14,7 +14,6 @@
# limitations under the License.
from ._base import Config
import os
class ContentRepositoryConfig(Config):
def __init__(self, args):

41
synapse/config/voip.py Normal file
View file

@ -0,0 +1,41 @@
# 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 VoipConfig(Config):
def __init__(self, args):
super(VoipConfig, self).__init__(args)
self.turn_uris = args.turn_uris
self.turn_shared_secret = args.turn_shared_secret
self.turn_user_lifetime = args.turn_user_lifetime
@classmethod
def add_arguments(cls, parser):
super(VoipConfig, cls).add_arguments(parser)
group = parser.add_argument_group("voip")
group.add_argument(
"--turn-uris", type=str, default=None,
help="The public URIs of the TURN server to give to clients"
)
group.add_argument(
"--turn-shared-secret", type=str, default=None,
help="The shared secret used to compute passwords for the TURN server"
)
group.add_argument(
"--turn-user-lifetime", type=int, default=(1000 * 60 * 60),
help="How long generated TURN credentials last, in ms"
)

View file

@ -25,6 +25,7 @@ from .profile import ProfileHandler
from .presence import PresenceHandler
from .directory import DirectoryHandler
from .typing import TypingNotificationHandler
from .admin import AdminHandler
class Handlers(object):
@ -49,3 +50,4 @@ class Handlers(object):
self.login_handler = LoginHandler(hs)
self.directory_handler = DirectoryHandler(hs)
self.typing_notification_handler = TypingNotificationHandler(hs)
self.admin_handler = AdminHandler(hs)

62
synapse/handlers/admin.py Normal file
View file

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Copyright 2014 OpenMarket Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.internet import defer
from ._base import BaseHandler
import logging
logger = logging.getLogger(__name__)
class AdminHandler(BaseHandler):
def __init__(self, hs):
super(AdminHandler, self).__init__(hs)
@defer.inlineCallbacks
def get_whois(self, user):
res = yield self.store.get_user_ip_and_agents(user)
d = {}
for r in res:
device = d.setdefault(r["device_id"], {})
session = device.setdefault(r["access_token"], [])
session.append({
"ip": r["ip"],
"user_agent": r["user_agent"],
"last_seen": r["last_seen"],
})
ret = {
"user_id": user.to_string(),
"devices": [
{
"device_id": k,
"sessions": [
{
# "access_token": x, TODO (erikj)
"connections": y,
}
for x, y in v.items()
]
}
for k, v in d.items()
],
}
defer.returnValue(ret)

View file

@ -57,7 +57,6 @@ class DirectoryHandler(BaseHandler):
if not servers:
raise SynapseError(400, "Failed to get server list")
try:
yield self.store.create_room_alias_association(
room_alias,
@ -68,25 +67,19 @@ class DirectoryHandler(BaseHandler):
defer.returnValue("Already exists")
# TODO: Send the room event.
yield self._update_room_alias_events(user_id, room_id)
aliases = yield self.store.get_aliases_for_room(room_id)
@defer.inlineCallbacks
def delete_association(self, user_id, room_alias):
# TODO Check if server admin
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},
)
if not room_alias.is_mine:
raise SynapseError(400, "Room alias must be local")
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])
room_id = yield self.store.delete_room_alias(room_alias)
if room_id:
yield self._update_room_alias_events(user_id, room_id)
@defer.inlineCallbacks
def get_association(self, room_alias):
@ -142,3 +135,23 @@ class DirectoryHandler(BaseHandler):
"room_id": result.room_id,
"servers": result.servers,
})
@defer.inlineCallbacks
def _update_room_alias_events(self, user_id, room_id):
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])

View file

@ -169,7 +169,15 @@ class FederationHandler(BaseHandler):
)
if not backfilled:
yield self.notifier.on_new_room_event(event)
extra_users = []
if event.type == RoomMemberEvent.TYPE:
target_user_id = event.state_key
target_user = self.hs.parse_userid(target_user_id)
extra_users.append(target_user)
yield self.notifier.on_new_room_event(
event, extra_users=extra_users
)
if event.type == RoomMemberEvent.TYPE:
if event.membership == Membership.JOIN:

View file

@ -232,6 +232,22 @@ class MessageHandler(BaseHandler):
# store message in db
yield self._on_new_room_event(event, snapshot)
@defer.inlineCallbacks
def get_state_events(self, user_id, room_id):
"""Retrieve all state events for a given room.
Args:
user_id(str): The user requesting state events.
room_id(str): The room ID to get all state events from.
Returns:
A list of dicts representing state events. [{}, {}, {}]
"""
yield self.auth.check_joined_room(room_id, user_id)
# TODO: This is duplicating logic from snapshot_all_rooms
current_state = yield self.store.get_current_state(room_id)
defer.returnValue([self.hs.serialize_event(c) for c in current_state])
@defer.inlineCallbacks
def snapshot_all_rooms(self, user_id=None, pagin_config=None,
feedback=False):

View file

@ -145,17 +145,6 @@ class RoomCreationHandler(BaseHandler):
content={"name": name},
)
yield handle_event(name_event)
elif room_alias:
name = room_alias.to_string()
name_event = self.event_factory.create_event(
etype=RoomNameEvent.TYPE,
room_id=room_id,
user_id=user_id,
required_power_level=50,
content={"name": name},
)
yield handle_event(name_event)
if "topic" in config:
@ -255,6 +244,7 @@ class RoomCreationHandler(BaseHandler):
etype=RoomOpsPowerLevelsEvent.TYPE,
ban_level=50,
kick_level=50,
redact_level=50,
)
return [

View file

@ -15,7 +15,8 @@
from . import (
room, events, register, login, profile, presence, initial_sync, directory
room, events, register, login, profile, presence, initial_sync, directory,
voip, admin,
)
@ -42,3 +43,5 @@ class RestServletFactory(object):
presence.register_servlets(hs, client_resource)
initial_sync.register_servlets(hs, client_resource)
directory.register_servlets(hs, client_resource)
voip.register_servlets(hs, client_resource)
admin.register_servlets(hs, client_resource)

47
synapse/rest/admin.py Normal file
View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright 2014 OpenMarket Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.internet import defer
from synapse.api.errors import AuthError, SynapseError
from base import RestServlet, client_path_pattern
import logging
logger = logging.getLogger(__name__)
class WhoisRestServlet(RestServlet):
PATTERN = client_path_pattern("/admin/whois/(?P<user_id>[^/]*)")
@defer.inlineCallbacks
def on_GET(self, request, user_id):
target_user = self.hs.parse_userid(user_id)
auth_user = yield self.auth.get_user_by_req(request)
is_admin = yield self.auth.is_server_admin(auth_user)
if not is_admin and target_user != auth_user:
raise AuthError(403, "You are not a server admin")
if not target_user.is_mine:
raise SynapseError(400, "Can only whois a local user")
ret = yield self.handlers.admin_handler.get_whois(target_user)
defer.returnValue((200, ret))
def register_servlets(hs, http_server):
WhoisRestServlet(hs).register(http_server)

View file

@ -16,7 +16,7 @@
from twisted.internet import defer
from synapse.api.errors import SynapseError, Codes
from synapse.api.errors import AuthError, SynapseError, Codes
from base import RestServlet, client_path_pattern
import json
@ -81,6 +81,24 @@ class ClientDirectoryServer(RestServlet):
defer.returnValue((200, {}))
@defer.inlineCallbacks
def on_DELETE(self, request, room_alias):
user = yield self.auth.get_user_by_req(request)
is_admin = yield self.auth.is_server_admin(user)
if not is_admin:
raise AuthError(403, "You need to be a server admin")
dir_handler = self.handlers.directory_handler
room_alias = self.hs.parse_roomalias(urllib.unquote(room_alias))
yield dir_handler.delete_association(
user.to_string(), room_alias
)
defer.returnValue((200, {}))
def _parse_json(request):
try:

View file

@ -21,6 +21,8 @@ from synapse.api.constants import LoginType
from base import RestServlet, client_path_pattern
import synapse.util.stringutils as stringutils
from hashlib import sha1
import hmac
import json
import logging
import urllib
@ -28,6 +30,16 @@ import urllib
logger = logging.getLogger(__name__)
# We ought to be using hmac.compare_digest() but on older pythons it doesn't
# exist. It's a _really minor_ security flaw to use plain string comparison
# because the timing attack is so obscured by all the other code here it's
# unlikely to make much difference
if hasattr(hmac, "compare_digest"):
compare_digest = hmac.compare_digest
else:
compare_digest = lambda a, b: a == b
class RegisterRestServlet(RestServlet):
"""Handles registration with the home server.
@ -142,6 +154,38 @@ class RegisterRestServlet(RestServlet):
if not self.hs.config.enable_registration_captcha:
raise SynapseError(400, "Captcha not required.")
yield self._check_recaptcha(request, register_json, session)
session[LoginType.RECAPTCHA] = True # mark captcha as done
self._save_session(session)
defer.returnValue({
"next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
})
@defer.inlineCallbacks
def _check_recaptcha(self, request, register_json, session):
if ("captcha_bypass_hmac" in register_json and
self.hs.config.captcha_bypass_secret):
if "user" not in register_json:
raise SynapseError(400, "Captcha bypass needs 'user'")
want = hmac.new(
key=self.hs.config.captcha_bypass_secret,
msg=register_json["user"],
digestmod=sha1,
).hexdigest()
# str() because otherwise hmac complains that 'unicode' does not
# have the buffer interface
got = str(register_json["captcha_bypass_hmac"])
if compare_digest(want, got):
session["user"] = register_json["user"]
defer.returnValue(None)
else:
raise SynapseError(400, "Captcha bypass HMAC incorrect",
errcode=Codes.CAPTCHA_NEEDED)
challenge = None
user_response = None
try:
@ -151,13 +195,7 @@ class RegisterRestServlet(RestServlet):
raise SynapseError(400, "Captcha response is required",
errcode=Codes.CAPTCHA_NEEDED)
# May be an X-Forwarding-For header depending on config
ip_addr = request.getClientIP()
if self.hs.config.captcha_ip_origin_is_x_forwarded:
# use the header
if request.requestHeaders.hasHeader("X-Forwarded-For"):
ip_addr = request.requestHeaders.getRawHeaders(
"X-Forwarded-For")[0]
ip_addr = self.hs.get_ip_from_request(request)
handler = self.handlers.registration_handler
yield handler.check_recaptcha(
@ -166,11 +204,6 @@ class RegisterRestServlet(RestServlet):
challenge,
user_response
)
session[LoginType.RECAPTCHA] = True # mark captcha as done
self._save_session(session)
defer.returnValue({
"next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
})
@defer.inlineCallbacks
def _do_email_identity(self, request, register_json, session):
@ -195,6 +228,10 @@ class RegisterRestServlet(RestServlet):
# captcha should've been done by this stage!
raise SynapseError(400, "Captcha is required.")
if ("user" in session and "user" in register_json and
session["user"] != register_json["user"]):
raise SynapseError(400, "Cannot change user ID during registration")
password = register_json["password"].encode("utf-8")
desired_user_id = (register_json["user"].encode("utf-8") if "user"
in register_json else None)

View file

@ -19,7 +19,7 @@ from twisted.internet import defer
from base import RestServlet, client_path_pattern
from synapse.api.errors import SynapseError, Codes
from synapse.streams.config import PaginationConfig
from synapse.api.events.room import RoomMemberEvent
from synapse.api.events.room import RoomMemberEvent, RoomRedactionEvent
from synapse.api.constants import Membership
import json
@ -329,12 +329,13 @@ class RoomStateRestServlet(RestServlet):
@defer.inlineCallbacks
def on_GET(self, request, room_id):
user = yield self.auth.get_user_by_req(request)
# TODO: Get all the current state for this room and return in the same
# format as initial sync, that is:
# [
# { state event }, { state event }
# ]
defer.returnValue((200, []))
handler = self.handlers.message_handler
# Get all the current state for this room
events = yield handler.get_state_events(
room_id=urllib.unquote(room_id),
user_id=user.to_string(),
)
defer.returnValue((200, events))
# TODO: Needs unit testing
@ -430,6 +431,41 @@ class RoomMembershipRestServlet(RestServlet):
self.txns.store_client_transaction(request, txn_id, response)
defer.returnValue(response)
class RoomRedactEventRestServlet(RestServlet):
def register(self, http_server):
PATTERN = ("/rooms/(?P<room_id>[^/]*)/redact/(?P<event_id>[^/]*)")
register_txn_path(self, PATTERN, http_server)
@defer.inlineCallbacks
def on_POST(self, request, room_id, event_id):
user = yield self.auth.get_user_by_req(request)
content = _parse_json(request)
event = self.event_factory.create_event(
etype=RoomRedactionEvent.TYPE,
room_id=urllib.unquote(room_id),
user_id=user.to_string(),
content=content,
redacts=event_id,
)
msg_handler = self.handlers.message_handler
yield msg_handler.send_message(event)
defer.returnValue((200, {"event_id": event.event_id}))
@defer.inlineCallbacks
def on_PUT(self, request, room_id, event_id, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
except KeyError:
pass
response = yield self.on_POST(request, room_id, event_id)
self.txns.store_client_transaction(request, txn_id, response)
defer.returnValue(response)
def _parse_json(request):
try:
@ -485,3 +521,4 @@ def register_servlets(hs, http_server):
PublicRoomListRestServlet(hs).register(http_server)
RoomStateRestServlet(hs).register(http_server)
RoomInitialSyncRestServlet(hs).register(http_server)
RoomRedactEventRestServlet(hs).register(http_server)

60
synapse/rest/voip.py Normal file
View file

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2014 OpenMarket Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.internet import defer
from base import RestServlet, client_path_pattern
import hmac
import hashlib
import base64
class VoipRestServlet(RestServlet):
PATTERN = client_path_pattern("/voip/turnServer$")
@defer.inlineCallbacks
def on_GET(self, request):
auth_user = yield self.auth.get_user_by_req(request)
turnUris = self.hs.config.turn_uris
turnSecret = self.hs.config.turn_shared_secret
userLifetime = self.hs.config.turn_user_lifetime
if not turnUris or not turnSecret or not userLifetime:
defer.returnValue( (200, {}) )
expiry = self.hs.get_clock().time_msec() + userLifetime
username = "%d:%s" % (expiry, auth_user.to_string())
mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1)
# We need to use standard base64 encoding here, *not* syutil's encode_base64
# because we need to add the standard padding to get the same result as the
# TURN server.
password = base64.b64encode(mac.digest())
defer.returnValue( (200, {
'username': username,
'password': password,
'ttl': userLifetime / 1000,
'uris': turnUris,
}) )
def on_OPTIONS(self, request):
return (200, {})
def register_servlets(hs, http_server):
VoipRestServlet(hs).register(http_server)

View file

@ -146,6 +146,18 @@ class BaseHomeServer(object):
def serialize_event(self, e):
return serialize_event(self, e)
def get_ip_from_request(self, request):
# May be an X-Forwarding-For header depending on config
ip_addr = request.getClientIP()
if self.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]
return ip_addr
# Build magic accessors for every dependency
for depname in BaseHomeServer.DEPENDENCIES:
BaseHomeServer._make_dependency_method(depname)

View file

@ -24,6 +24,7 @@ from synapse.api.events.room import (
RoomAddStateLevelEvent,
RoomSendEventLevelEvent,
RoomOpsPowerLevelsEvent,
RoomRedactionEvent,
)
from synapse.util.logutils import log_function
@ -57,12 +58,13 @@ SCHEMAS = [
"im",
"room_aliases",
"keys",
"redactions",
]
# 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 = 3
SCHEMA_VERSION = 5
class _RollbackButIsFineException(Exception):
@ -104,7 +106,7 @@ class DataStore(RoomMemberStore, RoomStore,
stream_ordering=stream_ordering,
is_new_state=is_new_state,
)
except _RollbackButIsFineException as e:
except _RollbackButIsFineException:
pass
@defer.inlineCallbacks
@ -183,6 +185,8 @@ class DataStore(RoomMemberStore, RoomStore,
self._store_send_event_level(txn, event)
elif event.type == RoomOpsPowerLevelsEvent.TYPE:
self._store_ops_level(txn, event)
elif event.type == RoomRedactionEvent.TYPE:
self._store_redaction(txn, event)
vals = {
"topological_ordering": event.depth,
@ -204,7 +208,7 @@ class DataStore(RoomMemberStore, RoomStore,
unrec = {
k: v
for k, v in event.get_full_dict().items()
if k not in vals.keys()
if k not in vals.keys() and k not in ["redacted", "redacted_because"]
}
vals["unrecognized_keys"] = json.dumps(unrec)
@ -218,7 +222,8 @@ class DataStore(RoomMemberStore, RoomStore,
)
raise _RollbackButIsFineException("_persist_event")
if is_new_state and hasattr(event, "state_key"):
is_state = hasattr(event, "state_key") and event.state_key is not None
if is_new_state and is_state:
vals = {
"event_id": event.event_id,
"room_id": event.room_id,
@ -242,14 +247,28 @@ class DataStore(RoomMemberStore, RoomStore,
}
)
def _store_redaction(self, txn, event):
txn.execute(
"INSERT OR IGNORE INTO redactions "
"(event_id, redacts) VALUES (?,?)",
(event.event_id, event.redacts)
)
@defer.inlineCallbacks
def get_current_state(self, room_id, event_type=None, state_key=""):
del_sql = (
"SELECT event_id FROM redactions WHERE redacts = e.event_id "
"LIMIT 1"
)
sql = (
"SELECT e.* FROM events as e "
"SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
"INNER JOIN current_state_events as c ON e.event_id = c.event_id "
"INNER JOIN state_events as s ON e.event_id = s.event_id "
"WHERE c.room_id = ? "
)
) % {
"redacted": del_sql,
}
if event_type:
sql += " AND s.type = ? AND s.state_key = ? "
@ -276,6 +295,28 @@ class DataStore(RoomMemberStore, RoomStore,
defer.returnValue(self.min_token)
def insert_client_ip(self, user, access_token, device_id, ip, user_agent):
return self._simple_insert(
"user_ips",
{
"user": user.to_string(),
"access_token": access_token,
"device_id": device_id,
"ip": ip,
"user_agent": user_agent,
"last_seen": int(self._clock.time_msec()),
}
)
def get_user_ip_and_agents(self, user):
return self._simple_select_list(
table="user_ips",
keyvalues={"user": user.to_string()},
retcols=[
"device_id", "access_token", "ip", "user_agent", "last_seen"
],
)
def snapshot_room(self, room_id, user_id, state_type=None, state_key=None):
"""Snapshot the room for an update by a user
Args:

View file

@ -17,6 +17,7 @@ import logging
from twisted.internet import defer
from synapse.api.errors import StoreError
from synapse.api.events.utils import prune_event
from synapse.util.logutils import log_function
import collections
@ -345,7 +346,7 @@ class SQLBaseStore(object):
return self.runInteraction(func)
def _parse_event_from_row(self, row_dict):
d = copy.deepcopy({k: v for k, v in row_dict.items() if v})
d = copy.deepcopy({k: v for k, v in row_dict.items()})
d.pop("stream_ordering", None)
d.pop("topological_ordering", None)
@ -373,8 +374,8 @@ class SQLBaseStore(object):
sql = "SELECT * FROM events WHERE event_id = ?"
for ev in events:
if hasattr(ev, "prev_state"):
# Load previous state_content.
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)
@ -382,8 +383,32 @@ class SQLBaseStore(object):
prev = self._parse_event_from_row(prevs[0])
ev.prev_content = prev.content
if not hasattr(ev, "redacted"):
logger.debug("Doesn't have redacted key: %s", ev)
ev.redacted = self._has_been_redacted_txn(txn, ev)
if ev.redacted:
# Get the redaction event.
sql = "SELECT * FROM events WHERE event_id = ?"
txn.execute(sql, (ev.redacted,))
del_evs = self._parse_events_txn(
txn, self.cursor_to_dict(txn)
)
if del_evs:
prune_event(ev)
ev.redacted_because = del_evs[0]
return events
def _has_been_redacted_txn(self, txn, event):
sql = "SELECT event_id FROM redactions WHERE redacts = ?"
txn.execute(sql, (event.event_id,))
result = txn.fetchone()
return result[0] if result else None
class Table(object):
""" A base class used to store information about a particular table.
"""

View file

@ -93,6 +93,36 @@ class DirectoryStore(SQLBaseStore):
}
)
def delete_room_alias(self, room_alias):
return self.runInteraction(
self._delete_room_alias_txn,
room_alias,
)
def _delete_room_alias_txn(self, txn, room_alias):
cursor = txn.execute(
"SELECT room_id FROM room_aliases WHERE room_alias = ?",
(room_alias.to_string(),)
)
res = cursor.fetchone()
if res:
room_id = res[0]
else:
return None
txn.execute(
"DELETE FROM room_aliases WHERE room_alias = ?",
(room_alias.to_string(),)
)
txn.execute(
"DELETE FROM room_alias_servers WHERE room_alias = ?",
(room_alias.to_string(),)
)
return room_id
def get_aliases_for_room(self, room_id):
return self._simple_select_onecol(
"room_aliases",

View file

@ -88,27 +88,40 @@ class RegistrationStore(SQLBaseStore):
query, user_id
)
@defer.inlineCallbacks
def get_user_by_token(self, token):
"""Get a user from the given access token.
Args:
token (str): The access token of a user.
Returns:
str: The user ID of the user.
dict: Including the name (user_id), device_id and whether they are
an admin.
Raises:
StoreError if no user was found.
"""
user_id = yield self.runInteraction(self._query_for_auth,
token)
defer.returnValue(user_id)
return self.runInteraction(
self._query_for_auth,
token
)
def is_server_admin(self, user):
return self._simple_select_one_onecol(
table="users",
keyvalues={"name": user.to_string()},
retcol="admin",
)
def _query_for_auth(self, txn, token):
txn.execute("SELECT users.name FROM access_tokens LEFT JOIN users" +
" ON users.id = access_tokens.user_id WHERE token = ?",
[token])
row = txn.fetchone()
if row:
return row[0]
sql = (
"SELECT users.name, users.admin, access_tokens.device_id "
"FROM users "
"INNER JOIN access_tokens on users.id = access_tokens.user_id "
"WHERE token = ?"
)
cursor = txn.execute(sql, (token,))
rows = self.cursor_to_dict(cursor)
if rows:
return rows[0]
raise StoreError(404, "Token not found.")

View file

@ -27,7 +27,7 @@ import logging
logger = logging.getLogger(__name__)
OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level"))
OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level", "redact_level"))
class RoomStore(SQLBaseStore):
@ -189,7 +189,8 @@ class RoomStore(SQLBaseStore):
def _get_ops_levels(self, txn, room_id):
sql = (
"SELECT ban_level, kick_level FROM room_ops_levels as r "
"SELECT ban_level, kick_level, redact_level "
"FROM room_ops_levels as r "
"INNER JOIN current_state_events as c "
"ON r.event_id = c.event_id "
"WHERE c.room_id = ? "
@ -198,7 +199,7 @@ class RoomStore(SQLBaseStore):
rows = txn.execute(sql, (room_id,)).fetchall()
if len(rows) == 1:
return OpsLevel(rows[0][0], rows[0][1])
return OpsLevel(rows[0][0], rows[0][1], rows[0][2])
else:
return OpsLevel(None, None)
@ -326,6 +327,9 @@ class RoomStore(SQLBaseStore):
if "ban_level" in event.content:
content["ban_level"] = event.content["ban_level"]
if "redact_level" in event.content:
content["redact_level"] = event.content["redact_level"]
self._simple_insert_txn(
txn,
"room_ops_levels",

View file

@ -18,7 +18,6 @@ from twisted.internet import defer
from ._base import SQLBaseStore
from synapse.api.constants import Membership
from synapse.util.logutils import log_function
import logging
@ -182,14 +181,22 @@ class RoomMemberStore(SQLBaseStore):
)
def _get_members_query_txn(self, txn, where_clause, where_values):
del_sql = (
"SELECT event_id FROM redactions WHERE redacts = e.event_id "
"LIMIT 1"
)
sql = (
"SELECT e.* FROM events as e "
"SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
"INNER JOIN room_memberships as m "
"ON e.event_id = m.event_id "
"INNER JOIN current_state_events as c "
"ON m.event_id = c.event_id "
"WHERE %s "
) % (where_clause,)
"WHERE %(where)s "
) % {
"redacted": del_sql,
"where": where_clause,
}
txn.execute(sql, where_values)
rows = self.cursor_to_dict(txn)

View file

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS redactions (
event_id TEXT NOT NULL,
redacts TEXT NOT NULL,
CONSTRAINT ev_uniq UNIQUE (event_id)
);
CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);
ALTER TABLE room_ops_levels ADD COLUMN redact_level INTEGER;
PRAGMA user_version = 4;

View file

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS user_ips (
user TEXT NOT NULL,
access_token TEXT NOT NULL,
device_id TEXT,
ip TEXT NOT NULL,
user_agent TEXT NOT NULL,
last_seen INTEGER NOT NULL,
CONSTRAINT user_ip UNIQUE (user, access_token, ip, user_agent) ON CONFLICT REPLACE
);
CREATE INDEX IF NOT EXISTS user_ips_user ON user_ips(user);
ALTER TABLE users ADD COLUMN admin BOOL DEFAULT 0 NOT NULL;
PRAGMA user_version = 5;

View file

@ -150,7 +150,8 @@ CREATE TABLE IF NOT EXISTS room_ops_levels(
event_id TEXT NOT NULL,
room_id TEXT NOT NULL,
ban_level INTEGER,
kick_level INTEGER
kick_level INTEGER,
redact_level INTEGER
);
CREATE INDEX IF NOT EXISTS room_ops_levels_event_id ON room_ops_levels(event_id);

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS redactions (
event_id TEXT NOT NULL,
redacts TEXT NOT NULL,
CONSTRAINT ev_uniq UNIQUE (event_id)
);
CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);

View file

@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS users(
name TEXT,
password_hash TEXT,
creation_ts INTEGER,
admin BOOL DEFAULT 0 NOT NULL,
UNIQUE(name) ON CONFLICT ROLLBACK
);
@ -29,3 +30,16 @@ CREATE TABLE IF NOT EXISTS access_tokens(
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(token) ON CONFLICT ROLLBACK
);
CREATE TABLE IF NOT EXISTS user_ips (
user TEXT NOT NULL,
access_token TEXT NOT NULL,
device_id TEXT,
ip TEXT NOT NULL,
user_agent TEXT NOT NULL,
last_seen INTEGER NOT NULL,
CONSTRAINT user_ip UNIQUE (user, access_token, ip, user_agent) ON CONFLICT REPLACE
);
CREATE INDEX IF NOT EXISTS user_ips_user ON user_ips(user);

View file

@ -157,6 +157,11 @@ class StreamStore(SQLBaseStore):
"WHERE m.user_id = ? "
)
del_sql = (
"SELECT event_id FROM redactions WHERE redacts = e.event_id "
"LIMIT 1"
)
if limit:
limit = max(limit, MAX_STREAM_SIZE)
else:
@ -171,13 +176,14 @@ class StreamStore(SQLBaseStore):
return
sql = (
"SELECT * FROM events as e WHERE "
"SELECT *, (%(redacted)s) AS redacted FROM events AS e WHERE "
"((room_id IN (%(current)s)) OR "
"(event_id IN (%(invites)s))) "
"AND e.stream_ordering > ? AND e.stream_ordering <= ? "
"AND e.outlier = 0 "
"ORDER BY stream_ordering ASC LIMIT %(limit)d "
) % {
"redacted": del_sql,
"current": current_room_membership_sql,
"invites": membership_sql,
"limit": limit
@ -224,11 +230,21 @@ class StreamStore(SQLBaseStore):
else:
limit_str = ""
del_sql = (
"SELECT event_id FROM redactions WHERE redacts = events.event_id "
"LIMIT 1"
)
sql = (
"SELECT * FROM events "
"SELECT *, (%(redacted)s) AS redacted FROM events "
"WHERE outlier = 0 AND room_id = ? AND %(bounds)s "
"ORDER BY topological_ordering %(order)s, stream_ordering %(order)s %(limit)s "
) % {"bounds": bounds, "order": order, "limit": limit_str}
) % {
"redacted": del_sql,
"bounds": bounds,
"order": order,
"limit": limit_str
}
rows = yield self._execute_and_decode(
sql,
@ -257,11 +273,18 @@ class StreamStore(SQLBaseStore):
with_feedback=False):
# TODO (erikj): Handle compressed feedback
del_sql = (
"SELECT event_id FROM redactions WHERE redacts = events.event_id "
"LIMIT 1"
)
sql = (
"SELECT * FROM events "
"SELECT *, (%(redacted)s) AS redacted FROM events "
"WHERE room_id = ? AND stream_ordering <= ? "
"ORDER BY topological_ordering DESC, stream_ordering DESC LIMIT ? "
)
) % {
"redacted": del_sql,
}
rows = yield self._execute_and_decode(
sql,

View file

@ -82,7 +82,7 @@ class FederationTestCase(unittest.TestCase):
self.datastore.persist_event.assert_called_once_with(
ANY, False, is_new_state=False
)
self.notifier.on_new_room_event.assert_called_once_with(ANY)
self.notifier.on_new_room_event.assert_called_once_with(ANY, extra_users=[])
@defer.inlineCallbacks
def test_invite_join_target_this(self):

View file

@ -52,6 +52,7 @@ class PresenceStateTestCase(unittest.TestCase):
datastore=Mock(spec=[
"get_presence_state",
"set_presence_state",
"insert_client_ip",
]),
http_client=None,
resource_for_client=self.mock_resource,
@ -67,7 +68,11 @@ class PresenceStateTestCase(unittest.TestCase):
self.datastore.get_presence_list = get_presence_list
def _get_user_by_token(token=None):
return hs.parse_userid(myid)
return {
"user": hs.parse_userid(myid),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
@ -135,6 +140,7 @@ class PresenceListTestCase(unittest.TestCase):
"set_presence_list_accepted",
"del_presence_list",
"get_presence_list",
"insert_client_ip",
]),
http_client=None,
resource_for_client=self.mock_resource,
@ -152,7 +158,11 @@ class PresenceListTestCase(unittest.TestCase):
self.datastore.has_presence_state = has_presence_state
def _get_user_by_token(token=None):
return hs.parse_userid(myid)
return {
"user": hs.parse_userid(myid),
"admin": False,
"device_id": None,
}
room_member_handler = hs.handlers.room_member_handler = Mock(
spec=[

View file

@ -50,10 +50,10 @@ class ProfileTestCase(unittest.TestCase):
datastore=None,
)
def _get_user_by_token(token=None):
def _get_user_by_req(request=None):
return hs.parse_userid(myid)
hs.get_auth().get_user_by_token = _get_user_by_token
hs.get_auth().get_user_by_req = _get_user_by_req
hs.get_handlers().profile_handler = self.mock_handler

View file

@ -69,7 +69,11 @@ class RoomPermissionsTestCase(RestTestCase):
hs.get_handlers().federation_handler = Mock()
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
self.auth_user_id = self.rmcreator_id
@ -425,7 +429,11 @@ class RoomsMemberListTestCase(RestTestCase):
self.auth_user_id = self.user_id
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
synapse.rest.room.register_servlets(hs, self.mock_resource)
@ -508,7 +516,11 @@ class RoomsCreateTestCase(RestTestCase):
hs.get_handlers().federation_handler = Mock()
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
synapse.rest.room.register_servlets(hs, self.mock_resource)
@ -605,7 +617,11 @@ class RoomTopicTestCase(RestTestCase):
hs.get_handlers().federation_handler = Mock()
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
synapse.rest.room.register_servlets(hs, self.mock_resource)
@ -715,7 +731,16 @@ class RoomMemberStateTestCase(RestTestCase):
hs.get_handlers().federation_handler = Mock()
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
synapse.rest.room.register_servlets(hs, self.mock_resource)
@ -847,7 +872,11 @@ class RoomMessagesTestCase(RestTestCase):
hs.get_handlers().federation_handler = Mock()
def _get_user_by_token(token=None):
return hs.parse_userid(self.auth_user_id)
return {
"user": hs.parse_userid(self.auth_user_id),
"admin": False,
"device_id": None,
}
hs.get_auth().get_user_by_token = _get_user_by_token
synapse.rest.room.register_servlets(hs, self.mock_resource)

View file

@ -30,7 +30,8 @@ class DirectoryStoreTestCase(unittest.TestCase):
db_pool = SQLiteMemoryDbPool()
yield db_pool.prepare()
hs = HomeServer("test",
hs = HomeServer(
"test",
db_pool=db_pool,
)
@ -60,9 +61,25 @@ class DirectoryStoreTestCase(unittest.TestCase):
servers=["test"],
)
self.assertObjectHasAttributes(
{"room_id": self.room.to_string(),
"servers": ["test"]},
{
"room_id": self.room.to_string(),
"servers": ["test"],
},
(yield self.store.get_association_from_room_alias(self.alias))
)
@defer.inlineCallbacks
def test_delete_alias(self):
yield self.store.create_room_alias_association(
room_alias=self.alias,
room_id=self.room.to_string(),
servers=["test"],
)
room_id = yield self.store.delete_room_alias(self.alias)
self.assertEqual(self.room.to_string(), room_id)
self.assertIsNone(
(yield self.store.get_association_from_room_alias(self.alias))
)

View file

@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
# Copyright 2014 OpenMarket Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from tests import unittest
from twisted.internet import defer
from synapse.server import HomeServer
from synapse.api.constants import Membership
from synapse.api.events.room import (
RoomMemberEvent, MessageEvent, RoomRedactionEvent,
)
from tests.utils import SQLiteMemoryDbPool
class RedactionTestCase(unittest.TestCase):
@defer.inlineCallbacks
def setUp(self):
db_pool = SQLiteMemoryDbPool()
yield db_pool.prepare()
hs = HomeServer(
"test",
db_pool=db_pool,
)
self.store = hs.get_datastore()
self.event_factory = hs.get_event_factory()
self.u_alice = hs.parse_userid("@alice:test")
self.u_bob = hs.parse_userid("@bob:test")
self.room1 = hs.parse_roomid("!abc123:test")
self.depth = 1
@defer.inlineCallbacks
def inject_room_member(self, room, user, membership, prev_state=None,
extra_content={}):
self.depth += 1
event = self.event_factory.create_event(
etype=RoomMemberEvent.TYPE,
user_id=user.to_string(),
state_key=user.to_string(),
room_id=room.to_string(),
membership=membership,
content={"membership": membership},
depth=self.depth,
)
event.content.update(extra_content)
if prev_state:
event.prev_state = prev_state
# Have to create a join event using the eventfactory
yield self.store.persist_event(
event
)
defer.returnValue(event)
@defer.inlineCallbacks
def inject_message(self, room, user, body):
self.depth += 1
event = self.event_factory.create_event(
etype=MessageEvent.TYPE,
user_id=user.to_string(),
room_id=room.to_string(),
content={"body": body, "msgtype": u"message"},
depth=self.depth,
)
yield self.store.persist_event(
event
)
defer.returnValue(event)
@defer.inlineCallbacks
def inject_redaction(self, room, event_id, user, reason):
event = self.event_factory.create_event(
etype=RoomRedactionEvent.TYPE,
user_id=user.to_string(),
room_id=room.to_string(),
content={"reason": reason},
depth=self.depth,
redacts=event_id,
)
yield self.store.persist_event(
event
)
defer.returnValue(event)
@defer.inlineCallbacks
def test_redact(self):
yield self.inject_room_member(
self.room1, self.u_alice, Membership.JOIN
)
start = yield self.store.get_room_events_max_id()
msg_event = yield self.inject_message(self.room1, self.u_alice, u"t")
end = yield self.store.get_room_events_max_id()
results, _ = yield self.store.get_room_events_stream(
self.u_alice.to_string(),
start,
end,
None, # Is currently ignored
)
self.assertEqual(1, len(results))
# Check event has not been redacted:
event = results[0]
self.assertObjectHasAttributes(
{
"type": MessageEvent.TYPE,
"user_id": self.u_alice.to_string(),
"content": {"body": "t", "msgtype": "message"},
},
event,
)
self.assertFalse(hasattr(event, "redacted_because"))
# Redact event
reason = "Because I said so"
yield self.inject_redaction(
self.room1, msg_event.event_id, self.u_alice, reason
)
results, _ = yield self.store.get_room_events_stream(
self.u_alice.to_string(),
start,
end,
None, # Is currently ignored
)
self.assertEqual(1, len(results))
# Check redaction
event = results[0]
self.assertObjectHasAttributes(
{
"type": MessageEvent.TYPE,
"user_id": self.u_alice.to_string(),
"content": {},
},
event,
)
self.assertTrue(hasattr(event, "redacted_because"))
self.assertObjectHasAttributes(
{
"type": RoomRedactionEvent.TYPE,
"user_id": self.u_alice.to_string(),
"content": {"reason": reason},
},
event.redacted_because,
)
@defer.inlineCallbacks
def test_redact_join(self):
yield self.inject_room_member(
self.room1, self.u_alice, Membership.JOIN
)
start = yield self.store.get_room_events_max_id()
msg_event = yield self.inject_room_member(
self.room1, self.u_bob, Membership.JOIN,
extra_content={"blue": "red"},
)
end = yield self.store.get_room_events_max_id()
results, _ = yield self.store.get_room_events_stream(
self.u_alice.to_string(),
start,
end,
None, # Is currently ignored
)
self.assertEqual(1, len(results))
# Check event has not been redacted:
event = results[0]
self.assertObjectHasAttributes(
{
"type": RoomMemberEvent.TYPE,
"user_id": self.u_bob.to_string(),
"content": {"membership": Membership.JOIN, "blue": "red"},
},
event,
)
self.assertFalse(hasattr(event, "redacted_because"))
# Redact event
reason = "Because I said so"
yield self.inject_redaction(
self.room1, msg_event.event_id, self.u_alice, reason
)
results, _ = yield self.store.get_room_events_stream(
self.u_alice.to_string(),
start,
end,
None, # Is currently ignored
)
self.assertEqual(1, len(results))
# Check redaction
event = results[0]
self.assertObjectHasAttributes(
{
"type": RoomMemberEvent.TYPE,
"user_id": self.u_bob.to_string(),
"content": {"membership": Membership.JOIN},
},
event,
)
self.assertTrue(hasattr(event, "redacted_because"))
self.assertObjectHasAttributes(
{
"type": RoomRedactionEvent.TYPE,
"user_id": self.u_alice.to_string(),
"content": {"reason": reason},
},
event.redacted_because,
)

View file

@ -53,7 +53,7 @@ class RegistrationStoreTestCase(unittest.TestCase):
)
self.assertEquals(
self.user_id,
{"admin": 0, "device_id": None, "name": self.user_id},
(yield self.store.get_user_by_token(self.tokens[0]))
)
@ -63,7 +63,7 @@ class RegistrationStoreTestCase(unittest.TestCase):
yield self.store.add_access_token_to_user(self.user_id, self.tokens[1])
self.assertEquals(
self.user_id,
{"admin": 0, "device_id": None, "name": self.user_id},
(yield self.store.get_user_by_token(self.tokens[1]))
)

View file

@ -182,7 +182,11 @@ class MemoryDataStore(object):
def get_user_by_token(self, token):
try:
return self.tokens_to_users[token]
return {
"name": self.tokens_to_users[token],
"admin": 0,
"device_id": None,
}
except:
raise StoreError(400, "User does not exist.")
@ -277,7 +281,10 @@ class MemoryDataStore(object):
return defer.succeed("invite")
def get_ops_levels(self, room_id):
return defer.succeed((5, 5))
return defer.succeed((5, 5, 5))
def insert_client_ip(self, user, device_id, access_token, ip, user_agent):
return defer.succeed(None)
def _format_call(args, kwargs):

View file

@ -67,6 +67,16 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
}
};
$scope.leave = function(room_id) {
matrixService.leave(room_id).then(
function(response) {
console.log("Left room " + room_id);
},
function(error) {
console.log("Failed to leave room " + room_id + ": " + error.data.error);
});
};
// Logs the user out
$scope.logout = function() {

View file

@ -45,32 +45,33 @@ angular.module('matrixWebClient')
angular.forEach(members, function(value, key) {
value["id"] = key;
filtered.push( value );
if (value["displayname"]) {
if (!displayNames[value["displayname"]]) {
displayNames[value["displayname"]] = [];
}
displayNames[value["displayname"]].push(key);
}
});
// FIXME: we shouldn't disambiguate displayNames on every orderMembersList
// invocation but keep track of duplicates incrementally somewhere
angular.forEach(displayNames, function(value, key) {
if (value.length > 1) {
// console.log(key + ": " + value);
for (var i=0; i < value.length; i++) {
var v = value[i];
// FIXME: this permenantly rewrites the displayname for a given
// room member. which means we can't reset their name if it is
// no longer ambiguous!
members[v].displayname += " (" + v + ")";
// console.log(v + " " + members[v]);
};
}
});
filtered.sort(function (a, b) {
return ((a["last_active_ago"] || 10e10) > (b["last_active_ago"] || 10e10) ? 1 : -1);
// Sort members on their last_active absolute time
var aLastActiveTS = 0, bLastActiveTS = 0;
if (undefined !== a.last_active_ago) {
aLastActiveTS = a.last_updated - a.last_active_ago;
}
if (undefined !== b.last_active_ago) {
bLastActiveTS = b.last_updated - b.last_active_ago;
}
if (aLastActiveTS || bLastActiveTS) {
return bLastActiveTS - aLastActiveTS;
}
else {
// If they do not have last_active_ago, sort them according to their presence state
// Online users go first amongs members who do not have last_active_ago
var presenceLevels = {
offline: 1,
unavailable: 2,
online: 4,
free_for_chat: 3
};
var aPresence = (a.presence in presenceLevels) ? presenceLevels[a.presence] : 0;
var bPresence = (b.presence in presenceLevels) ? presenceLevels[b.presence] : 0;
return bPresence - aPresence;
}
});
return filtered;
};

View file

@ -101,7 +101,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
var initRoom = function(room_id, room) {
if (!(room_id in $rootScope.events.rooms)) {
console.log("Creating new handler entry for " + room_id);
console.log("Creating new rooms entry for " + room_id);
$rootScope.events.rooms[room_id] = {
room_id: room_id,
messages: [],
@ -113,10 +113,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
};
}
if (room) {
if (room) { // we got an existing room object from initialsync, seemingly.
// Report all other metadata of the room object (membership, inviter, visibility, ...)
for (var field in room) {
if (-1 === ["room_id", "messages", "state"].indexOf(field)) {
if (!room.hasOwnProperty(field)) continue;
if (-1 === ["room_id", "messages", "state"].indexOf(field)) { // why indexOf - why not ===? --Matthew
$rootScope.events.rooms[room_id][field] = room[field];
}
}
@ -211,11 +213,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
if (shouldBing && isIdle) {
console.log("Displaying notification for "+JSON.stringify(event));
var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
var displayname = undefined;
if (member) {
displayname = member.displayname;
}
var member = getMember(event.room_id, event.user_id);
var displayname = getUserDisplayName(event.room_id, event.user_id);
var message = event.content.body;
if (event.content.msgtype === "m.emote") {
@ -223,7 +222,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
}
var notification = new window.Notification(
(displayname || event.user_id) +
displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": message,
@ -260,8 +259,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
if (event.content.prev !== event.content.membership) {
memberChanges = "membership";
}
else if (event.prev_content.displayname !==
event.content.displayname) {
else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
memberChanges = "displayname";
}
@ -346,6 +344,65 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
return index;
};
/**
* Get the member object of a room member
* @param {String} room_id the room id
* @param {String} user_id the id of the user
* @returns {undefined | Object} the member object of this user in this room if he is part of the room
*/
var getMember = function(room_id, user_id) {
var member;
var room = $rootScope.events.rooms[room_id];
if (room) {
member = room.members[user_id];
}
return member;
};
/**
* Return the display name of an user acccording to data already downloaded
* @param {String} room_id the room id
* @param {String} user_id the id of the user
* @returns {String} the user displayname or user_id if not available
*/
var getUserDisplayName = function(room_id, user_id) {
var displayName;
// Get the user display name from the member list of the room
var member = getMember(room_id, user_id);
if (member && member.content.displayname) { // Do not consider null displayname
displayName = member.content.displayname;
// Disambiguate users who have the same displayname in the room
if (user_id !== matrixService.config().user_id) {
var room = $rootScope.events.rooms[room_id];
for (var member_id in room.members) {
if (room.members.hasOwnProperty(member_id) && member_id !== user_id) {
var member2 = room.members[member_id];
if (member2.content.displayname && member2.content.displayname === displayName) {
displayName = displayName + " (" + user_id + ")";
break;
}
}
}
}
}
// The user may not have joined the room yet. So try to resolve display name from presence data
// Note: This data may not be available
if (undefined === displayName && user_id in $rootScope.presence) {
displayName = $rootScope.presence[user_id].content.displayname;
}
if (undefined === displayName) {
// By default, use the user ID
displayName = user_id;
}
return displayName;
};
return {
ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
MSG_EVENT: MSG_EVENT,
@ -517,6 +574,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
memberCount = 0;
for (var i in room.members) {
if (!room.members.hasOwnProperty(i)) continue;
var member = room.members[i];
if ("join" === member.membership) {
@ -535,15 +594,19 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
* @returns {undefined | Object} the member object of this user in this room if he is part of the room
*/
getMember: function(room_id, user_id) {
var member;
var room = $rootScope.events.rooms[room_id];
if (room) {
member = room.members[user_id];
}
return member;
return getMember(room_id, user_id);
},
/**
* Return the display name of an user acccording to data already downloaded
* @param {String} room_id the room id
* @param {String} user_id the id of the user
* @returns {String} the user displayname or user_id if not available
*/
getUserDisplayName: function(room_id, user_id) {
return getUserDisplayName(room_id, user_id);
},
setRoomVisibility: function(room_id, visible) {
if (!visible) {
return;

View file

@ -66,15 +66,67 @@ angular.module('MatrixCall', [])
}
MatrixCall.getTurnServer = function() {
matrixService.getTurnServer().then(function(response) {
if (response.data.uris) {
console.log("Got TURN URIs: "+response.data.uris);
MatrixCall.turnServer = response.data;
$rootScope.haveTurn = true;
// re-fetch when we're about to reach the TTL
$timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9);
} else {
console.log("Got no TURN URIs from HS");
$rootScope.haveTurn = false;
}
}, function(error) {
console.log("Failed to get TURN URIs");
MatrixCall.turnServer = {};
$timeout(MatrixCall.getTurnServer, 60000);
});
}
// FIXME: we should prevent any class from being placed or accepted before this has finished
MatrixCall.getTurnServer();
MatrixCall.CALL_TIMEOUT = 60000;
MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
MatrixCall.prototype.createPeerConnection = function() {
var stunServer = 'stun:stun.l.google.com:19302';
var pc;
if (window.mozRTCPeerConnection) {
pc = new window.mozRTCPeerConnection({'url': stunServer});
var iceServers = [];
if (MatrixCall.turnServer) {
if (MatrixCall.turnServer.uris) {
for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) {
iceServers.push({
'url': MatrixCall.turnServer.uris[i],
'username': MatrixCall.turnServer.username,
'credential': MatrixCall.turnServer.password,
});
}
} else {
console.log("No TURN server: using fallback STUN server");
iceServers.push({ 'url' : MatrixCall.FALLBACK_STUN_SERVER });
}
}
pc = new window.mozRTCPeerConnection({"iceServers":iceServers});
} else {
pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
var iceServers = [];
if (MatrixCall.turnServer) {
if (MatrixCall.turnServer.uris) {
iceServers.push({
'urls': MatrixCall.turnServer.uris,
'username': MatrixCall.turnServer.username,
'credential': MatrixCall.turnServer.password,
});
} else {
console.log("No TURN server: using fallback STUN server");
iceServers.push({ 'urls' : MatrixCall.FALLBACK_STUN_SERVER });
}
}
pc = new window.RTCPeerConnection({"iceServers":iceServers});
}
var self = this;
pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };

View file

@ -19,7 +19,7 @@
angular.module('matrixFilter', [])
// Compute the room name according to information we have
.filter('mRoomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) {
.filter('mRoomName', ['$rootScope', 'matrixService', 'eventHandlerService', function($rootScope, matrixService, eventHandlerService) {
return function(room_id) {
var roomName;
@ -31,49 +31,57 @@ angular.module('matrixFilter', [])
if (room) {
// Get name from room state date
var room_name_event = room["m.room.name"];
// Determine if it is a public room
var isPublicRoom = false;
if (room["m.room.join_rules"] && room["m.room.join_rules"].content) {
isPublicRoom = ("public" === room["m.room.join_rules"].content.join_rule);
}
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (alias) {
roomName = alias;
}
else if (room.members) {
else if (room.members && !isPublicRoom) { // Do not rename public room
var user_id = matrixService.config().user_id;
// Else, build the name from its users
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
for (var i in room.members) {
if (!room.members.hasOwnProperty(i)) continue;
var member = room.members[i];
if (member.state_key !== 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;
}
roomName = eventHandlerService.getUserDisplayName(room_id, member.state_key);
break;
}
}
}
else if (1 === Object.keys(room.members).length) {
else if (Object.keys(room.members).length <= 1) {
var otherUserId;
if (Object.keys(room.members)[0] !== user_id) {
if (Object.keys(room.members)[0] && Object.keys(room.members)[0] !== user_id) {
otherUserId = Object.keys(room.members)[0];
}
else {
// it's got to be an invite, or failing that a self-chat;
otherUserId = room.inviter || user_id;
/*
// XXX: This should all be unnecessary now thanks to using the /rooms/<room>/roomid API
// The other member may be in the invite list, get all invited users
var invitedUserIDs = [];
// XXX: *SURELY* we shouldn't have to trawl through the whole messages list to
// find invite - surely the other user should be in room.members with state invited? :/ --Matthew
for (var i in room.messages) {
var message = room.messages[i];
if ("m.room.member" === message.type && "invite" === message.membership) {
if ("m.room.member" === message.type && "invite" === message.content.membership) {
// Filter out the current user
var member_id = message.state_key;
if (member_id === user_id) {
@ -92,15 +100,11 @@ angular.module('matrixFilter', [])
if (1 === invitedUserIDs.length) {
otherUserId = invitedUserIDs[0];
}
*/
}
// Try to resolve his displayname in presence global data
if (otherUserId in $rootScope.presence) {
roomName = $rootScope.presence[otherUserId].content.displayname;
}
else {
roomName = otherUserId;
}
// Get the user display name
roomName = eventHandlerService.getUserDisplayName(room_id, otherUserId);
}
}
}
@ -127,37 +131,9 @@ angular.module('matrixFilter', [])
};
}])
// Compute the user display name in a room according to the data already downloaded
.filter('mUserDisplayName', ['$rootScope', function($rootScope) {
// Return the user display name
.filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) {
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;
return eventHandlerService.getUserDisplayName(room_id, user_id);
};
}]);

View file

@ -264,7 +264,13 @@ angular.module('matrixService', [])
return doRequest("GET", path, params);
},
// get room state for a specific room
roomState: function(room_id) {
var path = "/rooms/" + room_id + "/state";
return doRequest("GET", path);
},
// Joins a room
join: function(room_id) {
return this.membershipChange(room_id, undefined, "join");
@ -697,11 +703,10 @@ angular.module('matrixService', [])
createRoomIdToAliasMapping: function(roomId, alias) {
roomIdToAlias[roomId] = alias;
aliasToRoomId[alias] = roomId;
// localStorage.setItem(MAPPING_PREFIX+roomId, alias);
},
getRoomIdToAliasMapping: function(roomId) {
var alias = roomIdToAlias[roomId]; // was localStorage.getItem(MAPPING_PREFIX+roomId)
var alias = roomIdToAlias[roomId];
//console.log("looking for alias for " + roomId + "; found: " + alias);
return alias;
},
@ -762,6 +767,10 @@ angular.module('matrixService', [])
var deferred = $q.defer();
deferred.reject({data:{error: "Invalid room: " + room_id}});
return deferred.promise;
},
getTurnServer: function() {
return doRequest("GET", "/voip/turnServer");
}
};

View file

@ -42,6 +42,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
displayName: "",
avatarUrl: ""
};
$scope.newChat = {
user: ""
};
var refresh = function() {
@ -82,18 +86,24 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
// Go to a room
$scope.goToRoom = function(room_id) {
// Simply open the room page on this room id
//$location.url("room/" + room_id);
matrixService.join(room_id).then(
function(response) {
var final_room_id = room_id;
if (response.data.hasOwnProperty("room_id")) {
if (response.data.room_id != room_id) {
$location.url("room/" + response.data.room_id);
return;
}
final_room_id = response.data.room_id;
}
$location.url("room/" + room_id);
// TODO: factor out the common housekeeping whenever we try to join a room or alias
matrixService.roomState(final_room_id).then(
function(response) {
eventHandlerService.handleEvents(response.data, false, true);
},
function(error) {
$scope.feedback = "Failed to get room state for: " + final_room_id;
}
);
$location.url("room/" + final_room_id);
},
function(error) {
$scope.feedback = "Can't join room: " + JSON.stringify(error.data);
@ -104,6 +114,15 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
$scope.joinAlias = function(room_alias) {
matrixService.joinAlias(room_alias).then(
function(response) {
// TODO: factor out the common housekeeping whenever we try to join a room or alias
matrixService.roomState(response.room_id).then(
function(response) {
eventHandlerService.handleEvents(response.data, false, true);
},
function(error) {
$scope.feedback = "Failed to get room state for: " + response.room_id;
}
);
// Go to this room
$location.url("room/" + room_alias);
},
@ -112,6 +131,32 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
}
);
};
// FIXME: factor this out between user-controller and home-controller etc.
$scope.messageUser = function() {
// FIXME: create a new room every time, for now
matrixService.create(null, 'private').then(
function(response) {
// This room has been created. Refresh the rooms list
var room_id = response.data.room_id;
console.log("Created room with id: "+ room_id);
matrixService.invite(room_id, $scope.newChat.user).then(
function() {
$scope.feedback = "Invite sent successfully";
$scope.$parent.goToPage("/room/" + room_id);
},
function(reason) {
$scope.feedback = "Failure: " + JSON.stringify(reason);
});
},
function(error) {
$scope.feedback = "Failure: " + JSON.stringify(error.data);
});
};
$scope.onInit = function() {
// Load profile data

View file

@ -17,7 +17,7 @@
<div>{{ config.user_id }}</div>
</div>
</div>
<h3>Recent conversations</h3>
<div ng-include="'recents/recents.html'"></div>
<br/>
@ -52,17 +52,24 @@
<div>
<form>
<input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo_channel)"/>
<input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo)"/>
<input type="checkbox" ng-model="newRoom.private">private
<button ng-disabled="!newRoom.room_alias" ng-click="createNewRoom(newRoom.room_alias, newRoom.private)">Create room</button>
</form>
</div>
<div>
<form>
<input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo_channel:example.org)"/>
<input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo:example.org)"/>
<button ng-disabled="!joinAlias.room_alias" ng-click="joinAlias(joinAlias.room_alias)">Join room</button>
</form>
</div>
<div>
<form>
<input size="40" ng-model="newChat.user" ng-enter="messageUser()" placeholder="e.g. @user:domain.com"/>
<button ng-disabled="!newChat.user" ng-click="messageUser()">Message user</button>
</form>
</div>
<br/>
{{ feedback }}

BIN
webclient/img/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

View file

@ -69,7 +69,7 @@
<span ng-show="currentCall.state == 'ringing' && currentCall && currentCall.type == 'voice'">Incoming Voice Call</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.hangupReason == 'ice_failed'">Media Connection Failed</span>
<span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed{{ haveTurn ? "" : " (VoIP relaying unsupported by Home Server)" }}</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
<span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'invite_timeout' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">User Not Responding</span>

View file

@ -2,7 +2,7 @@
<table class="recentsTable">
<tbody ng-repeat="(index, room) in events.rooms | orderRecents"
ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )"
class ="recentsRoom"
class="recentsRoom"
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
<tr>
<td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
@ -19,6 +19,8 @@
{{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
{{ (lastMsg.ts) | date:'MMM d HH:mm' }}
<img ng-click="leave(room.room_id); $event.stopPropagation();" src="img/close.png" width="10" height="10" style="margin-bottom: -1px; margin-left: 2px;" alt="close"/>
</td>
</tr>
@ -31,28 +33,35 @@
<div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
<div ng-switch-when="m.room.member">
<span ng-if="'join' === lastMsg.content.membership">
{{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
</span>
<span ng-if="'leave' === lastMsg.content.membership">
<span ng-if="lastMsg.user_id === lastMsg.state_key">
{{lastMsg.state_key | mUserDisplayName: room.room_id }} left
<span ng-switch="lastMsg.changedKey">
<span ng-switch-when="membership">
<span ng-if="'join' === lastMsg.content.membership">
{{ lastMsg.state_key | mUserDisplayName: room.room_id }} joined
</span>
<span ng-if="'leave' === lastMsg.content.membership">
<span ng-if="lastMsg.user_id === lastMsg.state_key">
{{lastMsg.state_key | mUserDisplayName: room.room_id }} left
</span>
<span ng-if="lastMsg.user_id !== lastMsg.state_key">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
</span>
<span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>
<span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
<span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>
</span>
<span ng-if="lastMsg.user_id !== lastMsg.state_key">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
</span>
<span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>
<span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
<span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
<span ng-switch-when="displayname">
{{ lastMsg.user_id }} changed their display name from {{ lastMsg.prev_content.displayname }} to {{ lastMsg.content.displayname }}
</span>
</span>
</div>

View file

@ -400,6 +400,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Find the max power level
var maxPowerLevel = 0;
for (var i in $scope.members) {
if (!$scope.members.hasOwnProperty(i)) continue;
var member = $scope.members[i];
if (member.powerLevel) {
maxPowerLevel = Math.max(maxPowerLevel, member.powerLevel);
@ -409,6 +411,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Normalized them on a 0..100% scale to be use in css width
if (maxPowerLevel) {
for (var i in $scope.members) {
if (!$scope.members.hasOwnProperty(i)) continue;
var member = $scope.members[i];
member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel;
}
@ -479,6 +483,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
else {
promise = matrixService.joinAlias(room_alias).then(
function(response) {
// TODO: factor out the common housekeeping whenever we try to join a room or alias
matrixService.roomState(response.room_id).then(
function(response) {
eventHandlerService.handleEvents(response.data, false, true);
},
function(error) {
$scope.feedback = "Failed to get room state for: " + response.room_id;
}
);
$location.url("room/" + room_alias);
},
function(error) {
@ -702,19 +715,24 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// The room members is available in the data fetched by initialSync
if ($rootScope.events.rooms[$scope.room_id]) {
// There is no need to do a 1st pagination (initialSync provided enough to fill a page)
if ($rootScope.events.rooms[$scope.room_id].messages.length) {
$scope.state.first_pagination = false;
var messages = $rootScope.events.rooms[$scope.room_id].messages;
if (0 === messages.length
|| (1 === messages.length && "m.room.member" === messages[0].type && "invite" === messages[0].content.membership && $scope.state.user_id === messages[0].state_key)) {
// If we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway
$scope.state.first_pagination = true;
}
else {
// except if we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway
$scope.state.first_pagination = true;
// There is no need to do a 1st pagination (initialSync provided enough to fill a page)
$scope.state.first_pagination = false;
}
var members = $rootScope.events.rooms[$scope.room_id].members;
// Update the member list
for (var i in members) {
if (!members.hasOwnProperty(i)) continue;
var member = members[i];
updateMemberList(member);
}
@ -732,6 +750,16 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$scope.state.waiting_for_joined_event = true;
matrixService.join($scope.room_id).then(
function() {
// TODO: factor out the common housekeeping whenever we try to join a room or alias
matrixService.roomState($scope.room_id).then(
function(response) {
eventHandlerService.handleEvents(response.data, false, true);
},
function(error) {
console.error("Failed to get room state for: " + $scope.room_id);
}
);
// onInit3 will be called once the joined m.room.member event is received from the events stream
// This avoids to get the joined information twice in parallel:
// - one from the events stream
@ -740,6 +768,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
},
function(reason) {
console.log("Can't join room: " + JSON.stringify(reason));
// FIXME: what if it wasn't a perms problem?
$scope.state.permission_denied = "You do not have permission to join this room";
});
}
@ -809,7 +838,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
matrixService.leave($scope.room_id).then(
function(response) {
console.log("Left room ");
console.log("Left room " + $scope.room_id);
$location.url("home");
},
function(error) {

View file

@ -21,39 +21,62 @@ angular.module('RoomController')
return function (scope, element, attrs) {
element.bind("keydown keypress", function (event) {
// console.log("event: " + event.which);
if (event.which === 9) {
var TAB = 9;
var SHIFT = 16;
var keypressCode = event.which;
if (keypressCode === TAB) {
if (!scope.tabCompleting) { // cache our starting text
// console.log("caching " + element[0].value);
scope.tabCompleteOriginal = element[0].value;
scope.tabCompleting = true;
scope.tabCompleteIndex = 0;
}
// loop in the right direction
if (event.shiftKey) {
scope.tabCompleteIndex--;
if (scope.tabCompleteIndex < 0) {
scope.tabCompleteIndex = 0;
// wrap to the last search match, and fix up to a real
// index value after we've matched
scope.tabCompleteIndex = Number.MAX_VALUE;
}
}
else {
scope.tabCompleteIndex++;
}
var searchIndex = 0;
var targetIndex = scope.tabCompleteIndex;
var text = scope.tabCompleteOriginal;
// console.log("targetIndex: " + targetIndex + ", text=" + text);
// console.log("targetIndex: " + targetIndex + ",
// text=" + text);
// FIXME: use the correct regexp to recognise userIDs
// FIXME: use the correct regexp to recognise userIDs --M
//
// XXX: I don't really know what the point of this is. You
// WANT to match freeform text given you want to match display
// names AND user IDs. Surely you just want to get the last
// word out of the input text and that's that?
// Am I missing something here? -- Kegan
//
// You're not missing anything - my point was that we should
// explicitly define the syntax for user IDs /somewhere/.
// Meanwhile as long as the delimeters are well defined, we
// could just pick "the last word". But to know what the
// correct delimeters are, we probably do need a formal
// syntax for user IDs to refer to... --Matthew
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
if (targetIndex === 0) { // 0 is always the original text
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);
// console.log("search found: " + search+" from "+text);
var expansion;
// FIXME: could do better than linear search here
@ -68,6 +91,7 @@ angular.module('RoomController')
if (searchIndex < targetIndex) { // then search raw mxids
angular.forEach(scope.members, function(item, name) {
if (searchIndex < targetIndex) {
// === 1 because mxids are @username
if (name.toLowerCase().indexOf(search[1].toLowerCase()) === 1) {
expansion = name;
searchIndex++;
@ -76,18 +100,22 @@ angular.module('RoomController')
});
}
if (searchIndex === targetIndex) {
// xchat-style tab complete
if (searchIndex === targetIndex ||
targetIndex === Number.MAX_VALUE) {
// xchat-style tab complete, add a colon if tab
// completing at the start of the text
if (search[0].length === text.length)
expansion += " : ";
expansion += ": ";
else
expansion += " ";
element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
// cancel blink
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');
if (targetIndex === Number.MAX_VALUE) {
// wrap the index around to the last index found
scope.tabCompleteIndex = searchIndex;
targetIndex = searchIndex;
}
}
else {
// console.log("wrapped!");
@ -97,23 +125,40 @@ 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');
}
// Force angular to wak up and update the input ng-model by
// firing up input event
angular.element(element[0]).triggerHandler('input');
}
else {
scope.tabCompleteIndex = 0;
}
// prevent the default TAB operation (typically focus shifting)
event.preventDefault();
}
else if (event.which !== 16 && scope.tabCompleting) {
else if (keypressCode !== SHIFT && scope.tabCompleting) {
scope.tabCompleting = false;
scope.tabCompleteIndex = 0;
}
});
};
}])
.directive('commandHistory', [ function() {
return function (scope, element, attrs) {
element.bind("keydown", function (event) {
var keycodePressed = event.which;
var UP_ARROW = 38;
var DOWN_ARROW = 40;
if (keycodePressed === UP_ARROW) {
scope.history.goUp(event);
}
else if (keycodePressed === DOWN_ARROW) {
scope.history.goDown(event);
}
});
}
}])
// A directive to anchor the scroller position at the bottom when the browser is resizing.
// When the screen resizes, the bottom of the element remains the same, not the top.

View file

@ -48,7 +48,15 @@
width="80" height="80"/>
<img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/>
<div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div>
<div class="userName">{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}<br/>{{ member.displayname ? "" : member.id.substr(member.id.indexOf(':')) }}</div>
<div class="userName">
<div ng-show="member.displayname">
{{ member.id | mUserDisplayName: room_id }}
</div>
<div ng-hide="member.displayname">
{{ member.id.substr(0, member.id.indexOf(':')) }}<br/>
{{ member.id.substr(member.id.indexOf(':')) }}
</div>
</div>
</td>
<td class="userPresence" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
<span ng-show="member.last_active_ago">{{ member.last_active_ago + (now - member.last_updated) | duration }}<br/>ago</span>
@ -65,7 +73,7 @@
<tr ng-repeat="msg in events.rooms[room_id].messages"
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="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id"> {{ msg.user_id | mUserDisplayName: room_id }}</div>
<div class="timestamp"
ng-class="msg.echo_msg_state">
{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}
@ -163,8 +171,7 @@
<td width="*">
<textarea id="mainInput" rows="1" ng-enter="send()"
ng-disabled="state.permission_denied"
ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
ng-focus="true" autocomplete="off" tab-complete/>
ng-focus="true" autocomplete="off" tab-complete command-history/>
</td>
<td id="buttonsCell">
<button ng-click="send()" ng-disabled="state.permission_denied">Send</button>

View file

@ -38,7 +38,8 @@ angular.module('UserController', ['matrixService'])
$scope.user.avatar_url = response.data.avatar_url;
}
);
// FIXME: factor this out between user-controller and home-controller etc.
$scope.messageUser = function() {
// FIXME: create a new room every time, for now