Skip to content

Commit 9ee17fa

Browse files
authored
feat: deleting media when redacted events get removed (#225)
Handles the deletion part of the media
2 parents 538f463 + ae1c161 commit 9ee17fa

6 files changed

Lines changed: 530 additions & 2 deletions

File tree

synapse/media/media_repository.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@
7171
from synapse.media.thumbnailer import Thumbnailer, ThumbnailError
7272
from synapse.media.url_previewer import UrlPreviewer
7373
from synapse.metrics.background_process_metrics import run_as_background_process
74-
from synapse.replication.http.media import ReplicationCopyMediaServlet
74+
from synapse.replication.http.media import (
75+
ReplicationCopyMediaServlet,
76+
ReplicationDeleteMediaServlet,
77+
)
7578
from synapse.state import CREATE_KEY, POWER_KEY
7679
from synapse.storage.databases.main.media_repository import (
7780
LocalMedia,
@@ -186,6 +189,13 @@ async def copy_media(
186189
"Sorry Mario, your MediaRepository related function is in another castle"
187190
)
188191

192+
async def _remove_local_media_from_disk(
193+
self, media_ids: List[str]
194+
) -> Tuple[List[str], int]:
195+
raise NotImplementedError(
196+
"Sorry Mario, your MediaRepository related function is in another castle"
197+
)
198+
189199
async def reached_pending_media_limit(self, auth_user: UserID) -> Tuple[bool, int]:
190200
raise NotImplementedError(
191201
"Sorry Mario, your MediaRepository related function is in another castle"
@@ -581,6 +591,7 @@ def __init__(self, hs: "HomeServer"):
581591
super().__init__(hs)
582592
# initialize replication endpoint here
583593
self.copy_media_client = ReplicationCopyMediaServlet.make_client(hs)
594+
self.delete_media_client = ReplicationDeleteMediaServlet.make_client(hs)
584595

585596
async def copy_media(
586597
self, existing_mxc: MXCUri, auth_user: UserID, max_timeout_ms: int
@@ -597,6 +608,18 @@ async def copy_media(
597608
)
598609
return MXCUri.from_str(result["content_uri"])
599610

611+
async def _remove_local_media_from_disk(
612+
self, media_ids: List[str]
613+
) -> Tuple[List[str], int]:
614+
"""
615+
Call out to the worker responsible for handling media to delete this media object
616+
"""
617+
result = await self.delete_media_client(
618+
instance_name=self.hs.config.worker.workers_doing_media_duty[0],
619+
media_ids=media_ids,
620+
)
621+
return result["deleted"], result["count"]
622+
600623

601624
class MediaRepository(AbstractMediaRepository):
602625
def __init__(self, hs: "HomeServer"):

synapse/replication/http/media.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,46 @@ async def _handle_request( # type: ignore[override]
9696
return 200, {"content_uri": str(mxc_uri)}
9797

9898

99+
class ReplicationDeleteMediaServlet(ReplicationEndpoint):
100+
"""Request the MediaRepository to delete a piece of media from filesystem.
101+
102+
Request format:
103+
104+
DELETE /_synapse/replication/delete_media
105+
106+
{
107+
"media_ids": [...], # List of media IDs to delete
108+
}
109+
110+
"""
111+
112+
NAME = "delete_media"
113+
PATH_ARGS = ()
114+
115+
def __init__(self, hs: "HomeServer"):
116+
super().__init__(hs)
117+
self.media_repo = hs.get_media_repository()
118+
119+
@staticmethod
120+
async def _serialize_payload( # type: ignore[override]
121+
media_ids: list[str],
122+
) -> JsonDict:
123+
"""
124+
Args:
125+
media_ids: The list of media IDs to delete.
126+
"""
127+
return {"media_ids": media_ids}
128+
129+
async def _handle_request( # type: ignore[override]
130+
self,
131+
request: Request,
132+
content: JsonDict,
133+
) -> Tuple[int, JsonDict]:
134+
media_ids = content["media_ids"]
135+
deleted, count = await self.media_repo._remove_local_media_from_disk(media_ids)
136+
return 200, {"deleted": deleted, "count": count}
137+
138+
99139
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
100140
ReplicationCopyMediaServlet(hs).register(http_server)
141+
ReplicationDeleteMediaServlet(hs).register(http_server)

synapse/storage/databases/main/censor_events.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(
5959
@wrap_as_background_process("_censor_redactions")
6060
async def _censor_redactions(self) -> None:
6161
"""Censors all redactions older than the configured period that haven't
62-
been censored yet.
62+
been censored yet and deletes any media attached to the redacted events.
6363
6464
By censor we mean update the event_json table with the redacted event.
6565
"""
@@ -104,12 +104,16 @@ async def _censor_redactions(self) -> None:
104104
)
105105

106106
updates = []
107+
media = []
107108

108109
for redaction_id, event_id in rows:
109110
redaction_event = await self.get_event(redaction_id, allow_none=True)
110111
original_event = await self.get_event(
111112
event_id, allow_rejected=True, allow_none=True
112113
)
114+
attached_media_ids = (
115+
await self.hs.get_datastores().main.get_attached_media_ids(event_id)
116+
)
113117

114118
# The SQL above ensures that we have both the redaction and
115119
# original event, so if the `get_event` calls return None it
@@ -131,6 +135,7 @@ async def _censor_redactions(self) -> None:
131135
pruned_json = None
132136

133137
updates.append((redaction_id, event_id, pruned_json))
138+
media.extend(attached_media_ids)
134139

135140
def _update_censor_txn(txn: LoggingTransaction) -> None:
136141
for redaction_id, event_id, pruned_json in updates:
@@ -145,6 +150,7 @@ def _update_censor_txn(txn: LoggingTransaction) -> None:
145150
)
146151

147152
await self.db_pool.runInteraction("_update_censor_txn", _update_censor_txn)
153+
await self.hs.get_media_repository()._remove_local_media_from_disk(media)
148154

149155
def _censor_event_txn(
150156
self, txn: LoggingTransaction, event_id: str, pruned_json: str

synapse/storage/databases/main/media_repository.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
# [This file includes modifications made by New Vector Limited]
2020
#
2121
#
22+
import json
2223
import logging
2324
from enum import Enum
2425
from http import HTTPStatus
@@ -45,6 +46,7 @@
4546
LoggingDatabaseConnection,
4647
LoggingTransaction,
4748
)
49+
from synapse.storage.engines import PostgresEngine
4850
from synapse.types import JsonDict, UserID
4951
from synapse.util import json_encoder
5052

@@ -1022,6 +1024,11 @@ def delete_remote_media_txn(txn: LoggingTransaction) -> None:
10221024
"remote_media_cache_thumbnails",
10231025
keyvalues={"media_origin": media_origin, "media_id": media_id},
10241026
)
1027+
self.db_pool.simple_delete_txn(
1028+
txn,
1029+
"media_attachments",
1030+
keyvalues={"server_name": media_origin, "media_id": media_id},
1031+
)
10251032

10261033
await self.db_pool.runInteraction(
10271034
"delete_remote_media", delete_remote_media_txn
@@ -1413,3 +1420,33 @@ def _get_reference_count_txn(txn: LoggingTransaction) -> int:
14131420
return await self.db_pool.runInteraction(
14141421
"get_media_reference_count_for_sha256", _get_reference_count_txn
14151422
)
1423+
1424+
async def get_attached_media_ids(self, event_id: str) -> list[str]:
1425+
"""
1426+
Get a list of media_ids that are attached to a specific event_id.
1427+
"""
1428+
1429+
def get_attached_media_ids_txn(txn: LoggingTransaction) -> list[str]:
1430+
if isinstance(self.db_pool.engine, PostgresEngine):
1431+
# Use GIN index for Postgres
1432+
sql = """
1433+
SELECT media_id
1434+
FROM media_attachments
1435+
WHERE restrictions_json @> %s AND server_name = %s
1436+
"""
1437+
json_param = json.dumps({"restrictions": {"event_id": event_id}})
1438+
txn.execute(sql, (json_param, self.hs.hostname))
1439+
else:
1440+
sql = """
1441+
SELECT media_id
1442+
FROM media_attachments
1443+
WHERE restrictions_json->'restrictions'->>'event_id' = ? AND server_name = ?
1444+
"""
1445+
txn.execute(sql, (event_id, self.hs.hostname))
1446+
1447+
return [row[0] for row in txn.fetchall()]
1448+
1449+
return await self.db_pool.runInteraction(
1450+
"get_attached_media_ids",
1451+
get_attached_media_ids_txn,
1452+
)

tests/replication/test_multi_media_repo.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from twisted.web.server import Request
3434

3535
from synapse.api.constants import EventTypes, HistoryVisibility
36+
from synapse.config.workers import MAIN_PROCESS_INSTANCE_NAME
3637
from synapse.media._base import FileInfo
3738
from synapse.media.media_repository import MediaRepository
3839
from synapse.rest import admin
@@ -967,6 +968,173 @@ def test_copy_remote_restricted_resource_fails_when_requester_does_not_have_acce
967968
self.assertEqual(channel.code, 403)
968969

969970

971+
class DeleteRestrictedMediaOnEventRedactionReplicationTestCase(
972+
BaseMultiWorkerStreamTestCase
973+
):
974+
"""
975+
Tests that media attached to redacted events are deleted after the retention period
976+
when `msc3911.enabled` is configured to be True.
977+
"""
978+
979+
servlets = [
980+
login.register_servlets,
981+
admin.register_servlets,
982+
room.register_servlets,
983+
]
984+
use_isolated_media_paths = True
985+
986+
def default_config(self) -> Dict[str, Any]:
987+
config = super().default_config()
988+
config.update(
989+
{
990+
"experimental_features": {"msc3911": {"enabled": True}},
991+
"media_repo_instances": ["media_worker_1"],
992+
"run_background_tasks_on": MAIN_PROCESS_INSTANCE_NAME,
993+
"redaction_retention_period": "7d",
994+
}
995+
)
996+
config["instance_map"] = {
997+
"main": {"host": "testserv", "port": 8765},
998+
"media_worker_1": {"host": "testserv", "port": 1001},
999+
}
1000+
1001+
return config
1002+
1003+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
1004+
self.user = self.register_user("user", "testpass")
1005+
self.user_tok = self.login("user", "testpass")
1006+
self.admin_user = self.register_user("admin", "pass", admin=True)
1007+
self.admin_user_tok = self.login("admin", "pass")
1008+
1009+
def test_delete_media_on_event_redaction(self) -> None:
1010+
"""
1011+
Tests that media is deleted when its attached event is redacted.
1012+
"""
1013+
# Make sure that censor_redaction loops runs on main hs
1014+
assert self.hs.config.worker.run_background_tasks, (
1015+
"Main HS should run background tasks"
1016+
)
1017+
assert self.hs.config.server.redaction_retention_period is not None, (
1018+
"Redaction retention should be configured"
1019+
)
1020+
1021+
# Create media worker and it does not run the background tasks
1022+
media_worker = self.make_worker_hs(
1023+
"synapse.app.generic_worker",
1024+
{
1025+
"worker_name": "media_worker_1",
1026+
"run_background_tasks_on": MAIN_PROCESS_INSTANCE_NAME,
1027+
},
1028+
)
1029+
media_worker.get_media_repository_resource().register_servlets(
1030+
self._hs_to_site[media_worker].resource, media_worker
1031+
)
1032+
media.register_servlets(media_worker, self._hs_to_site[media_worker].resource)
1033+
media_repo = media_worker.get_media_repository()
1034+
1035+
assert not media_worker.config.worker.run_background_tasks, (
1036+
"Worker should not run background tasks"
1037+
)
1038+
1039+
# Create a private room
1040+
room_id = self.helper.create_room_as(
1041+
self.user,
1042+
is_public=False,
1043+
tok=self.user_tok,
1044+
)
1045+
1046+
# The media is created with user_tok
1047+
content = io.BytesIO(SMALL_PNG)
1048+
content_uri = self.get_success(
1049+
media_repo.create_or_update_content(
1050+
"image/png",
1051+
"test_png_upload",
1052+
content,
1053+
67,
1054+
UserID.from_string(self.user),
1055+
restricted=True,
1056+
)
1057+
)
1058+
media_id = content_uri.media_id
1059+
1060+
# User sends a message with media
1061+
channel = self.make_request(
1062+
"PUT",
1063+
f"/rooms/{room_id}/send/m.room.message/{str(time.time())}?org.matrix.msc3911.attach_media={str(content_uri)}",
1064+
content={"msgtype": "m.text", "body": "Hi, this is a message"},
1065+
access_token=self.user_tok,
1066+
)
1067+
assert channel.code == HTTPStatus.OK, channel.json_body
1068+
assert "event_id" in channel.json_body
1069+
event_id = channel.json_body["event_id"]
1070+
1071+
# Redact the event
1072+
channel = self.make_request(
1073+
"POST",
1074+
f"/_matrix/client/r0/rooms/{room_id}/redact/{event_id}",
1075+
content={},
1076+
access_token=self.user_tok,
1077+
)
1078+
assert channel.code == HTTPStatus.OK, channel.json_body
1079+
1080+
# Verify the event is redacted before censoring
1081+
event_dict = self.helper.get_event(room_id, event_id, self.user_tok)
1082+
assert "redacted_because" in event_dict, "Event should be redacted"
1083+
1084+
# Media should still be accessible before retention period is over
1085+
channel = make_request(
1086+
self.reactor,
1087+
self._hs_to_site[media_worker],
1088+
"GET",
1089+
f"/_matrix/client/v1/media/download/{self.hs.hostname}/{media_id}?allow_redacted_media=true",
1090+
shorthand=False,
1091+
access_token=self.user_tok,
1092+
)
1093+
assert channel.code == 200, channel.result
1094+
assert channel.result["body"] == SMALL_PNG
1095+
1096+
# Fast forward 7 days and 6 minutes to make sure the censor_redactions looping
1097+
# call detects the events are eligible for censorship.
1098+
self.reactor.advance(7 * 24 * 60 * 60 + 6 * 60)
1099+
1100+
# Since we fast forward the reactor time, give some moment for the background
1101+
# censor redactions task to get caught up.
1102+
self.pump(0.01)
1103+
1104+
# Check that the media has been deleted from the database
1105+
deleted_media = self.get_success(
1106+
self.hs.get_datastores().main.get_local_media(media_id)
1107+
)
1108+
assert deleted_media is None, deleted_media
1109+
1110+
# Check if the file is deleted from the storage as well.
1111+
assert isinstance(media_repo, MediaRepository)
1112+
assert not os.path.exists(media_repo.filepaths.local_media_filepath(media_id))
1113+
1114+
# Verify the redaction was censored in the database
1115+
redaction_censored = self.get_success(
1116+
self.hs.get_datastores().main.db_pool.simple_select_one_onecol(
1117+
table="redactions",
1118+
keyvalues={"redacts": event_id},
1119+
retcol="have_censored",
1120+
)
1121+
)
1122+
assert redaction_censored, (
1123+
"Redaction should have been censored by _censor_redactions loop"
1124+
)
1125+
1126+
channel = make_request(
1127+
self.reactor,
1128+
self._hs_to_site[media_worker],
1129+
"GET",
1130+
f"/_matrix/client/v1/media/download/{self.hs.hostname}/{media_id}?allow_redacted_media=true",
1131+
shorthand=False,
1132+
access_token=self.user_tok,
1133+
)
1134+
assert channel.code == 404, channel.result
1135+
assert channel.json_body["errcode"] == "M_NOT_FOUND"
1136+
1137+
9701138
def _log_request(request: Request) -> None:
9711139
"""Implements Factory.log, which is expected by Request.finish"""
9721140
logger.info("Completed request %s", request)

0 commit comments

Comments
 (0)