diff --git a/.editorconfig b/.editorconfig index 108919f..92ed11e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -78,6 +78,17 @@ dotnet_naming_rule.parameters_locals_must_be_camel_case.symbols = parameters_loc dotnet_naming_rule.parameters_locals_must_be_camel_case.style = camel_case_style dotnet_naming_rule.parameters_locals_must_be_camel_case.severity = warning +# Name all private fields starting with underscore +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + [*.cs] csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion diff --git a/.gitignore b/.gitignore index 28690ca..e140a18 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ *.userosscache *.sln.docstates -nuget.config build coverage.json diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2e23646..b8a4a9a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,115 +1,151 @@ -# The image has to use the same version as the .NET UnitTest project -image: mcr.microsoft.com/dotnet/sdk:6.0 - -variables: - TZ: "Europe/Berlin" - LANG: "de" - - - -stages: - - build - - test - - deploy - - - -debug-build: - stage: build - tags: - - docker - - lnx - except: - - tags - script: - - dotnet restore --no-cache --force - - dotnet build -c Debug --nologo --no-restore --no-incremental - - mkdir ./artifacts - - mv ./AMWD.Common/bin/Debug/*.nupkg ./artifacts/ - - mv ./AMWD.Common/bin/Debug/*.snupkg ./artifacts/ - - mv ./AMWD.Common.AspNetCore/bin/Debug/*.nupkg ./artifacts/ - - mv ./AMWD.Common.AspNetCore/bin/Debug/*.snupkg ./artifacts/ - - mv ./AMWD.Common.EntityFrameworkCore/bin/Debug/*.nupkg ./artifacts/ - - mv ./AMWD.Common.EntityFrameworkCore/bin/Debug/*.snupkg ./artifacts/ - - mv ./AMWD.Common.Moq/bin/Debug/*.nupkg ./artifacts/ - - mv ./AMWD.Common.Moq/bin/Debug/*.snupkg ./artifacts/ - artifacts: - paths: - - artifacts/*.nupkg - - artifacts/*.snupkg - expire_in: 7 days - -debug-test: +# The image should use the same version as the UnitTests are +image: mcr.microsoft.com/dotnet/sdk:8.0 + +variables: + TZ: Europe/Berlin + LANG: de + +stages: + - build + - test + - deploy + + + +build-debug: + stage: build + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG == null + script: + - dotnet restore --no-cache --force + - dotnet build -c Debug --nologo --no-restore --no-incremental + - mkdir ./artifacts + - mv ./AMWD.Common/bin/Debug/*.nupkg ./artifacts/ + - mv ./AMWD.Common/bin/Debug/*.snupkg ./artifacts/ + - mv ./AMWD.Common.AspNetCore/bin/Debug/*.nupkg ./artifacts/ + - mv ./AMWD.Common.AspNetCore/bin/Debug/*.snupkg ./artifacts/ + - mv ./AMWD.Common.EntityFrameworkCore/bin/Debug/*.nupkg ./artifacts/ + - mv ./AMWD.Common.EntityFrameworkCore/bin/Debug/*.snupkg ./artifacts/ + - mv ./AMWD.Common.Test/bin/Debug/*.nupkg ./artifacts/ + - mv ./AMWD.Common.Test/bin/Debug/*.snupkg ./artifacts/ + artifacts: + paths: + - artifacts/*.nupkg + - artifacts/*.snupkg + expire_in: 7 days + +test-debug: stage: test - dependencies: - - debug-build - tags: - - docker - - lnx - except: - - tags - # branch-coverage - coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/' - script: - - dotnet restore --no-cache --force + dependencies: + - build-debug + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG == null + # line-coverage + #coverage: '/Total[^|]*\|\s*([0-9.%]+)/' + # branch-coverage + coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/' + script: + - dotnet restore --no-cache --force - dotnet test -c Debug --nologo --no-restore - - -release-build: - stage: build - tags: - - docker - - lnx - - amd64 - only: - - tags - script: - - dotnet restore --no-cache --force - - dotnet build -c Release --nologo --no-restore --no-incremental - - mkdir ./artifacts - - mv ./AMWD.Common/bin/Release/*.nupkg ./artifacts/ - - mv ./AMWD.Common/bin/Release/*.snupkg ./artifacts/ - - mv ./AMWD.Common.AspNetCore/bin/Release/*.nupkg ./artifacts/ - - mv ./AMWD.Common.AspNetCore/bin/Release/*.snupkg ./artifacts/ - - mv ./AMWD.Common.EntityFrameworkCore/bin/Release/*.nupkg ./artifacts/ - - mv ./AMWD.Common.EntityFrameworkCore/bin/Release/*.snupkg ./artifacts/ - - mv ./AMWD.Common.Moq/bin/Release/*.nupkg ./artifacts/ - - mv ./AMWD.Common.Moq/bin/Release/*.snupkg ./artifacts/ - artifacts: - paths: - - artifacts/*.nupkg - - artifacts/*.snupkg - expire_in: 1 day - -release-test: + +build-release: + stage: build + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG != null + script: + - dotnet restore --no-cache --force + - dotnet build -c Release --nologo --no-restore --no-incremental + - mkdir ./artifacts + - mv ./AMWD.Common/bin/Release/*.nupkg ./artifacts/ + - mv ./AMWD.Common/bin/Release/*.snupkg ./artifacts/ + - mv ./AMWD.Common.AspNetCore/bin/Release/*.nupkg ./artifacts/ + - mv ./AMWD.Common.AspNetCore/bin/Release/*.snupkg ./artifacts/ + - mv ./AMWD.Common.EntityFrameworkCore/bin/Release/*.nupkg ./artifacts/ + - mv ./AMWD.Common.EntityFrameworkCore/bin/Release/*.snupkg ./artifacts/ + - mv ./AMWD.Common.Test/bin/Release/*.nupkg ./artifacts/ + - mv ./AMWD.Common.Test/bin/Release/*.snupkg ./artifacts/ + artifacts: + paths: + - artifacts/*.nupkg + - artifacts/*.snupkg + expire_in: 1 days + +test-release: stage: test - dependencies: - - release-build - tags: - - docker - - lnx - - amd64 - only: - - tags - # line-coverage - coverage: '/Total[^|]*\|\s*([0-9.%]+)/' - script: - - dotnet restore --no-cache --force - - dotnet test -c Release --nologo --no-restore - - -release-deploy: + dependencies: + - build-release + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG != null + # line-coverage + #coverage: '/Total[^|]*\|\s*([0-9.%]+)/' + # branch-coverage + coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/' + script: + - dotnet restore --no-cache --force + - dotnet test -c Release --nologo --no-restore + +deploy-common: + stage: deploy + dependencies: + - build-release + - test-release + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/AMWD.Common.[0-9]*.nupkg + +deploy-aspnet: stage: deploy dependencies: - - release-build - - release-test - tags: - - docker - - lnx - - amd64 - only: - - tags - script: - - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg + - build-release + - test-release + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG =~ /^asp\/v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/AMWD.Common.AspNetCore.*.nupkg + +deploy-entityframework: + stage: deploy + dependencies: + - build-release + - test-release + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG =~ /^efc\/v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/AMWD.Common.EntityFrameworkCore.*.nupkg + +deploy-test: + stage: deploy + dependencies: + - build-release + - test-release + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG =~ /^test\/v[0-9.]+/ + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/AMWD.Common.Test.*.nupkg diff --git a/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj index d960fa8..e7d5277 100644 --- a/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj +++ b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj @@ -1,41 +1,30 @@ - + - Debug;Release;DebugLocal - netcoreapp3.1;net6.0 + net6.0;net8.0 10.0 + asp/v[0-9]* AMWD.Common.AspNetCore AMWD.Common.AspNetCore true AMWD.Common.AspNetCore icon.png + README.md AM.WD Common Library for ASP.NET Core - - true - - - - - - - + - + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file diff --git a/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs b/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs index 3a357d5..ee7271c 100644 --- a/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/BasicAuthenticationAttribute.cs @@ -4,7 +4,7 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using System.Threading.Tasks; -using AMWD.Common.AspNetCore.BasicAuthentication; +using AMWD.Common.AspNetCore.Security.BasicAuthentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -40,18 +40,18 @@ namespace Microsoft.AspNetCore.Authorization var logger = context.HttpContext.RequestServices.GetService>(); try { - var validatorResult = await TrySetHttpUser(context); + var validatorResult = await TrySetHttpUser(context).ConfigureAwait(false); bool isAllowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType().Any(); if (isAllowAnonymous) return; - if (!context.HttpContext.Request.Headers.ContainsKey("Authorization")) + if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeaderValue)) { SetAuthenticateRequest(context); return; } - var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]); + var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue); byte[] decoded = Convert.FromBase64String(authHeader.Parameter); string plain = Encoding.UTF8.GetString(decoded); @@ -84,22 +84,22 @@ namespace Microsoft.AspNetCore.Authorization : validator.Realm : Realm; - context.HttpContext.Response.Headers["WWW-Authenticate"] = "Basic"; + context.HttpContext.Response.Headers.WWWAuthenticate = "Basic"; if (!string.IsNullOrWhiteSpace(realm)) - context.HttpContext.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{realm.Trim().Replace("\"", "")}\""; + context.HttpContext.Response.Headers.WWWAuthenticate = $"Basic realm=\"{realm.Trim().Replace("\"", "")}\""; context.HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Result = new StatusCodeResult(StatusCodes.Status401Unauthorized); } - private async Task TrySetHttpUser(AuthorizationFilterContext context) + private static async Task TrySetHttpUser(AuthorizationFilterContext context) { var logger = context.HttpContext.RequestServices.GetService>(); try { - if (context.HttpContext.Request.Headers.ContainsKey("Authorization")) + if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeaderValue)) { - var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]); + var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue); byte[] decoded = Convert.FromBase64String(authHeader.Parameter); string plain = Encoding.UTF8.GetString(decoded); @@ -111,7 +111,7 @@ namespace Microsoft.AspNetCore.Authorization if (validator == null) return null; - var result = await validator.ValidateAsync(username, password, context.HttpContext.GetRemoteIpAddress(), context.HttpContext.RequestAborted); + var result = await validator.ValidateAsync(username, password, context.HttpContext.GetRemoteIpAddress(), context.HttpContext.RequestAborted).ConfigureAwait(false); if (result == null) return null; diff --git a/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs b/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs index 37a6cd6..faf646a 100644 --- a/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs @@ -50,7 +50,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters private const string VerificationUrl = "https://www.google.com/recaptcha/api/siteverify"; - private string privateKey; + private string _privateKey; /// /// Executes the validattion in background. @@ -61,13 +61,13 @@ namespace Microsoft.AspNetCore.Mvc.Filters public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var configuration = context.HttpContext.RequestServices.GetService(); - privateKey = configuration?.GetValue("Google:ReCaptcha:PrivateKey"); + _privateKey = configuration?.GetValue("Google:ReCaptcha:PrivateKey"); - if (string.IsNullOrWhiteSpace(privateKey)) + if (string.IsNullOrWhiteSpace(_privateKey)) return; - await DoValidation(context); - await base.OnActionExecutionAsync(context, next); + await DoValidation(context).ConfigureAwait(false); + await base.OnActionExecutionAsync(context, next).ConfigureAwait(false); } private async Task DoValidation(ActionExecutingContext context) @@ -82,7 +82,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters return; } - await Validate(context, token); + await Validate(context, token).ConfigureAwait(false); } private async Task Validate(ActionExecutingContext context, string token) @@ -90,11 +90,11 @@ namespace Microsoft.AspNetCore.Mvc.Filters using var httpClient = new HttpClient(); var param = new Dictionary { - { "secret", privateKey }, + { "secret", _privateKey }, { "response", token } }; - var response = await httpClient.PostAsync(VerificationUrl, new FormUrlEncodedContent(param)); - string json = await response.Content.ReadAsStringAsync(); + var response = await httpClient.PostAsync(VerificationUrl, new FormUrlEncodedContent(param)).ConfigureAwait(false); + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); var result = JsonConvert.DeserializeObject(json); if (result?.Success != true) diff --git a/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs similarity index 92% rename from AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs rename to AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs index ef2f9bb..5db301a 100644 --- a/AMWD.Common.AspNetCore/Attributes/IPWhitelistAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/IPAllowListAttribute.cs @@ -1,16 +1,16 @@ using System.Linq; using System.Net; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Microsoft.AspNetCore.Mvc.Filters { /// /// Implements an IP filter. Only defined addresses are allowed to access. /// - public class IPWhitelistAttribute : ActionFilterAttribute + public class IPAllowListAttribute : ActionFilterAttribute { /// /// Gets or sets a value indicating whether local (localhost) access is granted (Default: true). diff --git a/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs b/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs similarity index 83% rename from AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs rename to AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs index 9ca9c88..3b4ba29 100644 --- a/AMWD.Common.AspNetCore/Attributes/IPBlacklistAttribute.cs +++ b/AMWD.Common.AspNetCore/Attributes/IPBlockListAttribute.cs @@ -1,21 +1,21 @@ using System.Linq; using System.Net; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Microsoft.AspNetCore.Mvc.Filters { /// /// Implements an IP filter. The defined addresses are blocked. /// - public class IPBlacklistAttribute : ActionFilterAttribute + public class IPBlockListAttribute : ActionFilterAttribute { /// /// Gets or sets a value indicating whether local (localhost) access is blocked (Default: false). /// - public bool RestrictLocalAccess { get; set; } + public bool BlockLocalAccess { get; set; } /// /// Gets or sets a configuration key where the blocked IP addresses are defined. @@ -35,7 +35,7 @@ namespace Microsoft.AspNetCore.Mvc.Filters /// /// Gets or sets a comma separated list of blocked IP addresses. /// - public string RestrictedIpAddresses { get; set; } + public string BlockedIpAddresses { get; set; } /// public override void OnActionExecuting(ActionExecutingContext context) @@ -43,13 +43,13 @@ namespace Microsoft.AspNetCore.Mvc.Filters base.OnActionExecuting(context); context.HttpContext.Items["RemoteAddress"] = context.HttpContext.GetRemoteIpAddress(); - if (!RestrictLocalAccess && context.HttpContext.IsLocalRequest()) + if (!BlockLocalAccess && context.HttpContext.IsLocalRequest()) return; var remoteIpAddress = context.HttpContext.GetRemoteIpAddress(); - if (!string.IsNullOrWhiteSpace(RestrictedIpAddresses)) + if (!string.IsNullOrWhiteSpace(BlockedIpAddresses)) { - string[] ipAddresses = RestrictedIpAddresses.Split(','); + string[] ipAddresses = BlockedIpAddresses.Split(','); foreach (string ipAddress in ipAddresses) { if (string.IsNullOrWhiteSpace(ipAddress)) diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs b/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs deleted file mode 100644 index 6d0f33b..0000000 --- a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationHandler.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text; -using System.Text.Encodings.Web; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace AMWD.Common.AspNetCore.BasicAuthentication -{ - /// - /// Implements the for Basic Authentication. - /// - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public class BasicAuthenticationHandler : AuthenticationHandler - { - private readonly ILogger logger; - private readonly IBasicAuthenticationValidator validator; - - /// - /// Initializes a new instance of the class. - /// - /// The authentication scheme options. - /// The logger factory. - /// The URL encoder. - /// The system clock. - /// An basic autentication validator implementation. - public BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory loggerFactory, UrlEncoder encoder, ISystemClock clock, IBasicAuthenticationValidator validator) - : base(options, loggerFactory, encoder, clock) - { - logger = loggerFactory.CreateLogger(); - this.validator = validator; - } - - /// - protected override async Task HandleAuthenticateAsync() - { - var endpoint = Context.GetEndpoint(); - if (endpoint?.Metadata?.GetMetadata() != null) - return AuthenticateResult.NoResult(); - - if (!Request.Headers.ContainsKey("Authorization")) - return AuthenticateResult.Fail("Authorization header missing"); - - ClaimsPrincipal principal; - try - { - var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); - string plain = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter)); - - // See: https://www.rfc-editor.org/rfc/rfc2617, page 6 - string username = plain.Split(':').First(); - string password = plain[(username.Length + 1)..]; - - var ipAddress = Context.GetRemoteIpAddress(); - principal = await validator.ValidateAsync(username, password, ipAddress, Context.RequestAborted); - } - catch (Exception ex) - { - logger.LogError(ex, $"Handling the Basic Authentication failed: {ex.Message}"); - return AuthenticateResult.Fail("Authorization header invalid"); - } - - if (principal == null) - return AuthenticateResult.Fail("Invalid credentials"); - - var ticket = new AuthenticationTicket(principal, Scheme.Name); - return AuthenticateResult.Success(ticket); - } - } -} diff --git a/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs index 34b492f..108bfb1 100644 --- a/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs @@ -2,6 +2,7 @@ using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; +using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Microsoft.AspNetCore.Builder { @@ -20,15 +21,19 @@ namespace Microsoft.AspNetCore.Builder ///
/// Additionally you can specify the proxy server by using or a when there are multiple proxy servers. ///
- /// When no oder is set, the default IPv4 private subnets are configured:
+ /// When no oder is set, the default subnets are configured:
+ /// - 127.0.0.0/8
/// - 10.0.0.0/8
/// - 172.16.0.0/12
- /// - 192.168.0.0/16 + /// - 192.168.0.0/16
+ /// + /// - ::1/128
+ /// - fd00::/8 /// /// The application builder. /// The where proxy requests are received from (optional). /// The where proxy requests are received from (optional). - public static void UseProxyHosting(this IApplicationBuilder app, IPNetwork network = null, IPAddress address = null) + public static IApplicationBuilder UseProxyHosting(this IApplicationBuilder app, IPNetwork network = null, IPAddress address = null) { string path = Environment.GetEnvironmentVariable("ASPNETCORE_APPL_PATH"); if (!string.IsNullOrWhiteSpace(path)) @@ -40,9 +45,17 @@ namespace Microsoft.AspNetCore.Builder if (network == null && address == null) { + // localhost + options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("127.0.0.0"), 8)); + options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("::1"), 128)); + + // private IPv4 networks options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8)); options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("172.16.0.0"), 12)); options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("192.168.0.0"), 16)); + + // private IPv6 networks + options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("fd00::"), 8)); } if (network != null) @@ -52,6 +65,8 @@ namespace Microsoft.AspNetCore.Builder options.KnownProxies.Add(address); app.UseForwardedHeaders(options); + + return app; } } } diff --git a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs index d630d61..c341fca 100644 --- a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs @@ -1,4 +1,6 @@ -using System.Net; +using System; +using System.Linq; +using System.Net; using Microsoft.AspNetCore.Antiforgery; using Microsoft.Extensions.DependencyInjection; @@ -9,43 +11,90 @@ namespace Microsoft.AspNetCore.Http ///
public static class HttpContextExtensions { + // Search these additional headers for a remote client ip address. + private static readonly string[] _defaultIpHeaderNames = new[] + { + "Cf-Connecting-Ip", // set by Cloudflare + "X-Real-IP", // wide-spread alternative to X-Forwarded-For + "X-Forwarded-For", // commonly used on all known proxies + }; + /// /// Retrieves the antiforgery token. /// /// The current . - /// Name and value of the token. - public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext) + /// FormName, HeaderName and Value of the antiforgery token. + public static (string FormName, string HeaderName, string Value) GetAntiforgeryToken(this HttpContext httpContext) { - var af = httpContext.RequestServices.GetService(); - var set = af?.GetAndStoreTokens(httpContext); + var antiforgery = httpContext.RequestServices.GetService(); + var tokenSet = antiforgery?.GetAndStoreTokens(httpContext); - return (Name: set?.FormFieldName, Value: set?.RequestToken); + return (tokenSet?.FormFieldName, tokenSet?.HeaderName, tokenSet?.RequestToken); } /// /// Returns the remote ip address. /// + /// + /// Searches for additional headers in the following order: + /// + /// Cf-Connecting-Ip + /// X-Real-IP + /// X-Forwarded-For + /// + /// /// The current . - /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// The name of the header to resolve the when behind a proxy. /// The ip address of the client. - public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For") + public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string ipHeaderName = null) { - string forwardedHeader = httpContext.Request.Headers[headerName].ToString(); - if (!string.IsNullOrWhiteSpace(forwardedHeader) && IPAddress.TryParse(forwardedHeader, out var forwarded)) - return forwarded; + string forwardedForAddress = null; - return httpContext.Connection.RemoteIpAddress; + var headerNames = string.IsNullOrWhiteSpace(ipHeaderName) + ? _defaultIpHeaderNames + : new[] { ipHeaderName }.Concat(_defaultIpHeaderNames); + foreach (string headerName in headerNames) + { + if (!httpContext.Request.Headers.ContainsKey(headerName)) + continue; + + // X-Forwarded-For can contain multiple comma-separated addresses. + forwardedForAddress = httpContext.Request.Headers[headerName].ToString() + .Split(',', StringSplitOptions.TrimEntries) + .First(); + + break; + } + + if (!string.IsNullOrWhiteSpace(forwardedForAddress) && IPAddress.TryParse(forwardedForAddress, out var remoteAddress)) + { + return remoteAddress.IsIPv4MappedToIPv6 + ? remoteAddress.MapToIPv4() + : remoteAddress; + } + + return httpContext.Connection.RemoteIpAddress.IsIPv4MappedToIPv6 + ? httpContext.Connection.RemoteIpAddress.MapToIPv4() + : httpContext.Connection.RemoteIpAddress; } /// /// Returns whether the request was made locally. /// + /// + /// Searches for additional headers in the following order: + /// + /// Cf-Connecting-Ip + /// X-Real-IP + /// X-Forwarded-For + /// + /// /// The current . - /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// The name of the header to resolve the when behind a proxy. /// - public static bool IsLocalRequest(this HttpContext httpContext, string headerName = "X-Forwarded-For") + public static bool IsLocalRequest(this HttpContext httpContext, string ipHeaderName = null) { - var remoteIpAddress = httpContext.GetRemoteIpAddress(headerName); + var remoteIpAddress = httpContext.GetRemoteIpAddress(ipHeaderName); return httpContext.Connection.LocalIpAddress.Equals(remoteIpAddress); } diff --git a/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs index d539746..32ad11e 100644 --- a/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs @@ -15,11 +15,11 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// The type of the model. /// The type of the property. /// The instance. - /// The model. Only used to infer the model type. + /// The model. Only used to infer the model type. /// The that specifies the property. /// The error message to add. /// No member expression provided. - public static void AddModelError(this ModelStateDictionary modelState, TModel model, Expression> keyExpression, string errorMessage) + public static void AddModelError(this ModelStateDictionary modelState, TModel _, Expression> keyExpression, string errorMessage) { if (modelState is null) throw new ArgumentNullException(nameof(modelState)); diff --git a/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs index 7d4511e..17b3e28 100644 --- a/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs +++ b/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,8 @@ namespace Microsoft.Extensions.DependencyInjection where TService : class, IHostedService { services.AddSingleton(); - services.AddSingleton>(); + services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); + return services; } @@ -30,10 +31,11 @@ namespace Microsoft.Extensions.DependencyInjection /// The to add the service to. /// A reference to this instance after the operation has completed. public static IServiceCollection AddSingletonHostedService(this IServiceCollection services) - where TService : class, IHostedService where TImplementation : class, TService + where TService : class, IHostedService + where TImplementation : class, TService { services.AddSingleton(); - services.AddSingleton>(); + services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); return services; } diff --git a/AMWD.Common.AspNetCore/ModelBinders/InvariantFloatingPointModelBinder.cs b/AMWD.Common.AspNetCore/ModelBinders/InvariantFloatingPointModelBinder.cs index b335dbc..515b572 100644 --- a/AMWD.Common.AspNetCore/ModelBinders/InvariantFloatingPointModelBinder.cs +++ b/AMWD.Common.AspNetCore/ModelBinders/InvariantFloatingPointModelBinder.cs @@ -12,9 +12,9 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public class InvariantFloatingPointModelBinder : IModelBinder { - private readonly NumberStyles supportedNumberStyles; - private readonly ILogger logger; - private readonly CultureInfo cultureInfo; + private readonly NumberStyles _supportedNumberStyles; + private readonly ILogger _logger; + private readonly CultureInfo _cultureInfo; /// /// Initializes a new instance of . @@ -24,10 +24,10 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// The . public InvariantFloatingPointModelBinder(NumberStyles supportedStyles, CultureInfo cultureInfo, ILoggerFactory loggerFactory) { - this.cultureInfo = cultureInfo ?? throw new ArgumentNullException(nameof(cultureInfo)); + _cultureInfo = cultureInfo ?? throw new ArgumentNullException(nameof(cultureInfo)); - supportedNumberStyles = supportedStyles; - logger = loggerFactory?.CreateLogger(); + _supportedNumberStyles = supportedStyles; + _logger = loggerFactory?.CreateLogger(); } /// @@ -36,15 +36,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding if (bindingContext == null) throw new ArgumentNullException(nameof(bindingContext)); - logger?.AttemptingToBindModel(bindingContext); + _logger?.AttemptingToBindModel(bindingContext); string modelName = bindingContext.ModelName; var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); if (valueProviderResult == ValueProviderResult.None) { - logger?.FoundNoValueInRequest(bindingContext); + _logger?.FoundNoValueInRequest(bindingContext); // no entry - logger?.DoneAttemptingToBindModel(bindingContext); + _logger?.DoneAttemptingToBindModel(bindingContext); return Task.CompletedTask; } @@ -56,7 +56,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding try { string value = valueProviderResult.FirstValue; - var culture = cultureInfo ?? valueProviderResult.Culture; + var culture = _cultureInfo ?? valueProviderResult.Culture; object model; if (string.IsNullOrWhiteSpace(value)) @@ -66,15 +66,15 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding } else if (type == typeof(float)) { - model = float.Parse(value, supportedNumberStyles, culture); + model = float.Parse(value, _supportedNumberStyles, culture); } else if (type == typeof(double)) { - model = double.Parse(value, supportedNumberStyles, culture); + model = double.Parse(value, _supportedNumberStyles, culture); } else if (type == typeof(decimal)) { - model = decimal.Parse(value, supportedNumberStyles, culture); + model = decimal.Parse(value, _supportedNumberStyles, culture); } else { @@ -113,7 +113,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding // Conversion failed. } - logger?.DoneAttemptingToBindModel(bindingContext); + _logger?.DoneAttemptingToBindModel(bindingContext); return Task.CompletedTask; } } diff --git a/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..f6b90ab --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationHandler.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication +{ + /// + /// Implements the for Basic Authentication. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public class BasicAuthenticationHandler : AuthenticationHandler + { + private readonly ILogger _logger; + private readonly IBasicAuthenticationValidator _validator; + +#if NET8_0_OR_GREATER + /// + /// Initializes a new instance of the class. + /// + /// The monitor for the options instance. + /// The . + /// The . + /// An basic autentication validator implementation. + public BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IBasicAuthenticationValidator validator) + : base(options, logger, encoder) + { + _logger = logger.CreateLogger(); + _validator = validator; + } +#endif + +#if NET6_0 + + /// + /// Initializes a new instance of the class. + /// + /// The monitor for the options instance. + /// The . + /// The . + /// The . + /// An basic autentication validator implementation. + public BasicAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IBasicAuthenticationValidator validator) + : base(options, logger, encoder, clock) + { + _logger = logger.CreateLogger(); + _validator = validator; + } + +#endif + + /// + protected override async Task HandleAuthenticateAsync() + { + var endpoint = Context.GetEndpoint(); + if (endpoint?.Metadata?.GetMetadata() != null) + return AuthenticateResult.NoResult(); + + if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValue)) + return AuthenticateResult.Fail("Authorization header missing"); + + ClaimsPrincipal principal; + try + { + var authHeader = AuthenticationHeaderValue.Parse(authHeaderValue); + string plain = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter)); + + // See: https://www.rfc-editor.org/rfc/rfc2617, page 6 + string username = plain.Split(':').First(); + string password = plain[(username.Length + 1)..]; + + var ipAddress = Context.GetRemoteIpAddress(); + principal = await _validator.ValidateAsync(username, password, ipAddress, Context.RequestAborted).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Handling the Basic Authentication failed: {ex.Message}"); + return AuthenticateResult.Fail("Authorization header invalid"); + } + + if (principal == null) + return AuthenticateResult.Fail("Invalid credentials"); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return AuthenticateResult.Success(ticket); + } + } +} diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs similarity index 78% rename from AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs rename to AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs index 4e14801..9bde87a 100644 --- a/AMWD.Common.AspNetCore/BasicAuthentication/BasicAuthenticationMiddleware.cs +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/BasicAuthenticationMiddleware.cs @@ -6,15 +6,15 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace AMWD.Common.AspNetCore.BasicAuthentication +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication { /// /// Implements a basic authentication. /// public class BasicAuthenticationMiddleware { - private readonly RequestDelegate next; - private readonly IBasicAuthenticationValidator validator; + private readonly RequestDelegate _next; + private readonly IBasicAuthenticationValidator _validator; /// /// Initializes a new instance of the class. @@ -23,8 +23,8 @@ namespace AMWD.Common.AspNetCore.BasicAuthentication /// A basic authentication validator. public BasicAuthenticationMiddleware(RequestDelegate next, IBasicAuthenticationValidator validator) { - this.next = next; - this.validator = validator; + _next = next; + _validator = validator; } /// @@ -37,7 +37,7 @@ namespace AMWD.Common.AspNetCore.BasicAuthentication { if (!httpContext.Request.Headers.ContainsKey("Authorization")) { - SetAuthenticateRequest(httpContext, validator.Realm); + SetAuthenticateRequest(httpContext, _validator.Realm); return; } @@ -51,14 +51,14 @@ namespace AMWD.Common.AspNetCore.BasicAuthentication string username = plain.Split(':').First(); string password = plain[(username.Length + 1)..]; - var principal = await validator.ValidateAsync(username, password, httpContext.GetRemoteIpAddress(), httpContext.RequestAborted); + var principal = await _validator.ValidateAsync(username, password, httpContext.GetRemoteIpAddress(), httpContext.RequestAborted).ConfigureAwait(false); if (principal == null) { - SetAuthenticateRequest(httpContext, validator.Realm); + SetAuthenticateRequest(httpContext, _validator.Realm); return; } - await next.Invoke(httpContext); + await _next.Invoke(httpContext).ConfigureAwait(false); } catch (Exception ex) { diff --git a/AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs b/AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs similarity index 91% rename from AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs rename to AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs index 47fd1ef..0d440d1 100644 --- a/AMWD.Common.AspNetCore/BasicAuthentication/IBasicAuthenticationValidator.cs +++ b/AMWD.Common.AspNetCore/Security/BasicAuthentication/IBasicAuthenticationValidator.cs @@ -3,7 +3,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; -namespace AMWD.Common.AspNetCore.BasicAuthentication +namespace AMWD.Common.AspNetCore.Security.BasicAuthentication { /// /// Interface representing the validation of a basic authentication. diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs new file mode 100644 index 0000000..3362365 --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Extension for to enable folder protection. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public static class ProtectedPathExtensions + { + /// + /// Provide protected paths even for static files. + /// + /// The . + /// The with path and policy name. + public static IApplicationBuilder UseProtectedPath(this IApplicationBuilder app, ProtectedPathOptions options) + => app.UseMiddleware(options); + } +} diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs new file mode 100644 index 0000000..8800f3f --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathMiddleware.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Implements a check to provide protected paths. + /// + public class ProtectedPathMiddleware + { + private readonly RequestDelegate _next; + private readonly PathString _path; + private readonly string _policyName; + + /// + /// Initializes a new instance of the class. + /// + /// The following delegate in the process chain. + /// The options to configure the middleware. + public ProtectedPathMiddleware(RequestDelegate next, ProtectedPathOptions options) + { + _next = next; + _path = options.Path; + _policyName = options.PolicyName; + } + + /// + /// The delegate invokation. + /// Performs the protection check. + /// + /// The corresponding HTTP context. + /// The . + /// An awaitable task. + public async Task InvokeAsync(HttpContext httpContext, IAuthorizationService authorizationService) + { + if (httpContext.Request.Path.StartsWithSegments(_path)) + { + var result = await authorizationService.AuthorizeAsync(httpContext.User, null, _policyName).ConfigureAwait(false); + if (!result.Succeeded) + { + await httpContext.ChallengeAsync().ConfigureAwait(false); + return; + } + } + await _next.Invoke(httpContext).ConfigureAwait(false); + } + } +} diff --git a/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs new file mode 100644 index 0000000..aa46d86 --- /dev/null +++ b/AMWD.Common.AspNetCore/Security/PathProtection/ProtectedPathOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace AMWD.Common.AspNetCore.Security.PathProtection +{ + /// + /// Options to define which folder should be protected. + /// + public class ProtectedPathOptions + { + /// + /// Gets or sets the path to the protected folder. + /// + public PathString Path { get; set; } + + /// + /// Gets or sets the policy name to use. + /// + public string PolicyName { get; set; } + } +} diff --git a/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs b/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs index ca65fab..8492cea 100644 --- a/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs +++ b/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs @@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers [HtmlAttributeName("class")] public string CssClass { get; set; } - private IDictionary classValues; + private IDictionary _classValues; /// /// Gets or sets a dictionary containing all conditional class names and a boolean condition @@ -32,11 +32,11 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers { get { - return classValues ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + return _classValues ??= new Dictionary(StringComparer.OrdinalIgnoreCase); } set { - classValues = value; + _classValues = value; } } @@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers /// A stateful HTML element used to generate an HTML tag. public override void Process(TagHelperContext context, TagHelperOutput output) { - var items = classValues.Where(e => e.Value).Select(e => e.Key).ToList(); + var items = _classValues.Where(e => e.Value).Select(e => e.Key).ToList(); if (!string.IsNullOrEmpty(CssClass)) items.Insert(0, CssClass); diff --git a/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs b/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs index 036ddea..c523a8d 100644 --- a/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs +++ b/AMWD.Common.AspNetCore/TagHelpers/IntegrityHashTagHelper.cs @@ -18,8 +18,8 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers [HtmlTargetElement("script")] public class IntegrityHashTagHelper : TagHelper { - private readonly IWebHostEnvironment env; - private readonly string hostUrl; + private readonly IWebHostEnvironment _env; + private readonly string _hostUrl; /// /// Initializes a new instance of the class. @@ -28,8 +28,8 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers /// The application configuration. public IntegrityHashTagHelper(IWebHostEnvironment env, IConfiguration configuration) { - this.env = env; - hostUrl = configuration.GetValue("ASPNETCORE_APPL_URL", "http://localhost/"); + _env = env; + _hostUrl = configuration.GetValue("ASPNETCORE_APPL_URL", "http://localhost/"); } /// @@ -84,11 +84,11 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers { using var client = new HttpClient(); - if (!string.IsNullOrWhiteSpace(hostUrl)) - client.DefaultRequestHeaders.Referrer = new Uri(hostUrl); + if (!string.IsNullOrWhiteSpace(_hostUrl)) + client.DefaultRequestHeaders.Referrer = new Uri(_hostUrl); - var response = await client.GetAsync(source); - fileBytes = await response.Content.ReadAsByteArrayAsync(); + var response = await client.GetAsync(source).ConfigureAwait(false); + fileBytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); } catch { @@ -103,13 +103,13 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers if (source.StartsWith("/")) source = source[1..]; - if (source.Contains("?")) + if (source.Contains('?')) source = source[..source.IndexOf("?")]; try { - string path = Path.Combine(env.WebRootPath, source); - fileBytes = await File.ReadAllBytesAsync(path); + string path = Path.Combine(_env.WebRootPath, source); + fileBytes = await File.ReadAllBytesAsync(path).ConfigureAwait(false); } catch { @@ -118,7 +118,7 @@ namespace Microsoft.AspNetCore.Razor.TagHelpers } string type; - byte[] hashBytes = new byte[0]; + byte[] hashBytes = Array.Empty(); switch (IntegrityStrength) { case 512: diff --git a/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs b/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs deleted file mode 100644 index c4b7345..0000000 --- a/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.Hosting -{ - /// - /// Wrapper class to start a background service. - /// - /// The service type. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public class BackgroundServiceStarter : IHostedService - where TService : class, IHostedService - { - private readonly TService service; - - /// - /// Initializes an new instance of the class. - /// - /// The service to work in background. - public BackgroundServiceStarter(TService backgroundService) - { - service = backgroundService; - } - - /// - /// Starts the service. - /// - /// - /// - public Task StartAsync(CancellationToken cancellationToken) - { - return service.StartAsync(cancellationToken); - } - - /// - /// Stops the service. - /// - /// - /// - public Task StopAsync(CancellationToken cancellationToken) - { - return service.StopAsync(cancellationToken); - } - } -} diff --git a/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj index 6d425e2..8409a10 100644 --- a/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj +++ b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj @@ -1,51 +1,38 @@  - Debug;Release;DebugLocal - netcoreapp3.1;net6.0 + net6.0;net8.0 10.0 + efc/v[0-9]* AMWD.Common.EntityFrameworkCore AMWD.Common.EntityFrameworkCore true AMWD.Common.EntityFrameworkCore icon.png + README.md AM.WD Common Library for EntityFramework Core - - true - - - - - - - - - - - - - + - - + + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + diff --git a/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs index 460e6a7..6dd9ea2 100644 --- a/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs +++ b/AMWD.Common.EntityFrameworkCore/Converters/DateOnlyConverter.cs @@ -1,6 +1,4 @@ -#if NET6_0_OR_GREATER - -using System; +using System; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AMWD.Common.EntityFrameworkCore.Converters @@ -24,5 +22,3 @@ namespace AMWD.Common.EntityFrameworkCore.Converters { } } } - -#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs index 2587912..a002be5 100644 --- a/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs +++ b/AMWD.Common.EntityFrameworkCore/Converters/NullableDateOnlyConverter.cs @@ -1,6 +1,4 @@ -#if NET6_0_OR_GREATER - -using System; +using System; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AMWD.Common.EntityFrameworkCore.Converters @@ -24,5 +22,3 @@ namespace AMWD.Common.EntityFrameworkCore.Converters { } } } - -#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs index bafc8ca..5399ace 100644 --- a/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs +++ b/AMWD.Common.EntityFrameworkCore/Converters/NullableTimeOnlyConverter.cs @@ -1,6 +1,4 @@ -#if NET6_0_OR_GREATER - -using System; +using System; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AMWD.Common.EntityFrameworkCore.Converters @@ -24,5 +22,3 @@ namespace AMWD.Common.EntityFrameworkCore.Converters { } } } - -#endif diff --git a/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs b/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs index acb4ccc..4a56df5 100644 --- a/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs +++ b/AMWD.Common.EntityFrameworkCore/Converters/TimeOnlyConverter.cs @@ -1,6 +1,4 @@ -#if NET6_0_OR_GREATER - -using System; +using System; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AMWD.Common.EntityFrameworkCore.Converters @@ -24,5 +22,3 @@ namespace AMWD.Common.EntityFrameworkCore.Converters { } } } - -#endif diff --git a/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs b/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs index 5e41dde..6475393 100644 --- a/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs +++ b/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs @@ -33,6 +33,8 @@ namespace System : base(message, innerException) { } +#if NET6_0 + /// /// Initializes a new instance of the class with serialized data. /// @@ -43,5 +45,7 @@ namespace System protected DatabaseProviderException(SerializationInfo info, StreamingContext context) : base(info, context) { } + +#endif } } diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs index c41c2fa..1e6f27c 100644 --- a/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs +++ b/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs @@ -24,6 +24,7 @@ namespace Microsoft.EntityFrameworkCore /// An action to set additional options. /// The cancellation token. /// true on success, otherwise false or an exception is thrown. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2208")] public static async Task ApplyMigrationsAsync(this DatabaseFacade database, Action optionsAction, CancellationToken cancellationToken = default) { if (database == null) @@ -45,21 +46,21 @@ namespace Microsoft.EntityFrameworkCore { opts.WaitDelay = options.WaitDelay; opts.Logger = options.Logger; - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); var connection = database.GetDbConnection(); try { - await connection.OpenAsync(cancellationToken); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - if (!await connection.CreateMigrationsTable(options, cancellationToken)) + if (!await connection.CreateMigrationsTable(options, cancellationToken).ConfigureAwait(false)) return false; - return await connection.Migrate(options, cancellationToken); + return await connection.Migrate(options, cancellationToken).ConfigureAwait(false); } finally { - await connection.CloseAsync(); + await connection.CloseAsync().ConfigureAwait(false); } } @@ -87,7 +88,7 @@ namespace Microsoft.EntityFrameworkCore { try { - await connection.OpenAsync(cancellationToken); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); options.Logger?.LogInformation("Database connection available"); return; } @@ -96,7 +97,7 @@ namespace Microsoft.EntityFrameworkCore // keep things quiet try { - await Task.Delay(options.WaitDelay, cancellationToken); + await Task.Delay(options.WaitDelay, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -109,7 +110,7 @@ namespace Microsoft.EntityFrameworkCore } finally { - await connection.CloseAsync(); + await connection.CloseAsync().ConfigureAwait(false); } } } @@ -192,7 +193,7 @@ BEGIN END;" }; - await command.ExecuteNonQueryAsync(cancellationToken); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); return true; } catch (Exception ex) @@ -235,9 +236,9 @@ END;" DatabaseProvider.SQLServer => $"SELECT [schema_file] FROM [{options.MigrationsTableName}];", _ => $@"SELECT ""schema_file"" FROM ""{options.MigrationsTableName}"";", }; - using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)) { - while (await reader.ReadAsync(cancellationToken)) + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) migratedFiles.Add(reader.GetString(0)); } @@ -246,7 +247,7 @@ END;" { // remove path including the separator string fileName = migrationFile.Replace(options.Path, "")[1..]; - using var transaction = await connection.BeginTransactionAsync(cancellationToken); + using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { // max length in the database: 250 chars @@ -263,13 +264,13 @@ END;" string sqlScript = null; if (options.SourceAssembly == null) { - sqlScript = await File.ReadAllTextAsync(migrationFile, cancellationToken); + sqlScript = await File.ReadAllTextAsync(migrationFile, cancellationToken).ConfigureAwait(false); } else { using var stream = options.SourceAssembly.GetManifestResourceStream(migrationFile); using var sr = new StreamReader(stream); - sqlScript = await sr.ReadToEndAsync(); + sqlScript = await sr.ReadToEndAsync().ConfigureAwait(false); } if (string.IsNullOrWhiteSpace(sqlScript)) @@ -278,7 +279,7 @@ END;" options.Logger?.LogDebug($" Migrating file '{fileName}' started"); command.Transaction = transaction; - await command.ExecuteScript(sqlScript, cancellationToken); + await command.ExecuteScript(sqlScript, cancellationToken).ConfigureAwait(false); command.CommandText = connection.GetProviderType() switch { @@ -286,15 +287,15 @@ END;" DatabaseProvider.SQLServer => $"INSERT INTO [{options.MigrationsTableName}] ([schema_file], [installed_at]) VALUES ('{trimmedFileName.Replace("'", "\\'")}', '{DateTime.UtcNow:yyyy-MM-dd HH:mm}');", _ => $@"INSERT INTO ""{options.MigrationsTableName}"" (""schema_file"", ""installed_at"") VALUES ('{trimmedFileName.Replace("'", "\\'")}', '{DateTime.UtcNow:yyyy-MM-dd HH:mm}');", }; - await command.ExecuteNonQueryAsync(cancellationToken); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - await transaction.CommitAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); command.Transaction = null; options.Logger?.LogDebug($" Migrating file '{fileName}' successful"); } catch (Exception ex) { - await transaction.RollbackAsync(cancellationToken); + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); options.Logger?.LogError($"Migrating file '{fileName}' failed: {ex.InnerException?.Message ?? ex.Message}"); return false; } @@ -330,7 +331,7 @@ END;" if (!string.IsNullOrWhiteSpace(pt)) { command.CommandText = pt; - affectedRows += await command.ExecuteNonQueryAsync(cancellationToken); + affectedRows += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } } return affectedRows; @@ -338,7 +339,7 @@ END;" else { command.CommandText = text; - return await command.ExecuteNonQueryAsync(cancellationToken); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs index cccaf0c..0b5329a 100644 --- a/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs +++ b/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs @@ -3,9 +3,7 @@ using System.Reflection; using System.Text; using AMWD.Common.EntityFrameworkCore.Attributes; using Microsoft.EntityFrameworkCore; -#if NET6_0_OR_GREATER using Microsoft.EntityFrameworkCore.Metadata; -#endif namespace AMWD.Common.EntityFrameworkCore.Extensions { @@ -35,13 +33,7 @@ namespace AMWD.Common.EntityFrameworkCore.Extensions index.IsUnique = indexAttribute.IsUnique; if (!string.IsNullOrWhiteSpace(indexAttribute.Name)) - { -#if NET6_0_OR_GREATER index.SetDatabaseName(indexAttribute.Name.Trim()); -#else - index.SetName(indexAttribute.Name.Trim()); -#endif - } } } } @@ -60,28 +52,14 @@ namespace AMWD.Common.EntityFrameworkCore.Extensions { // skip conversion when table name is explicitly set if ((entityType.ClrType.GetCustomAttribute(typeof(TableAttribute), false) as TableAttribute) == null) - { -#if NET6_0_OR_GREATER entityType.SetTableName(ConvertToSnakeCase(entityType.GetTableName())); -#else - entityType.SetTableName(ConvertToSnakeCase(entityType.GetTableName())); -#endif - } -#if NET6_0_OR_GREATER var identifier = StoreObjectIdentifier.Table(entityType.GetTableName(), entityType.GetSchema()); -#endif foreach (var property in entityType.GetProperties()) { // skip conversion when column name is explicitly set if ((entityType.ClrType.GetProperty(property.Name)?.GetCustomAttribute(typeof(ColumnAttribute), false) as ColumnAttribute) == null) - { -#if NET6_0_OR_GREATER property.SetColumnName(ConvertToSnakeCase(property.GetColumnName(identifier))); -#else - property.SetColumnName(ConvertToSnakeCase(property.GetColumnName())); -#endif - } } } diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs index 8c8f8b1..b244536 100644 --- a/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs +++ b/AMWD.Common.EntityFrameworkCore/Extensions/ModelConfigurationBuilderExtensions.cs @@ -1,6 +1,4 @@ -#if NET6_0_OR_GREATER - -using System; +using System; using AMWD.Common.EntityFrameworkCore.Converters; using Microsoft.EntityFrameworkCore; @@ -12,14 +10,14 @@ namespace AMWD.Common.EntityFrameworkCore.Extensions public static class ModelConfigurationBuilderExtensions { /// - /// Adds converters for the and datatypes introduced with .NET 6.0. + /// Adds converters for the datatype introduced with .NET 6.0. /// /// /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. /// /// The instance. /// The instance after applying the converters. - public static ModelConfigurationBuilder AddDateOnlyTimeOnlyConverters(this ModelConfigurationBuilder builder) + public static ModelConfigurationBuilder AddDateOnlyConverters(this ModelConfigurationBuilder builder) { builder.Properties() .HaveConversion() @@ -28,6 +26,19 @@ namespace AMWD.Common.EntityFrameworkCore.Extensions .HaveConversion() .HaveColumnType("date"); + return builder; + } + + /// + /// Adds converters for the datatype introduced with .NET 6.0. + /// + /// + /// As of 2022-06-04 only required for Microsoft SQL server on .NET 6.0. + /// + /// The instance. + /// The instance after applying the converters. + public static ModelConfigurationBuilder AddTimeOnlyConverters(this ModelConfigurationBuilder builder) + { builder.Properties() .HaveConversion() .HaveColumnType("time"); @@ -39,5 +50,3 @@ namespace AMWD.Common.EntityFrameworkCore.Extensions } } } - -#endif diff --git a/AMWD.Common.EntityFrameworkCore/GlobalSuppressions.cs b/AMWD.Common.EntityFrameworkCore/GlobalSuppressions.cs new file mode 100644 index 0000000..3b7c57c --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/GlobalSuppressions.cs @@ -0,0 +1,3 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Usage", "CA2254")] diff --git a/AMWD.Common.Moq/AMWD.Common.Moq.csproj b/AMWD.Common.Moq/AMWD.Common.Moq.csproj deleted file mode 100644 index 9f28ded..0000000 --- a/AMWD.Common.Moq/AMWD.Common.Moq.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - Debug;Release;DebugLocal - netstandard2.0 - 10.0 - - AMWD.Common.Moq - AMWD.Common.Moq - - true - AMWD.Common.Moq - icon.png - - AM.WD Common Library for Moq - - - - true - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/AMWD.Common.Test/AMWD.Common.Test.csproj b/AMWD.Common.Test/AMWD.Common.Test.csproj new file mode 100644 index 0000000..f68fc70 --- /dev/null +++ b/AMWD.Common.Test/AMWD.Common.Test.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + 10.0 + + test/v[0-9]* + AMWD.Common.Test + AMWD.Common.Test + + true + AMWD.Common.Test + icon.png + README.md + + AM.WD Common Library for Unit-Testing + + + + + + + + + + + + + diff --git a/AMWD.Common.Moq/HttpMessageHandlerMoq.cs b/AMWD.Common.Test/HttpMessageHandlerMoq.cs similarity index 94% rename from AMWD.Common.Moq/HttpMessageHandlerMoq.cs rename to AMWD.Common.Test/HttpMessageHandlerMoq.cs index d5b18dd..3102348 100644 --- a/AMWD.Common.Moq/HttpMessageHandlerMoq.cs +++ b/AMWD.Common.Test/HttpMessageHandlerMoq.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; using Moq; using Moq.Protected; -namespace AMWD.Common.Moq +namespace AMWD.Common.Test { /// /// Wrapps the including the setup. @@ -39,8 +39,8 @@ namespace AMWD.Common.Moq if (req.Content != null) { - callback.ContentBytes = await req.Content.ReadAsByteArrayAsync(); - callback.ContentString = await req.Content.ReadAsStringAsync(); + callback.ContentBytes = await req.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + callback.ContentString = await req.Content.ReadAsStringAsync().ConfigureAwait(false); } Callbacks.Add(callback); diff --git a/AMWD.Common.Test/SnapshotAssert.cs b/AMWD.Common.Test/SnapshotAssert.cs new file mode 100644 index 0000000..f418a31 --- /dev/null +++ b/AMWD.Common.Test/SnapshotAssert.cs @@ -0,0 +1,87 @@ +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AMWD.Common.Test +{ + /// + /// Implements a snapshot comparison for content aggregation (e.g. files). + /// + public sealed class SnapshotAssert + { + /// + /// Tests whether the specified string is equal to the saved snapshot. + /// + /// The current aggregated content string. + /// An error message. + /// The absolute file path of the calling file (filled automatically on compile time). + /// The name of the calling method (filled automatically on compile time). + public static void AreEqual(string actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null) + { + string cleanLineEnding = actual + .Replace("\r\n", "\n") // Windows + .Replace("\r", "\n"); // old MacOS + AreEqual(Encoding.UTF8.GetBytes(cleanLineEnding), message, callerFilePath, callerMemberName); + } + + /// + /// Tests whether the specified byte array is equal to the saved snapshot. + /// + /// The current aggregated content bytes. + /// An error message. + /// The absolute file path of the calling file (filled automatically on compile time). + /// The name of the calling method (filled automatically on compile time). + public static void AreEqual(byte[] actual, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null) + => AreEqual(actual, 0, -1, message, callerFilePath, callerMemberName); + + /// + /// Tests whether the specified byte array is equal to the saved snapshot. + /// + /// + /// The past has shown, that e.g. wkhtmltopdf prints the current timestamp at the beginning of the PDF file. + /// Therefore only a specific part of that file can be asserted to be equal. + /// + /// The current aggregated content bytes. + /// The first byte to compare. + /// The last byte to compare. + /// An error message. + /// The absolute file path of the calling file (filled automatically on compile time). + /// The name of the calling method (filled automatically on compile time). + public static void AreEqual(byte[] actual, int firstByteIndex, int lastByteIndex, string message = null, [CallerFilePath] string callerFilePath = null, [CallerMemberName] string callerMemberName = null) + { + string callerDir = Path.GetDirectoryName(callerFilePath); + string callerFile = Path.GetFileNameWithoutExtension(callerFilePath); + + string snapshotDir = Path.Combine(callerDir, "Snapshots", callerFile); + string snapshotFile = Path.Combine(snapshotDir, $"{callerMemberName}.snap"); + + if (File.Exists(snapshotFile)) + { + byte[] expected = File.ReadAllBytes(snapshotFile); + + var actualBytes = actual.Skip(firstByteIndex); + var expectedBytes = expected.Skip(firstByteIndex); + + if (lastByteIndex > firstByteIndex) + { + actualBytes = actualBytes.Take(lastByteIndex - firstByteIndex); + expectedBytes = expectedBytes.Take(lastByteIndex - firstByteIndex); + } + + if (message == null) + CollectionAssert.AreEqual(expectedBytes.ToArray(), actualBytes.ToArray()); + else + CollectionAssert.AreEqual(expectedBytes.ToArray(), actualBytes.ToArray(), message); + } + else + { + if (!Directory.Exists(snapshotDir)) + Directory.CreateDirectory(snapshotDir); + + File.WriteAllBytes(snapshotFile, actual); + } + } + } +} diff --git a/AMWD.Common.Moq/TcpClientMoq.cs b/AMWD.Common.Test/TcpClientMoq.cs similarity index 85% rename from AMWD.Common.Moq/TcpClientMoq.cs rename to AMWD.Common.Test/TcpClientMoq.cs index cc1eced..3962940 100644 --- a/AMWD.Common.Moq/TcpClientMoq.cs +++ b/AMWD.Common.Test/TcpClientMoq.cs @@ -5,14 +5,14 @@ using System.Threading; using System.Threading.Tasks; using Moq; -namespace AMWD.Common.Moq +namespace AMWD.Common.Test { /// /// Wrapps the including the setup. /// public class TcpClientMoq { - private readonly Mock streamMock; + private readonly Mock _streamMock; /// /// Initializes a new instance of the class. @@ -22,8 +22,8 @@ namespace AMWD.Common.Moq Callbacks = new(); Response = new byte[0]; - streamMock = new(); - streamMock + _streamMock = new(); + _streamMock .Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((buffer, offset, count, _) => { @@ -39,7 +39,7 @@ namespace AMWD.Common.Moq Callbacks.Add(callback); }) .Returns(Task.CompletedTask); - streamMock + _streamMock .Setup(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((buffer, offset, count) => { @@ -55,7 +55,7 @@ namespace AMWD.Common.Moq Callbacks.Add(callback); }); - streamMock + _streamMock .Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((buffer, offset, count, _) => { @@ -63,7 +63,7 @@ namespace AMWD.Common.Moq Array.Copy(bytes, 0, buffer, offset, Math.Min(bytes.Length, count)); }) .ReturnsAsync(Response?.Length ?? 0); - streamMock + _streamMock .Setup(s => s.Read(It.IsAny(), It.IsAny(), It.IsAny())) .Callback((buffer, offset, count) => { @@ -75,7 +75,7 @@ namespace AMWD.Common.Moq Mock = new(); Mock .Setup(c => c.GetStream()) - .Returns(streamMock.Object); + .Returns(_streamMock.Object); } /// @@ -107,28 +107,28 @@ namespace AMWD.Common.Moq /// /// Number of calls. public void VerifyWriteAsync(Times times) - => streamMock.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); + => _streamMock.Verify(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); /// /// Verifies the number of calls writing synchronous to the stream. /// /// Number of calls. public void VerifyWriteSync(Times times) - => streamMock.Verify(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny()), times); + => _streamMock.Verify(s => s.Write(It.IsAny(), It.IsAny(), It.IsAny()), times); /// /// Verifies the number of calls reading asynchronous from the stream. /// /// Number of calls. public void VerifyReadAsync(Times times) - => streamMock.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); + => _streamMock.Verify(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times); /// /// Verifies the number of calls reading synchronous from the stream. /// /// Number of calls. public void VerifyReadSync(Times times) - => streamMock.Verify(s => s.Read(It.IsAny(), It.IsAny(), It.IsAny()), times); + => _streamMock.Verify(s => s.Read(It.IsAny(), It.IsAny(), It.IsAny()), times); /// /// Represents the placed TCP request. diff --git a/AMWD.Common/AMWD.Common.csproj b/AMWD.Common/AMWD.Common.csproj index 333d45a..e2a49d9 100644 --- a/AMWD.Common/AMWD.Common.csproj +++ b/AMWD.Common/AMWD.Common.csproj @@ -1,7 +1,6 @@ - + - Debug;Release;DebugLocal netstandard2.0 10.0 @@ -11,32 +10,21 @@ true AMWD.Common icon.png + README.md AM.WD Common Library - - true - - - - - - - + - + - + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/AMWD.Common/Cli/Argument.cs b/AMWD.Common/Cli/Argument.cs new file mode 100644 index 0000000..79a0b9b --- /dev/null +++ b/AMWD.Common/Cli/Argument.cs @@ -0,0 +1,36 @@ +namespace AMWD.Common.Cli +{ + /// + /// Represents a logical argument in the command line. Options with their additional + /// parameters are combined in one argument. + /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + public class Argument + { + /// + /// Initialises a new instance of the class. + /// + /// The that is set in this argument; or null. + /// The additional parameter values for the option; or the argument value. + internal Argument(Option option, string[] values) + { + Option = option; + Values = values; + } + + /// + /// Gets the that is set in this argument; or null. + /// + public Option Option { get; private set; } + + /// + /// Gets the additional parameter values for the option; or the argument value. + /// + public string[] Values { get; private set; } + + /// + /// Gets the first item of ; or null. + /// + public string Value => Values.Length > 0 ? Values[0] : null; + } +} diff --git a/AMWD.Common/Cli/CommandLineParser.cs b/AMWD.Common/Cli/CommandLineParser.cs new file mode 100644 index 0000000..76e3537 --- /dev/null +++ b/AMWD.Common/Cli/CommandLineParser.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace AMWD.Common.Cli +{ + /// + /// Provides options and arguments parsing from command line arguments or a single string. + /// + public class CommandLineParser + { + #region Private data + + private string[] _args; + private List _parsedArguments; + private readonly List