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' &&
action.contains('/client/v3/rooms/') &&
action.contains('/state/m.room.member/') &&
!action.endsWith('%40alicyy%3Aexample.com')) {
res = {'displayname': ''};
!action.endsWith('%40alicyy%3Aexample.com') &&
!action.contains('%40getme')) {
res = {'displayname': '', 'membership': 'ban'};
} else if (method == 'PUT' &&
action.contains(
'/client/v3/rooms/!1234%3AfakeServer.notExisting/send/')) {
@ -1522,14 +1523,27 @@ class FakeMatrixApi extends BaseClient {
{'visibility': 'public'},
'/client/v3/rooms/1/state/m.room.member/@alice:example.com': (var req) =>
{'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) => {
'avatar_url': 'mxc://test',
'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) => {
'avatar_url': 'mxc://test',
'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) => [
{
@ -1596,11 +1610,6 @@ class FakeMatrixApi extends BaseClient {
'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) => {
'content': {
'body': 'This is an example text message',

View File

@ -18,7 +18,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
@ -1648,102 +1650,171 @@ class Room {
}
}
final Set<String> _requestingMatrixIds = {};
/// 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
/// the displayname and avatar from the profile if [requestProfile] is true.
Future<User?> requestUser(
// Internal helper to implement requestUser
Future<User?> _requestSingleParticipantViaState(
String mxID, {
bool ignoreErrors = false,
bool requestProfile = true,
required bool ignoreErrors,
}) async {
assert(mxID.isValidMatrixId);
// Is user already in cache?
var foundUser = getState(EventTypes.RoomMember, mxID)?.asUser(this);
// If not, is it in the database?
foundUser ??= await client.database?.getUser(mxID, this);
// If not, can we request it from the server?
if (foundUser == null) {
if (!_requestingMatrixIds.add(mxID)) return null;
Map<String, dynamic>? resp;
try {
Logs().v(
'Request missing user $mxID in room ${getLocalizedDisplayname()} from the server...');
resp = await client.getRoomStateWithKey(
'Request missing user $mxID in room ${getLocalizedDisplayname()} from the server...',
);
final resp = await client.getRoomStateWithKey(
id,
EventTypes.RoomMember,
mxID,
);
foundUser = User(
// 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['displayname'],
avatarUrl: resp['avatar_url'],
membership: resp['membership'],
displayName: resp.tryGet<String>('displayname', TryGet.silent),
avatarUrl: resp.tryGet<String>('avatar_url', TryGet.silent),
membership: membership,
);
_requestingMatrixIds.remove(mxID);
// Store user in database:
await client.database?.transaction(() async {
await client.database?.storeEventUpdate(
EventUpdate(
content: foundUser!.toJson(),
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) {
_requestingMatrixIds.remove(mxID);
rethrow;
} else {
Logs().w('Unable to request the user $mxID from the server', e, s);
return null;
}
}
}
// User not found anywhere? Set a blank one:
foundUser ??= User(mxID, room: this, membership: 'leave');
// 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);
// 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) {
// 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: Membership.leave.name,
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);
Logs()
.w('Unable to request the profile $mxID from the server', e, s);
}
}
}
}
// Set user in the local state
setState(foundUser!);
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
/// lazy loading. If the user can't be found this method tries to fetch
/// 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(
String mxID, {
bool ignoreErrors = false,
bool requestState = true,
bool requestProfile = true,
}) async {
assert(mxID.isValidMatrixId);
final parameters = (
mxID: mxID,
ignoreErrors: ignoreErrors,
requestState: requestState,
requestProfile: requestProfile,
);
final cache = _inflightUserRequests[parameters] ??= AsyncCache.ephemeral();
try {
final user = await cache.fetch(() => _requestUser(
mxID,
ignoreErrors: ignoreErrors,
requestState: requestState,
requestProfile: requestProfile,
));
_inflightUserRequests.remove(parameters);
return user;
} catch (_) {
_inflightUserRequests.remove(parameters);
rethrow;
}
}
/// Searches for the event in the local cache and then on the server if not
/// found. Returns null if not found anywhere.
Future<Event?> getEventById(String eventID) async {
@ -2272,7 +2343,7 @@ class Room {
'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias)}');
}
final List queryParameters = [];
final users = await requestParticipants();
final users = await requestParticipants([Membership.join]);
final currentPowerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
final temp = List<User>.from(users);
@ -2301,18 +2372,17 @@ class Room {
}
}
final sortedServers = Map.fromEntries(servers.entries.toList()
..sort((e1, e2) => e1.value.compareTo(e2.value)));
for (var i = 0; i <= 2; i++) {
if (!queryParameters.contains(sortedServers.keys.last)) {
queryParameters.add(sortedServers.keys.last);
..sort((e1, e2) => e2.value.compareTo(e1.value)))
.keys
.take(3);
for (final server in sortedServers) {
if (!queryParameters.contains(server)) {
queryParameters.add(server);
}
sortedServers.remove(sortedServers.keys.last);
}
var queryString = '?';
for (var i = 0;
i <= (queryParameters.length > 2 ? 2 : queryParameters.length);
i++) {
for (var i = 0; i < min(queryParameters.length, 3); i++) {
if (i != 0) {
queryString += '&';
}

View File

@ -682,12 +682,83 @@ void main() {
});
test('getUserByMXID', () async {
User? user;
try {
user = await room.requestUser('@getme:example.com');
} catch (_) {}
final List<String> called = [];
final List<String> called2 = [];
// ignore: deprecated_member_use_from_same_package
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?.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 {
@ -1319,10 +1390,12 @@ void main() {
});
test('inviteLink', () async {
// ensure we don't rerequest members
room.summary.mJoinedMemberCount = 4;
room.summary.mJoinedMemberCount = 3;
var matrixToLink = await room.matrixToInviteLink();
expect(matrixToLink.toString(),
'https://matrix.to/#/%23testalias%3Aexample.com');
room.setState(
Event(
senderId: '@test:example.com',
@ -1333,9 +1406,10 @@ void main() {
originServerTs: DateTime.now(),
stateKey: ''),
);
matrixToLink = await room.matrixToInviteLink();
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 {