diff options
Diffstat (limited to 'synapse')
35 files changed, 781 insertions, 100 deletions
diff --git a/synapse/__init__.py b/synapse/__init__.py index bba551b2c4..a340a5db66 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.3.3" +__version__ = "0.3.4" diff --git a/synapse/api/auth.py b/synapse/api/auth.py index 8f32191b57..e1b1823cd7 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -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): @@ -322,6 +357,29 @@ class Auth(object): ) @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(): if k == "default": @@ -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, ) diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py index 0cee196851..f66fea2904 100644 --- a/synapse/api/events/__init__.py +++ b/synapse/api/events/__init__.py @@ -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 = [ diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py index d3d96d73eb..0d94850cec 100644 --- a/synapse/api/events/factory.py +++ b/synapse/api/events/factory.py @@ -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): diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py index 3a4dbc58ce..cd936074fc 100644 --- a/synapse/api/events/room.py +++ b/synapse/api/events/room.py @@ -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 {} diff --git a/synapse/api/events/utils.py b/synapse/api/events/utils.py new file mode 100644 index 0000000000..c3a32be8c1 --- /dev/null +++ b/synapse/api/events/utils.py @@ -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 diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py index 8ebcfc3623..4ed9070b9e 100644 --- a/synapse/config/captcha.py +++ b/synapse/config/captcha.py @@ -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." - ) \ No newline at end of file + ) + group.add_argument( + "--captcha_bypass_secret", type=str, + help="A secret key used to bypass the captcha test entirely." + ) diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 4b810a2302..5a11fd6c76 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -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 diff --git a/synapse/config/repository.py b/synapse/config/repository.py index 407c8d6c24..b71d30227c 100644 --- a/synapse/config/repository.py +++ b/synapse/config/repository.py @@ -14,7 +14,6 @@ # limitations under the License. from ._base import Config -import os class ContentRepositoryConfig(Config): def __init__(self, args): diff --git a/synapse/config/voip.py b/synapse/config/voip.py new file mode 100644 index 0000000000..3a51664f46 --- /dev/null +++ b/synapse/config/voip.py @@ -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" + ) diff --git a/synapse/handlers/__init__.py b/synapse/handlers/__init__.py index 5308e2c8e1..d5df3c630b 100644 --- a/synapse/handlers/__init__.py +++ b/synapse/handlers/__init__.py @@ -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) diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py new file mode 100644 index 0000000000..687b343a1d --- /dev/null +++ b/synapse/handlers/admin.py @@ -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) diff --git a/synapse/handlers/directory.py b/synapse/handlers/directory.py index 4ab00a761a..84c3a1d56f 100644 --- a/synapse/handlers/directory.py +++ b/synapse/handlers/directory.py @@ -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) - - 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}, - ) + @defer.inlineCallbacks + def delete_association(self, user_id, room_alias): + # TODO Check if server admin - snapshot = yield self.store.snapshot_room( - room_id=room_id, - user_id=user_id, - ) + if not room_alias.is_mine: + raise SynapseError(400, "Room alias must be local") - 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]) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 001c6c110c..f52591d2a3 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -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: diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 14fae689f2..317ef2c80c 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -233,6 +233,22 @@ class MessageHandler(BaseHandler): 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): """Retrieve a snapshot of all rooms the user is invited or has joined. diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 5bc1280432..c0f9a7c807 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -146,17 +146,6 @@ class RoomCreationHandler(BaseHandler): ) 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: topic = config["topic"] @@ -255,6 +244,7 @@ class RoomCreationHandler(BaseHandler): etype=RoomOpsPowerLevelsEvent.TYPE, ban_level=50, kick_level=50, + redact_level=50, ) return [ diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index ed785cfbd5..e391e5678d 100644 --- a/synapse/rest/__init__.py +++ b/synapse/rest/__init__.py @@ -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) diff --git a/synapse/rest/admin.py b/synapse/rest/admin.py new file mode 100644 index 0000000000..ed9b484623 --- /dev/null +++ b/synapse/rest/admin.py @@ -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) diff --git a/synapse/rest/directory.py b/synapse/rest/directory.py index 31849246a1..6c260e7102 100644 --- a/synapse/rest/directory.py +++ b/synapse/rest/directory.py @@ -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: diff --git a/synapse/rest/register.py b/synapse/rest/register.py index af528a44f6..804117ee09 100644 --- a/synapse/rest/register.py +++ b/synapse/rest/register.py @@ -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) diff --git a/synapse/rest/room.py b/synapse/rest/room.py index ecb1e346d9..a01dab1b8e 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -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) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py new file mode 100644 index 0000000000..2e4627606f --- /dev/null +++ b/synapse/rest/voip.py @@ -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) diff --git a/synapse/server.py b/synapse/server.py index ed5b810d3e..a4d2d4aba5 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -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) diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py index ef98b6a444..6dadeb8cce 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -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: diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py index 76ed7d06fb..889de2bedc 100644 --- a/synapse/storage/_base.py +++ b/synapse/storage/_base.py @@ -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. """ diff --git a/synapse/storage/directory.py b/synapse/storage/directory.py index 540eb4c2c4..52373a28a6 100644 --- a/synapse/storage/directory.py +++ b/synapse/storage/directory.py @@ -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", diff --git a/synapse/storage/registration.py b/synapse/storage/registration.py index db20b1daa0..719806f82b 100644 --- a/synapse/storage/registration.py +++ b/synapse/storage/registration.py @@ -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.") diff --git a/synapse/storage/room.py b/synapse/storage/room.py index 5adf8cdf1b..8cd46334cf 100644 --- a/synapse/storage/room.py +++ b/synapse/storage/room.py @@ -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", diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py index 04b4067d03..ceeef5880e 100644 --- a/synapse/storage/roommember.py +++ b/synapse/storage/roommember.py @@ -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) diff --git a/synapse/storage/schema/delta/v4.sql b/synapse/storage/schema/delta/v4.sql new file mode 100644 index 0000000000..25d2ead450 --- /dev/null +++ b/synapse/storage/schema/delta/v4.sql @@ -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; diff --git a/synapse/storage/schema/delta/v5.sql b/synapse/storage/schema/delta/v5.sql new file mode 100644 index 0000000000..af9df11aa9 --- /dev/null +++ b/synapse/storage/schema/delta/v5.sql @@ -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; diff --git a/synapse/storage/schema/im.sql b/synapse/storage/schema/im.sql index 6ffea51310..3aa83f5c8c 100644 --- a/synapse/storage/schema/im.sql +++ b/synapse/storage/schema/im.sql @@ -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); diff --git a/synapse/storage/schema/redactions.sql b/synapse/storage/schema/redactions.sql new file mode 100644 index 0000000000..4c2829d05d --- /dev/null +++ b/synapse/storage/schema/redactions.sql @@ -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); diff --git a/synapse/storage/schema/users.sql b/synapse/storage/schema/users.sql index 2519702971..8244f733bd 100644 --- a/synapse/storage/schema/users.sql +++ b/synapse/storage/schema/users.sql @@ -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); + diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py index a76fecf24f..d61f909939 100644 --- a/synapse/storage/stream.py +++ b/synapse/storage/stream.py @@ -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, |