From 42788834f8873d17d0bd0e64deee05ffb2bd3432 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Fri, 10 Jan 2025 20:28:04 +0900 Subject: [PATCH 01/15] Add LIO service helper Mostly a copy-paste from https://github.com/ppy/osu-server-beatmap-submission/blob/82190f3130bae07747fe03a2eadf8cafa81fe401/osu.Server.BeatmapSubmission/Services/LegacyIO.cs, but supports string responses. --- .../Multiplayer/MultiplayerTest.cs | 6 +- .../Multiplayer/TestMultiplayerHub.cs | 6 +- osu.Server.Spectator.sln.DotSettings | 1 + osu.Server.Spectator/AppSettings.cs | 11 +++ .../Extensions/ServiceCollectionExtensions.cs | 32 +++--- .../Hubs/Multiplayer/MultiplayerHub.cs | 6 +- osu.Server.Spectator/Services/ILegacyIO.cs | 9 ++ osu.Server.Spectator/Services/LegacyIO.cs | 98 +++++++++++++++++++ 8 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 osu.Server.Spectator/Services/ILegacyIO.cs create mode 100644 osu.Server.Spectator/Services/LegacyIO.cs diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs index 0e774719..38d38def 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs @@ -16,6 +16,7 @@ using osu.Server.Spectator.Database.Models; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Hubs.Multiplayer; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Tests.Multiplayer { @@ -130,13 +131,16 @@ protected MultiplayerTest() loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>())) .Returns(new Mock<ILogger>().Object); + var legacyIOMock = new Mock<ILegacyIO>(); + Hub = new TestMultiplayerHub( loggerFactoryMock.Object, Rooms, UserStates, DatabaseFactory.Object, new ChatFilters(DatabaseFactory.Object), - hubContext.Object); + hubContext.Object, + legacyIOMock.Object); Hub.Groups = Groups.Object; Hub.Clients = Clients.Object; diff --git a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs index 54024987..83d46934 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs @@ -6,6 +6,7 @@ using osu.Server.Spectator.Database; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Hubs.Multiplayer; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Tests.Multiplayer { @@ -19,8 +20,9 @@ public TestMultiplayerHub( EntityStore<MultiplayerClientState> users, IDatabaseFactory databaseFactory, ChatFilters chatFilters, - IHubContext<MultiplayerHub> hubContext) - : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext) + IHubContext<MultiplayerHub> hubContext, + ILegacyIO legacyIO) + : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext, legacyIO) { } diff --git a/osu.Server.Spectator.sln.DotSettings b/osu.Server.Spectator.sln.DotSettings index 4b60715d..d3b2e19f 100644 --- a/osu.Server.Spectator.sln.DotSettings +++ b/osu.Server.Spectator.sln.DotSettings @@ -354,6 +354,7 @@ <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HUD/@EntryIndexedValue">HUD</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ID/@EntryIndexedValue">ID</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IL/@EntryIndexedValue">IL</s:String> + <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IO/@EntryIndexedValue">IO</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IOS/@EntryIndexedValue">IOS</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IPC/@EntryIndexedValue">IPC</s:String> diff --git a/osu.Server.Spectator/AppSettings.cs b/osu.Server.Spectator/AppSettings.cs index b8598cdb..17161f27 100644 --- a/osu.Server.Spectator/AppSettings.cs +++ b/osu.Server.Spectator/AppSettings.cs @@ -34,6 +34,9 @@ public static class AppSettings public static string DatabaseUser { get; } public static string DatabasePort { get; } + public static string LegacyIODomain { get; } + public static string SharedInteropSecret { get; } + static AppSettings() { SaveReplays = Environment.GetEnvironmentVariable("SAVE_REPLAYS") == "1"; @@ -53,6 +56,14 @@ static AppSettings() DatabaseHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb"; DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306"; + + LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") + ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. " + + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted."); + + SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") + ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. " + + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`."); } } } diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs index c1d90a31..84d55414 100644 --- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs +++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using osu.Server.Spectator.Hubs.Metadata; using osu.Server.Spectator.Hubs.Multiplayer; using osu.Server.Spectator.Hubs.Spectator; +using osu.Server.Spectator.Services; using osu.Server.Spectator.Storage; using StackExchange.Redis; @@ -17,20 +18,23 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) { - return serviceCollection.AddSingleton<EntityStore<SpectatorClientState>>() - .AddSingleton<EntityStore<MultiplayerClientState>>() - .AddSingleton<EntityStore<ServerMultiplayerRoom>>() - .AddSingleton<EntityStore<ConnectionState>>() - .AddSingleton<EntityStore<MetadataClientState>>() - .AddSingleton<GracefulShutdownManager>() - .AddSingleton<MetadataBroadcaster>() - .AddSingleton<IScoreStorage, S3ScoreStorage>() - .AddSingleton<ScoreUploader>() - .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>() - .AddSingleton<BuildUserCountUpdater>() - .AddSingleton<ChatFilters>() - .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>() - .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>()); + return serviceCollection + .AddHttpClient() + .AddTransient<ILegacyIO, LegacyIO>() + .AddSingleton<EntityStore<SpectatorClientState>>() + .AddSingleton<EntityStore<MultiplayerClientState>>() + .AddSingleton<EntityStore<ServerMultiplayerRoom>>() + .AddSingleton<EntityStore<ConnectionState>>() + .AddSingleton<EntityStore<MetadataClientState>>() + .AddSingleton<GracefulShutdownManager>() + .AddSingleton<MetadataBroadcaster>() + .AddSingleton<IScoreStorage, S3ScoreStorage>() + .AddSingleton<ScoreUploader>() + .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>() + .AddSingleton<BuildUserCountUpdater>() + .AddSingleton<ChatFilters>() + .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>() + .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>()); } /// <summary> diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index e2d5837e..af7fa745 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -16,6 +16,7 @@ using osu.Server.Spectator.Database.Models; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Extensions; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Hubs.Multiplayer { @@ -25,6 +26,7 @@ public class MultiplayerHub : StatefulUserHub<IMultiplayerClient, MultiplayerCli protected readonly MultiplayerHubContext HubContext; private readonly IDatabaseFactory databaseFactory; private readonly ChatFilters chatFilters; + private readonly ILegacyIO legacyIO; public MultiplayerHub( ILoggerFactory loggerFactory, @@ -32,12 +34,14 @@ public MultiplayerHub( EntityStore<MultiplayerClientState> users, IDatabaseFactory databaseFactory, ChatFilters chatFilters, - IHubContext<MultiplayerHub> hubContext) + IHubContext<MultiplayerHub> hubContext, + ILegacyIO legacyIO) : base(loggerFactory, users) { Rooms = rooms; this.databaseFactory = databaseFactory; this.chatFilters = chatFilters; + this.legacyIO = legacyIO; HubContext = new MultiplayerHubContext(hubContext, rooms, users, databaseFactory, loggerFactory); } diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs new file mode 100644 index 00000000..be57a09e --- /dev/null +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -0,0 +1,9 @@ +// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Server.Spectator.Services +{ + public interface ILegacyIO + { + } +} diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs new file mode 100644 index 00000000..34854ab1 --- /dev/null +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace osu.Server.Spectator.Services +{ + public class LegacyIO : ILegacyIO + { + private readonly HttpClient httpClient; + private readonly ILogger logger; + + public LegacyIO(HttpClient httpClient, ILoggerFactory loggerFactory) + { + this.httpClient = httpClient; + logger = loggerFactory.CreateLogger("LIO"); + } + + private async Task<string> runLegacyIO(HttpMethod method, string command, dynamic? postObject = null) + { + int retryCount = 3; + + retry: + + long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; + + string? serialisedPostObject = postObject == null ? null : JsonSerializer.Serialize(postObject); + logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject); + + try + { + string signature = hmacEncode(url, Encoding.UTF8.GetBytes(AppSettings.SharedInteropSecret)); + + var httpRequestMessage = new HttpRequestMessage + { + RequestUri = new Uri(url), + Method = method, + Headers = + { + { "X-LIO-Signature", signature }, + { "Accept", "application/json" }, + }, + }; + + if (serialisedPostObject != null) + { + httpRequestMessage.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(serialisedPostObject)); + httpRequestMessage.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + } + + var response = await httpClient.SendAsync(httpRequestMessage); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"Legacy IO request to {url} failed with {response.StatusCode} ({response.Content.ReadAsStringAsync().Result})"); + + if ((int)response.StatusCode >= 300) + throw new Exception($"Legacy IO request to {url} returned unexpected response {response.StatusCode} ({response.ReasonPhrase})"); + + return await response.Content.ReadAsStringAsync(); + } + catch (Exception e) + { + if (retryCount-- > 0) + { + logger.LogError(e, "Legacy IO request to {url} failed, retrying ({retries} remaining)", url, retryCount); + Thread.Sleep(1000); + goto retry; + } + + throw; + } + } + + private static string hmacEncode(string input, byte[] key) + { + byte[] byteArray = Encoding.ASCII.GetBytes(input); + + using (var hmac = new HMACSHA1(key)) + { + byte[] hashArray = hmac.ComputeHash(byteArray); + return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s); + } + } + + // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. + // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method. + } +} From 9d51555f6fe9bfe2cd0df9499944dea0abd04c92 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Fri, 17 Jan 2025 21:45:44 +0900 Subject: [PATCH 02/15] Add support for creating rooms via LIO --- SampleMultiplayerClient/MultiplayerClient.cs | 5 +++ .../Hubs/Multiplayer/MultiplayerHub.cs | 14 +++++++ osu.Server.Spectator/Services/ILegacyIO.cs | 4 ++ osu.Server.Spectator/Services/LegacyIO.cs | 41 ++++++++++++++++++- 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/SampleMultiplayerClient/MultiplayerClient.cs b/SampleMultiplayerClient/MultiplayerClient.cs index 026cd8e0..6e47164e 100644 --- a/SampleMultiplayerClient/MultiplayerClient.cs +++ b/SampleMultiplayerClient/MultiplayerClient.cs @@ -61,6 +61,11 @@ public MultiplayerClient(HubConnection connection, int userId) public MultiplayerRoom? Room { get; private set; } + public Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) + { + throw new NotImplementedException(); + } + public async Task<MultiplayerRoom> JoinRoom(long roomId) { return await JoinRoomWithPassword(roomId, string.Empty); diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index af7fa745..65ea4468 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -45,6 +45,20 @@ public MultiplayerHub( HubContext = new MultiplayerHubContext(hubContext, rooms, users, databaseFactory, loggerFactory); } + public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) + { + Log($"{Context.GetUserId()} creating room"); + + try + { + return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password); + } + catch (Exception ex) + { + throw new InvalidStateException($"Failed to create the multiplayer room ({ex.Message})."); + } + } + public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty); public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string password) diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index be57a09e..1e21d669 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading.Tasks; +using osu.Game.Online.Multiplayer; + namespace osu.Server.Spectator.Services { public interface ILegacyIO { + Task<long> CreateRoom(int userId, MultiplayerRoom room); } } diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 34854ab1..7e4ebd13 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -11,6 +11,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; namespace osu.Server.Spectator.Services { @@ -34,7 +36,13 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; - string? serialisedPostObject = postObject == null ? null : JsonSerializer.Serialize(postObject); + string? serialisedPostObject = postObject switch + { + null => null, + string => postObject, + _ => JsonSerializer.Serialize(postObject) + }; + logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject); try @@ -94,5 +102,36 @@ private static string hmacEncode(string input, byte[] key) // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method. + + public async Task<long> CreateRoom(int userId, MultiplayerRoom room) + { + return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room) + { + UserId = userId + }))); + } + + private class CreateRoomRequest : Room + { + [Newtonsoft.Json.JsonProperty("user_id")] + public required int UserId { get; init; } + + /// <summary> + /// Creates a <see cref="Room"/> from a <see cref="MultiplayerRoom"/>. + /// </summary> + public CreateRoomRequest(MultiplayerRoom room) + { + RoomID = room.RoomID; + Host = room.Host?.User; + Name = room.Settings.Name; + Password = room.Settings.Password; + Type = room.Settings.MatchType; + QueueMode = room.Settings.QueueMode; + AutoStartDuration = room.Settings.AutoStartDuration; + AutoSkip = room.Settings.AutoSkip; + Playlist = room.Playlist.Select(item => new PlaylistItem(item)).ToArray(); + CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId); + } + } } } From 1ee9712f21a1c3f0821c42c4be492026b6108c99 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Wed, 22 Jan 2025 21:43:21 +0900 Subject: [PATCH 03/15] Add support for joining/parting rooms via LIO --- .../Hubs/Multiplayer/MultiplayerHub.cs | 11 +++++++---- osu.Server.Spectator/Services/ILegacyIO.cs | 4 ++++ osu.Server.Spectator/Services/LegacyIO.cs | 10 ++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index 65ea4468..13abe669 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -65,12 +65,13 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass { Log($"Attempting to join room {roomId}"); - bool isRestricted; using (var db = databaseFactory.GetInstance()) - isRestricted = await db.IsUserRestrictedAsync(Context.GetUserId()); + { + if (await db.IsUserRestrictedAsync(Context.GetUserId())) + throw new InvalidStateException("Can't join a room when restricted."); + } - if (isRestricted) - throw new InvalidStateException("Can't join a room when restricted."); + await legacyIO.JoinRoom(roomId, Context.GetUserId()); using (var userUsage = await GetOrCreateLocalUserState()) { @@ -901,6 +902,8 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye private async Task leaveRoom(MultiplayerClientState state, bool wasKick) { + await legacyIO.PartRoom(state.CurrentRoomID, state.UserId); + using (var roomUsage = await getLocalUserRoom(state)) await leaveRoom(state, roomUsage, wasKick); } diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index 1e21d669..9877109f 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -9,5 +9,9 @@ namespace osu.Server.Spectator.Services public interface ILegacyIO { Task<long> CreateRoom(int userId, MultiplayerRoom room); + + Task JoinRoom(long roomId, int userId); + + Task PartRoom(long roomId, int userId); } } diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 7e4ebd13..8fe91c15 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -111,6 +111,16 @@ public async Task<long> CreateRoom(int userId, MultiplayerRoom room) }))); } + public async Task JoinRoom(long roomId, int userId) + { + await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}"); + } + + public async Task PartRoom(long roomId, int userId) + { + await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); + } + private class CreateRoomRequest : Room { [Newtonsoft.Json.JsonProperty("user_id")] From b5b5a9f4db1856581b4051e97268e1bbbf8328d9 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Thu, 23 Jan 2025 18:39:51 +0900 Subject: [PATCH 04/15] Adjust exception handling for hub usage Avoid serialising raw details about the exception (e.g. URL) and unwrap `{ "error": "" }` JSON responses, for instance as returned by the room creation endpoint. Client receives: - `APP_DEBUG=true` -> "422: Unprocessable Entity" (HTTP status code msg) - `APP_DEBUG=false` -> "beatmaps not found: ..." (osu!web API error msg) --- .../Hubs/Multiplayer/MultiplayerHub.cs | 12 +---- osu.Server.Spectator/Services/LegacyIO.cs | 45 ++++++++++++++++--- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index 13abe669..40367e1e 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -48,15 +48,7 @@ public MultiplayerHub( public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) { Log($"{Context.GetUserId()} creating room"); - - try - { - return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password); - } - catch (Exception ex) - { - throw new InvalidStateException($"Failed to create the multiplayer room ({ex.Message})."); - } + return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password); } public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty); @@ -739,8 +731,8 @@ private async Task removeDatabaseUser(MultiplayerRoom room, MultiplayerRoomUser protected override async Task CleanUpState(MultiplayerClientState state) { - await leaveRoom(state, true); await base.CleanUpState(state); + await leaveRoom(state, true); } private async Task setNewHost(MultiplayerRoom room, MultiplayerRoomUser newHost) diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 8fe91c15..10c122cf 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -8,8 +8,10 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -68,13 +70,10 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami var response = await httpClient.SendAsync(httpRequestMessage); - if (!response.IsSuccessStatusCode) - throw new InvalidOperationException($"Legacy IO request to {url} failed with {response.StatusCode} ({response.Content.ReadAsStringAsync().Result})"); + if (response.IsSuccessStatusCode) + return await response.Content.ReadAsStringAsync(); - if ((int)response.StatusCode >= 300) - throw new Exception($"Legacy IO request to {url} returned unexpected response {response.StatusCode} ({response.ReasonPhrase})"); - - return await response.Content.ReadAsStringAsync(); + throw await LegacyIORequestFailedException.Create(url, response); } catch (Exception e) { @@ -143,5 +142,39 @@ public CreateRoomRequest(MultiplayerRoom room) CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId); } } + + [Serializable] + private class LegacyIORequestFailedException : HubException + { + private LegacyIORequestFailedException(string message, Exception innerException) + : base(message, innerException) + { + } + + public static async Task<LegacyIORequestFailedException> Create(string url, HttpResponseMessage response) + { + string errorMessage = $"{(int)response.StatusCode}: {response.ReasonPhrase}"; + + try + { + APIErrorMessage? apiError = await JsonSerializer.DeserializeAsync<APIErrorMessage>(await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); + if (!string.IsNullOrEmpty(apiError?.Error)) + errorMessage = apiError.Error; + } + catch + { + } + + // Outer exception message is serialised to clients, inner exception is logged to the server and NOT serialised to the client. + return new LegacyIORequestFailedException(errorMessage, new Exception($"Legacy IO request to {url} failed with {response.StatusCode} ({response.ReasonPhrase}).")); + } + + [Serializable] + private class APIErrorMessage + { + [JsonPropertyName("error")] + public string Error { get; set; } = string.Empty; + } + } } } From 76bd08483781d6f7b92892005ec4c0b9d15a5aee Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Thu, 23 Jan 2025 19:30:29 +0900 Subject: [PATCH 05/15] Refactorings and cleanups --- SampleMultiplayerClient/MultiplayerClient.cs | 12 ++----- .../Extensions/ServiceCollectionExtensions.cs | 33 +++++++++---------- osu.Server.Spectator/Services/ILegacyIO.cs | 16 +++++++++ 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/SampleMultiplayerClient/MultiplayerClient.cs b/SampleMultiplayerClient/MultiplayerClient.cs index 6e47164e..c3c86d58 100644 --- a/SampleMultiplayerClient/MultiplayerClient.cs +++ b/SampleMultiplayerClient/MultiplayerClient.cs @@ -62,19 +62,13 @@ public MultiplayerClient(HubConnection connection, int userId) public MultiplayerRoom? Room { get; private set; } public Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) - { - throw new NotImplementedException(); - } + => connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.CreateRoom), room); public async Task<MultiplayerRoom> JoinRoom(long roomId) - { - return await JoinRoomWithPassword(roomId, string.Empty); - } + => await JoinRoomWithPassword(roomId, string.Empty); public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string? password = null) - { - return Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); - } + => Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); public async Task LeaveRoom() { diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs index 84d55414..d5243813 100644 --- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs +++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs @@ -18,23 +18,22 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) { - return serviceCollection - .AddHttpClient() - .AddTransient<ILegacyIO, LegacyIO>() - .AddSingleton<EntityStore<SpectatorClientState>>() - .AddSingleton<EntityStore<MultiplayerClientState>>() - .AddSingleton<EntityStore<ServerMultiplayerRoom>>() - .AddSingleton<EntityStore<ConnectionState>>() - .AddSingleton<EntityStore<MetadataClientState>>() - .AddSingleton<GracefulShutdownManager>() - .AddSingleton<MetadataBroadcaster>() - .AddSingleton<IScoreStorage, S3ScoreStorage>() - .AddSingleton<ScoreUploader>() - .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>() - .AddSingleton<BuildUserCountUpdater>() - .AddSingleton<ChatFilters>() - .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>() - .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>()); + return serviceCollection.AddHttpClient() + .AddTransient<ILegacyIO, LegacyIO>() + .AddSingleton<EntityStore<SpectatorClientState>>() + .AddSingleton<EntityStore<MultiplayerClientState>>() + .AddSingleton<EntityStore<ServerMultiplayerRoom>>() + .AddSingleton<EntityStore<ConnectionState>>() + .AddSingleton<EntityStore<MetadataClientState>>() + .AddSingleton<GracefulShutdownManager>() + .AddSingleton<MetadataBroadcaster>() + .AddSingleton<IScoreStorage, S3ScoreStorage>() + .AddSingleton<ScoreUploader>() + .AddSingleton<IScoreProcessedSubscriber, ScoreProcessedSubscriber>() + .AddSingleton<BuildUserCountUpdater>() + .AddSingleton<ChatFilters>() + .AddSingleton<IDailyChallengeUpdater, DailyChallengeUpdater>() + .AddHostedService<IDailyChallengeUpdater>(ctx => ctx.GetRequiredService<IDailyChallengeUpdater>()); } /// <summary> diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index 9877109f..284fa76d 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -8,10 +8,26 @@ namespace osu.Server.Spectator.Services { public interface ILegacyIO { + /// <summary> + /// Creates an osu!web Room. + /// </summary> + /// <param name="userId">The ID of the user that wants to create the room.</param> + /// <param name="room">The room.</param> + /// <returns>The room's ID.</returns> Task<long> CreateRoom(int userId, MultiplayerRoom room); + /// <summary> + /// Joins an osu!web Room. + /// </summary> + /// <param name="roomId">The ID of the room to join.</param> + /// <param name="userId">The ID of the user wanting to join the room.</param> Task JoinRoom(long roomId, int userId); + /// <summary> + /// Parts an osu!web Room. + /// </summary> + /// <param name="roomId">The ID of the room to part.</param> + /// <param name="userId">The ID of the user wanting to part the room.</param> Task PartRoom(long roomId, int userId); } } From 39bdadab5374fb7fdf1d8cf0f5f7e9e091eabc02 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Sat, 25 Jan 2025 19:36:10 +0900 Subject: [PATCH 06/15] Add "Async" suffix --- osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs | 6 +++--- osu.Server.Spectator/Services/ILegacyIO.cs | 6 +++--- osu.Server.Spectator/Services/LegacyIO.cs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index 40367e1e..e36eefb9 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -48,7 +48,7 @@ public MultiplayerHub( public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) { Log($"{Context.GetUserId()} creating room"); - return await JoinRoomWithPassword(await legacyIO.CreateRoom(Context.GetUserId(), room), room.Settings.Password); + return await JoinRoomWithPassword(await legacyIO.CreateRoomAsync(Context.GetUserId(), room), room.Settings.Password); } public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty); @@ -63,7 +63,7 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass throw new InvalidStateException("Can't join a room when restricted."); } - await legacyIO.JoinRoom(roomId, Context.GetUserId()); + await legacyIO.JoinRoomAsync(roomId, Context.GetUserId()); using (var userUsage = await GetOrCreateLocalUserState()) { @@ -894,7 +894,7 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye private async Task leaveRoom(MultiplayerClientState state, bool wasKick) { - await legacyIO.PartRoom(state.CurrentRoomID, state.UserId); + await legacyIO.PartRoomAsync(state.CurrentRoomID, state.UserId); using (var roomUsage = await getLocalUserRoom(state)) await leaveRoom(state, roomUsage, wasKick); diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index 284fa76d..7741583a 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -14,20 +14,20 @@ public interface ILegacyIO /// <param name="userId">The ID of the user that wants to create the room.</param> /// <param name="room">The room.</param> /// <returns>The room's ID.</returns> - Task<long> CreateRoom(int userId, MultiplayerRoom room); + Task<long> CreateRoomAsync(int userId, MultiplayerRoom room); /// <summary> /// Joins an osu!web Room. /// </summary> /// <param name="roomId">The ID of the room to join.</param> /// <param name="userId">The ID of the user wanting to join the room.</param> - Task JoinRoom(long roomId, int userId); + Task JoinRoomAsync(long roomId, int userId); /// <summary> /// Parts an osu!web Room. /// </summary> /// <param name="roomId">The ID of the room to part.</param> /// <param name="userId">The ID of the user wanting to part the room.</param> - Task PartRoom(long roomId, int userId); + Task PartRoomAsync(long roomId, int userId); } } diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 10c122cf..f56910f6 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -102,7 +102,7 @@ private static string hmacEncode(string input, byte[] key) // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method. - public async Task<long> CreateRoom(int userId, MultiplayerRoom room) + public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room) { return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room) { @@ -110,12 +110,12 @@ public async Task<long> CreateRoom(int userId, MultiplayerRoom room) }))); } - public async Task JoinRoom(long roomId, int userId) + public async Task JoinRoomAsync(long roomId, int userId) { await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}"); } - public async Task PartRoom(long roomId, int userId) + public async Task PartRoomAsync(long roomId, int userId) { await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); } From 5288875e7bee8781b34ebccba96255d7698071fe Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Sat, 25 Jan 2025 19:39:06 +0900 Subject: [PATCH 07/15] Add env vars to readme --- README.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 36788894..c56c8e18 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,20 @@ To deploy this as part of a full osu! server stack deployment, [this wiki page]( For advanced testing purposes. -| Envvar name | Description | Default value | -| :- | :- | :- | -| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) | -| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` | -| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` | -| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) | -| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` | -| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` | -| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` | -| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` | -| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` | -| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` | +| Envvar name | Description | Default value | +| :- | :- |:------------------| +| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) | +| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` | +| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` | +| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) | +| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` | +| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` | +| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` | +| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` | +| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` | +| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` | +| `LEGACY_IO_DOMAIN` | The root URL of the osu-web instance to which legacy IO call should be submitted | `null` (required) | +| `SHARED_INTEROP_SECRET` | The value of the same environment variable that the target osu-web instance specifies in `.env`. | `null` (required) | \ No newline at end of file From 977d127e9bd9e732b711d3e07bae23651361f6dd Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Mon, 27 Jan 2025 14:08:03 +0900 Subject: [PATCH 08/15] Clean up hashing Co-authored-by: Berkan Diler <berkan.diler1@ingka.ikea.com> --- osu.Server.Spectator/Services/LegacyIO.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index f56910f6..228a1150 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -92,11 +92,8 @@ private static string hmacEncode(string input, byte[] key) { byte[] byteArray = Encoding.ASCII.GetBytes(input); - using (var hmac = new HMACSHA1(key)) - { - byte[] hashArray = hmac.ComputeHash(byteArray); - return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s); - } + byte[] hashArray = HMACSHA1.HashData(key, byteArray); + return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s); } // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. From 8e46fabb3511f5220c39d4bc7c4faa1d6001b7de Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Mon, 27 Jan 2025 17:07:22 +0900 Subject: [PATCH 09/15] Fix tests --- osu.Server.Spectator/AppSettings.cs | 9 ++------- osu.Server.Spectator/Services/LegacyIO.cs | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/osu.Server.Spectator/AppSettings.cs b/osu.Server.Spectator/AppSettings.cs index 17161f27..f16325db 100644 --- a/osu.Server.Spectator/AppSettings.cs +++ b/osu.Server.Spectator/AppSettings.cs @@ -57,13 +57,8 @@ static AppSettings() DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb"; DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306"; - LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") - ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. " - + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted."); - - SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") - ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. " - + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`."); + LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") ?? string.Empty; + SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") ?? string.Empty; } } } diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 228a1150..cb166d49 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -23,10 +23,20 @@ public class LegacyIO : ILegacyIO private readonly HttpClient httpClient; private readonly ILogger logger; + private readonly string interopDomain; + private readonly string interopSecret; + public LegacyIO(HttpClient httpClient, ILoggerFactory loggerFactory) { this.httpClient = httpClient; logger = loggerFactory.CreateLogger("LIO"); + + interopDomain = AppSettings.LegacyIODomain + ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. " + + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted."); + interopSecret = AppSettings.SharedInteropSecret + ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. " + + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`."); } private async Task<string> runLegacyIO(HttpMethod method, string command, dynamic? postObject = null) @@ -36,7 +46,7 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami retry: long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - string url = $"{AppSettings.LegacyIODomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; + string url = $"{interopDomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; string? serialisedPostObject = postObject switch { @@ -49,7 +59,7 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami try { - string signature = hmacEncode(url, Encoding.UTF8.GetBytes(AppSettings.SharedInteropSecret)); + string signature = hmacEncode(url, Encoding.UTF8.GetBytes(interopSecret)); var httpRequestMessage = new HttpRequestMessage { From 32a591f2ced8124ef4124450514752dddb0d1953 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Mon, 27 Jan 2025 17:40:13 +0900 Subject: [PATCH 10/15] Add some basic interop tests --- .../Multiplayer/MultiplayerTest.cs | 6 ++- .../Multiplayer/RoomInteropTest.cs | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs index 38d38def..1ccea078 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs @@ -38,6 +38,8 @@ public abstract class MultiplayerTest protected readonly Mock<IDatabaseFactory> DatabaseFactory; protected readonly Mock<IDatabaseAccess> Database; + protected readonly Mock<ILegacyIO> LegacyIO; + /// <summary> /// A general non-gameplay receiver for the room with ID <see cref="ROOM_ID"/>. /// </summary> @@ -131,7 +133,7 @@ protected MultiplayerTest() loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>())) .Returns(new Mock<ILogger>().Object); - var legacyIOMock = new Mock<ILegacyIO>(); + LegacyIO = new Mock<ILegacyIO>(); Hub = new TestMultiplayerHub( loggerFactoryMock.Object, @@ -140,7 +142,7 @@ protected MultiplayerTest() DatabaseFactory.Object, new ChatFilters(DatabaseFactory.Object), hubContext.Object, - legacyIOMock.Object); + LegacyIO.Object); Hub.Groups = Groups.Object; Hub.Clients = Clients.Object; diff --git a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs new file mode 100644 index 00000000..b29fafa5 --- /dev/null +++ b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using osu.Game.Online.Multiplayer; +using Xunit; + +namespace osu.Server.Spectator.Tests.Multiplayer +{ + public class RoomInteropTest : MultiplayerTest + { + [Fact] + public async Task CreateRoom() + { + LegacyIO.Setup(io => io.CreateRoomAsync(It.IsAny<int>(), It.IsAny<MultiplayerRoom>())) + .ReturnsAsync(() => ROOM_ID); + + await Hub.CreateRoom(new MultiplayerRoom(0)); + LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny<MultiplayerRoom>()), Times.Once); + LegacyIO.Verify(io => io.JoinRoomAsync(ROOM_ID, USER_ID), Times.Once); + + using (var usage = await Hub.GetRoom(ROOM_ID)) + { + Assert.NotNull(usage.Item); + Assert.Equal(USER_ID, usage.Item.Users.Single().UserID); + } + } + + [Fact] + public async Task LeaveRoom() + { + await Hub.JoinRoom(ROOM_ID); + LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Never); + + await Hub.LeaveRoom(); + LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Once); + + await Assert.ThrowsAsync<KeyNotFoundException>(() => Hub.GetRoom(ROOM_ID)); + } + } +} From 9b33f0515181cfbe96b18bb07605e00beca88ad1 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Tue, 28 Jan 2025 19:42:57 +0900 Subject: [PATCH 11/15] Don't use switch expression --- osu.Server.Spectator/Services/LegacyIO.cs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index cb166d49..108ca581 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -48,12 +48,22 @@ private async Task<string> runLegacyIO(HttpMethod method, string command, dynami long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); string url = $"{interopDomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; - string? serialisedPostObject = postObject switch + string? serialisedPostObject; + + switch (postObject) { - null => null, - string => postObject, - _ => JsonSerializer.Serialize(postObject) - }; + case null: + serialisedPostObject = null; + break; + + case string: + serialisedPostObject = postObject; + break; + + default: + serialisedPostObject = JsonSerializer.Serialize(postObject); + break; + } logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject); From ca2e0c30273d78ec854f4c3703d5f8fc4ba4d9eb Mon Sep 17 00:00:00 2001 From: Dean Herbert <pe@ppy.sh> Date: Mon, 10 Feb 2025 15:41:27 +0900 Subject: [PATCH 12/15] Rename IO methods and adjust xmldoc --- .../Multiplayer/RoomInteropTest.cs | 6 +++--- .../Hubs/Multiplayer/MultiplayerHub.cs | 9 ++++++--- osu.Server.Spectator/Services/ILegacyIO.cs | 19 ++++++++++++++----- osu.Server.Spectator/Services/LegacyIO.cs | 4 ++-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs index b29fafa5..d1809ac3 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs @@ -20,7 +20,7 @@ public async Task CreateRoom() await Hub.CreateRoom(new MultiplayerRoom(0)); LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny<MultiplayerRoom>()), Times.Once); - LegacyIO.Verify(io => io.JoinRoomAsync(ROOM_ID, USER_ID), Times.Once); + LegacyIO.Verify(io => io.AddUserToRoomAsync(ROOM_ID, USER_ID), Times.Once); using (var usage = await Hub.GetRoom(ROOM_ID)) { @@ -33,10 +33,10 @@ public async Task CreateRoom() public async Task LeaveRoom() { await Hub.JoinRoom(ROOM_ID); - LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Never); + LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Never); await Hub.LeaveRoom(); - LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Once); + LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Once); await Assert.ThrowsAsync<KeyNotFoundException>(() => Hub.GetRoom(ROOM_ID)); } diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index 2d3f9f00..324b6b71 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -49,7 +49,10 @@ public MultiplayerHub( public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room) { Log($"{Context.GetUserId()} creating room"); - return await JoinRoomWithPassword(await legacyIO.CreateRoomAsync(Context.GetUserId(), room), room.Settings.Password); + + long roomId = await legacyIO.CreateRoomAsync(Context.GetUserId(), room); + + return await JoinRoomWithPassword(roomId, room.Settings.Password); } public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty); @@ -64,7 +67,7 @@ public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string pass throw new InvalidStateException("Can't join a room when restricted."); } - await legacyIO.JoinRoomAsync(roomId, Context.GetUserId()); + await legacyIO.AddUserToRoomAsync(roomId, Context.GetUserId()); using (var userUsage = await GetOrCreateLocalUserState()) { @@ -914,7 +917,7 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye private async Task leaveRoom(MultiplayerClientState state, bool wasKick) { - await legacyIO.PartRoomAsync(state.CurrentRoomID, state.UserId); + await legacyIO.RemoveUserFromRoomAsync(state.CurrentRoomID, state.UserId); using (var roomUsage = await getLocalUserRoom(state)) await leaveRoom(state, roomUsage, wasKick); diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index 7741583a..3543f695 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -9,25 +9,34 @@ namespace osu.Server.Spectator.Services public interface ILegacyIO { /// <summary> - /// Creates an osu!web Room. + /// Creates an osu!web room. /// </summary> + /// <remarks> + /// This does not join the creating user to the room. A subsequent call to <see cref="AddUserToRoomAsync"/> should be made if required. + /// </remarks> /// <param name="userId">The ID of the user that wants to create the room.</param> /// <param name="room">The room.</param> /// <returns>The room's ID.</returns> Task<long> CreateRoomAsync(int userId, MultiplayerRoom room); /// <summary> - /// Joins an osu!web Room. + /// Adds a user to an osu!web room. /// </summary> + /// <remarks> + /// This performs setup tasks like adding the user to the relevant chat channel. + /// </remarks> /// <param name="roomId">The ID of the room to join.</param> /// <param name="userId">The ID of the user wanting to join the room.</param> - Task JoinRoomAsync(long roomId, int userId); + Task AddUserToRoomAsync(long roomId, int userId); /// <summary> - /// Parts an osu!web Room. + /// Parts an osu!web room. /// </summary> + /// <remarks> + /// This performs setup tasks like removing the user from any relevant chat channels. + /// </remarks> /// <param name="roomId">The ID of the room to part.</param> /// <param name="userId">The ID of the user wanting to part the room.</param> - Task PartRoomAsync(long roomId, int userId); + Task RemoveUserFromRoomAsync(long roomId, int userId); } } diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 108ca581..c2b32b1e 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -127,12 +127,12 @@ public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room) }))); } - public async Task JoinRoomAsync(long roomId, int userId) + public async Task AddUserToRoomAsync(long roomId, int userId) { await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}"); } - public async Task PartRoomAsync(long roomId, int userId) + public async Task RemoveUserFromRoomAsync(long roomId, int userId) { await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); } From 7aa4aa7e9a76c8e7c317ff06b3c60e66499264f2 Mon Sep 17 00:00:00 2001 From: Dean Herbert <pe@ppy.sh> Date: Mon, 10 Feb 2025 15:50:41 +0900 Subject: [PATCH 13/15] Explicitly mention that user is host in creation request --- osu.Server.Spectator/Services/ILegacyIO.cs | 4 ++-- osu.Server.Spectator/Services/LegacyIO.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs index 3543f695..61607f1b 100644 --- a/osu.Server.Spectator/Services/ILegacyIO.cs +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -14,10 +14,10 @@ public interface ILegacyIO /// <remarks> /// This does not join the creating user to the room. A subsequent call to <see cref="AddUserToRoomAsync"/> should be made if required. /// </remarks> - /// <param name="userId">The ID of the user that wants to create the room.</param> + /// <param name="hostUserId">The ID of the user that wants to create the room.</param> /// <param name="room">The room.</param> /// <returns>The room's ID.</returns> - Task<long> CreateRoomAsync(int userId, MultiplayerRoom room); + Task<long> CreateRoomAsync(int hostUserId, MultiplayerRoom room); /// <summary> /// Adds a user to an osu!web room. diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index c2b32b1e..7b01563b 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -119,11 +119,11 @@ private static string hmacEncode(string input, byte[] key) // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method. - public async Task<long> CreateRoomAsync(int userId, MultiplayerRoom room) + public async Task<long> CreateRoomAsync(int hostUserId, MultiplayerRoom room) { - return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room) + return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new RoomWithHostId(room) { - UserId = userId + HostUserId = hostUserId }))); } @@ -137,15 +137,15 @@ public async Task RemoveUserFromRoomAsync(long roomId, int userId) await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); } - private class CreateRoomRequest : Room + private class RoomWithHostId : Room { [Newtonsoft.Json.JsonProperty("user_id")] - public required int UserId { get; init; } + public required int HostUserId { get; init; } /// <summary> /// Creates a <see cref="Room"/> from a <see cref="MultiplayerRoom"/>. /// </summary> - public CreateRoomRequest(MultiplayerRoom room) + public RoomWithHostId(MultiplayerRoom room) { RoomID = room.RoomID; Host = room.Host?.User; From ebf3a4b1940849f063c377a6a672a5a4a43215fe Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Tue, 11 Feb 2025 20:10:28 +0900 Subject: [PATCH 14/15] Remove populating of `CurrentPlaylistItem` + document --- osu.Server.Spectator/Services/LegacyIO.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index 7b01563b..c9b42ab8 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -156,7 +156,6 @@ public RoomWithHostId(MultiplayerRoom room) AutoStartDuration = room.Settings.AutoStartDuration; AutoSkip = room.Settings.AutoSkip; Playlist = room.Playlist.Select(item => new PlaylistItem(item)).ToArray(); - CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId); } } From 0a5343377a508d685c5df3fa1a91ea0a2c219727 Mon Sep 17 00:00:00 2001 From: Dan Balasescu <smoogipoo@smgi.me> Date: Tue, 11 Feb 2025 20:16:36 +0900 Subject: [PATCH 15/15] Add some documentation --- osu.Server.Spectator/Services/LegacyIO.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs index c9b42ab8..dba74ec1 100644 --- a/osu.Server.Spectator/Services/LegacyIO.cs +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -137,8 +137,14 @@ public async Task RemoveUserFromRoomAsync(long roomId, int userId) await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); } + /// <summary> + /// A special <see cref="Room"/> that can be serialised with Newtonsoft.Json to create rooms hosted by a given <see cref="HostUserId">user</see>. + /// </summary> private class RoomWithHostId : Room { + /// <summary> + /// The ID of the user to host the room. + /// </summary> [Newtonsoft.Json.JsonProperty("user_id")] public required int HostUserId { get; init; }