diff --git a/GuildWarsPartySearch.Common/Converters/CampaignJsonConverter.cs b/GuildWarsPartySearch.Common/Converters/CampaignJsonConverter.cs index 8e3084b..c54f9ab 100644 --- a/GuildWarsPartySearch.Common/Converters/CampaignJsonConverter.cs +++ b/GuildWarsPartySearch.Common/Converters/CampaignJsonConverter.cs @@ -1,22 +1,16 @@ using GuildWarsPartySearch.Common.Models.GuildWars; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Converters; -public sealed class CampaignJsonConverter : JsonConverter +public sealed class CampaignJsonConverter : JsonConverter { - public override bool CanConvert(Type objectType) => true; - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + public override Campaign? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (!objectType.IsAssignableTo(typeof(Campaign))) - { - return default; - } - switch (reader.TokenType) { - case JsonToken.String: - var name = reader.Value as string; + case JsonTokenType.String: + var name = reader.GetString(); if (name is null || !Campaign.TryParse(name, out var namedCampaign)) { @@ -24,10 +18,9 @@ public sealed class CampaignJsonConverter : JsonConverter } return namedCampaign; - case JsonToken.Integer: - var id = reader.Value as long?; - if (id is not long || - !Campaign.TryParse((int)id.Value, out var parsedCampaign)) + case JsonTokenType.Number: + reader.TryGetInt64(out var id); + if (!Campaign.TryParse((int)id, out var parsedCampaign)) { return default; } @@ -39,13 +32,13 @@ public sealed class CampaignJsonConverter : JsonConverter } } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Campaign value, JsonSerializerOptions options) { if (value is not Campaign campaign) { return; } - writer.WriteValue(campaign.Name); + writer.WriteStringValue(campaign.Name); } } diff --git a/GuildWarsPartySearch.Common/Converters/ContinentJsonConverter.cs b/GuildWarsPartySearch.Common/Converters/ContinentJsonConverter.cs index 5acc86b..2a5878c 100644 --- a/GuildWarsPartySearch.Common/Converters/ContinentJsonConverter.cs +++ b/GuildWarsPartySearch.Common/Converters/ContinentJsonConverter.cs @@ -1,22 +1,16 @@ using GuildWarsPartySearch.Common.Models.GuildWars; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Converters; -public sealed class ContinentJsonConverter : JsonConverter +public sealed class ContinentJsonConverter : JsonConverter { - public override bool CanConvert(Type objectType) => true; - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + public override Continent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (!objectType.IsAssignableTo(typeof(Continent))) - { - return default; - } - switch (reader.TokenType) { - case JsonToken.String: - var name = reader.Value as string; + case JsonTokenType.String: + var name = reader.GetString(); if (name is null || !Continent.TryParse(name, out var namedContinent)) { @@ -24,10 +18,9 @@ public sealed class ContinentJsonConverter : JsonConverter } return namedContinent; - case JsonToken.Integer: - var id = reader.Value as long?; - if (id is not long || - !Continent.TryParse((int)id.Value, out var parsedContinent)) + case JsonTokenType.Number: + reader.TryGetInt64(out var id); + if (!Continent.TryParse((int)id, out var parsedContinent)) { return default; } @@ -39,13 +32,13 @@ public sealed class ContinentJsonConverter : JsonConverter } } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Continent value, JsonSerializerOptions options) { if (value is not Continent continent) { return; } - writer.WriteValue(continent.Name); + writer.WriteStringValue(continent.Name); } } diff --git a/GuildWarsPartySearch.Common/Converters/MapJsonConverter.cs b/GuildWarsPartySearch.Common/Converters/MapJsonConverter.cs index c1a0755..4f3ba4a 100644 --- a/GuildWarsPartySearch.Common/Converters/MapJsonConverter.cs +++ b/GuildWarsPartySearch.Common/Converters/MapJsonConverter.cs @@ -1,22 +1,16 @@ using GuildWarsPartySearch.Common.Models.GuildWars; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Converters; -public sealed class MapJsonConverter : JsonConverter +public sealed class MapJsonConverter : JsonConverter { - public override bool CanConvert(Type objectType) => true; - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + public override Map? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (!objectType.IsAssignableTo(typeof(Map))) - { - return default; - } - switch (reader.TokenType) { - case JsonToken.String: - var name = reader.Value as string; + case JsonTokenType.String: + var name = reader.GetString(); if (name is null || !Map.TryParse(name, out var namedMap)) { @@ -24,10 +18,9 @@ public sealed class MapJsonConverter : JsonConverter } return namedMap; - case JsonToken.Integer: - var id = reader.Value as long?; - if (id is not long || - !Map.TryParse((int)id.Value, out var parsedMap)) + case JsonTokenType.Number: + reader.TryGetInt64(out var id); + if (!Map.TryParse((int)id, out var parsedMap)) { return default; } @@ -39,13 +32,13 @@ public sealed class MapJsonConverter : JsonConverter } } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Map value, JsonSerializerOptions options) { if (value is not Map map) { return; } - writer.WriteValue(map.Name); + writer.WriteStringValue(map.Name); } } diff --git a/GuildWarsPartySearch.Common/Converters/RegionJsonConverter.cs b/GuildWarsPartySearch.Common/Converters/RegionJsonConverter.cs index dae04b8..decba22 100644 --- a/GuildWarsPartySearch.Common/Converters/RegionJsonConverter.cs +++ b/GuildWarsPartySearch.Common/Converters/RegionJsonConverter.cs @@ -1,22 +1,16 @@ using GuildWarsPartySearch.Common.Models.GuildWars; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Converters; -public sealed class RegionJsonConverter : JsonConverter +public sealed class RegionJsonConverter : JsonConverter { - public override bool CanConvert(Type objectType) => true; - - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + public override Region? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (!objectType.IsAssignableTo(typeof(Region))) - { - return default; - } - switch (reader.TokenType) { - case JsonToken.String: - var name = reader.Value as string; + case JsonTokenType.String: + var name = reader.GetString(); if (name is null || !Region.TryParse(name, out var namedRegion)) { @@ -24,10 +18,9 @@ public sealed class RegionJsonConverter : JsonConverter } return namedRegion; - case JsonToken.Integer: - var id = reader.Value as long?; - if (id is not long || - !Region.TryParse((int)id.Value, out var parsedRegion)) + case JsonTokenType.Number: + reader.TryGetInt64(out var id); + if (!Region.TryParse((int)id, out var parsedRegion)) { return default; } @@ -39,13 +32,13 @@ public sealed class RegionJsonConverter : JsonConverter } } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Region value, JsonSerializerOptions options) { if (value is not Region region) { return; } - writer.WriteValue(region.Name); + writer.WriteStringValue(region.Name); } } diff --git a/GuildWarsPartySearch.Common/GuildWarsPartySearch.Common.csproj b/GuildWarsPartySearch.Common/GuildWarsPartySearch.Common.csproj index 1789cf1..9f686a8 100644 --- a/GuildWarsPartySearch.Common/GuildWarsPartySearch.Common.csproj +++ b/GuildWarsPartySearch.Common/GuildWarsPartySearch.Common.csproj @@ -7,8 +7,4 @@ Debug;Release;Local - - - - diff --git a/GuildWarsPartySearch.Common/Models/GuildWars/Campaign.cs b/GuildWarsPartySearch.Common/Models/GuildWars/Campaign.cs index a67a65c..6cf60ad 100644 --- a/GuildWarsPartySearch.Common/Models/GuildWars/Campaign.cs +++ b/GuildWarsPartySearch.Common/Models/GuildWars/Campaign.cs @@ -1,6 +1,6 @@ using GuildWarsPartySearch.Common.Converters; -using Newtonsoft.Json; using System.ComponentModel; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Models.GuildWars; diff --git a/GuildWarsPartySearch.Common/Models/GuildWars/Continent.cs b/GuildWarsPartySearch.Common/Models/GuildWars/Continent.cs index 07913d3..8274908 100644 --- a/GuildWarsPartySearch.Common/Models/GuildWars/Continent.cs +++ b/GuildWarsPartySearch.Common/Models/GuildWars/Continent.cs @@ -1,6 +1,6 @@ using GuildWarsPartySearch.Common.Converters; -using Newtonsoft.Json; using System.ComponentModel; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Models.GuildWars; diff --git a/GuildWarsPartySearch.Common/Models/GuildWars/Map.cs b/GuildWarsPartySearch.Common/Models/GuildWars/Map.cs index 083cef6..258492a 100644 --- a/GuildWarsPartySearch.Common/Models/GuildWars/Map.cs +++ b/GuildWarsPartySearch.Common/Models/GuildWars/Map.cs @@ -1,6 +1,6 @@ using GuildWarsPartySearch.Common.Converters; -using Newtonsoft.Json; using System.ComponentModel; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Models.GuildWars; diff --git a/GuildWarsPartySearch.Common/Models/GuildWars/Region.cs b/GuildWarsPartySearch.Common/Models/GuildWars/Region.cs index 19630f4..3a4f22b 100644 --- a/GuildWarsPartySearch.Common/Models/GuildWars/Region.cs +++ b/GuildWarsPartySearch.Common/Models/GuildWars/Region.cs @@ -1,6 +1,6 @@ using GuildWarsPartySearch.Common.Converters; -using Newtonsoft.Json; using System.ComponentModel; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Common.Models.GuildWars; diff --git a/GuildWarsPartySearch.Tests/GuildWarsPartySearch.Tests.csproj b/GuildWarsPartySearch.Tests/GuildWarsPartySearch.Tests.csproj index 059f70c..39dd8bc 100644 --- a/GuildWarsPartySearch.Tests/GuildWarsPartySearch.Tests.csproj +++ b/GuildWarsPartySearch.Tests/GuildWarsPartySearch.Tests.csproj @@ -11,10 +11,15 @@ + + + + + diff --git a/GuildWarsPartySearch.Tests/Services/CharName/CharNameValidatorTests.cs b/GuildWarsPartySearch.Tests/Services/CharName/CharNameValidatorTests.cs new file mode 100644 index 0000000..69f0b80 --- /dev/null +++ b/GuildWarsPartySearch.Tests/Services/CharName/CharNameValidatorTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using GuildWarsPartySearch.Server.Services.CharName; + +namespace GuildWarsPartySearch.Tests.Services.CharName; + +[TestClass] +public sealed class CharNameValidatorTests +{ + private readonly CharNameValidator charNameValidator; + + public CharNameValidatorTests() + { + this.charNameValidator = new CharNameValidator(); + } + + [TestMethod] + [DataRow("-eq", false)] //Invalid chars + [DataRow(";_;_;", false)] //Invalid chars + [DataRow("D@ddy UwU", false)] //Invalid chars + [DataRow("Kormir Fr3@kin Sucks", false)] // Invalid chars + [DataRow("Myself", false)] // Only one word + [DataRow("Fentanyl Ascetic", true)] //Correct + [DataRow("Kormir", false)] // Only one word + [DataRow("Kormir Sucks", true)] // Correct + [DataRow("Bob'); DROP TABLE", false)] // Invalid chars + [DataRow("Why are we still here, just to suffer", false)] // Too long + Invalid chars + [DataRow("Why are we still here just to suffer", false)] // Too long + [DataRow(null, false)] // Null + [DataRow("", false)] // Empty + [DataRow(" ", false)] // Whitespace + [DataRow(" ", false)] // Whitespace + [DataRow("Me", false)] // Too short + public void ValidateNames_ShouldReturnExpected(string name, bool expected) + { + var result = this.charNameValidator.Validate(name); + result.Should().Be(expected); + } +} diff --git a/GuildWarsPartySearch/Attributes/DoNotInjectAttribute.cs b/GuildWarsPartySearch/Attributes/DoNotInjectAttribute.cs new file mode 100644 index 0000000..aa3001b --- /dev/null +++ b/GuildWarsPartySearch/Attributes/DoNotInjectAttribute.cs @@ -0,0 +1,6 @@ +namespace GuildWarsPartySearch.Server.Attributes; + +[AttributeUsage(AttributeTargets.Constructor)] +public class DoNotInjectAttribute : Attribute +{ +} diff --git a/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs b/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs index 05b1fac..a86c12d 100644 --- a/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs +++ b/GuildWarsPartySearch/Converters/Base64ToCertificateConverter.cs @@ -1,24 +1,25 @@ -using Newtonsoft.Json; -using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Server.Converters; public sealed class Base64ToCertificateConverter : JsonConverter { - public override X509Certificate2? ReadJson(JsonReader reader, Type objectType, X509Certificate2? existingValue, bool hasExistingValue, JsonSerializer serializer) + public override X509Certificate2? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - if (reader.Value is not string base64) + if (reader.GetString() is not string base64) { - throw new InvalidOperationException($"Cannot deserialize {nameof(X509Certificate2)} from {reader.Value}"); + throw new InvalidOperationException($"Cannot deserialize {nameof(X509Certificate2)} from {reader.GetString()}"); } var bytes = Convert.FromBase64String(base64); return new X509Certificate2(bytes); } - public override void WriteJson(JsonWriter writer, X509Certificate2? value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, X509Certificate2 value, JsonSerializerOptions options) { var base64 = Convert.ToBase64String(value!.GetRawCertData()); - writer.WriteValue(base64); + writer.WriteStringValue(base64); } } diff --git a/GuildWarsPartySearch/Converters/JsonWebSocketMessageConverter.cs b/GuildWarsPartySearch/Converters/JsonWebSocketMessageConverter.cs index f817e93..cb969a3 100644 --- a/GuildWarsPartySearch/Converters/JsonWebSocketMessageConverter.cs +++ b/GuildWarsPartySearch/Converters/JsonWebSocketMessageConverter.cs @@ -1,11 +1,25 @@ -using GuildWarsPartySearch.Server.Models; +using GuildWarsPartySearch.Server.Attributes; +using GuildWarsPartySearch.Server.Models; +using System.Core.Extensions; using System.Text; -using Newtonsoft.Json; +using System.Text.Json; namespace GuildWarsPartySearch.Server.Converters; public class JsonWebSocketMessageConverter : WebSocketMessageConverter { + private readonly JsonSerializerOptions jsonSerializerOptions; + + [DoNotInject] + public JsonWebSocketMessageConverter() + { + } + + public JsonWebSocketMessageConverter(JsonSerializerOptions options) + { + this.jsonSerializerOptions = options.ThrowIfNull(); + } + public override T ConvertTo(WebSocketConverterRequest request) { if (request.Type != System.Net.WebSockets.WebSocketMessageType.Text) @@ -14,13 +28,13 @@ public override T ConvertTo(WebSocketConverterRequest request) } var stringData = Encoding.UTF8.GetString(request.Payload!); - var objData = JsonConvert.DeserializeObject(stringData); + var objData = JsonSerializer.Deserialize(stringData, this.jsonSerializerOptions); return objData ?? throw new InvalidOperationException($"Unable to deserialize message to {typeof(T).Name}"); } public override WebSocketConverterResponse ConvertFrom(T message) { - var serialized = JsonConvert.SerializeObject(message); + var serialized = JsonSerializer.Serialize(message, this.jsonSerializerOptions); var data = Encoding.UTF8.GetBytes(serialized); return new WebSocketConverterResponse { EndOfMessage = true, Type = System.Net.WebSockets.WebSocketMessageType.Text, Payload = data }; } diff --git a/GuildWarsPartySearch/Endpoints/CharactersController.cs b/GuildWarsPartySearch/Endpoints/CharactersController.cs new file mode 100644 index 0000000..1b64f15 --- /dev/null +++ b/GuildWarsPartySearch/Endpoints/CharactersController.cs @@ -0,0 +1,31 @@ +using GuildWarsPartySearch.Server.Models.Endpoints; +using GuildWarsPartySearch.Server.Services.PartySearch; +using Microsoft.AspNetCore.Mvc; +using System.Core.Extensions; + +namespace GuildWarsPartySearch.Server.Endpoints; + +[Route("party-search/characters")] +public sealed class CharactersController : Controller +{ + private readonly IPartySearchService partySearchService; + + public CharactersController( + IPartySearchService partySearchService) + { + this.partySearchService = partySearchService.ThrowIfNull(); + } + + [HttpGet("{charName}")] + public async Task GetByCharName(string charName) + { + var result = await this.partySearchService.GetPartySearchesByCharName(charName, this.HttpContext.RequestAborted); + return result.Switch( + onSuccess: list => this.Ok(list), + onFailure: failure => failure switch + { + GetPartySearchFailure.InvalidCharName => this.BadRequest("Invalid char name"), + _ => this.Problem() + }); + } +} diff --git a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs index 950953b..b613353 100644 --- a/GuildWarsPartySearch/Endpoints/PostPartySearch.cs +++ b/GuildWarsPartySearch/Endpoints/PostPartySearch.cs @@ -1,12 +1,15 @@ -using GuildWarsPartySearch.Server.Models; +using GuildWarsPartySearch.Server.Filters; +using GuildWarsPartySearch.Server.Models; using GuildWarsPartySearch.Server.Models.Endpoints; using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.PartySearch; +using Microsoft.AspNetCore.Mvc; using System.Core.Extensions; using System.Extensions; namespace GuildWarsPartySearch.Server.Endpoints; +[ServiceFilter] public sealed class PostPartySearch : WebSocketRouteBase { private readonly ILiveFeedService liveFeedService; diff --git a/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs b/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs index 978a878..3068533 100644 --- a/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs +++ b/GuildWarsPartySearch/Endpoints/WebSocketRouteBase.cs @@ -1,5 +1,7 @@ using GuildWarsPartySearch.Server.Attributes; using GuildWarsPartySearch.Server.Converters; +using System; +using System.Diagnostics.CodeAnalysis; using System.Extensions; using System.Net.WebSockets; @@ -27,12 +29,17 @@ public virtual Task SocketClosed() public abstract class WebSocketRouteBase : WebSocketRouteBase where TReceiveType : class, new() { - private readonly Lazy converter = new(() => + private readonly Lazy converter; + + public WebSocketRouteBase() { - var attribute = typeof(TReceiveType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast(); - var converter = Activator.CreateInstance(attribute.ConverterType)!.Cast(); - return converter; - }); + this.converter = new Lazy(() => + { + var attribute = typeof(TReceiveType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast(); + var parsedConverter = GetConverter(attribute.ConverterType, this.Context!); + return parsedConverter; + }); + } public sealed override Task ExecuteAsync(WebSocketMessageType type, byte[] data, CancellationToken cancellationToken) { @@ -50,6 +57,29 @@ public sealed override Task ExecuteAsync(WebSocketMessageType type, byte[] data, } public abstract Task ExecuteAsync(TReceiveType? type, CancellationToken cancellationToken); + + internal static WebSocketMessageConverterBase GetConverter(Type converterType, HttpContext context) + { + var constructors = converterType.GetConstructors(); + foreach (var constructor in constructors) + { + if (constructor.GetCustomAttributes(false).Any(a => a is DoNotInjectAttribute)) + { + continue; + } + + 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 {converterType.Name}"); + } } public abstract class WebSocketRouteBase : WebSocketRouteBase @@ -62,6 +92,16 @@ public abstract class WebSocketRouteBase : WebSocketRou return converter; }); + public WebSocketRouteBase() + { + this.converter = new Lazy(() => + { + var attribute = typeof(TSendType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast(); + var parsedConverter = GetConverter(attribute.ConverterType, this.Context!); + return parsedConverter; + }); + } + public Task SendMessage(TSendType sendType, CancellationToken cancellationToken) { try diff --git a/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs b/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs index 28dbe21..2175f48 100644 --- a/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs +++ b/GuildWarsPartySearch/Extensions/WebApplicationExtensions.cs @@ -1,4 +1,7 @@ using GuildWarsPartySearch.Server.Endpoints; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; using System.Core.Extensions; using System.Diagnostics.CodeAnalysis; using System.Extensions; @@ -8,31 +11,35 @@ namespace GuildWarsPartySearch.Server.Extensions; public static class WebApplicationExtensions { - public static WebApplication MapWebSocket<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(this WebApplication app, string route, Func>? routeFilter = default) + public static WebApplication MapWebSocket<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(this WebApplication app, string route) 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); + var routeFilters = GetRouteFilters(context).ToList(); + + var actionContext = new ActionContext( + context, + new RouteData(), + new ActionDescriptor()); + var actionExecutingContext = new ActionExecutingContext( + actionContext, + routeFilters, + new Dictionary(), + route); + var actionExecutedContext = new ActionExecutedContext( + actionContext, + routeFilters, + route); 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); + var processingTask = new Func(() => ProcessWebSocketRequest(route, context)); + await BeginProcessingPipeline(actionExecutingContext, actionExecutedContext, processingTask); } catch(WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { @@ -60,6 +67,53 @@ await routeFilter(context) is false) return app; } + private static async Task ProcessWebSocketRequest(WebSocketRouteBase route, HttpContext httpContext) + { + using var webSocket = await httpContext.WebSockets.AcceptWebSocketAsync(); + route.WebSocket = webSocket; + route.Context = httpContext; + await route.SocketAccepted(httpContext.RequestAborted); + await HandleWebSocket(webSocket, route, httpContext.RequestAborted); + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", httpContext.RequestAborted); + } + + private static async Task BeginProcessingPipeline(ActionExecutingContext actionExecutingContext, ActionExecutedContext actionExecutedContext, Func processWebSocket) + { + foreach (var filter in actionExecutingContext.Filters.OfType()) + { + filter.OnActionExecuting(actionExecutingContext); + if (actionExecutingContext.Result is IActionResult result) + { + await result.ExecuteResultAsync(actionExecutedContext); + return; + } + } + + ActionExecutionDelegate pipeline = async () => + { + await processWebSocket(); + return actionExecutedContext; + }; + + foreach (var filter in actionExecutingContext.Filters.OfType()) + { + var next = pipeline; + pipeline = async () => + { + if (actionExecutingContext.Result is IActionResult result) + { + await result.ExecuteResultAsync(actionExecutedContext); + return actionExecutedContext; + } + + await filter.OnActionExecutionAsync(actionExecutingContext, next); + return actionExecutedContext; + }; + } + + await pipeline(); + } + private static async Task HandleWebSocket(WebSocket webSocket, WebSocketRouteBase route, CancellationToken cancellationToken) { var buffer = new byte[1024]; @@ -102,4 +156,14 @@ private static async Task HandleWebSocket(WebSocket webSocket, WebSocketRouteBas throw new InvalidOperationException($"Unable to resolve {typeof(TWebSocketRoute).Name}"); } + + private static IEnumerable GetRouteFilters<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(HttpContext context) + where TWebSocketRoute : WebSocketRouteBase + { + foreach(var attribute in typeof(TWebSocketRoute).GetCustomAttributes(true).OfType()) + { + var filter = context.RequestServices.GetRequiredService(attribute.ServiceType); + yield return filter.Cast(); + } + } } diff --git a/GuildWarsPartySearch/Filters/ApiKeyProtected.cs b/GuildWarsPartySearch/Filters/ApiKeyProtected.cs new file mode 100644 index 0000000..e45abc3 --- /dev/null +++ b/GuildWarsPartySearch/Filters/ApiKeyProtected.cs @@ -0,0 +1,35 @@ +using GuildWarsPartySearch.Server.Options; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using System.Extensions; + +namespace GuildWarsPartySearch.Server.Filters; + +public sealed class ApiKeyProtected : IActionFilter +{ + private const string ApiKeyHeader = "X-ApiKey"; + + public void OnActionExecuting(ActionExecutingContext context) + { + var serverOptions = context.HttpContext.RequestServices.GetRequiredService>(); + if (serverOptions.Value.ApiKey!.IsNullOrWhiteSpace()) + { + context.Result = new ForbiddenResponseActionResult(); + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeader, out var value) || + value.FirstOrDefault() is not string headerValue || + headerValue != serverOptions.Value.ApiKey) + { + context.Result = new ForbiddenResponseActionResult(); + return; + } + + return; + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } +} diff --git a/GuildWarsPartySearch/Filters/ForbiddenResponseActionResult.cs b/GuildWarsPartySearch/Filters/ForbiddenResponseActionResult.cs new file mode 100644 index 0000000..9fc14b1 --- /dev/null +++ b/GuildWarsPartySearch/Filters/ForbiddenResponseActionResult.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace GuildWarsPartySearch.Server.Filters; + +public class ForbiddenResponseActionResult : IActionResult +{ + public Task ExecuteResultAsync(ActionContext context) + { + context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } +} diff --git a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj index 79121c6..dfd365c 100644 --- a/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj +++ b/GuildWarsPartySearch/GuildWarsPartySearch.Server.csproj @@ -9,13 +9,13 @@ + - diff --git a/GuildWarsPartySearch/Launch/Program.cs b/GuildWarsPartySearch/Launch/Program.cs index 0e31703..0bb0b5f 100644 --- a/GuildWarsPartySearch/Launch/Program.cs +++ b/GuildWarsPartySearch/Launch/Program.cs @@ -1,8 +1,11 @@ // See https://aka.ms/new-console-template for more information +using AspNetCoreRateLimit; using GuildWarsPartySearch.Server.Options; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; using System.Net; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; namespace GuildWarsPartySearch.Server.Launch; @@ -16,11 +19,17 @@ private static async Task Main() .SetupConfiguration() .Build(); + var jsonOptions = new JsonSerializerOptions(); + jsonOptions.Converters.SetupConverters(); + jsonOptions.AllowTrailingCommas = true; + var builder = WebApplication.CreateBuilder() .SetupOptions() .SetupHostedServices(); + builder.Services.AddSingleton(jsonOptions); builder.Logging.SetupLogging(); builder.Services.SetupServices(); + builder.Services.AddControllers(); builder.Configuration.AddConfiguration(config); builder.WebHost.ConfigureKestrel(kestrelOptions => { @@ -45,18 +54,25 @@ private static async Task Main() } var app = builder.Build(); - app.UseWebSockets() + app.UseIpRateLimiting() + .UseWebSockets() + .UseRouting() + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }) .UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(contentDirectory.FullName) }); - app.SetupRoutes() - .MapGet("/", context => + app.MapGet("/", context => { context.Response.Redirect("/index.html"); return Task.CompletedTask; }); + app.SetupRoutes(); + await app.RunAsync(); } } \ No newline at end of file diff --git a/GuildWarsPartySearch/Launch/ServerConfiguration.cs b/GuildWarsPartySearch/Launch/ServerConfiguration.cs index c2e4ab2..7f1209e 100644 --- a/GuildWarsPartySearch/Launch/ServerConfiguration.cs +++ b/GuildWarsPartySearch/Launch/ServerConfiguration.cs @@ -1,20 +1,33 @@ -using GuildWarsPartySearch.Server.Endpoints; +using AspNetCoreRateLimit; +using GuildWarsPartySearch.Common.Converters; +using GuildWarsPartySearch.Server.Endpoints; using GuildWarsPartySearch.Server.Extensions; +using GuildWarsPartySearch.Server.Filters; using GuildWarsPartySearch.Server.Options; +using GuildWarsPartySearch.Server.Services.CharName; using GuildWarsPartySearch.Server.Services.Content; using GuildWarsPartySearch.Server.Services.Database; using GuildWarsPartySearch.Server.Services.Feed; using GuildWarsPartySearch.Server.Services.Lifetime; using GuildWarsPartySearch.Server.Services.PartySearch; -using Microsoft.Extensions.Options; using System.Core.Extensions; using System.Extensions; +using System.Text.Json.Serialization; namespace GuildWarsPartySearch.Server.Launch; public static class ServerConfiguration { - private const string ApiKeyHeader = "X-ApiKey"; + public static IList SetupConverters(this IList converters) + { + converters.ThrowIfNull(); + converters.Add(new CampaignJsonConverter()); + converters.Add(new ContinentJsonConverter()); + converters.Add(new MapJsonConverter()); + converters.Add(new RegionJsonConverter()); + + return converters; + } public static WebApplicationBuilder SetupHostedServices(this WebApplicationBuilder builder) { @@ -49,7 +62,9 @@ public static WebApplicationBuilder SetupOptions(this WebApplicationBuilder buil .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))); + .Configure(builder.Configuration.GetSection(nameof(ServerOptions))) + .Configure(builder.Configuration.GetSection("IpRateLimiting")) + .Configure(builder.Configuration.GetSection("IpRateLimitPolicies")); return builder; } @@ -58,10 +73,15 @@ public static IServiceCollection SetupServices(this IServiceCollection services) { services.ThrowIfNull(); + services.AddMemoryCache(); + services.AddInMemoryRateLimiting(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); return services; } @@ -69,26 +89,8 @@ public static WebApplication SetupRoutes(this WebApplication app) { app.ThrowIfNull() .MapWebSocket("party-search/live-feed") - .MapWebSocket("party-search/update", FilterUpdateMessages); + .MapWebSocket("party-search/update"); return app; } - - private static Task FilterUpdateMessages(HttpContext context) - { - var serverOptions = context.RequestServices.GetRequiredService>(); - if (serverOptions.Value.ApiKey!.IsNullOrWhiteSpace()) - { - return Task.FromResult(false); - } - - if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var value) || - value.FirstOrDefault() is not string headerValue || - headerValue != serverOptions.Value.ApiKey) - { - return Task.FromResult(false); - } - - return Task.FromResult(true); - } } diff --git a/GuildWarsPartySearch/Models/Endpoints/GetPartySearchFailure.cs b/GuildWarsPartySearch/Models/Endpoints/GetPartySearchFailure.cs index eeb5ed2..fb8de0d 100644 --- a/GuildWarsPartySearch/Models/Endpoints/GetPartySearchFailure.cs +++ b/GuildWarsPartySearch/Models/Endpoints/GetPartySearchFailure.cs @@ -32,6 +32,10 @@ public sealed class EntriesNotFound : GetPartySearchFailure { } + public sealed class InvalidCharName : GetPartySearchFailure + { + } + public sealed class UnspecifiedFailure : GetPartySearchFailure { } diff --git a/GuildWarsPartySearch/Services/CharName/CharNameValidator.cs b/GuildWarsPartySearch/Services/CharName/CharNameValidator.cs new file mode 100644 index 0000000..f3b04cf --- /dev/null +++ b/GuildWarsPartySearch/Services/CharName/CharNameValidator.cs @@ -0,0 +1,42 @@ +using System.Extensions; + +namespace GuildWarsPartySearch.Server.Services.CharName; + +public class CharNameValidator : ICharNameValidator +{ + /// + /// Validates char names. Returns false if the name is invalid + /// + /// + /// Based on GuildWars requirements. + /// Names must not exceed the character limit of 19. + /// Names must not be shorter than 3. + /// Names must contain only alphanumeric characters + /// Names must use two or more words + /// + public bool Validate(string charName) + { + if (charName.IsNullOrWhiteSpace()) + { + return false; + } + + if (charName.Length < 3 || + charName.Length > 19) + { + return false; + } + + if (charName.Any(c => !char.IsLetterOrDigit(c) && !char.IsWhiteSpace(c))) + { + return false; + } + + if (!charName.Contains(" ")) + { + return false; + } + + return true; + } +} diff --git a/GuildWarsPartySearch/Services/CharName/ICharNameValidator.cs b/GuildWarsPartySearch/Services/CharName/ICharNameValidator.cs new file mode 100644 index 0000000..75e7ce0 --- /dev/null +++ b/GuildWarsPartySearch/Services/CharName/ICharNameValidator.cs @@ -0,0 +1,6 @@ +namespace GuildWarsPartySearch.Server.Services.CharName; + +public interface ICharNameValidator +{ + bool Validate(string charName); +} diff --git a/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs b/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs index b293b4e..6c65f50 100644 --- a/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs +++ b/GuildWarsPartySearch/Services/Database/IPartySearchDatabase.cs @@ -5,6 +5,7 @@ namespace GuildWarsPartySearch.Server.Services.Database; public interface IPartySearchDatabase { + Task> GetPartySearchesByCharName(string charName, CancellationToken cancellationToken); Task> GetAllPartySearches(CancellationToken cancellationToken); Task SetPartySearches(Campaign campaign, Continent continent, Region region, Map map, string district, List partySearch, CancellationToken cancellationToken); Task?> GetPartySearches(Campaign campaign, Continent continent, Region region, Map map, string district, CancellationToken cancellationToken); diff --git a/GuildWarsPartySearch/Services/Database/TableStorageDatabase.cs b/GuildWarsPartySearch/Services/Database/TableStorageDatabase.cs index 25cb6ed..fb3ab77 100644 --- a/GuildWarsPartySearch/Services/Database/TableStorageDatabase.cs +++ b/GuildWarsPartySearch/Services/Database/TableStorageDatabase.cs @@ -46,6 +46,11 @@ public TableStorageDatabase( } } + public async Task> GetPartySearchesByCharName(string charName, CancellationToken cancellationToken) + { + return await this.QuerySearches($"{nameof(PartySearchTableEntity.CharName)} eq '{charName}'", cancellationToken); + } + public async Task?> GetPartySearches(Campaign campaign, Continent continent, Region region, Map map, string district, CancellationToken cancellationToken) { var partitionKey = BuildPartitionKey(campaign, continent, region, map, district); diff --git a/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs b/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs deleted file mode 100644 index c10a84e..0000000 --- a/GuildWarsPartySearch/Services/Logging/ConsoleLogger.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Logging; - -namespace GuildWarsPartySearch.Server.Services.Logging -{ - public sealed class ConsoleLogger : ILogsWriter - { - public ConsoleLogger() - { - } - - public void WriteLog(Log log) - { - if (log.LogLevel is Microsoft.Extensions.Logging.LogLevel.Debug or Microsoft.Extensions.Logging.LogLevel.Trace) - { - return; - } - - Console.WriteLine($"[{log.LogLevel}] [{log.LogTime.ToString("s")}] [{log.Category}] [{log.CorrelationVector}]\n{log.Message}\n{log.Exception}"); - } - } -} diff --git a/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs b/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs deleted file mode 100644 index 6411852..0000000 --- a/GuildWarsPartySearch/Services/Options/JsonOptionsManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Configuration; - -namespace GuildWarsPartySearch.Server.Services.Options; - -public sealed class JsonOptionsManager : IOptionsManager -{ - private const string ConfigurationFile = "Config.json"; - private static readonly Dictionary Configuration = []; - - public JsonOptionsManager() - { - LoadConfiguration(); - } - - public T GetOptions() where T : class - { - if (!Configuration.TryGetValue(typeof(T).Name, out var token) || - token is null) - { - throw new InvalidOperationException($"Unable to find entry {typeof(T).Name}"); - } - - return token.ToObject() ?? throw new InvalidOperationException($"Unable to deserialize entry {typeof(T).Name}"); - } - - public void UpdateOptions(T value) where T : class - { - throw new NotImplementedException(); - } - - private static void LoadConfiguration() - { - Configuration.Clear(); - if (!File.Exists(ConfigurationFile)) - { - throw new InvalidOperationException("Unable to load configuration"); - } - - var config = File.ReadAllText(ConfigurationFile); - var configObj = JsonConvert.DeserializeObject(config); - if (configObj is null) - { - throw new InvalidOperationException("Unable to load configuration"); - } - - foreach(var prop in configObj) - { - Configuration[prop.Key] = prop.Value; - } - } -} diff --git a/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs b/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs index d25554c..2eb6a45 100644 --- a/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs +++ b/GuildWarsPartySearch/Services/PartySearch/IPartySearchService.cs @@ -7,6 +7,8 @@ namespace GuildWarsPartySearch.Server.Services.PartySearch; public interface IPartySearchService { + Task, GetPartySearchFailure>> GetPartySearchesByCharName(string charName, CancellationToken cancellationToken); + Task> GetAllPartySearches(CancellationToken cancellationToken); Task> PostPartySearch(PostPartySearchRequest? request, CancellationToken cancellationToken); diff --git a/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs b/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs index cea2e20..c98e9c6 100644 --- a/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs +++ b/GuildWarsPartySearch/Services/PartySearch/PartySearchService.cs @@ -1,8 +1,8 @@ using GuildWarsPartySearch.Common.Models.GuildWars; using GuildWarsPartySearch.Server.Models; using GuildWarsPartySearch.Server.Models.Endpoints; +using GuildWarsPartySearch.Server.Services.CharName; using GuildWarsPartySearch.Server.Services.Database; -using Microsoft.Extensions.Logging; using System.Core.Extensions; using System.Extensions; @@ -10,17 +10,30 @@ namespace GuildWarsPartySearch.Server.Services.PartySearch; public sealed class PartySearchService : IPartySearchService { + private readonly ICharNameValidator charNameValidator; private readonly IPartySearchDatabase partySearchDatabase; private readonly ILogger logger; public PartySearchService( + ICharNameValidator charNameValidator, IPartySearchDatabase partySearchDatabase, ILogger logger) { + this.charNameValidator = charNameValidator.ThrowIfNull(); this.partySearchDatabase = partySearchDatabase.ThrowIfNull(); this.logger = logger.ThrowIfNull(); } + public async Task, GetPartySearchFailure>> GetPartySearchesByCharName(string charName, CancellationToken cancellationToken) + { + if (!this.charNameValidator.Validate(charName)) + { + return new GetPartySearchFailure.InvalidCharName(); + } + + return await this.partySearchDatabase.GetPartySearchesByCharName(charName, cancellationToken); + } + public Task> GetAllPartySearches(CancellationToken cancellationToken) { return this.partySearchDatabase.GetAllPartySearches(cancellationToken);