Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 85ca963

Browse files
authored
Add Module API for reading and writing global account data. (#12391)
1 parent 98ec375 commit 85ca963

File tree

3 files changed

+234
-0
lines changed

3 files changed

+234
-0
lines changed

changelog.d/12391.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a module API for reading and writing global account data.

synapse/module_api/__init__.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
from synapse.util import Clock
120120
from synapse.util.async_helpers import maybe_awaitable
121121
from synapse.util.caches.descriptors import cached
122+
from synapse.util.frozenutils import freeze
122123

123124
if TYPE_CHECKING:
124125
from synapse.app.generic_worker import GenericWorkerSlavedStore
@@ -211,6 +212,7 @@ def __init__(self, hs: "HomeServer", auth_handler: AuthHandler) -> None:
211212
# We expose these as properties below in order to attach a helpful docstring.
212213
self._http_client: SimpleHttpClient = hs.get_simple_http_client()
213214
self._public_room_list_manager = PublicRoomListManager(hs)
215+
self._account_data_manager = AccountDataManager(hs)
214216

215217
self._spam_checker = hs.get_spam_checker()
216218
self._account_validity_handler = hs.get_account_validity_handler()
@@ -431,6 +433,14 @@ def public_room_list_manager(self) -> "PublicRoomListManager":
431433
"""
432434
return self._public_room_list_manager
433435

436+
@property
437+
def account_data_manager(self) -> "AccountDataManager":
438+
"""Allows reading and modifying users' account data.
439+
440+
Added in Synapse v1.57.0.
441+
"""
442+
return self._account_data_manager
443+
434444
@property
435445
def public_baseurl(self) -> str:
436446
"""The configured public base URL for this homeserver.
@@ -1386,3 +1396,69 @@ async def remove_room_from_public_room_list(self, room_id: str) -> None:
13861396
room_id: The ID of the room.
13871397
"""
13881398
await self._store.set_room_is_public(room_id, False)
1399+
1400+
1401+
class AccountDataManager:
1402+
"""
1403+
Allows modules to manage account data.
1404+
"""
1405+
1406+
def __init__(self, hs: "HomeServer") -> None:
1407+
self._hs = hs
1408+
self._store = hs.get_datastores().main
1409+
self._handler = hs.get_account_data_handler()
1410+
1411+
def _validate_user_id(self, user_id: str) -> None:
1412+
"""
1413+
Validates a user ID is valid and local.
1414+
Private method to be used in other account data methods.
1415+
"""
1416+
user = UserID.from_string(user_id)
1417+
if not self._hs.is_mine(user):
1418+
raise ValueError(
1419+
f"{user_id} is not local to this homeserver; can't access account data for remote users."
1420+
)
1421+
1422+
async def get_global(self, user_id: str, data_type: str) -> Optional[JsonDict]:
1423+
"""
1424+
Gets some global account data, of a specified type, for the specified user.
1425+
1426+
The provided user ID must be a valid user ID of a local user.
1427+
1428+
Added in Synapse v1.57.0.
1429+
"""
1430+
self._validate_user_id(user_id)
1431+
1432+
data = await self._store.get_global_account_data_by_type_for_user(
1433+
user_id, data_type
1434+
)
1435+
# We clone and freeze to prevent the module accidentally mutating the
1436+
# dict that lives in the cache, as that could introduce nasty bugs.
1437+
return freeze(data)
1438+
1439+
async def put_global(
1440+
self, user_id: str, data_type: str, new_data: JsonDict
1441+
) -> None:
1442+
"""
1443+
Puts some global account data, of a specified type, for the specified user.
1444+
1445+
The provided user ID must be a valid user ID of a local user.
1446+
1447+
Please note that this will overwrite existing the account data of that type
1448+
for that user!
1449+
1450+
Added in Synapse v1.57.0.
1451+
"""
1452+
self._validate_user_id(user_id)
1453+
1454+
if not isinstance(data_type, str):
1455+
raise TypeError(f"data_type must be a str; got {type(data_type).__name__}")
1456+
1457+
if not isinstance(new_data, dict):
1458+
raise TypeError(f"new_data must be a dict; got {type(new_data).__name__}")
1459+
1460+
# Ensure the user exists, so we don't just write to users that aren't there.
1461+
if await self._store.get_userinfo_by_id(user_id) is None:
1462+
raise ValueError(f"User {user_id} does not exist on this server.")
1463+
1464+
await self._handler.add_account_data_for_user(user_id, data_type, new_data)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright 2022 The Matrix.org Foundation C.I.C.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from synapse.api.errors import SynapseError
15+
from synapse.rest import admin
16+
17+
from tests.unittest import HomeserverTestCase
18+
19+
20+
class ModuleApiTestCase(HomeserverTestCase):
21+
servlets = [
22+
admin.register_servlets,
23+
]
24+
25+
def prepare(self, reactor, clock, homeserver) -> None:
26+
self._store = homeserver.get_datastores().main
27+
self._module_api = homeserver.get_module_api()
28+
self._account_data_mgr = self._module_api.account_data_manager
29+
30+
self.user_id = self.register_user("kristina", "secret")
31+
32+
def test_get_global(self) -> None:
33+
"""
34+
Tests that getting global account data through the module API works as
35+
expected, including getting `None` for unset account data.
36+
"""
37+
self.get_success(
38+
self._store.add_account_data_for_user(
39+
self.user_id, "test.data", {"wombat": True}
40+
)
41+
)
42+
43+
# Getting existent account data works as expected.
44+
self.assertEqual(
45+
self.get_success(
46+
self._account_data_mgr.get_global(self.user_id, "test.data")
47+
),
48+
{"wombat": True},
49+
)
50+
51+
# Getting non-existent account data returns None.
52+
self.assertIsNone(
53+
self.get_success(
54+
self._account_data_mgr.get_global(self.user_id, "no.data.at.all")
55+
)
56+
)
57+
58+
def test_get_global_validation(self) -> None:
59+
"""
60+
Tests that invalid or remote user IDs are treated as errors and raised as exceptions,
61+
whilst getting global account data for a user.
62+
63+
This is a design choice to try and communicate potential bugs to modules
64+
earlier on.
65+
"""
66+
with self.assertRaises(SynapseError):
67+
self.get_success_or_raise(
68+
self._account_data_mgr.get_global("this isn't a user id", "test.data")
69+
)
70+
71+
with self.assertRaises(ValueError):
72+
self.get_success_or_raise(
73+
self._account_data_mgr.get_global("@valid.but:remote", "test.data")
74+
)
75+
76+
def test_get_global_no_mutability(self) -> None:
77+
"""
78+
Tests that modules can't introduce bugs into Synapse by mutating the result
79+
of `get_global`.
80+
"""
81+
# First add some account data to set up the test.
82+
self.get_success(
83+
self._store.add_account_data_for_user(
84+
self.user_id, "test.data", {"wombat": True}
85+
)
86+
)
87+
88+
# Now request that data and then mutate it (out of negligence or otherwise).
89+
the_data = self.get_success(
90+
self._account_data_mgr.get_global(self.user_id, "test.data")
91+
)
92+
with self.assertRaises(TypeError):
93+
# This throws an exception because it's a frozen dict.
94+
the_data["wombat"] = False
95+
96+
def test_put_global(self) -> None:
97+
"""
98+
Tests that written account data using `put_global` can be read out again later.
99+
"""
100+
101+
self.get_success(
102+
self._module_api.account_data_manager.put_global(
103+
self.user_id, "test.data", {"wombat": True}
104+
)
105+
)
106+
107+
# Request that account data from the normal store; check it's as we expect.
108+
self.assertEqual(
109+
self.get_success(
110+
self._store.get_global_account_data_by_type_for_user(
111+
self.user_id, "test.data"
112+
)
113+
),
114+
{"wombat": True},
115+
)
116+
117+
def test_put_global_validation(self) -> None:
118+
"""
119+
Tests that a module can't write account data to user IDs that don't have
120+
actual users registered to them.
121+
Modules also must supply the correct types.
122+
"""
123+
124+
with self.assertRaises(SynapseError):
125+
self.get_success_or_raise(
126+
self._account_data_mgr.put_global(
127+
"this isn't a user id", "test.data", {}
128+
)
129+
)
130+
131+
with self.assertRaises(ValueError):
132+
self.get_success_or_raise(
133+
self._account_data_mgr.put_global("@valid.but:remote", "test.data", {})
134+
)
135+
136+
with self.assertRaises(ValueError):
137+
self.get_success_or_raise(
138+
self._module_api.account_data_manager.put_global(
139+
"@notregistered:test", "test.data", {}
140+
)
141+
)
142+
143+
with self.assertRaises(TypeError):
144+
# The account data type must be a string.
145+
self.get_success_or_raise(
146+
self._module_api.account_data_manager.put_global(
147+
self.user_id, 42, {} # type: ignore
148+
)
149+
)
150+
151+
with self.assertRaises(TypeError):
152+
# The account data dict must be a dict.
153+
self.get_success_or_raise(
154+
self._module_api.account_data_manager.put_global(
155+
self.user_id, "test.data", 42 # type: ignore
156+
)
157+
)

0 commit comments

Comments
 (0)