diff --git a/osu.Server.Spectator.Tests/AnonymousClientProxy.cs b/osu.Server.Spectator.Tests/AnonymousClientProxy.cs new file mode 100644 index 00000000..821ff561 --- /dev/null +++ b/osu.Server.Spectator.Tests/AnonymousClientProxy.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Server.Spectator.Tests +{ + /// + /// Proxies objects of type as an anonymous object. + /// Useful in testing where is used. + /// + /// The typed clients object. + /// The type of clients being proxied. + public class AnonymousClientProxy(IHubClients clients) : IHubClients + { + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => new ClientProxy(clients.AllExcept(excludedConnectionIds)); + public IClientProxy Client(string connectionId) => new ClientProxy(clients.Client(connectionId)); + public IClientProxy Clients(IReadOnlyList connectionIds) => new ClientProxy(clients.Clients(connectionIds)); + public IClientProxy Group(string groupName) => new ClientProxy(clients.Group(groupName)); + public IClientProxy Groups(IReadOnlyList groupNames) => new ClientProxy(clients.Groups(groupNames)); + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => new ClientProxy(clients.GroupExcept(groupName, excludedConnectionIds)); + public IClientProxy User(string userId) => new ClientProxy(clients.User(userId)); + public IClientProxy Users(IReadOnlyList userIds) => new ClientProxy(clients.Users(userIds)); + public IClientProxy All => new ClientProxy(clients.All); + + private class ClientProxy(T? target) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = new CancellationToken()) + { + return target == null + ? Task.CompletedTask + : (Task)typeof(T).GetMethod(method, BindingFlags.Instance | BindingFlags.Public)!.Invoke(target, args)!; + } + } + } +} diff --git a/osu.Server.Spectator.Tests/MetadataHubTest.cs b/osu.Server.Spectator.Tests/MetadataHubTest.cs index 5f8aa086..4a3aaaf4 100644 --- a/osu.Server.Spectator.Tests/MetadataHubTest.cs +++ b/osu.Server.Spectator.Tests/MetadataHubTest.cs @@ -31,43 +31,53 @@ public class MetadataHubTest public MetadataHubTest() { userStates = new EntityStore(); + mockGroupManager = new Mock(); + mockWatchersGroup = new Mock(); + mockCaller = new Mock(); var mockDatabase = new Mock(); + var databaseFactory = new Mock(); - databaseFactory.Setup(factory => factory.GetInstance()).Returns(mockDatabase.Object); + databaseFactory.Setup(factory => factory.GetInstance()) + .Returns(mockDatabase.Object); + var loggerFactoryMock = new Mock(); loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny())) .Returns(new Mock().Object); - hub = new MetadataHub( - loggerFactoryMock.Object, - new MemoryCache(new MemoryCacheOptions()), - userStates, - databaseFactory.Object, - new Mock().Object, - new Mock().Object); - - var mockContext = new Mock(); - mockContext.Setup(ctx => ctx.UserIdentifier).Returns(user_id.ToString()); - - mockWatchersGroup = new Mock(); - mockCaller = new Mock(); - var mockClients = new Mock>(); mockClients.Setup(clients => clients.Group(MetadataHub.ONLINE_PRESENCE_WATCHERS_GROUP)) .Returns(mockWatchersGroup.Object); mockClients.Setup(clients => clients.Caller) .Returns(mockCaller.Object); - mockGroupManager = new Mock(); + var mockHubContext = new Mock>(); + mockHubContext.Setup(ctx => ctx.Groups) + .Returns(mockGroupManager.Object); + mockHubContext.Setup(ctx => ctx.Clients) + .Returns(new AnonymousClientProxy(mockClients.Object)); + var mockContext = new Mock(); + mockContext.Setup(ctx => ctx.UserIdentifier) + .Returns(user_id.ToString()); // this is to ensure that the `Context.GetHttpContext()` call in `MetadataHub.OnConnectedAsync()` doesn't nullref // (the method in question is an extension, and it accesses `Features`; mocking further is not required). - mockContext.Setup(ctx => ctx.Features).Returns(new Mock().Object); + mockContext.Setup(ctx => ctx.Features) + .Returns(new Mock().Object); - hub.Context = mockContext.Object; - hub.Clients = mockClients.Object; - hub.Groups = mockGroupManager.Object; + hub = new MetadataHub( + loggerFactoryMock.Object, + new MemoryCache(new MemoryCacheOptions()), + userStates, + databaseFactory.Object, + new Mock().Object, + new Mock().Object, + mockHubContext.Object) + { + Context = mockContext.Object, + Clients = mockClients.Object, + Groups = mockGroupManager.Object + }; } [Fact] diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerInviteTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerInviteTest.cs index 9e49c510..0eaae29d 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerInviteTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerInviteTest.cs @@ -17,7 +17,7 @@ public async Task UserCanInviteFriends() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserRelation(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); + Database.Setup(d => d.GetUserRelationAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); SetUserContext(ContextUser); await Hub.InvitePlayer(USER_ID_2); @@ -35,8 +35,8 @@ public async Task UserCantInviteUserTheyBlocked() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserRelation(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { foe = true }); - Database.Setup(d => d.GetUserRelation(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { friend = true }); + Database.Setup(d => d.GetUserRelationAsync(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { foe = true }); + Database.Setup(d => d.GetUserRelationAsync(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { friend = true }); SetUserContext(ContextUser); await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); @@ -54,8 +54,8 @@ public async Task UserCantInviteUserTheyAreBlockedBy() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserRelation(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { friend = true }); - Database.Setup(d => d.GetUserRelation(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { foe = true }); + Database.Setup(d => d.GetUserRelationAsync(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { friend = true }); + Database.Setup(d => d.GetUserRelationAsync(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { foe = true }); SetUserContext(ContextUser); await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); @@ -73,7 +73,7 @@ public async Task UserCantInviteUserWithDisabledPMs() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(false); + Database.Setup(d => d.GetUserAllowsPMsAsync(USER_ID_2)).ReturnsAsync(false); SetUserContext(ContextUser); await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); @@ -91,7 +91,7 @@ public async Task UserCantInviteRestrictedUser() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserRelation(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); + Database.Setup(d => d.GetUserRelationAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); Database.Setup(d => d.IsUserRestrictedAsync(It.IsAny())).ReturnsAsync(true); SetUserContext(ContextUser); @@ -110,7 +110,7 @@ public async Task UserCanInviteUserWithEnabledPMs() SetUserContext(ContextUser); await Hub.JoinRoom(ROOM_ID); - Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(true); + Database.Setup(d => d.GetUserAllowsPMsAsync(USER_ID_2)).ReturnsAsync(true); SetUserContext(ContextUser); await Hub.InvitePlayer(USER_ID_2); @@ -138,7 +138,7 @@ public async Task UserCanInviteIntoRoomWithPassword() SetUserContext(ContextUser); await Hub.JoinRoomWithPassword(ROOM_ID, password); - Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(true); + Database.Setup(d => d.GetUserAllowsPMsAsync(USER_ID_2)).ReturnsAsync(true); SetUserContext(ContextUser); await Hub.InvitePlayer(USER_ID_2); diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs index 0e774719..d4cc0691 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs @@ -103,9 +103,7 @@ protected MultiplayerTest() var hubContext = new Mock>(); hubContext.Setup(ctx => ctx.Groups).Returns(Groups.Object); - hubContext.Setup(ctx => ctx.Clients.Client(It.IsAny())).Returns(connectionId => (ISingleClientProxy)Clients.Object.Client(connectionId)); - hubContext.Setup(ctx => ctx.Clients.Group(It.IsAny())).Returns(groupName => (ISingleClientProxy)Clients.Object.Group(groupName)); - hubContext.Setup(ctx => ctx.Clients.All).Returns((ISingleClientProxy)Clients.Object.All); + hubContext.Setup(ctx => ctx.Clients).Returns(new AnonymousClientProxy(Clients.Object)); Groups.Setup(g => g.AddToGroupAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((connectionId, groupId, _) => diff --git a/osu.Server.Spectator/Database/DatabaseAccess.cs b/osu.Server.Spectator/Database/DatabaseAccess.cs index 3e893582..04d891bc 100644 --- a/osu.Server.Spectator/Database/DatabaseAccess.cs +++ b/osu.Server.Spectator/Database/DatabaseAccess.cs @@ -286,7 +286,7 @@ public async Task GetAllPlaylistItemsAsync(long roo return (await connection.QueryAsync("SELECT * FROM multiplayer_playlist_items WHERE room_id = @RoomId", new { RoomId = roomId })).ToArray(); } - public async Task GetUpdatedBeatmapSets(int? lastQueueId, int limit = 50) + public async Task GetUpdatedBeatmapSetsAsync(int? lastQueueId, int limit = 50) { var connection = await getConnectionAsync(); @@ -306,7 +306,7 @@ public async Task GetUpdatedBeatmapSets(int? lastQueueId, int li return new BeatmapUpdates(Array.Empty(), lastEntry?.queue_id ?? 0); } - public async Task MarkScoreHasReplay(Score score) + public async Task MarkScoreHasReplayAsync(Score score) { var connection = await getConnectionAsync(); @@ -347,7 +347,7 @@ public async Task IsScoreProcessedAsync(long scoreId) }); } - public async Task GetUserRelation(int userId, int zebraId) + public async Task GetUserRelationAsync(int userId, int zebraId) { var connection = await getConnectionAsync(); @@ -358,7 +358,17 @@ public async Task IsScoreProcessedAsync(long scoreId) }); } - public async Task GetUserAllowsPMs(int userId) + public async Task> GetUserFriendsAsync(int userId) + { + var connection = await getConnectionAsync(); + + return await connection.QueryAsync("SELECT * FROM `phpbb_zebra` WHERE `user_id` = @UserId AND `friend` = 1", new + { + UserId = userId + }); + } + + public async Task GetUserAllowsPMsAsync(int userId) { var connection = await getConnectionAsync(); @@ -427,7 +437,7 @@ public async Task> GetActiveDailyChallengeRoomsAsy new { scoreId = scoreId }); } - public async Task> GetPassingScoresForPlaylistItem(long playlistItemId, ulong afterScoreId = 0) + public async Task> GetPassingScoresForPlaylistItemAsync(long playlistItemId, ulong afterScoreId = 0) { var connection = await getConnectionAsync(); diff --git a/osu.Server.Spectator/Database/IDatabaseAccess.cs b/osu.Server.Spectator/Database/IDatabaseAccess.cs index 8df0f0f6..03f690a5 100644 --- a/osu.Server.Spectator/Database/IDatabaseAccess.cs +++ b/osu.Server.Spectator/Database/IDatabaseAccess.cs @@ -124,13 +124,13 @@ public interface IDatabaseAccess : IDisposable /// A queue ID to fetch updated items since /// Maximum number of entries to return. Defaults to 50. /// Update metadata. - Task GetUpdatedBeatmapSets(int? lastQueueId, int limit = 50); + Task GetUpdatedBeatmapSetsAsync(int? lastQueueId, int limit = 50); /// /// Mark a score as having a replay available. /// /// The score to mark. - Task MarkScoreHasReplay(Score score); + Task MarkScoreHasReplayAsync(Score score); /// /// Retrieves the for a given score token. Will return null while the score has not yet been submitted. @@ -152,12 +152,14 @@ public interface IDatabaseAccess : IDisposable /// /// Returns information about if the user with the supplied has been added as a friend or blocked by the user with the supplied . /// - Task GetUserRelation(int userId, int zebraId); + Task GetUserRelationAsync(int userId, int zebraId); + + Task> GetUserFriendsAsync(int userId); /// /// Returns if the user with the supplied allows private messages from people not on their friends list. /// - Task GetUserAllowsPMs(int userId); + Task GetUserAllowsPMsAsync(int userId); /// /// Returns all available main builds from the lazer release stream. @@ -165,7 +167,7 @@ public interface IDatabaseAccess : IDisposable Task> GetAllMainLazerBuildsAsync(); /// - /// Returns all known platform-specifc lazer builds. + /// Returns all known platform-specific lazer builds. /// Task> GetAllPlatformSpecificLazerBuildsAsync(); @@ -196,7 +198,7 @@ public interface IDatabaseAccess : IDisposable /// The playlist item. /// An optional score ID to only fetch newer scores. /// - Task> GetPassingScoresForPlaylistItem(long playlistItemId, ulong afterScoreId = 0); + Task> GetPassingScoresForPlaylistItemAsync(long playlistItemId, ulong afterScoreId = 0); /// /// Returns the best score of user with on the playlist item with . diff --git a/osu.Server.Spectator/Hubs/Friends/MetadataHubFriendsContext.cs b/osu.Server.Spectator/Hubs/Friends/MetadataHubFriendsContext.cs new file mode 100644 index 00000000..3b40ddb3 --- /dev/null +++ b/osu.Server.Spectator/Hubs/Friends/MetadataHubFriendsContext.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using osu.Game.Online.Friends; +using osu.Server.Spectator.Database; + +namespace osu.Server.Spectator.Hubs.Friends +{ + public class MetadataHubFriendsContext + where THub : Hub + where T : class, IFriendsClient + { + private readonly IDatabaseFactory databaseFactory; + + public MetadataHubFriendsContext(IHubContext context, IDatabaseFactory databaseFactory) + { + this.databaseFactory = databaseFactory; + + Clients = context.Clients; + Groups = context.Groups; + } + + public async Task OnConnectedAsync(ClientState state) + { + using (var db = databaseFactory.GetInstance()) + { + foreach (var friend in await db.GetUserFriendsAsync(state.UserId)) + await Groups.AddToGroupAsync(state.ConnectionId, friend_presence_watchers(friend.zebra_id)); + } + + await Clients.Group(friend_presence_watchers(state.UserId)).SendAsync(nameof(IFriendsClient.FriendConnected), state.UserId); + } + + public async Task OnDisconnectedAsync(ClientState state) + { + using (var db = databaseFactory.GetInstance()) + { + foreach (var friend in await db.GetUserFriendsAsync(state.UserId)) + await Groups.RemoveFromGroupAsync(state.ConnectionId, friend_presence_watchers(friend.zebra_id)); + } + + await Clients.Group(friend_presence_watchers(state.UserId)).SendAsync(nameof(IFriendsClient.FriendDisconnected), state.UserId); + } + + private static string friend_presence_watchers(int userId) => $"friends:online-presence-watchers:{userId}"; + + public IHubClients Clients { get; } + public IGroupManager Groups { get; } + } +} diff --git a/osu.Server.Spectator/Hubs/Metadata/MetadataBroadcaster.cs b/osu.Server.Spectator/Hubs/Metadata/MetadataBroadcaster.cs index f45d63a5..12d116fd 100644 --- a/osu.Server.Spectator/Hubs/Metadata/MetadataBroadcaster.cs +++ b/osu.Server.Spectator/Hubs/Metadata/MetadataBroadcaster.cs @@ -53,7 +53,7 @@ private async void pollForChanges(object? sender, ElapsedEventArgs args) { using (var db = databaseFactory.GetInstance()) { - var updates = await db.GetUpdatedBeatmapSets(lastQueueId); + var updates = await db.GetUpdatedBeatmapSetsAsync(lastQueueId); lastQueueId = updates.LastProcessedQueueID; logger.LogInformation("Polled beatmap changes up to last queue id {lastProcessedQueueID}", updates.LastProcessedQueueID); diff --git a/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs b/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs index 1b9a3061..f6674fd4 100644 --- a/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs +++ b/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs @@ -17,12 +17,15 @@ using osu.Server.Spectator.Database.Models; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Extensions; +using osu.Server.Spectator.Hubs.Friends; using osu.Server.Spectator.Hubs.Spectator; namespace osu.Server.Spectator.Hubs.Metadata { - public class MetadataHub : StatefulUserHub, IMetadataServer + public partial class MetadataHub : StatefulUserHub, IMetadataServer { + private readonly MetadataHubFriendsContext friends; + private readonly IMemoryCache cache; private readonly IDatabaseFactory databaseFactory; private readonly IDailyChallengeUpdater dailyChallengeUpdater; @@ -38,13 +41,16 @@ public MetadataHub( EntityStore userStates, IDatabaseFactory databaseFactory, IDailyChallengeUpdater dailyChallengeUpdater, - IScoreProcessedSubscriber scoreProcessedSubscriber) + IScoreProcessedSubscriber scoreProcessedSubscriber, + IHubContext context) : base(loggerFactory, userStates) { this.cache = cache; this.databaseFactory = databaseFactory; this.dailyChallengeUpdater = dailyChallengeUpdater; this.scoreProcessedSubscriber = scoreProcessedSubscriber; + + friends = new MetadataHubFriendsContext(context, databaseFactory); } public override async Task OnConnectedAsync() @@ -68,13 +74,15 @@ public override async Task OnConnectedAsync() usage.Item = new MetadataClientState(Context.ConnectionId, Context.GetUserId(), versionHash); await broadcastUserPresenceUpdate(usage.Item.UserId, usage.Item.ToUserPresence()); await Clients.Caller.DailyChallengeUpdated(dailyChallengeUpdater.Current); + + await friends.OnConnectedAsync(usage.Item); } } public async Task GetChangesSince(int queueId) { using (var db = databaseFactory.GetInstance()) - return await db.GetUpdatedBeatmapSets(queueId); + return await db.GetUpdatedBeatmapSetsAsync(queueId); } public async Task BeginWatchingUserPresence() @@ -147,7 +155,7 @@ private async Task updateMultiplayerRoomStatsAsync(IDatabaseAccess db, Multiplay ulong lastProcessed = itemStats.LastProcessedScoreID; - SoloScore[] scores = (await db.GetPassingScoresForPlaylistItem(itemId, itemStats.LastProcessedScoreID)).ToArray(); + SoloScore[] scores = (await db.GetPassingScoresForPlaylistItemAsync(itemId, itemStats.LastProcessedScoreID)).ToArray(); if (scores.Length == 0) return; @@ -186,6 +194,8 @@ protected override async Task CleanUpState(MetadataClientState state) await base.CleanUpState(state); await broadcastUserPresenceUpdate(state.UserId, null); await scoreProcessedSubscriber.UnregisterFromAllMultiplayerRoomsAsync(state.UserId); + + await friends.OnDisconnectedAsync(state); } private Task broadcastUserPresenceUpdate(int userId, UserPresence? userPresence) diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index e2d5837e..c8a5afbb 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -248,20 +248,20 @@ public async Task InvitePlayer(int userId) if (isRestricted) throw new InvalidStateException("Can't invite a restricted user to a room."); - var relation = await db.GetUserRelation(Context.GetUserId(), userId); + var relation = await db.GetUserRelationAsync(Context.GetUserId(), userId); // The local user has the player they are trying to invite blocked. if (relation?.foe == true) throw new UserBlockedException(); - var inverseRelation = await db.GetUserRelation(userId, Context.GetUserId()); + var inverseRelation = await db.GetUserRelationAsync(userId, Context.GetUserId()); // The player being invited has the local user blocked. if (inverseRelation?.foe == true) throw new UserBlockedException(); // The player being invited disallows unsolicited PMs and the local user is not their friend. - if (inverseRelation?.friend != true && !await db.GetUserAllowsPMs(userId)) + if (inverseRelation?.friend != true && !await db.GetUserAllowsPMsAsync(userId)) throw new UserBlocksPMsException(); } diff --git a/osu.Server.Spectator/Hubs/ScoreUploader.cs b/osu.Server.Spectator/Hubs/ScoreUploader.cs index 9975a442..1ec729d1 100644 --- a/osu.Server.Spectator/Hubs/ScoreUploader.cs +++ b/osu.Server.Spectator/Hubs/ScoreUploader.cs @@ -106,7 +106,7 @@ private async Task readLoop() item.Score.ScoreInfo.Passed = dbScore.passed; await scoreStorage.WriteAsync(item.Score); - await db.MarkScoreHasReplay(item.Score); + await db.MarkScoreHasReplayAsync(item.Score); DogStatsd.Increment($"{statsd_prefix}.uploaded"); } finally