From 8453f165a125b571f992d389c431b26675709cd0 Mon Sep 17 00:00:00 2001 From: Alex Macocian Date: Sun, 26 Nov 2023 22:15:25 +0100 Subject: [PATCH] Switch backend to ASP and Kestrel --- GuildWarsPartySearch.sln | 9 +- .../ContentRetrievalService.cs | 103 +++++++++++++++ GuildWarsPartySearch/BuildDockerImage.ps1 | 4 +- GuildWarsPartySearch/Config.Debug.json | 9 +- GuildWarsPartySearch/Config.Release.json | 7 + .../Base64ToCertificateConverter.cs | 20 +-- .../Converters/NoneConverter.cs | 19 --- .../NoneWebsocketMessageConverter.cs | 18 --- ...stPartyRequestWebsocketMessageConverter.cs | 39 ------ ...tPartyResponseWebsocketMessageConverter.cs | 29 ---- GuildWarsPartySearch/Dockerfile | 4 +- GuildWarsPartySearch/Endpoints/Kill.cs | 36 ----- GuildWarsPartySearch/Endpoints/LiveFeed.cs | 57 +++----- .../Endpoints/PostPartySearch.cs | 34 ++--- .../Endpoints/WebSocketRouteBase.cs | 68 ++++++++++ .../Extensions/WebApplicationExtensions.cs | 101 ++++++++++++++ .../Filters/DecodeUrlFilterAttribute.cs | 29 ---- ...BadRequestOnDataBindingFailureAttribute.cs | 27 ---- .../SimpleStringTokenFilterAttribute.cs | 63 --------- .../GuildWarsPartySearch.Server.csproj | 12 +- .../HttpModules/ContentModule.cs | 98 -------------- GuildWarsPartySearch/Launch/Program.cs | 98 +++++++------- .../Launch/ServerConfiguration.cs | 103 +++++++-------- GuildWarsPartySearch/Models/Endpoints/None.cs | 8 +- .../Endpoints/PostPartySearchRequest.cs | 3 - .../Endpoints/PostPartySearchResponse.cs | 5 +- GuildWarsPartySearch/Options/ServerOptions.cs | 7 +- .../Properties/launchSettings.json | 13 ++ .../Scheduler/TaskWithExpiryScheduler.cs | 40 ------ .../ConnectionMonitorHandler.cs | 85 ------------ .../ContentManagementHandler.cs | 125 ------------------ .../ServerHandlers/StartupHandler.cs | 33 ----- .../Services/Feed/ILiveFeedService.cs | 8 +- .../Services/Feed/LiveFeedService.cs | 65 +++++---- 34 files changed, 487 insertions(+), 892 deletions(-) create mode 100644 GuildWarsPartySearch/BackgroundServices/ContentRetrievalService.cs delete mode 100644 GuildWarsPartySearch/Converters/NoneConverter.cs delete mode 100644 GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs delete mode 100644 GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs delete mode 100644 GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs delete mode 100644 GuildWarsPartySearch/Endpoints/Kill.cs create mode 100644 GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs create mode 100644 GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs delete mode 100644 GuildWarsPartySearch/Filters/DecodeUrlFilterAttribute.cs delete mode 100644 GuildWarsPartySearch/Filters/ReturnBadRequestOnDataBindingFailureAttribute.cs delete mode 100644 GuildWarsPartySearch/Filters/SimpleStringTokenFilterAttribute.cs delete mode 100644 GuildWarsPartySearch/HttpModules/ContentModule.cs create mode 100644 GuildWarsPartySearch/Properties/launchSettings.json delete mode 100644 GuildWarsPartySearch/Scheduler/TaskWithExpiryScheduler.cs delete mode 100644 GuildWarsPartySearch/ServerHandlers/ConnectionMonitorHandler.cs delete mode 100644 GuildWarsPartySearch/ServerHandlers/ContentManagementHandler.cs delete mode 100644 GuildWarsPartySearch/ServerHandlers/StartupHandler.cs diff --git a/GuildWarsPartySearch.sln b/GuildWarsPartySearch.sln index 0602c57..c01ee3a 100644 --- a/GuildWarsPartySearch.sln +++ b/GuildWarsPartySearch.sln @@ -12,14 +12,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GuildWarsPartySearch.Common EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GuildWarsPartySearch.Tests", "GuildWarsPartySearch.Tests\GuildWarsPartySearch.Tests.csproj", "{9FCED959-3EE9-40B7-B6A8-AF13FAB54B9C}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipelines", "Pipelines", "{6C63473D-A6D9-400D-94AD-2E4DBA48B8B8}" - ProjectSection(SolutionItems) = preProject - .github\workflows\content-deploy.yaml = .github\workflows\content-deploy.yaml - .github\workflows\docker-deploy.yaml = .github\workflows\docker-deploy.yaml - .github\workflows\test.yaml = .github\workflows\test.yaml - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuildWarsPartySearch.FrontEnd", "GuildWarsPartySearch.FrontEnd\GuildWarsPartySearch.FrontEnd.csproj", "{3BC53FC6-A022-4D14-B917-5A679AF41EC6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GuildWarsPartySearch.FrontEnd", "GuildWarsPartySearch.FrontEnd\GuildWarsPartySearch.FrontEnd.csproj", "{3BC53FC6-A022-4D14-B917-5A679AF41EC6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/GuildWarsPartySearch/BackgroundServices/ContentRetrievalService.cs b/GuildWarsPartySearch/BackgroundServices/ContentRetrievalService.cs new file mode 100644 index 0000000..0ec9a77 --- /dev/null +++ b/GuildWarsPartySearch/BackgroundServices/ContentRetrievalService.cs @@ -0,0 +1,103 @@ +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs; +using GuildWarsPartySearch.Server.Options; +using System.Extensions; +using Microsoft.Extensions.Options; +using System.Core.Extensions; + +namespace GuildWarsPartySearch.Server.BackgroundServices; + +public sealed class ContentRetrievalService : BackgroundService +{ + private readonly ContentOptions contentOptions; + private readonly StorageAccountOptions storageAccountOptions; + private readonly ILogger logger; + + public ContentRetrievalService( + IOptions contentOptions, + IOptions storageAccountOptions, + ILogger logger) + { + this.contentOptions = contentOptions.Value.ThrowIfNull(); + this.storageAccountOptions = storageAccountOptions.Value.ThrowIfNull(); + this.logger = logger.ThrowIfNull(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ExecuteAsync), string.Empty); + while(!stoppingToken.IsCancellationRequested) + { + try + { + await this.UpdateContent(stoppingToken); + } + catch (Exception ex) + { + scopedLogger.LogError(ex, "Encountered exception while retrieving content"); + } + + await Task.Delay(this.contentOptions.UpdateFrequency, stoppingToken); + } + } + + private async Task UpdateContent(CancellationToken cancellationToken) + { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.UpdateContent), string.Empty); + scopedLogger.LogInformation("Checking content to retrieve"); + var serviceBlobClient = new BlobServiceClient(this.storageAccountOptions.ConnectionString); + var blobContainerClient = serviceBlobClient.GetBlobContainerClient(this.storageAccountOptions.ContainerName); + var blobs = blobContainerClient.GetBlobsAsync(cancellationToken: cancellationToken); + + if (!Directory.Exists(this.contentOptions.StagingFolder)) + { + Directory.CreateDirectory(this.contentOptions.StagingFolder); + } + + scopedLogger.LogInformation($"Retrieving blobs"); + var blobList = new List(); + await foreach (var blob in blobs) + { + blobList.Add(blob); + } + + var stagingFolderFullPath = Path.GetFullPath(this.contentOptions.StagingFolder); + var stagedFiles = Directory.GetFiles(this.contentOptions.StagingFolder, "*", SearchOption.AllDirectories); + var filesToDelete = stagedFiles + .Select(f => Path.GetFullPath(f).Replace(stagingFolderFullPath, "").Replace('\\', '/').Trim('/')) + .Where(f => blobList.None(b => b.Name == f)); + foreach (var file in filesToDelete) + { + scopedLogger.LogInformation($"[{file}] File not in blob. Deleting"); + File.Delete($"{stagingFolderFullPath}\\{file}"); + } + + foreach (var blob in blobList) + { + var finalPath = Path.Combine(this.contentOptions.StagingFolder, blob.Name); + var fileInfo = new FileInfo(finalPath); + fileInfo.Directory!.Create(); + if (fileInfo.Exists && + fileInfo.CreationTimeUtc == blob.Properties.LastModified?.UtcDateTime && + fileInfo.Length == blob.Properties.ContentLength) + { + scopedLogger.LogInformation($"[{blob.Name}] File unchanged. Skipping"); + continue; + } + + var blobClient = blobContainerClient.GetBlobClient(blob.Name); + using var fileStream = new FileStream(finalPath, FileMode.Create); + using var blobStream = await blobClient.OpenReadAsync(new BlobOpenReadOptions(false) + { + BufferSize = 1024, + }, cancellationToken); + + scopedLogger.LogInformation($"[{blob.Name}] Downloading blob"); + await blobStream.CopyToAsync(fileStream, cancellationToken); + scopedLogger.LogInformation($"[{blob.Name}] Downloaded blob"); + + fileInfo = new FileInfo(finalPath); + fileInfo.CreationTimeUtc = blob.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow; + } + } +} diff --git a/GuildWarsPartySearch/BuildDockerImage.ps1 b/GuildWarsPartySearch/BuildDockerImage.ps1 index d8aac5d..5c21a3f 100644 --- a/GuildWarsPartySearch/BuildDockerImage.ps1 +++ b/GuildWarsPartySearch/BuildDockerImage.ps1 @@ -1,4 +1,4 @@ -dotnet publish -c Release -Copy-Item -Path Config.Release.json -Destination ./bin/Release/net8.0/publish/Config.json +dotnet publish -r linux-x64 -c Release -o Publish/ +Copy-Item -Path Config.Release.json -Destination Publish/Config.json docker build -t guildwarspartysearch.server . docker tag guildwarspartysearch.server guildwarspartysearch.azurecr.io/guildwarspartysearch.server \ No newline at end of file diff --git a/GuildWarsPartySearch/Config.Debug.json b/GuildWarsPartySearch/Config.Debug.json index 8302385..2b99c43 100644 --- a/GuildWarsPartySearch/Config.Debug.json +++ b/GuildWarsPartySearch/Config.Debug.json @@ -1,4 +1,11 @@ { + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, "EnvironmentOptions": { "Name": "Debug" }, @@ -15,6 +22,6 @@ }, "ContentOptions": { "UpdateFrequency": "0:5:0", - "StagingFolder": "Content" + "StagingFolder": "Content" } } \ No newline at end of file diff --git a/GuildWarsPartySearch/Config.Release.json b/GuildWarsPartySearch/Config.Release.json index 3a03b79..c714f07 100644 --- a/GuildWarsPartySearch/Config.Release.json +++ b/GuildWarsPartySearch/Config.Release.json @@ -1,4 +1,11 @@ { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, "EnvironmentOptions": { "Name": "Release" }, diff --git a/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs b/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs index 9f94369..05b1fac 100644 --- a/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs +++ b/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs @@ -3,32 +3,22 @@ namespace GuildWarsPartySearch.Server.Converters; -public sealed class Base64ToCertificateConverter : JsonConverter +public sealed class Base64ToCertificateConverter : JsonConverter { - public override bool CanConvert(Type objectType) - { - return objectType == typeof(X509Certificate2); - } - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + public override X509Certificate2? ReadJson(JsonReader reader, Type objectType, X509Certificate2? existingValue, bool hasExistingValue, JsonSerializer serializer) { if (reader.Value is not string base64) { - throw new InvalidOperationException($"Cannot convert {reader.Value} to {nameof(X509Certificate2)}"); + throw new InvalidOperationException($"Cannot deserialize {nameof(X509Certificate2)} from {reader.Value}"); } var bytes = Convert.FromBase64String(base64); return new X509Certificate2(bytes); } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, X509Certificate2? value, JsonSerializer serializer) { - if (value is not X509Certificate2 certificate2) - { - throw new InvalidOperationException($"Cannot convert {value} as {nameof(X509Certificate2)}"); - } - - var base64 = Convert.ToBase64String(certificate2.GetRawCertData()); + var base64 = Convert.ToBase64String(value!.GetRawCertData()); writer.WriteValue(base64); } } diff --git a/GuildWarsPartySearch/Converters/NoneConverter.cs b/GuildWarsPartySearch/Converters/NoneConverter.cs deleted file mode 100644 index e8ff1b4..0000000 --- a/GuildWarsPartySearch/Converters/NoneConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using GuildWarsPartySearch.Server.Models.Endpoints; -using MTSC.Common.Http; -using System.ComponentModel; -using System.Globalization; - -namespace GuildWarsPartySearch.Server.Converters; - -public sealed class NoneConverter : TypeConverter -{ - public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value) - { - if (value is HttpRequestContext) - { - return new None(); - } - - return base.ConvertFrom(context, culture, value!)!; - } -} diff --git a/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs deleted file mode 100644 index a98f319..0000000 --- a/GuildWarsPartySearch/Converters/NoneWebsocketMessageConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -using GuildWarsPartySearch.Server.Models.Endpoints; -using MTSC.Common.WebSockets; -using MTSC.Common.WebSockets.RoutingModules; - -namespace GuildWarsPartySearch.Server.Converters; - -public sealed class NoneWebsocketMessageConverter : IWebsocketMessageConverter -{ - public None ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) - { - return new None(); - } - - public WebsocketMessage ConvertToWebsocketMessage(None message) - { - return new WebsocketMessage { FIN = true }; - } -} diff --git a/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs deleted file mode 100644 index 5f34f99..0000000 --- a/GuildWarsPartySearch/Converters/PostPartyRequestWebsocketMessageConverter.cs +++ /dev/null @@ -1,39 +0,0 @@ -using GuildWarsPartySearch.Server.Models.Endpoints; -using MTSC.Common.WebSockets; -using MTSC.Common.WebSockets.RoutingModules; -using Newtonsoft.Json; -using System.Text; - -namespace GuildWarsPartySearch.Server.Converters; - -public sealed class PostPartyRequestWebsocketMessageConverter : IWebsocketMessageConverter -{ - public PostPartyRequestWebsocketMessageConverter() - { - } - - public WebsocketMessage ConvertToWebsocketMessage(PostPartySearchRequest message) - { - var serializedData = JsonConvert.SerializeObject(message); - var bytes = Encoding.UTF8.GetBytes(serializedData); - return new WebsocketMessage - { - Data = bytes, - Opcode = WebsocketMessage.Opcodes.Text - }; - } - - PostPartySearchRequest IWebsocketMessageConverter.ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) - { - var bytes = websocketMessage.Data; - var serializedData = Encoding.UTF8.GetString(bytes); - try - { - return JsonConvert.DeserializeObject(serializedData)!; - } - catch(Exception ex) - { - return default!; - } - } -} diff --git a/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs b/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs deleted file mode 100644 index 1e16bff..0000000 --- a/GuildWarsPartySearch/Converters/PostPartyResponseWebsocketMessageConverter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using GuildWarsPartySearch.Server.Models.Endpoints; -using MTSC.Common.WebSockets; -using MTSC.Common.WebSockets.RoutingModules; -using Newtonsoft.Json; -using System.Text; - -namespace GuildWarsPartySearch.Server.Converters; - -public sealed class PostPartyResponseWebsocketMessageConverter : IWebsocketMessageConverter -{ - public WebsocketMessage ConvertToWebsocketMessage(PostPartySearchResponse message) - { - var serializedData = JsonConvert.SerializeObject(message); - var bytes = Encoding.UTF8.GetBytes(serializedData); - return new WebsocketMessage - { - Data = bytes, - Opcode = WebsocketMessage.Opcodes.Text, - FIN = true - }; - } - - PostPartySearchResponse IWebsocketMessageConverter.ConvertFromWebsocketMessage(WebsocketMessage websocketMessage) - { - var bytes = websocketMessage.Data; - var serializedData = Encoding.UTF8.GetString(bytes); - return JsonConvert.DeserializeObject(serializedData)!; - } -} diff --git a/GuildWarsPartySearch/Dockerfile b/GuildWarsPartySearch/Dockerfile index d0e02b8..1528fd1 100644 --- a/GuildWarsPartySearch/Dockerfile +++ b/GuildWarsPartySearch/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/runtime:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0 # Set the working directory inside the container WORKDIR /app @@ -6,7 +6,7 @@ WORKDIR /app EXPOSE 80 443 # Copy the build output to the working directory -COPY ./bin/Release/net8.0/publish/ . +COPY Publish/ . # Command to run the executable ENTRYPOINT ["dotnet", "GuildWarsPartySearch.Server.dll"] \ No newline at end of file diff --git a/GuildWarsPartySearch/Endpoints/Kill.cs b/GuildWarsPartySearch/Endpoints/Kill.cs deleted file mode 100644 index 9d38268..0000000 --- a/GuildWarsPartySearch/Endpoints/Kill.cs +++ /dev/null @@ -1,36 +0,0 @@ -using GuildWarsPartySearch.Server.Filters; -using GuildWarsPartySearch.Server.Models.Endpoints; -using GuildWarsPartySearch.Server.Services.Lifetime; -using Microsoft.Extensions.Logging; -using MTSC.Common.Http; -using MTSC.Common.Http.RoutingModules; -using System.Core.Extensions; - -namespace GuildWarsPartySearch.Server.Endpoints; - -[SimpleStringTokenFilter] -public sealed class Kill : HttpRouteBase -{ - private IServerLifetimeService serverLifetimeService; - private ILogger logger; - - public Kill( - IServerLifetimeService serverLifetimeService, - ILogger logger) - { - this.serverLifetimeService = serverLifetimeService.ThrowIfNull(); - this.logger = logger.ThrowIfNull(); - } - - public override Task HandleRequest(None _) - { - this.serverLifetimeService.Kill(); - return Task.FromResult(Success200); - } - - private static HttpResponse Success200 => new() - { - StatusCode = HttpMessage.StatusCodes.OK, - BodyString = "Stopping server" - }; -} diff --git a/GuildWarsPartySearch/Endpoints/LiveFeed.cs b/GuildWarsPartySearch/Endpoints/LiveFeed.cs index a4714dd..56e2a3c 100644 --- a/GuildWarsPartySearch/Endpoints/LiveFeed.cs +++ b/GuildWarsPartySearch/Endpoints/LiveFeed.cs @@ -1,17 +1,12 @@ using GuildWarsPartySearch.Server.Models.Endpoints; using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.PartySearch; -using Microsoft.Extensions.Logging; -using MTSC.Common.WebSockets; -using MTSC.Common.WebSockets.RoutingModules; -using Newtonsoft.Json; using System.Core.Extensions; using System.Extensions; -using System.Text; namespace GuildWarsPartySearch.Server.Endpoints; -public sealed class LiveFeed : WebsocketRouteBase +public sealed class LiveFeed : WebSocketRouteBase> { private readonly IPartySearchService partySearchService; private readonly ILiveFeedService liveFeedService; @@ -27,49 +22,27 @@ public LiveFeed( this.logger = logger.ThrowIfNull(); } - public override void ConnectionClosed() + public override Task ExecuteAsync(None? type, CancellationToken cancellationToken) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionClosed), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); - try - { - scopedLogger.LogInformation("Client disconnected"); - this.liveFeedService.RemoveClient(this.ClientData); - } - catch(Exception e) - { - scopedLogger.LogError(e, "Encountered exception"); - } + return Task.CompletedTask; } - public override async void ConnectionInitialized() + public override async Task SocketAccepted(CancellationToken cancellationToken) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); - try - { - scopedLogger.LogInformation("Client connected"); - this.liveFeedService.AddClient(this.ClientData); - scopedLogger.LogInformation("Sending all party searches"); - var updates = await this.partySearchService.GetAllPartySearches(this.ClientData.CancellationToken); - var serialized = JsonConvert.SerializeObject(updates); - var payload = Encoding.UTF8.GetBytes(serialized); - this.SendMessage(new WebsocketMessage - { - Data = payload, - FIN = true, - Opcode = WebsocketMessage.Opcodes.Text - }); - } - catch(Exception e) - { - scopedLogger.LogError(e, "Encountered exception"); - } - } + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.SocketAccepted), this.Context?.Connection.RemoteIpAddress?.ToString() ?? string.Empty); + this.liveFeedService.AddClient(this.WebSocket!); + scopedLogger.LogInformation("Client accepted to livefeed"); - public override void HandleReceivedMessage(None message) - { + scopedLogger.LogInformation("Sending all party searches"); + var updates = await this.partySearchService.GetAllPartySearches(cancellationToken); + await this.SendMessage(updates, cancellationToken); } - public override void Tick() + public override Task SocketClosed() { + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.SocketAccepted), this.Context?.Connection.RemoteIpAddress?.ToString() ?? string.Empty); + this.liveFeedService.RemoveClient(this.WebSocket!); + scopedLogger.LogInformation("Client removed from livefeed"); + return Task.CompletedTask; } } diff --git a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs index 48a812e..950953b 100644 --- a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs +++ b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs @@ -2,14 +2,12 @@ using GuildWarsPartySearch.Server.Models.Endpoints; using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.PartySearch; -using Microsoft.Extensions.Logging; -using MTSC.Common.WebSockets.RoutingModules; using System.Core.Extensions; using System.Extensions; namespace GuildWarsPartySearch.Server.Endpoints; -public sealed class PostPartySearch : WebsocketRouteBase +public sealed class PostPartySearch : WebSocketRouteBase { private readonly ILiveFeedService liveFeedService; private readonly IPartySearchService partySearchService; @@ -25,28 +23,16 @@ public PostPartySearch( this.logger = logger.ThrowIfNull(); } - public override void ConnectionClosed() + public override async Task ExecuteAsync(PostPartySearchRequest? message, CancellationToken cancellationToken) { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); - scopedLogger.LogInformation("Client disconnected"); - } - - public override void ConnectionInitialized() - { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ConnectionInitialized), this.ClientData.Socket.RemoteEndPoint?.ToString() ?? string.Empty); - scopedLogger.LogInformation("Client connected"); - } - - public override async void HandleReceivedMessage(PostPartySearchRequest message) - { - var scopedLogger = this.logger.CreateScopedLogger(nameof(this.HandleReceivedMessage), string.Empty); + var scopedLogger = this.logger.CreateScopedLogger(nameof(this.ExecuteAsync), string.Empty); try { - var result = await this.partySearchService.PostPartySearch(message, this.ClientData.CancellationToken); + var result = await this.partySearchService.PostPartySearch(message, cancellationToken); var response = result.Switch( onSuccess: _ => { - this.liveFeedService.PushUpdate(this.Server, new PartySearch + this.liveFeedService.PushUpdate(new PartySearch { Campaign = message.Campaign, Continent = message.Continent, @@ -54,7 +40,7 @@ public override async void HandleReceivedMessage(PostPartySearchRequest message) Map = message.Map, PartySearchEntries = message.PartySearchEntries, Region = message.Region - }); + }, cancellationToken); return Success; }, onFailure: failure => failure switch @@ -74,18 +60,14 @@ public override async void HandleReceivedMessage(PostPartySearchRequest message) _ => UnspecifiedFailure }); - this.SendMessage(response); + await this.SendMessage(response, cancellationToken); } - catch(Exception e) + catch (Exception e) { scopedLogger.LogError(e, "Encountered exception"); } } - public override void Tick() - { - } - private static PostPartySearchResponse Success => new() { Result = 0, diff --git a/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs b/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs new file mode 100644 index 0000000..959ec73 --- /dev/null +++ b/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs @@ -0,0 +1,68 @@ +using Newtonsoft.Json; +using System.Extensions; +using System.Net.WebSockets; +using System.Text; + +namespace GuildWarsPartySearch.Server.Endpoints; + +public abstract class WebSocketRouteBase +{ + public HttpContext? Context { get; internal set; } + + public WebSocket? WebSocket { get; internal set; } + + public virtual Task SocketAccepted(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public virtual Task SocketClosed() + { + return Task.CompletedTask; + } + + public abstract Task ExecuteAsync(byte[] data, CancellationToken cancellationToken); +} + +public abstract class WebSocketRouteBase : WebSocketRouteBase + where TReceiveType : class, new() +{ + public sealed override Task ExecuteAsync(byte[] data, CancellationToken cancellationToken) + { + var stringData = Encoding.UTF8.GetString(data); + try + { + var objData = JsonConvert.DeserializeObject(stringData); + return this.ExecuteAsync(objData, cancellationToken); + } + catch(Exception ex) + { + var scoppedLogger = this.Context!.RequestServices.GetRequiredService>().CreateScopedLogger(nameof(this.ExecuteAsync), string.Empty); + scoppedLogger.LogError(ex, "Failed to process data"); + scoppedLogger.LogDebug($"Failed to process data. Content: {stringData}"); + throw; + } + } + + public abstract Task ExecuteAsync(TReceiveType? type, CancellationToken cancellationToken); +} + +public abstract class WebSocketRouteBase : WebSocketRouteBase + where TReceiveType : class, new() +{ + public Task SendMessage(TSendType sendType, CancellationToken cancellationToken) + { + try + { + var serialized = JsonConvert.SerializeObject(sendType); + var data = Encoding.UTF8.GetBytes(serialized); + return this.WebSocket!.SendAsync(data, WebSocketMessageType.Text, true, cancellationToken); + } + catch(Exception ex ) + { + var scoppedLogger = this.Context!.RequestServices.GetRequiredService>().CreateScopedLogger(nameof(this.SendMessage), typeof(TSendType).Name); + scoppedLogger.LogError(ex, "Failed to send data"); + throw; + } + } +} \ No newline at end of file diff --git a/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs b/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000..83dae28 --- /dev/null +++ b/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,101 @@ +using GuildWarsPartySearch.Server.Endpoints; +using System.Core.Extensions; +using System.Diagnostics.CodeAnalysis; +using System.Extensions; +using System.Net.WebSockets; + +namespace GuildWarsPartySearch.Server.Extensions; + +public static class WebApplicationExtensions +{ + public static WebApplication MapWebSocket<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(this WebApplication app, string route, Func>? routeFilter = default) + where TWebSocketRoute : WebSocketRouteBase + { + app.ThrowIfNull(); + app.Map(route, async context => + { + if (routeFilter is not null && + await routeFilter(context) is false) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (context.WebSockets.IsWebSocketRequest) + { + var logger = app.Services.GetRequiredService>(); + var route = GetRoute(context); + try + { + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + route.WebSocket = webSocket; + route.Context = context; + await route.SocketAccepted(context.RequestAborted); + await HandleWebSocket(webSocket, route, context.RequestAborted); + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", context.RequestAborted); + } + catch(WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) + { + logger.LogInformation("Websocket closed prematurely. Marking as closed"); + } + catch(Exception ex) + { + logger.LogError(ex, "Encountered exception while handling websocket. Closing"); + } + finally + { + await route.SocketClosed(); + } + } + else + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } + }); + + return app; + } + + private static async Task HandleWebSocket(WebSocket webSocket, WebSocketRouteBase route, CancellationToken cancellationToken) + { + var buffer = new byte[1024]; + using var memoryStream = new MemoryStream(1024); + ValueWebSocketReceiveResult result; + while(webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) + { + do + { + var memory = new Memory(buffer); + result = await webSocket.ReceiveAsync(memory, cancellationToken); + await memoryStream.WriteAsync(buffer, 0, result.Count, cancellationToken); + } while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) + { + return; + } + + await route.ExecuteAsync(memoryStream.ToArray(), cancellationToken); + memoryStream.SetLength(0); + } + } + + private static WebSocketRouteBase GetRoute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(HttpContext context) + where TWebSocketRoute : WebSocketRouteBase + { + var constructors = typeof(TWebSocketRoute).GetConstructors(); + foreach(var constructor in constructors) + { + var dependencies = constructor.GetParameters().Select(param => context.RequestServices.GetService(param.ParameterType)); + if (dependencies.Any(d => d is null)) + { + continue; + } + + var route = constructor.Invoke(dependencies.ToArray()); + return route.Cast(); + } + + throw new InvalidOperationException($"Unable to resolve {typeof(TWebSocketRoute).Name}"); + } +} diff --git a/GuildWarsPartySearch/Filters/DecodeUrlFilterAttribute.cs b/GuildWarsPartySearch/Filters/DecodeUrlFilterAttribute.cs deleted file mode 100644 index a5c8743..0000000 --- a/GuildWarsPartySearch/Filters/DecodeUrlFilterAttribute.cs +++ /dev/null @@ -1,29 +0,0 @@ -using MTSC.Common.Http; -using MTSC.Common.Http.Attributes; - -namespace GuildWarsPartySearch.Server.Filters; - -public sealed class DecodeUrlFilterAttribute : RouteFilterAttribute -{ - public override RouteEnablerResponse HandleRequest(RouteContext routeContext) - { - routeContext.HttpRequest.RequestURI = ReplaceUrlEncodings(routeContext.HttpRequest.RequestURI); - - foreach(var valueTuple in routeContext.UrlValues) - { - routeContext.UrlValues[valueTuple.Key] = ReplaceUrlEncodings(valueTuple.Value); - } - - return RouteEnablerResponse.Accept; - } - - private static string ReplaceUrlEncodings(string value) - { - return value.Replace("%20", " ") - .Replace("%21", "!") - .Replace("%22", "\"") - .Replace("%23", "#") - .Replace("%26", "&") - .Replace("%27", "'"); - } -} diff --git a/GuildWarsPartySearch/Filters/ReturnBadRequestOnDataBindingFailureAttribute.cs b/GuildWarsPartySearch/Filters/ReturnBadRequestOnDataBindingFailureAttribute.cs deleted file mode 100644 index f82bf1f..0000000 --- a/GuildWarsPartySearch/Filters/ReturnBadRequestOnDataBindingFailureAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -using MTSC.Common.Http.Attributes; -using MTSC.Common.Http; -using MTSC.Exceptions; - -namespace GuildWarsPartySearch.Server.Filters; - -public sealed class ReturnBadRequestOnDataBindingFailureAttribute : RouteFilterAttribute -{ - public override RouteFilterExceptionHandlingResponse HandleException(RouteContext routeFilterContext, Exception exception) - { - if (exception is DataBindingException) - { - return BadRequest400("Bad request"); - } - else - { - return base.HandleException(routeFilterContext, exception); - } - } - - private static RouteFilterExceptionHandlingResponse BadRequest400(string message) => RouteFilterExceptionHandlingResponse.Handled( - new HttpResponse - { - StatusCode = HttpMessage.StatusCodes.BadRequest, - BodyString = message - }); -} diff --git a/GuildWarsPartySearch/Filters/SimpleStringTokenFilterAttribute.cs b/GuildWarsPartySearch/Filters/SimpleStringTokenFilterAttribute.cs deleted file mode 100644 index 193d727..0000000 --- a/GuildWarsPartySearch/Filters/SimpleStringTokenFilterAttribute.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.Logging; -using MTSC.Common.Http; -using MTSC.Common.Http.Attributes; -using Slim.Attributes; -using System.Core.Extensions; - -namespace GuildWarsPartySearch.Server.Filters; - -/// -/// Super stupid filter in place. -/// TODO: Change asap to a proper authentication scheme such as MTLS -/// -public sealed class SimpleStringTokenFilterAttribute : RouteFilterAttribute -{ - public static string StringTokenHeader = "X-Token"; - private static string StringTokenValue = "1234"; - - private readonly ILogger logger; - - /// - /// Need to keep this constructor so that this class can be used as an attribute. - /// This constructor will never be called by the DI engine during normal usage. - /// - [DoNotInject] - public SimpleStringTokenFilterAttribute() - { - this.logger = default!; - } - - public SimpleStringTokenFilterAttribute( - ILogger logger) - { - this.logger = logger.ThrowIfNull(); - } - - public override RouteEnablerResponse HandleRequest(RouteContext routeContext) - { - if (!routeContext.HttpRequest.Headers.ContainsHeader(StringTokenHeader)) - { - return RouteEnablerResponse.Error(MissingToken403); - } - - var token = routeContext.HttpRequest.Headers[StringTokenHeader]; - if (token != StringTokenValue) - { - return RouteEnablerResponse.Error(InvalidToken403); - } - - return RouteEnablerResponse.Accept; - } - - private static HttpResponse MissingToken403 => new() - { - StatusCode = HttpMessage.StatusCodes.Forbidden, - BodyString = "Missing Token" - }; - - private static HttpResponse InvalidToken403 => new() - { - StatusCode = HttpMessage.StatusCodes.Forbidden, - BodyString = "Invalid Token" - }; -} diff --git a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj index e1ef38d..79121c6 100644 --- a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj +++ b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj @@ -1,7 +1,6 @@ - + - Exe net8.0 enable enable @@ -9,18 +8,9 @@ Debug;Release;Local - - - - - - - - - diff --git a/GuildWarsPartySearch/HttpModules/ContentModule.cs b/GuildWarsPartySearch/HttpModules/ContentModule.cs deleted file mode 100644 index 3899016..0000000 --- a/GuildWarsPartySearch/HttpModules/ContentModule.cs +++ /dev/null @@ -1,98 +0,0 @@ -using GuildWarsPartySearch.Server.Options; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MTSC.Common.Http; -using MTSC.Common.Http.ServerModules; -using MTSC.ServerSide; -using MTSC.ServerSide.Handlers; -using System.Extensions; - -namespace GuildWarsPartySearch.Server.HttpModules; - -public sealed class ContentModule : IHttpModule -{ - private ContentOptions? contentOptions; - private ILogger logger; - - public bool HandleRequest(MTSC.ServerSide.Server server, HttpHandler handler, ClientData client, HttpRequest request, ref HttpResponse response) - { - if (this.contentOptions is null) - { - response = ContentUnavailable503; - return true; - } - - this.logger.LogInformation($"Requesting {request.RequestURI}"); - var path = request.RequestURI; - var contentRootPath = Path.GetFullPath(this.contentOptions.StagingFolder); - var requestUri = request.RequestURI; - if (requestUri.IsNullOrWhiteSpace() || - requestUri == "/") - { - requestUri = "index.html"; - } - - var requestedPath = Path.Combine(contentRootPath, requestUri); - this.logger.LogInformation($"Resolved path to {requestedPath}"); - if (!IsSubPathOf(contentRootPath, requestedPath)) - { - this.logger.LogWarning($"Forbidden to {requestedPath}"); - response = Forbidden403; - return true; - } - - if (!File.Exists(requestedPath)) - { - this.logger.LogWarning($"Not found {requestedPath}"); - response = NotFound404; - return true; - } - - this.logger.LogWarning($"Returning {requestedPath}"); - response = Ok200(File.ReadAllBytes(requestedPath)); - return true; - } - - public void Tick(MTSC.ServerSide.Server server, HttpHandler handler) - { - if (this.contentOptions is null) - { - this.contentOptions = server.ServiceManager.GetRequiredService>().Value; - this.logger = server.ServiceManager.GetRequiredService>(); - } - } - - private static HttpResponse Ok200(byte[] content) => new() - { - StatusCode = HttpMessage.StatusCodes.OK, - Body = content - }; - - private static readonly HttpResponse NotFound404 = new() - { - StatusCode = HttpMessage.StatusCodes.NotFound, - BodyString = "Content not found" - }; - - private static readonly HttpResponse ContentUnavailable503 = new() - { - StatusCode = HttpMessage.StatusCodes.ServiceUnavailable, - BodyString = "Content is not yet available. Please wait and try again" - }; - - private static readonly HttpResponse Forbidden403 = new() - { - StatusCode = HttpMessage.StatusCodes.Forbidden, - BodyString = "Requested content is forbidden. Please adjust request uri and try again" - }; - - private static bool IsSubPathOf(string basePath, string path) - { - // Normalize the base path and the test path - var normalizedBasePath = Path.GetFullPath(basePath); - var normalizedTestPath = Path.GetFullPath(path); - - return normalizedTestPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase); - } -} diff --git a/GuildWarsPartySearch/Launch/Program.cs b/GuildWarsPartySearch/Launch/Program.cs index 0e56026..63932fc 100644 --- a/GuildWarsPartySearch/Launch/Program.cs +++ b/GuildWarsPartySearch/Launch/Program.cs @@ -1,14 +1,10 @@ // See https://aka.ms/new-console-template for more information -using GuildWarsPartySearch.Server.HttpModules; +using GuildWarsPartySearch.Server.Endpoints; +using GuildWarsPartySearch.Server.Extensions; using GuildWarsPartySearch.Server.Options; -using GuildWarsPartySearch.Server.Scheduler; -using GuildWarsPartySearch.Server.ServerHandlers; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MTSC.ServerSide.Handlers; -using MTSC.ServerSide.Schedulers; -using MTSC.ServerSide.UsageMonitors; +using Microsoft.Extensions.FileProviders; +using System.Net; +using System.Security.Cryptography.X509Certificates; namespace GuildWarsPartySearch.Server.Launch; @@ -18,42 +14,52 @@ public class Program private static async Task Main() { - var httpsServer = new MTSC.ServerSide.Server(443); - httpsServer.ServiceCollection.SetupServices(); - httpsServer.ServiceManager.SetupServiceManager(); - httpsServer - .AddHandler( - new WebsocketRoutingHandler() - .SetupRoutes() - .WithHeartbeatEnabled(true) - .WithHeartbeatFrequency(httpsServer.ServiceManager.GetRequiredService>().Value.HeartbeatFrequency ?? TimeSpan.FromSeconds(5))) - .AddHandler(new ConnectionMonitorHandler()) - .AddHandler(new StartupHandler()) - .AddHandler(new ContentManagementHandler()) - .AddHandler(new HttpHandler() - .AddHttpModule(new ContentModule())) - .AddServerUsageMonitor(new TickrateEnforcer() { TicksPerSecond = 60, Silent = true }) - .SetScheduler(new TaskWithExpiryScheduler()) - .WithLoggingMessageContents(false); - var serverOptions = httpsServer.ServiceManager.GetRequiredService>(); - httpsServer.WithCertificate(serverOptions.Value.Certificate); - httpsServer.WithClientCertificate(false); - - var httpServer = new MTSC.ServerSide.Server(80); - httpServer.ServiceCollection.AddScoped(_ => httpsServer.ServiceManager.GetRequiredService>()); - httpServer.ServiceCollection.AddScoped(_ => httpsServer.ServiceManager.GetRequiredService>()); - httpServer.ServiceCollection.AddScoped(_ => httpsServer.ServiceManager.GetRequiredService>()); - httpServer.AddHandler(new HttpHandler() - .AddHttpModule(new ContentModule())) - .AddServerUsageMonitor(new TickrateEnforcer { TicksPerSecond = 10, Silent = true }) - .SetScheduler(new TaskWithExpiryScheduler()) - .WithLoggingMessageContents(false); - - var httpsServerTask = httpsServer.RunAsync(CancellationTokenSource.Token); - var httpServerTask = httpServer.RunAsync(CancellationTokenSource.Token); - - await Task.WhenAll( - httpsServerTask, - httpServerTask); + var config = new ConfigurationBuilder() + .SetupConfiguration() + .Build(); + + var builder = WebApplication.CreateBuilder() + .SetupOptions() + .SetupHostedServices(); + builder.Logging.SetupLogging(); + builder.Services.SetupServices(); + builder.Configuration.AddConfiguration(config); + builder.WebHost.ConfigureKestrel(kestrelOptions => + { + kestrelOptions.Listen(IPAddress.Any, 443, listenOptions => + { + var serverOptions = builder.Configuration.GetRequiredSection(nameof(ServerOptions)).Get(); + var certificateBytes = Convert.FromBase64String(serverOptions?.Certificate!); + var certificate = new X509Certificate2(certificateBytes); + listenOptions.UseHttps(certificate); + }); + + kestrelOptions.Listen(IPAddress.Any, 80, listenOptions => + { + }); + }); + + var contentOptions = builder.Configuration.GetRequiredSection(nameof(ContentOptions)).Get()!; + var contentDirectory = new DirectoryInfo(contentOptions.StagingFolder); + if (!contentDirectory.Exists) + { + contentDirectory.Create(); + } + + var app = builder.Build(); + app.UseWebSockets() + .UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(contentDirectory.FullName) + }); + app.MapGet("/", context => + { + context.Response.Redirect("/index.html"); + return Task.CompletedTask; + }); + app.MapWebSocket("party-search/update"); + app.MapWebSocket("party-search/live-feed"); + + await app.RunAsync(); } } \ No newline at end of file diff --git a/GuildWarsPartySearch/Launch/ServerConfiguration.cs b/GuildWarsPartySearch/Launch/ServerConfiguration.cs index 2cc078b..bb967e8 100644 --- a/GuildWarsPartySearch/Launch/ServerConfiguration.cs +++ b/GuildWarsPartySearch/Launch/ServerConfiguration.cs @@ -1,22 +1,14 @@ -using GuildWarsPartySearch.Server.Endpoints; +using GuildWarsPartySearch.Server.BackgroundServices; +using GuildWarsPartySearch.Server.Endpoints; +using GuildWarsPartySearch.Server.Extensions; using GuildWarsPartySearch.Server.Options; using GuildWarsPartySearch.Server.Services.Database; using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.Lifetime; -using GuildWarsPartySearch.Server.Services.Logging; -using GuildWarsPartySearch.Server.Services.Options; using GuildWarsPartySearch.Server.Services.PartySearch; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using MTSC.Common.Http; -using MTSC.ServerSide; -using MTSC.ServerSide.Handlers; -using Slim; using System.Core.Extensions; using System.Extensions; -using System.Logging; -using static MTSC.Common.Http.HttpMessage; namespace GuildWarsPartySearch.Server.Launch; @@ -24,24 +16,42 @@ public static class ServerConfiguration { private const string ApiKeyHeader = "X-ApiKey"; - public static IServiceManager SetupServiceManager(this IServiceManager serviceManager) + public static WebApplicationBuilder SetupHostedServices(this WebApplicationBuilder builder) { - serviceManager.ThrowIfNull(); + builder.ThrowIfNull() + .Services + .AddHostedService(); - serviceManager.RegisterResolver(new LoggerResolver()); - serviceManager.RegisterOptionsResolver(); - serviceManager.RegisterSingleton(); - serviceManager.RegisterScoped(sp => - { - var loggerFactory = LoggerFactory.Create(builder => - { - builder.AddFilter("Azure", LogLevel.Information); - builder.AddProvider(new CVLoggerProvider(sp.GetService())); - }); - return loggerFactory; - }); - serviceManager.RegisterOptionsManager(); - return serviceManager; + return builder; + } + + public static IConfigurationBuilder SetupConfiguration(this IConfigurationBuilder builder) + { + builder.ThrowIfNull() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("Config.json"); + + return builder; + } + + public static ILoggingBuilder SetupLogging(this ILoggingBuilder builder) + { + builder.ThrowIfNull() + .ClearProviders() + .AddConsole(); + + return builder; + } + + public static WebApplicationBuilder SetupOptions(this WebApplicationBuilder builder) + { + builder.ThrowIfNull() + .Services.Configure(builder.Configuration.GetSection(nameof(EnvironmentOptions))) + .Configure(builder.Configuration.GetSection(nameof(ContentOptions))) + .Configure(builder.Configuration.GetSection(nameof(StorageAccountOptions))) + .Configure(builder.Configuration.GetSection(nameof(ServerOptions))); + + return builder; } public static IServiceCollection SetupServices(this IServiceCollection services) @@ -55,41 +65,30 @@ public static IServiceCollection SetupServices(this IServiceCollection services) return services; } - public static WebsocketRoutingHandler SetupRoutes(this WebsocketRoutingHandler websocketRoutingHandler) + public static WebApplication SetupRoutes(this WebApplication app) { - websocketRoutingHandler.ThrowIfNull(); - websocketRoutingHandler - .AddRoute("party-search/live-feed") - .AddRoute("party-search/update", FilterUpdateMessages) - .WithHeartbeatEnabled(true) - .WithHeartbeatFrequency(TimeSpan.FromSeconds(5)); - return websocketRoutingHandler; + app.ThrowIfNull() + .MapWebSocket("party-search/live-feed") + .MapWebSocket("party-search/update", FilterUpdateMessages); + + return app; } - private static RouteEnablerResponse FilterUpdateMessages(MTSC.ServerSide.Server server, HttpRequest req, ClientData clientData) + private static Task FilterUpdateMessages(HttpContext context) { - var serverOptions = server.ServiceManager.GetRequiredService>(); + var serverOptions = context.RequestServices.GetRequiredService>(); if (serverOptions.Value.ApiKey!.IsNullOrWhiteSpace()) { - return RouteEnablerResponse.Error(Forbidden403); + return Task.FromResult(false); } - if (!req.Headers.ContainsHeader(ApiKeyHeader)) + if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var value) || + value.FirstOrDefault() is not string headerValue || + headerValue != serverOptions.Value.ApiKey) { - return RouteEnablerResponse.Error(Forbidden403); + return Task.FromResult(false); } - var apiKey = req.Headers[ApiKeyHeader]; - if (apiKey != serverOptions.Value.ApiKey) - { - return RouteEnablerResponse.Error(Forbidden403); - } - - return RouteEnablerResponse.Accept; + return Task.FromResult(true); } - - private static HttpResponse Forbidden403 => new() - { - StatusCode = StatusCodes.Forbidden - }; } diff --git a/GuildWarsPartySearch/Models/Endpoints/None.cs b/GuildWarsPartySearch/Models/Endpoints/None.cs index f3d55e9..614660c 100644 --- a/GuildWarsPartySearch/Models/Endpoints/None.cs +++ b/GuildWarsPartySearch/Models/Endpoints/None.cs @@ -1,11 +1,5 @@ -using GuildWarsPartySearch.Server.Converters; -using MTSC.Common.WebSockets.RoutingModules; -using System.ComponentModel; +namespace GuildWarsPartySearch.Server.Models.Endpoints; -namespace GuildWarsPartySearch.Server.Models.Endpoints; - -[TypeConverter(typeof(NoneConverter))] -[WebsocketMessageConvert(typeof(NoneWebsocketMessageConverter))] public sealed class None { } diff --git a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs index e1c844d..6f62fc1 100644 --- a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs +++ b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchRequest.cs @@ -1,11 +1,8 @@ using GuildWarsPartySearch.Common.Models.GuildWars; -using GuildWarsPartySearch.Server.Converters; -using MTSC.Common.WebSockets.RoutingModules; using Newtonsoft.Json; namespace GuildWarsPartySearch.Server.Models.Endpoints; -[WebsocketMessageConvert(typeof(PostPartyRequestWebsocketMessageConverter))] public sealed class PostPartySearchRequest { [JsonProperty(nameof(Campaign))] diff --git a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs index ce48f3b..e4e1316 100644 --- a/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs +++ b/GuildWarsPartySearch/Models/Endpoints/PostPartySearchResponse.cs @@ -1,10 +1,7 @@ -using GuildWarsPartySearch.Server.Converters; -using MTSC.Common.WebSockets.RoutingModules; -using Newtonsoft.Json; +using Newtonsoft.Json; namespace GuildWarsPartySearch.Server.Models.Endpoints; -[WebsocketMessageConvert(typeof(PostPartyResponseWebsocketMessageConverter))] public sealed class PostPartySearchResponse { [JsonProperty(nameof(Result))] diff --git a/GuildWarsPartySearch/Options/ServerOptions.cs b/GuildWarsPartySearch/Options/ServerOptions.cs index cab7717..efb9a7a 100644 --- a/GuildWarsPartySearch/Options/ServerOptions.cs +++ b/GuildWarsPartySearch/Options/ServerOptions.cs @@ -1,14 +1,11 @@ -using GuildWarsPartySearch.Server.Converters; -using Newtonsoft.Json; -using System.Security.Cryptography.X509Certificates; +using Newtonsoft.Json; namespace GuildWarsPartySearch.Server.Options; public sealed class ServerOptions { [JsonProperty(nameof(Certificate))] - [JsonConverter(typeof(Base64ToCertificateConverter))] - public X509Certificate2? Certificate { get; set; } + public string? Certificate { get; set; } [JsonProperty(nameof(ApiKey))] public string? ApiKey { get; set; } diff --git a/GuildWarsPartySearch/Properties/launchSettings.json b/GuildWarsPartySearch/Properties/launchSettings.json new file mode 100644 index 0000000..5f6c274 --- /dev/null +++ b/GuildWarsPartySearch/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "GuildWarsPartySearch.Server": { + "commandName": "Project", + "workingDirectory": "$(OutDir)", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57442;http://localhost:57443" + } + } +} \ No newline at end of file diff --git a/GuildWarsPartySearch/Scheduler/TaskWithExpiryScheduler.cs b/GuildWarsPartySearch/Scheduler/TaskWithExpiryScheduler.cs deleted file mode 100644 index 554bd81..0000000 --- a/GuildWarsPartySearch/Scheduler/TaskWithExpiryScheduler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using MTSC; -using MTSC.Common; -using MTSC.ServerSide; -using MTSC.ServerSide.BackgroundServices; -using MTSC.ServerSide.Schedulers; - -namespace GuildWarsPartySearch.Server.Scheduler; - -public sealed class TaskWithExpiryScheduler : IScheduler -{ - private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5); - private static readonly TaskFactory TaskFactory = new(); - - public void ScheduleBackgroundService(BackgroundServiceBase backgroundServiceBase) - { - try - { - TaskFactory.StartNew(backgroundServiceBase.Execute, new CancellationTokenSource(OperationTimeout).Token, TaskCreationOptions.LongRunning, TaskScheduler.Current); - } - catch - { - } - } - - public void ScheduleHandling(List<(ClientData, IConsumerQueue)> clientsQueues, Action> messageHandlingProcedure) - { - var cancellationTokenSource = new CancellationTokenSource(OperationTimeout); - foreach (var clientsQueue in clientsQueues) - { - var (client, messageQueue) = clientsQueue; - try - { - TaskFactory.StartNew(() => messageHandlingProcedure(client, messageQueue), cancellationTokenSource.Token, TaskCreationOptions.PreferFairness, TaskScheduler.Current); - } - catch - { - } - } - } -} diff --git a/GuildWarsPartySearch/ServerHandlers/ConnectionMonitorHandler.cs b/GuildWarsPartySearch/ServerHandlers/ConnectionMonitorHandler.cs deleted file mode 100644 index 1a5175f..0000000 --- a/GuildWarsPartySearch/ServerHandlers/ConnectionMonitorHandler.cs +++ /dev/null @@ -1,85 +0,0 @@ -using MTSC.ServerSide; -using MTSC; -using System.Net.Sockets; -using MTSC.ServerSide.Handlers; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using GuildWarsPartySearch.Server.Options; - -namespace GuildWarsPartySearch.Server.ServerHandlers; - -public class ConnectionMonitorHandler : IHandler -{ - private bool initialized; - private TimeSpan inactivityTimeout; - - void IHandler.ClientRemoved(MTSC.ServerSide.Server server, ClientData client) { } - - bool IHandler.HandleClient(MTSC.ServerSide.Server server, ClientData client) => false; - - bool IHandler.HandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, Message message) => false; - - bool IHandler.HandleSendMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - bool IHandler.PreHandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - void IHandler.Tick(MTSC.ServerSide.Server server) - { - if (!this.initialized) - { - this.initialized = true; - this.inactivityTimeout = server.ServiceManager.GetRequiredService>().Value.InactivityTimeout ?? TimeSpan.FromSeconds(15); - } - - foreach (ClientData client in server.Clients) - { - if (DateTime.Now - client.LastActivityTime > this.inactivityTimeout) - { - server.Log("Disconnected: " + client.Socket.RemoteEndPoint?.ToString()); - client.ToBeRemoved = true; - } - } - } - - private bool IsConnected(Socket client) - { - try - { - if (client is not null && client.Connected) - { - /* pear to the documentation on Poll: - * When passing SelectMode.SelectRead as a parameter to the Poll method it will return - * -either- true if Socket.Listen(Int32) has been called and a connection is pending; - * -or- true if data is available for reading; - * -or- true if the connection has been closed, reset, or terminated; - * otherwise, returns false - */ - - // Detect if client disconnected - if (client.Poll(0, SelectMode.SelectRead)) - { - byte[] buff = new byte[1]; - if (client.Receive(buff, SocketFlags.Peek) == 0) - { - // Client disconnected - return false; - } - else - { - return true; - } - } - - return true; - } - else - { - return false; - } - } - catch - { - return false; - } - } -} diff --git a/GuildWarsPartySearch/ServerHandlers/ContentManagementHandler.cs b/GuildWarsPartySearch/ServerHandlers/ContentManagementHandler.cs deleted file mode 100644 index aaee7ff..0000000 --- a/GuildWarsPartySearch/ServerHandlers/ContentManagementHandler.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using GuildWarsPartySearch.Server.Options; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MTSC; -using MTSC.ServerSide; -using MTSC.ServerSide.Handlers; -using System.Extensions; -using System.Logging; - -namespace GuildWarsPartySearch.Server.ServerHandlers; - -public sealed class ContentManagementHandler : IHandler -{ - private bool initialized = false; - private DateTime lastUpdateTime = DateTime.MinValue; - private TimeSpan updateFrequency = TimeSpan.FromMinutes(5); - private string stagingFolder = "Content"; - - public void ClientRemoved(MTSC.ServerSide.Server server, ClientData client) - { - } - - public bool HandleClient(MTSC.ServerSide.Server server, ClientData client) => false; - - public bool HandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, Message message) => false; - - public bool HandleSendMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - public bool PreHandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - public void Tick(MTSC.ServerSide.Server server) - { - if (!this.initialized) - { - this.initialized = true; - var options = server.ServiceManager.GetRequiredService>(); - this.updateFrequency = options.Value.UpdateFrequency; - this.stagingFolder = options.Value.StagingFolder; - } - - if (DateTime.Now - this.lastUpdateTime > this.updateFrequency) - { - this.lastUpdateTime = DateTime.Now; - var logger = server.ServiceManager.GetRequiredService>(); - var options = server.ServiceManager.GetRequiredService>(); - Task.Run(() => this.UpdateContentSafe(options.Value, logger)); - - } - } - - private async void UpdateContentSafe(StorageAccountOptions options, ILogger logger) - { - var scopedLogger = logger.CreateScopedLogger(nameof(this.UpdateContentSafe), string.Empty); - try - { - await this.UpdateContent(options, scopedLogger); - } - catch (Exception ex) - { - scopedLogger.LogError(ex, "Encountered exception"); - } - } - - private async Task UpdateContent(StorageAccountOptions options, ScopedLogger scopedLogger) - { - scopedLogger.LogInformation("Checking content to retrieve"); - var serviceBlobClient = new BlobServiceClient(options.ConnectionString); - var blobContainerClient = serviceBlobClient.GetBlobContainerClient(options.ContainerName); - var blobs = blobContainerClient.GetBlobsAsync(); - - if (!Directory.Exists(this.stagingFolder)) - { - Directory.CreateDirectory(this.stagingFolder); - } - - scopedLogger.LogInformation($"Retrieving blobs"); - var blobList = new List(); - await foreach(var blob in blobs) - { - blobList.Add(blob); - } - - var stagingFolderFullPath = Path.GetFullPath(this.stagingFolder); - var stagedFiles = Directory.GetFiles(this.stagingFolder, "*", SearchOption.AllDirectories); - var filesToDelete = stagedFiles - .Select(f => Path.GetFullPath(f).Replace(stagingFolderFullPath, "").Replace('\\', '/').Trim('/')) - .Where(f => blobList.None(b => b.Name == f)); - foreach (var file in filesToDelete) - { - scopedLogger.LogInformation($"[{file}] File not in blob. Deleting"); - File.Delete($"{stagingFolderFullPath}\\{file}"); - } - - foreach(var blob in blobList) - { - var finalPath = Path.Combine(this.stagingFolder, blob.Name); - var fileInfo = new FileInfo(finalPath); - fileInfo.Directory!.Create(); - if (fileInfo.Exists && - fileInfo.CreationTimeUtc == blob.Properties.LastModified?.UtcDateTime && - fileInfo.Length == blob.Properties.ContentLength) - { - scopedLogger.LogInformation($"[{blob.Name}] File unchanged. Skipping"); - continue; - } - - var blobClient = blobContainerClient.GetBlobClient(blob.Name); - using var fileStream = new FileStream(finalPath, FileMode.Create); - using var blobStream = await blobClient.OpenReadAsync(new BlobOpenReadOptions(false) - { - BufferSize = 1024, - }, CancellationToken.None); - - scopedLogger.LogInformation($"[{blob.Name}] Downloading blob"); - await blobStream.CopyToAsync(fileStream); - scopedLogger.LogInformation($"[{blob.Name}] Downloaded blob"); - - fileInfo = new FileInfo(finalPath); - fileInfo.CreationTimeUtc = blob.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow; - } - } -} diff --git a/GuildWarsPartySearch/ServerHandlers/StartupHandler.cs b/GuildWarsPartySearch/ServerHandlers/StartupHandler.cs deleted file mode 100644 index 3df74c6..0000000 --- a/GuildWarsPartySearch/ServerHandlers/StartupHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using GuildWarsPartySearch.Server.Options; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using MTSC; -using MTSC.ServerSide; -using MTSC.ServerSide.Handlers; - -namespace GuildWarsPartySearch.Server.ServerHandlers; - -public sealed class StartupHandler : IHandler -{ - private bool initialized; - - public void ClientRemoved(MTSC.ServerSide.Server server, ClientData client) { } - - public bool HandleClient(MTSC.ServerSide.Server server, ClientData client) => false; - - public bool HandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, Message message) => false; - - public bool HandleSendMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - public bool PreHandleReceivedMessage(MTSC.ServerSide.Server server, ClientData client, ref Message message) => false; - - public void Tick(MTSC.ServerSide.Server server) - { - if (!this.initialized) - { - this.initialized = true; - var options = server.ServiceManager.GetRequiredService>(); - server.Log($"Running environment {options.Value.Name}"); - } - } -} diff --git a/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs b/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs index 2be6eaa..f1923d0 100644 --- a/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs +++ b/GuildWarsPartySearch/Services/Feed/ILiveFeedService.cs @@ -1,10 +1,10 @@ -using MTSC.ServerSide; +using System.Net.WebSockets; namespace GuildWarsPartySearch.Server.Services.Feed; public interface ILiveFeedService { - void AddClient(ClientData client); - void RemoveClient(ClientData client); - void PushUpdate(MTSC.ServerSide.Server server, Models.PartySearch partySearchUpdate); + void AddClient(WebSocket webSocket); + void RemoveClient(WebSocket webSocket); + Task PushUpdate(Models.PartySearch partySearchUpdate, CancellationToken cancellationToken); } diff --git a/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs b/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs index 68ce717..12b17ab 100644 --- a/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs +++ b/GuildWarsPartySearch/Services/Feed/LiveFeedService.cs @@ -1,20 +1,29 @@ using MTSC.Common.WebSockets; -using MTSC.ServerSide; using Newtonsoft.Json; +using System.Core.Extensions; +using System.Net.WebSockets; using System.Text; namespace GuildWarsPartySearch.Server.Services.Feed; public sealed class LiveFeedService : ILiveFeedService { - private static readonly List Clients = []; + private static readonly List Clients = []; - public void AddClient(ClientData client) + private readonly ILogger logger; + + public LiveFeedService( + ILogger logger) + { + this.logger = logger.ThrowIfNull(); + } + + public void AddClient(WebSocket client) { AddClientInternal(client); } - public void PushUpdate(MTSC.ServerSide.Server server, Models.PartySearch partySearchUpdate) + public async Task PushUpdate(Models.PartySearch partySearchUpdate, CancellationToken cancellationToken) { var payloadString = JsonConvert.SerializeObject(partySearchUpdate); var payload = Encoding.UTF8.GetBytes(payloadString); @@ -25,41 +34,51 @@ public void PushUpdate(MTSC.ServerSide.Server server, Models.PartySearch partySe Opcode = WebsocketMessage.Opcodes.Text }; var messageBytes = websocketMessage.GetMessageBytes(); - ExecuteOnClientsInternal(client => + await ExecuteOnClientsInternal(async client => { - server.QueueMessage(client, messageBytes); + try + { + await client.SendAsync(payload, WebSocketMessageType.Text, true, cancellationToken); + } + catch(Exception ex) + { + this.logger.LogError(ex, $"Encountered exception while broadcasting update"); + } }); } - public void RemoveClient(ClientData client) + public void RemoveClient(WebSocket client) { RemoveClientInternal(client); } - private static void AddClientInternal(ClientData clientData) + private static void AddClientInternal(WebSocket client) { - lock (Clients) - { - Clients.Add(clientData); - } + while (!Monitor.TryEnter(Clients)) { } + + Clients.Add(client); + + Monitor.Exit(Clients); } - private static void RemoveClientInternal(ClientData clientData) + private static void RemoveClientInternal(WebSocket client) { - lock (Clients) - { - Clients.Remove(clientData); - } + while (!Monitor.TryEnter(Clients)) { } + + Clients.Remove(client); + + Monitor.Exit(Clients); } - private static void ExecuteOnClientsInternal(Action action) + private static async Task ExecuteOnClientsInternal(Func action) { - lock (Clients) + while (!Monitor.TryEnter(Clients)) { } + + foreach (var client in Clients) { - foreach (var client in Clients) - { - action(client); - } + await action(client); } + + Monitor.Exit(Clients); } }