Merge pull request #1854 from famedly/nico/fix-requestUser-update-spam

fix: Request user causing state update loops for apps
This commit is contained in:
Nicolas Werner 2024-06-14 15:47:10 +02:00 committed by GitHub
commit bb054026f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 258 additions and 105 deletions

View File

@ -168,8 +168,9 @@ class FakeMatrixApi extends BaseClient {
} else if (method == 'GET' && } else if (method == 'GET' &&
action.contains('/client/v3/rooms/') && action.contains('/client/v3/rooms/') &&
action.contains('/state/m.room.member/') && action.contains('/state/m.room.member/') &&
!action.endsWith('%40alicyy%3Aexample.com')) { !action.endsWith('%40alicyy%3Aexample.com') &&
res = {'displayname': ''}; !action.contains('%40getme')) {
res = {'displayname': '', 'membership': 'ban'};
} else if (method == 'PUT' && } else if (method == 'PUT' &&
action.contains( action.contains(
'/client/v3/rooms/!1234%3AfakeServer.notExisting/send/')) { '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/')) {
@ -1522,15 +1523,28 @@ class FakeMatrixApi extends BaseClient {
{'visibility': 'public'}, {'visibility': 'public'},
'/client/v3/rooms/1/state/m.room.member/@alice:example.com': (var req) => '/client/v3/rooms/1/state/m.room.member/@alice:example.com': (var req) =>
{'displayname': 'Alice'}, {'displayname': 'Alice'},
'/client/v3/profile/%40getmeprofile%3Aexample.com': (var req) => {
'avatar_url': 'mxc://test',
'displayname': 'You got me (profile)',
},
'/client/v3/profile/%40getme%3Aexample.com': (var req) => { '/client/v3/profile/%40getme%3Aexample.com': (var req) => {
'avatar_url': 'mxc://test', 'avatar_url': 'mxc://test',
'displayname': 'You got me', 'displayname': 'You got me',
}, },
'/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/@getme%3Aexample.com': '/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/%40getme%3Aexample.com':
(var req) => { (var req) => {
'avatar_url': 'mxc://test', 'avatar_url': 'mxc://test',
'displayname': 'You got me', 'displayname': 'You got me',
'membership': 'knock',
}, },
'/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/%40getmeempty%3Aexample.com':
(var req) => {
'membership': 'leave',
},
'/client/v3/profile/%40getmeempty%3Aexample.com': (var req) => {
'avatar_url': 'mxc://test',
'displayname': 'You got me (empty)',
},
'/client/v3/rooms/!localpart%3Aserver.abc/state': (var req) => [ '/client/v3/rooms/!localpart%3Aserver.abc/state': (var req) => [
{ {
'content': {'join_rule': 'public'}, 'content': {'join_rule': 'public'},
@ -1596,11 +1610,6 @@ class FakeMatrixApi extends BaseClient {
'state_key': '' 'state_key': ''
} }
], ],
'/client/v3/rooms/!localpart:server.abc/state/m.room.member/@getme:example.com':
(var req) => {
'avatar_url': 'mxc://test',
'displayname': 'You got me',
},
'/client/v3/rooms/!localpart:server.abc/event/1234': (var req) => { '/client/v3/rooms/!localpart:server.abc/event/1234': (var req) => {
'content': { 'content': {
'body': 'This is an example text message', 'body': 'This is an example text message',

View File

@ -18,7 +18,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:async/async.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart'; import 'package:html_unescape/html_unescape.dart';
@ -1648,100 +1650,169 @@ class Room {
} }
} }
final Set<String> _requestingMatrixIds = {}; // Internal helper to implement requestUser
Future<User?> _requestSingleParticipantViaState(
String mxID, {
required bool ignoreErrors,
}) async {
try {
Logs().v(
'Request missing user $mxID in room ${getLocalizedDisplayname()} from the server...',
);
final resp = await client.getRoomStateWithKey(
id,
EventTypes.RoomMember,
mxID,
);
// valid member events require a valid membership key
final membership = resp.tryGet<String>('membership', TryGet.required);
assert(membership != null);
final foundUser = User(
mxID,
room: this,
displayName: resp.tryGet<String>('displayname', TryGet.silent),
avatarUrl: resp.tryGet<String>('avatar_url', TryGet.silent),
membership: membership,
);
// Store user in database:
await client.database?.transaction(() async {
await client.database?.storeEventUpdate(
EventUpdate(
content: foundUser.toJson(),
roomID: id,
type: EventUpdateType.state,
),
client,
);
});
return foundUser;
} on MatrixException catch (_) {
// Ignore if we have no permission
return null;
} catch (e, s) {
if (!ignoreErrors) {
rethrow;
} else {
Logs().w('Unable to request the user $mxID from the server', e, s);
return null;
}
}
}
// Internal helper to implement requestUser
Future<User?> _requestUser(
String mxID, {
required bool ignoreErrors,
required bool requestState,
required bool requestProfile,
}) async {
// Is user already in cache?
final userFromState = getState(EventTypes.RoomMember, mxID)?.asUser(this);
// If not in cache, try the database
var foundUser = userFromState;
// If the room is not postloaded, check the database
if (partial && foundUser == null) {
foundUser = await client.database?.getUser(mxID, this);
}
// If not in the database, try fetching the member from the server
if (requestState && foundUser == null) {
foundUser = await _requestSingleParticipantViaState(mxID,
ignoreErrors: ignoreErrors);
}
// If the user isn't found or they have left and no displayname set anymore, request their profile from the server
if (requestProfile) {
if (foundUser
case null ||
User(
membership: Membership.ban || Membership.leave,
displayName: null
)) {
try {
final profile = await client.getProfileFromUserId(mxID);
foundUser = User(
mxID,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl?.toString(),
membership: foundUser?.membership.name ?? Membership.leave.name,
room: this,
);
} catch (e, s) {
if (!ignoreErrors) {
rethrow;
} else {
Logs()
.w('Unable to request the profile $mxID from the server', e, s);
}
}
}
}
if (foundUser == null) return null;
// Set user in the local state if the state changed.
// If we set the state unconditionally, we might end up with a client calling this over and over thinking the user changed.
if (userFromState == null ||
userFromState.displayName != foundUser.displayName) {
setState(foundUser);
// ignore: deprecated_member_use_from_same_package
onUpdate.add(id);
}
return foundUser;
}
final Map<
({
String mxID,
bool ignoreErrors,
bool requestState,
bool requestProfile,
}),
AsyncCache<User?>> _inflightUserRequests = {};
/// Requests a missing [User] for this room. Important for clients using /// Requests a missing [User] for this room. Important for clients using
/// lazy loading. If the user can't be found this method tries to fetch /// lazy loading. If the user can't be found this method tries to fetch
/// the displayname and avatar from the profile if [requestProfile] is true. /// the displayname and avatar from the server if [requestState] is true.
/// If that fails, it falls back to requesting the global profile if
/// [requestProfile] is true.
Future<User?> requestUser( Future<User?> requestUser(
String mxID, { String mxID, {
bool ignoreErrors = false, bool ignoreErrors = false,
bool requestState = true,
bool requestProfile = true, bool requestProfile = true,
}) async { }) async {
assert(mxID.isValidMatrixId); assert(mxID.isValidMatrixId);
// Is user already in cache? final parameters = (
var foundUser = getState(EventTypes.RoomMember, mxID)?.asUser(this); mxID: mxID,
ignoreErrors: ignoreErrors,
requestState: requestState,
requestProfile: requestProfile,
);
// If not, is it in the database? final cache = _inflightUserRequests[parameters] ??= AsyncCache.ephemeral();
foundUser ??= await client.database?.getUser(mxID, this);
// If not, can we request it from the server? try {
if (foundUser == null) { final user = await cache.fetch(() => _requestUser(
if (!_requestingMatrixIds.add(mxID)) return null; mxID,
Map<String, dynamic>? resp; ignoreErrors: ignoreErrors,
try { requestState: requestState,
Logs().v( requestProfile: requestProfile,
'Request missing user $mxID in room ${getLocalizedDisplayname()} from the server...'); ));
resp = await client.getRoomStateWithKey( _inflightUserRequests.remove(parameters);
id, return user;
EventTypes.RoomMember, } catch (_) {
mxID, _inflightUserRequests.remove(parameters);
); rethrow;
foundUser = User(
mxID,
room: this,
displayName: resp['displayname'],
avatarUrl: resp['avatar_url'],
membership: resp['membership'],
);
_requestingMatrixIds.remove(mxID);
// Store user in database:
await client.database?.transaction(() async {
await client.database?.storeEventUpdate(
EventUpdate(
content: foundUser!.toJson(),
roomID: id,
type: EventUpdateType.state,
),
client,
);
});
} on MatrixException catch (_) {
// Ignore if we have no permission
} catch (e, s) {
if (!ignoreErrors) {
_requestingMatrixIds.remove(mxID);
rethrow;
} else {
Logs().w('Unable to request the user $mxID from the server', e, s);
}
}
} }
// User not found anywhere? Set a blank one:
foundUser ??= User(mxID, room: this, membership: 'leave');
// Is it a left user without any displayname/avatar info? Try fetch profile:
if (requestProfile &&
{Membership.ban, Membership.leave}.contains(foundUser.membership) &&
foundUser.displayName == null &&
foundUser.avatarUrl == null) {
try {
final profile = await client.getProfileFromUserId(mxID);
foundUser = User(
mxID,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl?.toString(),
membership: Membership.leave.name,
room: this,
);
} catch (e, s) {
if (!ignoreErrors) {
rethrow;
} else {
Logs().w('Unable to request the profile $mxID from the server', e, s);
}
}
}
// Set user in the local state
setState(foundUser!);
// ignore: deprecated_member_use_from_same_package
onUpdate.add(id);
return foundUser;
} }
/// Searches for the event in the local cache and then on the server if not /// Searches for the event in the local cache and then on the server if not
@ -2272,7 +2343,7 @@ class Room {
'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias)}'); 'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias)}');
} }
final List queryParameters = []; final List queryParameters = [];
final users = await requestParticipants(); final users = await requestParticipants([Membership.join]);
final currentPowerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content; final currentPowerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
final temp = List<User>.from(users); final temp = List<User>.from(users);
@ -2301,18 +2372,17 @@ class Room {
} }
} }
final sortedServers = Map.fromEntries(servers.entries.toList() final sortedServers = Map.fromEntries(servers.entries.toList()
..sort((e1, e2) => e1.value.compareTo(e2.value))); ..sort((e1, e2) => e2.value.compareTo(e1.value)))
for (var i = 0; i <= 2; i++) { .keys
if (!queryParameters.contains(sortedServers.keys.last)) { .take(3);
queryParameters.add(sortedServers.keys.last); for (final server in sortedServers) {
if (!queryParameters.contains(server)) {
queryParameters.add(server);
} }
sortedServers.remove(sortedServers.keys.last);
} }
var queryString = '?'; var queryString = '?';
for (var i = 0; for (var i = 0; i < min(queryParameters.length, 3); i++) {
i <= (queryParameters.length > 2 ? 2 : queryParameters.length);
i++) {
if (i != 0) { if (i != 0) {
queryString += '&'; queryString += '&';
} }

View File

@ -682,12 +682,83 @@ void main() {
}); });
test('getUserByMXID', () async { test('getUserByMXID', () async {
User? user; final List<String> called = [];
try { final List<String> called2 = [];
user = await room.requestUser('@getme:example.com'); // ignore: deprecated_member_use_from_same_package
} catch (_) {} final subscription = room.onUpdate.stream.listen((i) {
called.add(i);
});
final subscription2 = room.client.onRoomState.stream.listen((i) {
called2.add(i.roomId);
});
FakeMatrixApi.calledEndpoints.clear();
final user = await room.requestUser('@getme:example.com');
expect(FakeMatrixApi.calledEndpoints.keys, [
'/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/%40getme%3Aexample.com'
]);
expect(user?.stateKey, '@getme:example.com'); expect(user?.stateKey, '@getme:example.com');
expect(user?.calcDisplayname(), 'Getme'); expect(user?.calcDisplayname(), 'You got me');
expect(user?.membership, Membership.knock);
// Yield for the onUpdate
await Future.delayed(Duration(
milliseconds: 1,
));
expect(called.length, 1);
expect(called2.length, 1);
FakeMatrixApi.calledEndpoints.clear();
final user2 = await room.requestUser('@getmeprofile:example.com');
expect(FakeMatrixApi.calledEndpoints.keys, [
'/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/%40getmeprofile%3Aexample.com',
'/client/v3/profile/%40getmeprofile%3Aexample.com'
]);
expect(user2?.stateKey, '@getmeprofile:example.com');
expect(user2?.calcDisplayname(), 'You got me (profile)');
expect(user2?.membership, Membership.leave);
// Yield for the onUpdate
await Future.delayed(Duration(
milliseconds: 1,
));
expect(called.length, 2);
expect(called2.length, 2);
FakeMatrixApi.calledEndpoints.clear();
final userAgain = await room.requestUser('@getme:example.com');
expect(FakeMatrixApi.calledEndpoints.keys, []);
expect(userAgain?.stateKey, '@getme:example.com');
expect(userAgain?.calcDisplayname(), 'You got me');
expect(userAgain?.membership, Membership.knock);
// Yield for the onUpdate
await Future.delayed(Duration(
milliseconds: 1,
));
expect(called.length, 2, reason: 'onUpdate should not have been called.');
expect(called2.length, 2,
reason: 'onRoomState should not have been called.');
FakeMatrixApi.calledEndpoints.clear();
final user3 = await room.requestUser('@getmeempty:example.com');
expect(FakeMatrixApi.calledEndpoints.keys, [
'/client/v3/rooms/!localpart%3Aserver.abc/state/m.room.member/%40getmeempty%3Aexample.com',
'/client/v3/profile/%40getmeempty%3Aexample.com'
]);
expect(user3?.stateKey, '@getmeempty:example.com');
expect(user3?.calcDisplayname(), 'You got me (empty)');
expect(user3?.membership, Membership.leave);
// Yield for the onUpdate
await Future.delayed(Duration(
milliseconds: 1,
));
expect(called.length, 3);
expect(called2.length, 3);
await subscription.cancel();
await subscription2.cancel();
}); });
test('setAvatar', () async { test('setAvatar', () async {
@ -1319,10 +1390,12 @@ void main() {
}); });
test('inviteLink', () async { test('inviteLink', () async {
// ensure we don't rerequest members // ensure we don't rerequest members
room.summary.mJoinedMemberCount = 4; room.summary.mJoinedMemberCount = 3;
var matrixToLink = await room.matrixToInviteLink(); var matrixToLink = await room.matrixToInviteLink();
expect(matrixToLink.toString(), expect(matrixToLink.toString(),
'https://matrix.to/#/%23testalias%3Aexample.com'); 'https://matrix.to/#/%23testalias%3Aexample.com');
room.setState( room.setState(
Event( Event(
senderId: '@test:example.com', senderId: '@test:example.com',
@ -1333,9 +1406,10 @@ void main() {
originServerTs: DateTime.now(), originServerTs: DateTime.now(),
stateKey: ''), stateKey: ''),
); );
matrixToLink = await room.matrixToInviteLink(); matrixToLink = await room.matrixToInviteLink();
expect(matrixToLink.toString(), expect(matrixToLink.toString(),
'https://matrix.to/#/!localpart%3Aserver.abc?via=example.com&via=test.abc&via=example.org'); 'https://matrix.to/#/!localpart%3Aserver.abc?via=fakeServer.notExisting&via=matrix.org&via=test.abc');
}); });
test('EventTooLarge on exceeding max PDU size', () async { test('EventTooLarge on exceeding max PDU size', () async {