commit ca9de13c9e9c6fa67c45729e75a493fb57a7b542 Author: Andreas Mueller Date: Fri Oct 22 21:05:37 2021 +0200 Refactoring diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5426f75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,148 @@ +# Documentation: +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference + +# Top-most EditorConfig file +root = true + +[*] +insert_final_newline = true +end_of_line = crlf +indent_style = tab + +[*.{cs,vb}] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# Suggest explicit accessibility modifiers +dotnet_style_require_accessibility_modifiers = always:suggestion + +# Suggest more modern language features when available +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:none +dotnet_style_prefer_conditional_expression_over_return = true:none +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion + +# Definitions +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate, type_parameter +dotnet_naming_symbols.methods_properties.applicable_kinds = method, local_function, property +dotnet_naming_symbols.public_symbols.applicable_kinds = property, method, field, event +dotnet_naming_symbols.public_symbols.applicable_accessibilities = public +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_symbols.private_protected_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_protected_internal_fields.applicable_accessibilities = private, protected, internal +dotnet_naming_symbols.parameters_locals.applicable_kinds = parameter, local +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Name all types using PascalCase +dotnet_naming_rule.types_must_be_capitalized.symbols = types +dotnet_naming_rule.types_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.types_must_be_capitalized.severity = warning + +# Name all methods and properties using PascalCase +dotnet_naming_rule.methods_properties_must_be_capitalized.symbols = methods_properties +dotnet_naming_rule.methods_properties_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.methods_properties_must_be_capitalized.severity = warning + +# Name all public members using PascalCase +dotnet_naming_rule.public_members_must_be_capitalized.symbols = public_symbols +dotnet_naming_rule.public_members_must_be_capitalized.style = pascal_case_style +dotnet_naming_rule.public_members_must_be_capitalized.severity = warning + +# Name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_must_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_must_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_must_be_pascal_case.severity = suggestion + +# Name all private and internal fields using camelCase +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.symbols = private_protected_internal_fields +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.style = camel_case_style +dotnet_naming_rule.private_protected_internal_fields_must_be_camel_case.severity = warning + +# Name all parameters and locals using camelCase +dotnet_naming_rule.parameters_locals_must_be_camel_case.symbols = parameters_locals +dotnet_naming_rule.parameters_locals_must_be_camel_case.style = camel_case_style +dotnet_naming_rule.parameters_locals_must_be_camel_case.severity = warning + +[*.cs] +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:suggestion + +# Only use "var" when it's obvious what the variable type is +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:none + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +[*.{xml,csproj,targets,props,json}] +indent_size = 2 +indent_style = space diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..983b1f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,564 @@ +# Created by https://www.gitignore.io/api/linux,macos,windows,aspnetcore,visualstudio,visualstudiocode,vim +# Edit at https://www.gitignore.io/?templates=linux,macos,windows,aspnetcore,visualstudio,visualstudiocode,vim + +### ASPNETCore ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +nuget.config +build + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/ + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### VisualStudio ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Build results + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results + +# NUNIT + +# Build Results of an ATL Project + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_h.h +*.iobj +*.ipdb +*_wpftmp.csproj + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# JustCode is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.ndf + +# Business Intelligence projects +*.rptproj.rsuser + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# JetBrains Rider + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# End of https://www.gitignore.io/api/linux,macos,windows,aspnetcore,visualstudio,visualstudiocode,vim diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..f24569f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,12 @@ +image: mcr.microsoft.com/dotnet/sdk + +stages: + - build + +build: + stage: build + tags: + - docker + script: + - bash build.sh + - dotnet nuget push -k $APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate build/*.nupkg diff --git a/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj new file mode 100644 index 0000000..98176c8 --- /dev/null +++ b/AMWD.Common.AspNetCore/AMWD.Common.AspNetCore.csproj @@ -0,0 +1,56 @@ + + + + netcoreapp3.1;net5.0 + 9.0 + + AMWD.Common.AspNetCore + AMWD.Common.AspNetCore + {semvertag:master:+chash}{!:-dirty} + + true + false + true + false + + true + true + snupkg + AMWD.Common.AspNetCore + + AM.WD Common Library for ASP.NET Core + Library with classes and extensions used frequently on AM.WD projects. + AM.WD + Andreas Müller + © {copyright:2020-} AM.WD + MIT + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs b/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs new file mode 100644 index 0000000..82fcd6c --- /dev/null +++ b/AMWD.Common.AspNetCore/Attributes/GoogleReCaptchaAttribute.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; + +namespace Microsoft.AspNetCore.Mvc.Filters +{ + /// + /// Custom filter attribute to use Google's reCaptcha (v3). + /// Usage: [ServiceFilter(typeof(GoogleReCaptchaAttribute))] + /// + public class GoogleReCaptchaAttribute : ActionFilterAttribute + { + /// + /// The error key used in . + /// + public const string ErrorKey = "GoogleReCaptcha"; + + /// + /// The key used in forms submitted to the backend. + /// + public const string ResponseTokenKey = "g-recaptcha-response"; + + /// + /// The key used in to transport the score (0 - bot, 1 - human). + /// + public const string ScoreKey = "GoogleReCaptchaScore"; + + private const string VerificationUrl = "https://www.google.com/recaptcha/api/siteverify"; + + private readonly string privateKey; + + /// + /// Initializes a new instance of the class. + /// + /// + /// appsettings.json: + ///
+ /// + /// {
+ /// [...]
+ /// "Google": {
+ /// "ReCaptcha": {
+ /// "PrivateKey": "__private reCaptcha key__",
+ /// "PublicKey": "__public reCaptcha key__"
+ /// }
+ /// }
+ /// } + ///
+ ///
+ /// The score from google can be found on HttpContext.Items[GoogleReCaptchaAttribute.ScoreKey]. + ///
+ /// The application configuration. + public GoogleReCaptchaAttribute(IConfiguration configuration) + { + privateKey = configuration.GetValue("Google:ReCaptcha:PrivateKey"); + } + + /// + /// Executes the validattion in background. + /// + /// The action context. + /// The following action delegate. + /// An awaitable task. + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + await DoValidation(context); + await base.OnActionExecutionAsync(context, next); + } + + private async Task DoValidation(ActionExecutingContext context) + { + if (string.IsNullOrWhiteSpace(privateKey)) + return; + + if (!context.HttpContext.Request.HasFormContentType) + return; + + var token = context.HttpContext.Request.Form[ResponseTokenKey]; + if (string.IsNullOrWhiteSpace(token)) + { + context.ModelState.TryAddModelError(ErrorKey, "No token to validate Google reCaptcha"); + return; + } + + await Validate(context, token); + } + + private async Task Validate(ActionExecutingContext context, string token) + { + using var httpClient = new HttpClient(); + var param = new Dictionary + { + { "secret", privateKey }, + { "response", token } + }; + var response = await httpClient.PostAsync(VerificationUrl, new FormUrlEncodedContent(param)); + string json = await response.Content.ReadAsStringAsync(); + + var result = JsonConvert.DeserializeObject(json); + if (result?.Success != true) + { + context.ModelState.TryAddModelError(ErrorKey, "Google reCaptcha verification failed"); + context.HttpContext.Items[ScoreKey] = null; + } + else + { + context.HttpContext.Items[ScoreKey] = result.Score; + } + } + + private class Response + { + public bool Success { get; set; } + + public decimal Score { get; set; } + + public string Action { get; set; } + + [JsonProperty("challenge_ts")] + public DateTime Timestamp { get; set; } + + public string Hostname { get; set; } + + [JsonProperty("error-codes")] + public List ErrorCodes { get; set; } + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..356080e --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extensions for the . + /// + public static class ApplicationBuilderExtensions + { + /// + /// Adds settings to run behind a reverse proxy (e.g. NginX). + /// + /// + /// A base path (e.g. running in a sub-directory /app) for the application is defined via
+ /// - ASPNETCORE_APPL_PATH environment variable (preferred)
+ /// - AspNetCore_Appl_Path in the settings file
+ ///
+ /// 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:
+ /// - 10.0.0.0/8
+ /// - 172.16.0.0/12
+ /// - 192.168.0.0/16 + ///
+ /// 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) + { + string path = Environment.GetEnvironmentVariable("ASPNETCORE_APPL_PATH"); + if (!string.IsNullOrWhiteSpace(path)) + app.UsePathBase(new PathString(path)); + + var options = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; + options.KnownProxies.Clear(); + options.KnownNetworks.Clear(); + + if (network == null && address == null) + { + 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)); + } + + if (network != null) + options.KnownNetworks.Add(network); + + if (address != null) + options.KnownProxies.Add(address); + + app.UseForwardedHeaders(options); + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/HtmlExtensions.cs b/AMWD.Common.AspNetCore/Extensions/HtmlExtensions.cs new file mode 100644 index 0000000..b64dd7d --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/HtmlExtensions.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace AMWD.Common.AspNetCore.Extensions +{ + /// + /// Extensions for the HTML (e.g. ). + /// + public static class HtmlExtensions + { + /// + /// The prefix used to identify JavaScript parts. + /// + public static string JSPrefix { get; set; } = "_JS_"; + + /// + /// The prefix used to identify CascadingStyleSheet parts. + /// + public static string CSSPrefix { get; set; } = "_CSS_"; + + /// + /// Add a js snippet. + /// + /// The dynamic type of the . + /// The instance. + /// The template to use to add the snippet. + /// + public static T AddJS(this IHtmlHelper htmlHelper, Func template) + { + htmlHelper.ViewContext.HttpContext.Items[$"{JSPrefix}{Guid.NewGuid()}"] = template; + return default; + } + + /// + /// Renders the js snippets into the view. + /// + /// The dynamic type of the . + /// The instance. + /// + public static T RenderJS(this IHtmlHelper htmlHelper) + { + foreach (object key in htmlHelper.ViewContext.HttpContext.Items.Keys) + { + if (key.ToString().StartsWith(JSPrefix) && htmlHelper.ViewContext.HttpContext.Items[key] is Func template) + htmlHelper.ViewContext.Writer.WriteLine(template(null)); + } + return default; + } + + /// + /// Add a css snippet. + /// + /// The dynamic type of the . + /// The instance. + /// The template to use to add the snippet. + /// + public static T AddCSS(this IHtmlHelper htmlHelper, Func template) + { + htmlHelper.ViewContext.HttpContext.Items[$"{CSSPrefix}{Guid.NewGuid()}"] = template; + return default; + } + + /// + /// Renders the css snippets into the view. + /// + /// The dynamic type of the . + /// The instance. + /// + public static T RenderCSS(this IHtmlHelper htmlHelper) + { + foreach (object key in htmlHelper.ViewContext.HttpContext.Items.Keys) + { + if (key.ToString().StartsWith(CSSPrefix) && htmlHelper.ViewContext.HttpContext.Items[key] is Func template) + htmlHelper.ViewContext.Writer.WriteLine(template(null)); + } + return default; + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..0f0c011 --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/HttpContextExtensions.cs @@ -0,0 +1,63 @@ +using System.Net; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// Extensions for the . + /// + public static class HttpContextExtensions + { + /// + /// Retrieves the antiforgery token. + /// + /// The web context. + /// Name and value of the token. + public static (string Name, string Value) GetAntiforgeryToken(this HttpContext httpContext) + { + var af = httpContext.RequestServices.GetService(); + var set = af?.GetAndStoreTokens(httpContext); + + return (Name: set?.FormFieldName, Value: set?.RequestToken); + } + + /// + /// Returns the remote ip address. + /// + /// The web context. + /// The name of the header to resolve the when behind a proxy (Default: X-Forwarded-For). + /// The ip address of the client. + public static IPAddress GetRemoteIpAddress(this HttpContext httpContext, string headerName = "X-Forwarded-For") + { + var remote = httpContext.Connection.RemoteIpAddress; + + string forwardedHeader = httpContext.Request.Headers[headerName].ToString(); + if (!string.IsNullOrWhiteSpace(forwardedHeader) && IPAddress.TryParse(forwardedHeader, out var forwarded)) + return forwarded; + + return remote; + } + + /// + /// Tries to retrieve the return url. + /// + /// + /// + public static string GetReturnUrl(this HttpContext httpContext) + { + string url = httpContext.Items["OriginalRequest"]?.ToString(); + if (string.IsNullOrWhiteSpace(url)) + url = httpContext.Request.Query["ReturnUrl"].ToString(); + + return url; + } + + /// + /// Clears a session when available. + /// + /// The current . + public static void ClearSession(this HttpContext httpContext) + => httpContext?.Session?.Clear(); + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/LoggerExtensions.cs b/AMWD.Common.AspNetCore/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..969020b --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/LoggerExtensions.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace Microsoft.Extensions.Logging +{ + /// + /// Extensions for the . + /// + internal static class LoggerExtensions + { + // Found here: + // https://github.com/dotnet/aspnetcore/blob/a4c45262fb8549bdb4f5e4f76b16f98a795211ae/src/Mvc/Mvc.Core/src/MvcCoreLoggerExtensions.cs + + public static void AttemptingToBindModel(this ILogger logger, ModelBindingContext bindingContext) + { + if (!logger.IsEnabled(LogLevel.Debug)) + return; + + var modelMetadata = bindingContext.ModelMetadata; + switch (modelMetadata.MetadataKind) + { + case ModelMetadataKind.Parameter: + logger.Log(LogLevel.Debug, + new EventId(44, "AttemptingToBindParameterModel"), + $"Attempting to bind parameter '{modelMetadata.ParameterName}' of type '{modelMetadata.ModelType}' using the name '{bindingContext.ModelName}' in request data ..."); + break; + + case ModelMetadataKind.Property: + logger.Log(LogLevel.Debug, + new EventId(13, "AttemptingToBindPropertyModel"), + $"Attempting to bind property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{modelMetadata.ModelType}' using the name '{bindingContext.ModelName}' in request data ..."); + break; + + case ModelMetadataKind.Type: + logger.Log(LogLevel.Debug, + new EventId(24, "AttemptingToBindModel"), + $"Attempting to bind model of type '{bindingContext.ModelType}' using the name '{bindingContext.ModelName}' in request data ..."); + break; + } + } + + public static void FoundNoValueInRequest(this ILogger logger, ModelBindingContext bindingContext) + { + if (!logger.IsEnabled(LogLevel.Debug)) + return; + + var modelMetadata = bindingContext.ModelMetadata; + switch (modelMetadata.MetadataKind) + { + case ModelMetadataKind.Parameter: + logger.Log(LogLevel.Debug, + new EventId(16, "FoundNoValueForParameterInRequest"), + $"Could not find a value in the request with name '{bindingContext.ModelName}' for binding parameter '{modelMetadata.ParameterName}' of typ//(('{bindingContext.ModelType}'."); + break; + + case ModelMetadataKind.Property: + logger.Log(LogLevel.Debug, + new EventId(15, "FoundNoValueForPropertyInRequest"), + $"Could not find a value in the request with name '{bindingContext.ModelName}' for binding property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{bindingContext.ModelType}'."); + break; + + case ModelMetadataKind.Type: + logger.Log(LogLevel.Debug, + new EventId(46, "FoundNoValueInRequest"), + $"Could not find a value in the request with name '{bindingContext.ModelName}' of type '{bindingContext.ModelType}'."); + break; + } + } + + public static void DoneAttemptingToBindModel(this ILogger logger, ModelBindingContext bindingContext) + { + if (!logger.IsEnabled(LogLevel.Debug)) + return; + + var modelMetadata = bindingContext.ModelMetadata; + switch (modelMetadata.MetadataKind) + { + case ModelMetadataKind.Parameter: + logger.Log(LogLevel.Debug, + new EventId(45, "DoneAttemptingToBindParameterModel"), + $"Done attempting to bind parameter '{modelMetadata.ParameterName}' of type '{modelMetadata.ModelType}'."); + break; + + case ModelMetadataKind.Property: + logger.Log(LogLevel.Debug, + new EventId(14, "DoneAttemptingToBindPropertyModel"), + $"Done attempting to bind property '{modelMetadata.ContainerType}.{modelMetadata.PropertyName}' of type '{modelMetadata.ModelType}'."); + break; + + case ModelMetadataKind.Type: + logger.Log(LogLevel.Debug, + new EventId(25, "DoneAttemptingToBindModel"), + $"Done attempting to bind model of type '{bindingContext.ModelType}' using the name '{bindingContext.ModelName}'."); + break; + } + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs new file mode 100644 index 0000000..c7c3c97 --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/ModelStateDictionaryExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq.Expressions; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + /// + /// Provides extension methods for the ASP.NET Core application. + /// + public static class ModelStateDictionaryExtensions + { + /// + /// Adds the specified to the + /// instance that is associated with the key specified as a . + /// + /// The type of the model. + /// The type of the property. + /// The instance. + /// The model. Only used to infer the model type. + /// The that specifies the property. + /// The error message to add. + /// No member expression provided. +#pragma warning disable IDE0060 // remove unused parameters + public static void AddModelError(this ModelStateDictionary modelState, TModel model, Expression> keyExpression, string errorMessage) +#pragma warning restore IDE0060 // remove unused parameters + { + if (modelState is null) + throw new ArgumentNullException(nameof(modelState)); + + string key = ""; + var expr = keyExpression.Body as MemberExpression; + while (expr != null) + { + key = expr.Member.Name + (key != "" ? "." + key : ""); + expr = expr.Expression as MemberExpression; + } + if (key == "") + throw new InvalidOperationException("No member expression provided."); + + modelState.AddModelError(key, errorMessage); + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs b/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..674b07e --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extensions for the . + /// + public static class ServiceCollectionExtensions + { + /// + /// Adds a hosted service that is instanciated only once. + /// + /// The type of the service to add. + /// 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 + { + services.AddSingleton(); + services.AddSingleton>(); + return services; + } + } +} diff --git a/AMWD.Common.AspNetCore/Extensions/SessionExtensions.cs b/AMWD.Common.AspNetCore/Extensions/SessionExtensions.cs new file mode 100644 index 0000000..7261aee --- /dev/null +++ b/AMWD.Common.AspNetCore/Extensions/SessionExtensions.cs @@ -0,0 +1,56 @@ +using System.Linq; +using Newtonsoft.Json; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// Extensions for the object. + /// + public static class SessionExtensions + { + /// + /// Sets a strong typed value. + /// + /// The value type. + /// The current session. + /// The key. + /// The value. + public static void SetValue(this ISession session, string key, T value) + => session.SetString(key, JsonConvert.SerializeObject(value)); + + /// + /// Gets a strong typed value. + /// + /// The value type. + /// The current session. + /// The key. + /// The value. + public static T GetValue(this ISession session, string key) + => session.HasKey(key) ? JsonConvert.DeserializeObject(session.GetString(key)) : default; + + /// + /// Gets a strong typed value or the fallback value. + /// + /// The value type. + /// The current session. + /// The key. + /// A fallback value when the key is not present. + /// The value. + public static T GetValue(this ISession session, string key, T fallback) + { + if (session.HasKey(key)) + return session.GetValue(key); + + return fallback; + } + + /// + /// Checks whether the session has the key available. + /// + /// The current session. + /// The key. + /// true when the key was found, otherwise false. + public static bool HasKey(this ISession session, string key) + => session.Keys.Contains(key); + } +} diff --git a/AMWD.Common.AspNetCore/Middlewares/BasicAuthMiddleware.cs b/AMWD.Common.AspNetCore/Middlewares/BasicAuthMiddleware.cs new file mode 100644 index 0000000..bd268b6 --- /dev/null +++ b/AMWD.Common.AspNetCore/Middlewares/BasicAuthMiddleware.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// Implements a basic authentication. + /// + public class BasicAuthMiddleware + { + private readonly RequestDelegate next; + private readonly string realm; + private readonly Func userPasswordAuth; + + /// + /// Initializes a new instance of the class. + /// + /// The following delegate in the process chain. + /// The realm to display when requesting for credentials. + /// The function (user, passwd) => result to validate username and password. + public BasicAuthMiddleware(RequestDelegate next, string realm, Func userPasswordAuth) + { + this.next = next; + this.realm = realm; + this.userPasswordAuth = userPasswordAuth; + } + + /// + /// The delegate invokation. + /// Performs the authentication check. + /// + /// The corresponding HTTP context. + /// An awaitable task. + public async Task InvokeAsync(HttpContext httpContext) + { + if (httpContext.Request.Headers.TryGetValue("Authorization", out var authHeader) + && ((string)authHeader).StartsWith("Basic ")) + { + string encoded = ((string)authHeader).Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? ""; + + string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + string[] parts = decoded.Split(':'); + + if (parts.Length >= 2) + { + string username = parts[0].Trim().ToLower(); + string password = parts[1].Trim(); + + if (userPasswordAuth(username, password)) + { + await next.Invoke(httpContext); + return; + } + } + } + + httpContext.Response.Headers["WWW-Authenticate"] = "Basic"; + if (!string.IsNullOrWhiteSpace(realm)) + { + httpContext.Response.Headers["WWW-Authenticate"] += $" realm=\"{realm}\""; + } + + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + } + } +} diff --git a/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinder.cs b/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinder.cs new file mode 100644 index 0000000..0ce5e52 --- /dev/null +++ b/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinder.cs @@ -0,0 +1,119 @@ +using System; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + /// + /// Custom floating point ModelBinder as the team of Microsoft is not capable of fixing their issue with other cultures than en-US. + /// + public class CustomFloatingPointModelBinder : IModelBinder + { + private readonly NumberStyles supportedNumberStyles; + private readonly ILogger logger; + private readonly CultureInfo cultureInfo; + + /// + /// Initializes a new instance of . + /// + /// The . + /// The . + /// The . + public CustomFloatingPointModelBinder(NumberStyles supportedStyles, CultureInfo cultureInfo, ILoggerFactory loggerFactory) + { + this.cultureInfo = cultureInfo ?? throw new ArgumentNullException(nameof(cultureInfo)); + + supportedNumberStyles = supportedStyles; + logger = loggerFactory?.CreateLogger(); + } + + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + throw new ArgumentNullException(nameof(bindingContext)); + + logger?.AttemptingToBindModel(bindingContext); + string modelName = bindingContext.ModelName; + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + logger?.FoundNoValueInRequest(bindingContext); + + // no entry + logger?.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } + + var modelState = bindingContext.ModelState; + modelState.SetModelValue(modelName, valueProviderResult); + + var metadata = bindingContext.ModelMetadata; + var type = metadata.UnderlyingOrModelType; + try + { + string value = valueProviderResult.FirstValue; + var culture = cultureInfo ?? valueProviderResult.Culture; + + object model; + if (string.IsNullOrWhiteSpace(value)) + { + // Parse() method trims the value (with common NumberStyles) then throws if the result is empty. + model = null; + } + else if (type == typeof(float)) + { + model = float.Parse(value, supportedNumberStyles, culture); + } + else if (type == typeof(double)) + { + model = double.Parse(value, supportedNumberStyles, culture); + } + else if (type == typeof(decimal)) + { + model = decimal.Parse(value, supportedNumberStyles, culture); + } + else + { + // unreachable + throw new NotSupportedException(); + } + + // When converting value, a null model may indicate a failed conversion for an otherwise required + // model (can't set a ValueType to null). This detects if a null model value is acceptable given the + // current bindingContext. If not, an error is logged. + if (model == null && !metadata.IsReferenceOrNullableType) + { + modelState.TryAddModelError( + modelName, + metadata + .ModelBindingMessageProvider + .ValueMustNotBeNullAccessor(valueProviderResult.ToString()) + ); + } + else + { + bindingContext.Result = ModelBindingResult.Success(model); + } + } + catch (Exception exception) + { + bool isFormatException = exception is FormatException; + if (!isFormatException && exception.InnerException != null) + { + // Unlike TypeConverters, floating point types do not seem to wrap FormatExceptions. Preserve + // this code in case a cursory review of the CoreFx code missed something. + exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException; + } + + modelState.TryAddModelError(modelName, exception, metadata); + // Conversion failed. + } + + logger?.DoneAttemptingToBindModel(bindingContext); + return Task.CompletedTask; + } + } +} diff --git a/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinderProvider.cs b/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinderProvider.cs new file mode 100644 index 0000000..fcaa809 --- /dev/null +++ b/AMWD.Common.AspNetCore/ModelBinders/CustomFloatingPointModelBinderProvider.cs @@ -0,0 +1,58 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding +{ + /// + /// An for binding , , + /// , and their wrappers. + /// Modified to set and . + /// + /// + /// To use this provider, insert it at the beginning of the providers list:
+ /// + /// services.AddControllersWithViews(options =>
+ /// {
+ /// options.ModelBinderProviders.Insert(0, new CustomFloatingPointModelBinderProvider());
+ /// });
+ ///
+ public class CustomFloatingPointModelBinderProvider : IModelBinderProvider + { + /// + /// Gets or sets the supported globally. + /// Default: and . + /// + /// + /// uses and similar. Those s default to . + /// + public static NumberStyles SupportedNumberStyles { get; set; } = NumberStyles.Float | NumberStyles.AllowThousands; + + /// + /// Gets or sets the to use while parsing globally. + /// Default: . + /// + public static CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + + /// + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var loggerFactory = context.Services.GetRequiredService(); + var modelType = context.Metadata.UnderlyingOrModelType; + if (modelType == typeof(decimal) || + modelType == typeof(double) || + modelType == typeof(float)) + { + return new CustomFloatingPointModelBinder(SupportedNumberStyles, CultureInfo, loggerFactory); + } + + return null; + } + } +} diff --git a/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs b/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs new file mode 100644 index 0000000..7d22355 --- /dev/null +++ b/AMWD.Common.AspNetCore/TagHelpers/ConditionClassTagHelper.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Razor.TagHelpers +{ + // Source: https://stackoverflow.com/a/42385059 + + /// + /// A tag helper that adds a CSS class attribute based on a condition. + /// + [HtmlTargetElement(Attributes = ClassPrefix + "*")] + public class ConditionClassTagHelper : TagHelper + { + private const string ClassPrefix = "condition-class-"; + + /// + /// Gets or sets the unconditional CSS class attribute value of the element. + /// + [HtmlAttributeName("class")] + public string CssClass { get; set; } + + private IDictionary classValues; + + /// + /// Gets or sets a dictionary containing all conditional class names and a boolean condition + /// value indicating whether the class should be added to the element. + /// + [HtmlAttributeName("", DictionaryAttributePrefix = ClassPrefix)] + public IDictionary ClassValues + { + get + { + return classValues ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + } + set + { + classValues = value; + } + } + + /// + /// Synchronously executes the + /// with the given and . + /// + /// Contains information associated with the current HTML tag. + /// 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(); + if (!string.IsNullOrEmpty(CssClass)) + items.Insert(0, CssClass); + + if (items.Any()) + { + string classes = string.Join(" ", items.ToArray()); + output.Attributes.Add("class", classes); + } + } + } +} diff --git a/AMWD.Common.AspNetCore/TagHelpers/EmailTagHelper.cs b/AMWD.Common.AspNetCore/TagHelpers/EmailTagHelper.cs new file mode 100644 index 0000000..8e8cac0 --- /dev/null +++ b/AMWD.Common.AspNetCore/TagHelpers/EmailTagHelper.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Html; + +namespace Microsoft.AspNetCore.Razor.TagHelpers +{ + /// + /// A tag helper to create a obfuscated email link. + /// + [HtmlTargetElement("email", TagStructure = TagStructure.WithoutEndTag)] + public class EmailTagHelper : TagHelper + { + /// + /// The e-mail address. + /// + [HtmlAttributeName("asp-address")] + public string Address { get; set; } + + /// + /// Processes the element. + /// + /// The tag helper context. + /// The tag helper output. + public override void Process(TagHelperContext context, TagHelperOutput output) + { + string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes($"mailto:{Address}")); + string reversed = new(Address.Reverse().ToArray()); + + output.TagName = "a"; + output.TagMode = TagMode.StartTagAndEndTag; + output.Attributes.SetAttribute("href", new HtmlString($"javascript:window.location.href=atob('{base64}')")); + output.Attributes.SetAttribute("style", "unicode-bidi: bidi-override; direction: rtl;"); + output.Attributes.RemoveAll("asp-address"); + output.Content.SetContent(reversed); + } + } +} diff --git a/AMWD.Common.AspNetCore/TagHelpers/NumberInputTagHelper.cs b/AMWD.Common.AspNetCore/TagHelpers/NumberInputTagHelper.cs new file mode 100644 index 0000000..4b2308a --- /dev/null +++ b/AMWD.Common.AspNetCore/TagHelpers/NumberInputTagHelper.cs @@ -0,0 +1,186 @@ +using System; +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + /// + /// Adds additional behavior to the modelbinding for numeric properties. + /// + [HtmlTargetElement("input", Attributes = "asp-for")] + public class NumberInputTagHelper : InputTagHelper + { + /// + /// Initializes a new instance of the class. + /// + /// The HTML generator. + public NumberInputTagHelper(IHtmlGenerator generator) + : base(generator) + { } + + /// + public override void Process(TagHelperContext context, TagHelperOutput output) + { + base.Process(context, output); + + var types = new[] { + typeof(byte), typeof(sbyte), + typeof(ushort), typeof(short), + typeof(uint), typeof(int), + typeof(ulong), typeof(long), + typeof(float), + typeof(double), + typeof(decimal) + }; + + var typeAttributes = output.Attributes + .Where(a => a.Name == "type") + .ToList(); + string typeAttributeValue = typeAttributes.First().Value as string; + + Type modelType = For.ModelExplorer.ModelType; + Type nullableType = Nullable.GetUnderlyingType(modelType); + + // the type itself or its nullable wrapper matching and + // the type attribute is number or there is only one type attribute + // IMPORTANT TO KNOW: if the type attribute is set in the view, there are two attributes with same value. + if ((types.Contains(modelType) || types.Contains(nullableType)) && (typeAttributeValue == "number" || typeAttributes.Count == 1)) + { + var culture = CultureInfo.InvariantCulture; + string min = ""; + string max = ""; + string step = ""; + string value = ""; + + if (modelType == typeof(byte) || nullableType == typeof(byte)) + { + min = byte.MinValue.ToString(culture); + max = byte.MaxValue.ToString(culture); + + if (For.Model != null) + { + byte val = (byte)For.Model; + value = value.ToString(culture); + } + } + else if (modelType == typeof(sbyte) || nullableType == typeof(sbyte)) + { + min = sbyte.MinValue.ToString(culture); + max = sbyte.MaxValue.ToString(culture); + + if (For.Model != null) + { + sbyte val = (sbyte)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(ushort) || nullableType == typeof(ushort)) + { + min = ushort.MinValue.ToString(culture); + max = ushort.MaxValue.ToString(culture); + + if (For.Model != null) + { + ushort val = (ushort)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(short) || nullableType == typeof(short)) + { + min = short.MinValue.ToString(culture); + max = short.MaxValue.ToString(culture); + + if (For.Model != null) + { + short val = (short)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(uint) || nullableType == typeof(uint)) + { + min = uint.MinValue.ToString(culture); + max = uint.MaxValue.ToString(culture); + + if (For.Model != null) + { + uint val = (uint)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(int) || nullableType == typeof(int)) + { + min = int.MinValue.ToString(culture); + max = int.MaxValue.ToString(culture); + + if (For.Model != null) + { + int val = (int)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(ulong) || nullableType == typeof(ulong)) + { + min = ulong.MinValue.ToString(culture); + + if (For.Model != null) + { + ulong val = (ulong)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(long) || nullableType == typeof(long)) + { + if (For.Model != null) + { + long val = (long)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(float) || nullableType == typeof(float)) + { + step = "any"; + + if (For.Model != null) + { + float val = (float)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(double) || nullableType == typeof(double)) + { + step = "any"; + + if (For.Model != null) + { + double val = (double)For.Model; + value = val.ToString(culture); + } + } + else if (modelType == typeof(decimal) || nullableType == typeof(decimal)) + { + step = "any"; + + if (For.Model != null) + { + decimal val = (decimal)For.Model; + value = val.ToString(culture); + } + } + + output.Attributes.SetAttribute(new TagHelperAttribute("type", "number")); + output.Attributes.SetAttribute(new TagHelperAttribute("value", value)); + + if (!string.IsNullOrWhiteSpace(min) && !output.Attributes.ContainsName("min")) + output.Attributes.SetAttribute(new TagHelperAttribute("min", min)); + + if (!string.IsNullOrWhiteSpace(max) && !output.Attributes.ContainsName("max")) + output.Attributes.SetAttribute(new TagHelperAttribute("max", max)); + + if (!string.IsNullOrWhiteSpace(step) && !output.Attributes.ContainsName("step")) + output.Attributes.SetAttribute(new TagHelperAttribute("step", step)); + } + } + } +} diff --git a/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs b/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs new file mode 100644 index 0000000..6e3afeb --- /dev/null +++ b/AMWD.Common.AspNetCore/Utilities/BackgroundServiceStarter.cs @@ -0,0 +1,44 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Hosting +{ + /// + /// Wrapper class to start a background service. + /// + /// The service type. + 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.AspNetCore/Utilities/PasswordHelper.cs b/AMWD.Common.AspNetCore/Utilities/PasswordHelper.cs new file mode 100644 index 0000000..618e392 --- /dev/null +++ b/AMWD.Common.AspNetCore/Utilities/PasswordHelper.cs @@ -0,0 +1,52 @@ +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Provides password hashing and verification methods. + /// + public static class PasswordHelper + { + /// + /// Hashes a password. + /// + /// The plain password. + /// + public static string HashPassword(string plainPassword) + { + if (string.IsNullOrWhiteSpace(plainPassword)) + return plainPassword?.Trim(); + + var ph = new PasswordHasher(); + return ph.HashPassword(null, plainPassword.Trim()); + } + + /// + /// Verifies a password with a hashed version. + /// + /// The plain password. + /// The password hash. + /// A value indicating whether the password needs a rehash. + /// + public static bool VerifyPassword(string plainPassword, string hashedPassword, out bool rehashNeeded) + { + rehashNeeded = false; + if (string.IsNullOrWhiteSpace(plainPassword) || string.IsNullOrWhiteSpace(hashedPassword)) + return false; + + var ph = new PasswordHasher(); + var result = ph.VerifyHashedPassword(null, hashedPassword, plainPassword); + switch (result) + { + case PasswordVerificationResult.Success: + return true; + + case PasswordVerificationResult.SuccessRehashNeeded: + rehashNeeded = true; + return true; + + case PasswordVerificationResult.Failed: + default: + return false; + } + } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj new file mode 100644 index 0000000..71cd629 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/AMWD.Common.EntityFrameworkCore.csproj @@ -0,0 +1,49 @@ + + + + netcoreapp3.1;net5.0 + 9.0 + + AMWD.Common.EntityFrameworkCore + AMWD.Common.EntityFrameworkCore + {semvertag:master:+chash}{!:-dirty} + + true + false + true + false + + true + true + snupkg + AMWD.Common.EntityFrameworkCore + + AM.WD Common Library for EntityFramework Core + Library with classes and extensions used frequently on AM.WD projects. + AM.WD + Andreas Müller + © {copyright:2020-} AM.WD + MIT + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/AMWD.Common.EntityFrameworkCore/Attributes/DatabaseIndexAttribute.cs b/AMWD.Common.EntityFrameworkCore/Attributes/DatabaseIndexAttribute.cs new file mode 100644 index 0000000..409e9e4 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Attributes/DatabaseIndexAttribute.cs @@ -0,0 +1,35 @@ +using System; +using AMWD.Common.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace AMWD.Common.EntityFrameworkCore.Attributes +{ + /// + /// Property attribute to create indices and unique constraints in the database. + /// + /// + /// Requires to be called within . + /// + [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] + public class DatabaseIndexAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// + public DatabaseIndexAttribute() + { + Name = null; + IsUnique = false; + } + + /// + /// Gets or sets a name. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether it is a unique constraint. + /// + public bool IsUnique { get; set; } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs b/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs new file mode 100644 index 0000000..5e41dde --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Exceptions/DatabaseProviderException.cs @@ -0,0 +1,47 @@ +using System.Runtime.Serialization; + +namespace System +{ + /// + /// A DatabaseProvider specific exception. + /// + public class DatabaseProviderException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public DatabaseProviderException() + : base() + { } + + /// + /// Initializes a new instance of the class + /// with a specified error message. + /// + /// The message that describes the error. + public DatabaseProviderException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class + /// with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public DatabaseProviderException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class with serialized data. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// The info parameter is null. + /// The class name is null or is zero (0). + protected DatabaseProviderException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs new file mode 100644 index 0000000..6535c1e --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Extensions/DatabaseFacadeExtensions.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Extensions for the . + /// + public static class DatabaseFacadeExtensions + { + /// + /// Applies migration files to the database. + /// + /// The database connection. + /// An action to set additional options. + /// The cancellation token. + /// true on success, otherwise false or an exception is thrown. + public static async Task ApplyMigrationsAsync(this DatabaseFacade database, Action optionsAction, CancellationToken cancellationToken = default) + { + if (database == null) + throw new ArgumentNullException(nameof(database)); + + var options = new DatabaseMigrationOptions(); + optionsAction?.Invoke(options); + + if (string.IsNullOrWhiteSpace(options.MigrationsTableName)) + throw new ArgumentNullException(nameof(options.MigrationsTableName), $"The property {nameof(options.MigrationsTableName)} of the {nameof(options)} parameter is required."); + + if (string.IsNullOrWhiteSpace(options.Path)) + throw new ArgumentNullException(nameof(options.Path), $"The property {nameof(options.Path)} of the {nameof(options)} parameter is required."); + + var connection = database.GetDbConnection(); + try + { + await connection.OpenAsync(cancellationToken); + if (!await connection.CreateMigrationsTable(options, cancellationToken)) + return false; + + return await connection.Migrate(options, cancellationToken); + } + finally + { + connection.Close(); + } + } + + private static DatabaseProvider GetProviderType(this DbConnection connection) + { + string provider = connection.GetType().FullName; + + if (provider.Contains("mysql", StringComparison.OrdinalIgnoreCase)) + return DatabaseProvider.MySQL; + if (provider.Contains("oracle", StringComparison.OrdinalIgnoreCase)) + return DatabaseProvider.Oracle; + if (provider.Contains("npgsql", StringComparison.OrdinalIgnoreCase)) + return DatabaseProvider.PostgreSQL; + if (provider.Contains("sqlite", StringComparison.OrdinalIgnoreCase)) + return DatabaseProvider.SQLite; + if (provider.Contains("sqlclient", StringComparison.OrdinalIgnoreCase)) + return DatabaseProvider.SQLServer; + + throw new DatabaseProviderException($"The database provider '{provider}' is unknown"); + } + + private static async Task CreateMigrationsTable(this DbConnection connection, DatabaseMigrationOptions options, CancellationToken cancellationToken) + { + try + { + using var command = connection.CreateCommand(); + +#pragma warning disable CS8524 // missing default case + command.CommandText = connection.GetProviderType() switch +#pragma warning restore CS8524 // missing default case + { + DatabaseProvider.MySQL => $@"CREATE TABLE IF NOT EXISTS `{options.MigrationsTableName}` ( + `id` INT NOT NULL AUTO_INCREMENT, + `schema_file` VARCHAR(250) NOT NULL, + `installed_at` VARCHAR(16) NOT NULL, + PRIMARY KEY (`id`) +);", + DatabaseProvider.Oracle => $@"DECLARE ncount NUMBER; +BEGIN + SELECT count(*) INTO ncount FROM dba_tables WHERE table_name = '{options.MigrationsTableName}'; + IF (ncount <= 0) + THEN + EXECUTE IMMEDIATE 'CREATE TABLE ""{options.MigrationsTableName}"" ( + ""id"" NUMBER GENERATED by default on null as IDENTITY, + ""schema_file"" VARCHAR2(250) NOT NULL, + ""installed_at"" VARCHAR2(16) NOT NULL, + PRIMARY KEY (""id""), + CONSTRAINT uq_schema_file UNIQUE (""schema_file"") +)'; + END IF; +END;", + DatabaseProvider.PostgreSQL => $@"CREATE TABLE IF NOT EXISTS ""{options.MigrationsTableName}"" ( + ""id"" SERIAL4 PRIMARY KEY, + ""schema_file"" VARCHAR(250) NOT NULL, + ""installed_at"" VARCHAR(16) NOT NULL, + CONSTRAINT ""uq_schema_file"" UNIQUE (""schema_file"") +);", + DatabaseProvider.SQLite => $@"CREATE TABLE IF NOT EXISTS ""{options.MigrationsTableName}"" ( + ""id"" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + ""schema_file"" TEXT(250) NOT NULL, + ""installed_at"" TEXT(16) NOT NULL, + CONSTRAINT ""uq_schema_file"" UNIQUE (""schema_file"") +);", + DatabaseProvider.SQLServer => $@"IF NOT EXISTS (SELECT * FROM [sysobjects] WHERE [name] = '{options.MigrationsTableName}' AND [xtype] = 'U') +BEGIN + CREATE TABLE [{options.MigrationsTableName}] ( + [id] int IDENTITY(1,1) NOT NULL PRIMARY KEY, + [schema_file] varchar(250) NOT NULL, + [installed_at] varchar(16) NOT NULL, + CONSTRAINT uq_schema_file UNIQUE (schema_file) + ) +END;" + }; + + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + catch (Exception ex) + { + options.Logger?.LogCritical(ex, $"Creating migrations table '{options.MigrationsTableName}' failed: {ex.InnerException?.Message ?? ex.Message}"); + return false; + } + } + + private static async Task Migrate(this DbConnection connection, DatabaseMigrationOptions options, CancellationToken cancellationToken) + { + try + { + List availableMigrationFiles; + if (options.SourceAssembly == null) + { + availableMigrationFiles = Directory.GetFiles(options.Path) + .Where(f => f.ToLower().StartsWith(options.Path.ToLower())) + .Where(f => f.ToLower().EndsWith(".sql")) + .ToList(); + } + else + { + availableMigrationFiles = options.SourceAssembly + .GetManifestResourceNames() + .Where(f => f.ToLower().StartsWith(options.Path.ToLower())) + .Where(f => f.ToLower().EndsWith(".sql")) + .ToList(); + } + + if (!availableMigrationFiles.Any()) + return true; + + using var command = connection.CreateCommand(); + + var migratedFiles = new List(); + command.CommandText = connection.GetProviderType() switch + { + DatabaseProvider.MySQL => $"SELECT `schema_file` FROM `{options.MigrationsTableName}`;", + DatabaseProvider.SQLServer => $"SELECT [schema_file] FROM [{options.MigrationsTableName}];", + _ => $@"SELECT ""schema_file"" FROM ""{options.MigrationsTableName}"";", + }; + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + migratedFiles.Add(reader.GetString(0)); + } + + int pathLength = options.Path.Length + 1; + foreach (string migrationFile in availableMigrationFiles) + { + // remove path including the separator + string fileName = migrationFile.Replace(options.Path, "")[1..]; + using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + // max length in the database: 250 chars + string trimmedFileName = fileName; + if (trimmedFileName.Length > 250) + fileName = fileName.Substring(0, 250); + + if (migratedFiles.Contains(trimmedFileName)) + { + options.Logger?.LogDebug($" Migrating file '{fileName}' done"); + continue; + } + + string sqlScript = null; + if (options.SourceAssembly == null) + { + sqlScript = await File.ReadAllTextAsync(migrationFile, cancellationToken); + } + else + { + using var stream = options.SourceAssembly.GetManifestResourceStream(migrationFile); + using var sr = new StreamReader(stream); + sqlScript = await sr.ReadToEndAsync(); + } + + if (string.IsNullOrWhiteSpace(sqlScript)) + continue; + + options.Logger?.LogDebug($" Migrating file '{fileName}' started"); + command.Transaction = transaction; + + await command.ExecuteScript(sqlScript, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + command.Transaction = null; + options.Logger?.LogDebug($" Migrating file '{fileName}' successful"); + } + catch (Exception ex) + { + await transaction.RollbackAsync(cancellationToken); + options.Logger?.LogError($"Migrating file '{fileName}' failed: {ex.InnerException?.Message ?? ex.Message}"); + return false; + } + } + + return true; + } + catch (Exception ex) + { + options.Logger?.LogCritical(ex, $"Migrating the database failed ({ex.GetType().Name}): {ex.InnerException?.Message ?? ex.Message}"); + return false; + } + } + + private static async Task ExecuteScript(this DbCommand command, string text, CancellationToken cancellationToken) + { + if (command.Connection.GetProviderType() == DatabaseProvider.Oracle) + { + int affectedRows = 0; + // Split script by a single slash in a line + string[] parts = Regex.Split(text, @"\r?\n[ \t]*/[ \t]*\r?\n"); + foreach (string part in parts) + { + // Make writable copy + string pt = part; + + // Remove the trailing semicolon from commands where they're not supported + // (Oracle doesn't like semicolons. To keep the semicolon, it must be directly + // preceeded by "end".) + pt = Regex.Replace(pt.TrimEnd(), @"(? + /// Extends the to use a configurable database provider. + /// + public static class DbContextOptionsBuilderExtensions + { + /// + /// Adds the supported database provider to the context. + /// + /// + /// The configuration provided requires the following entries: + /// + /// Provider: MySQL | Oracle | PostgreSQL | SQLite | SQLServer + /// Host: hostname or IP address + /// Port: port number + /// Name: database name + /// Schema: schema or search path (e.g. PostgreSQL: public) + /// Username: username credential on the database + /// Password: password credential on the database + /// File: file name / path (for SQLite) + /// + /// + /// The options builder. + /// The application configuration section for the database. + /// An optional action to set additional options. + /// The with applied settings. + public static DbContextOptionsBuilder UseDatabaseProvider(this DbContextOptionsBuilder optionsBuilder, IConfiguration configuration, Action optionsAction = null) + { + if (optionsBuilder == null) + throw new ArgumentNullException(nameof(optionsBuilder)); + + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); + + var options = new DatabaseProviderOptions(); + optionsAction?.Invoke(options); + + string connectionString = GetConnectionString(configuration, options); + string provider = configuration.GetValue("provider")?.ToLower(); + + var builderType = GetBuilderType(configuration); + var extensionType = GetExtensionType(configuration); + var actionType = typeof(Action<>).MakeGenericType(builderType); + + object serverVersion = null; + MethodInfo methodInfo; + switch (provider) + { + case "mysql": + methodInfo = extensionType.GetMethod("UseMySql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + if (methodInfo == null) + methodInfo = extensionType.GetMethod("UseMySQL", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + if (methodInfo == null) // Pomelo MySQL v5 + { + var serverVersionType = Type.GetType("Microsoft.EntityFrameworkCore.ServerVersion, Pomelo.EntityFrameworkCore.MySql"); + var autoDetectMethodInfo = serverVersionType.GetMethod("AutoDetect", new Type[] { typeof(string) }); + methodInfo = extensionType.GetMethod("UseMySql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), serverVersionType, actionType }); + serverVersion = autoDetectMethodInfo.Invoke(null, new object[] { connectionString }); + } + break; + case "oracle": + methodInfo = extensionType.GetMethod("UseOracle", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + break; + case "postgres": + case "postgresql": + methodInfo = extensionType.GetMethod("UseNpgsql", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + break; + case "sqlite": + methodInfo = extensionType.GetMethod("UseSqlite", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + break; + case "sqlserver": + case "mssql": + methodInfo = extensionType.GetMethod("UseSqlServer", new Type[] { typeof(DbContextOptionsBuilder), typeof(string), actionType }); + break; + default: + throw new DatabaseProviderException($"Unknown database provider: {provider}"); + } + + if (serverVersion == null) + { + methodInfo?.Invoke(null, new object[] { optionsBuilder, connectionString, null }); + } + else + { + methodInfo?.Invoke(null, new object[] { optionsBuilder, connectionString, serverVersion, null }); + } + + return optionsBuilder; + } + + private static Type GetBuilderType(IConfiguration configuration) + { + string provider = configuration.GetValue("provider")?.ToLower(); + Type builderType; + switch (provider) + { + case "mysql": + builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.MySqlDbContextOptionsBuilder, Pomelo.EntityFrameworkCore.MySql"); + if (builderType == null) + builderType = Type.GetType("MySql.Data.EntityFrameworkCore.Infrastructure.MySQLDbContextOptionsBuilder, MySql.Data.EntityFrameworkCore"); + break; + case "oracle": + builderType = Type.GetType("Oracle.EntityFrameworkCore.Infrastructure.OracleDbContextOptionsBuilder, Oracle.EntityFrameworkCore"); + break; + case "postgres": + case "postgresql": + builderType = Type.GetType("Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.NpgsqlDbContextOptionsBuilder, Npgsql.EntityFrameworkCore.PostgreSQL"); + break; + case "sqlite": + builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.SqliteDbContextOptionsBuilder, Microsoft.EntityFrameworkCore.Sqlite"); + break; + case "sqlserver": + case "mssql": + builderType = Type.GetType("Microsoft.EntityFrameworkCore.Infrastructure.SqlServerDbContextOptionsBuilder, Microsoft.EntityFrameworkCore.SqlServer"); + break; + default: + throw new ArgumentException($"Unknown database provider: {provider}"); + } + return builderType; + } + + private static Type GetExtensionType(IConfiguration configuration) + { + string provider = configuration.GetValue("provider")?.ToLower(); + Type extensionType; + switch (provider) + { + case "mysql": + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.MySqlDbContextOptionsBuilderExtensions, Pomelo.EntityFrameworkCore.MySql"); + if (extensionType == null) + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.MySQLDbContextOptionsBuilderExtensions, MySql.Data.EntityFrameworkCore"); + break; + case "oracle": + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.OracleDbContextOptionsBuilderExtensions, Oracle.EntityFrameworkCore"); + break; + case "postgres": + case "postgresql": + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.NpgsqlDbContextOptionsBuilderExtensions, Npgsql.EntityFrameworkCore.PostgreSQL"); + break; + case "sqlite": + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions, Microsoft.EntityFrameworkCore.Sqlite"); + break; + case "sqlserver": + case "mssql": + extensionType = Type.GetType("Microsoft.EntityFrameworkCore.SqlServerDbContextOptionsExtensions, Microsoft.EntityFrameworkCore.SqlServer"); + break; + default: + throw new ArgumentException($"Unknown database provider: {provider}"); + } + return extensionType; + } + + private static string GetConnectionString(IConfiguration configuration, DatabaseProviderOptions options) + { + var cs = new List(); + string provider = configuration.GetValue("provider")?.ToLower(); + switch (provider) + { + case "mysql": + cs.Add($"Server={configuration.GetValue("Host")}"); + cs.Add($"Port={configuration.GetValue("Port", 3306)}"); + cs.Add($"Database={configuration.GetValue("Name")}"); + cs.Add($"Uid={configuration.GetValue("Username")}"); + cs.Add($"Password={configuration.GetValue("Password")}"); + cs.Add($"Connection Timeout=15"); + break; + case "oracle": + cs.Add($"Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={configuration.GetValue("Host")})(PORT={configuration.GetValue("Port", 1521)}))(CONNECT_DATA=(SERVICE_NAME={configuration.GetValue("Name")})))"); + cs.Add($"User Id={configuration.GetValue("Username")}"); + cs.Add($"Password={configuration.GetValue("Password")}"); + cs.Add($"Connection Timeout=15"); + break; + case "postgres": + case "postgresql": + cs.Add($"Server={configuration.GetValue("Host")}"); + cs.Add($"Port={configuration.GetValue("Port", 5432)}"); + cs.Add($"Database={configuration.GetValue("Name")}"); + cs.Add($"Search Path={configuration.GetValue("Schema", "public")}"); + cs.Add($"User Id={configuration.GetValue("Username")}"); + cs.Add($"Password={configuration.GetValue("Password")}"); + cs.Add($"Timeout=15"); + break; + case "sqlite": + string path = configuration.GetValue("File"); + if (!Path.IsPathRooted(path)) + { + if (string.IsNullOrWhiteSpace(options.AbsoluteBasePath)) + options.AbsoluteBasePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + path = Path.Combine(options.AbsoluteBasePath, path); + } + cs.Add($"Data Source={path}"); + cs.Add("Foreign Keys=True"); + break; + case "sqlserver": + case "mssql": + cs.Add($"Server={configuration.GetValue("Host")},{configuration.GetValue("Port", 1433)}"); + cs.Add($"Database={configuration.GetValue("Name")}"); + if (!string.IsNullOrWhiteSpace(configuration.GetValue("Username"))) + { + cs.Add($"User Id={configuration.GetValue("Username")}"); + cs.Add($"Password={configuration.GetValue("Password")}"); + cs.Add("Integrated Security=False"); + } + else + { + cs.Add("Integrated Security=True"); + } + cs.Add("Connect Timeout=15"); + break; + default: + throw new DatabaseProviderException($"Unknown database provider: {provider}"); + } + return string.Join(";", cs); + } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs b/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs new file mode 100644 index 0000000..d8b80a1 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs @@ -0,0 +1,162 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using System.Text; +using AMWD.Common.EntityFrameworkCore.Attributes; +using Microsoft.EntityFrameworkCore; +#if NET5_0_OR_GREATER +using Microsoft.EntityFrameworkCore.Metadata; +#endif + +namespace AMWD.Common.EntityFrameworkCore.Extensions +{ + /// + /// Extensions for the of entity framework core. + /// + public static class ModelBuilderExtensions + { + /// + /// Applies indices and unique constraints to the properties. + /// + /// The database model builder. + /// A reference to this instance after the operation has completed. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0019", Justification = "No pattern comparison in this case due to readability.")] + public static ModelBuilder ApplyIndexAttributes(this ModelBuilder builder) + { + foreach (var entityType in builder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + var indexAttribute = entityType.ClrType + .GetProperty(property.Name) + ?.GetCustomAttribute(typeof(DatabaseIndexAttribute), false) as DatabaseIndexAttribute; + if (indexAttribute != null) + { + var index = entityType.AddIndex(property); + index.IsUnique = indexAttribute.IsUnique; + + if (!string.IsNullOrWhiteSpace(indexAttribute.Name)) + { +#if NET5_0_OR_GREATER + index.SetDatabaseName(indexAttribute.Name.Trim()); +#else + index.SetName(indexAttribute.Name.Trim()); +#endif + } + } + } + } + + return builder; + } + + /// + /// Converts all table and column names to snake_case_names. + /// + /// The database model builder. + /// A reference to this instance after the operation has completed. + public static ModelBuilder ApplySnakeCase(this ModelBuilder builder) + { + foreach (var entityType in builder.Model.GetEntityTypes()) + { + // skip conversion when table name is explicitly set + if ((entityType.ClrType.GetCustomAttribute(typeof(TableAttribute), false) as TableAttribute) == null) + { +#if NET5_0_OR_GREATER + entityType.SetTableName(ConvertToSnakeCase(entityType.GetTableName())); +#else + entityType.SetTableName(ConvertToSnakeCase(entityType.GetTableName())); +#endif + } + +#if NET5_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 NET5_0_OR_GREATER + property.SetColumnName(ConvertToSnakeCase(property.GetColumnName(identifier))); +#else + property.SetColumnName(ConvertToSnakeCase(property.GetColumnName())); +#endif + } + } + } + + return builder; + } + + /// + /// Converts a string to its snake_case equivalent. + /// + /// + /// Code borrowed from Npgsql.NameTranslation.NpgsqlSnakeCaseNameTranslator. + /// See https://github.com/npgsql/npgsql/blob/f2b2c98f45df6d2a78eec00ae867f18944d717ca/src/Npgsql/NameTranslation/NpgsqlSnakeCaseNameTranslator.cs#L76-L136. + /// + /// The value to convert. + private static string ConvertToSnakeCase(string value) + { + var sb = new StringBuilder(); + var state = SnakeCaseState.Start; + + for (int i = 0; i < value.Length; i++) + { + if (value[i] == ' ') + { + if (state != SnakeCaseState.Start) + state = SnakeCaseState.NewWord; + } + else if (char.IsUpper(value[i])) + { + switch (state) + { + case SnakeCaseState.Upper: + bool hasNext = (i + 1 < value.Length); + if (i > 0 && hasNext) + { + char nextChar = value[i + 1]; + if (!char.IsUpper(nextChar) && nextChar != '_') + { + sb.Append('_'); + } + } + break; + + case SnakeCaseState.Lower: + case SnakeCaseState.NewWord: + sb.Append('_'); + break; + } + + sb.Append(char.ToLowerInvariant(value[i])); + state = SnakeCaseState.Upper; + } + else if (value[i] == '_') + { + sb.Append('_'); + state = SnakeCaseState.Start; + } + else + { + if (state == SnakeCaseState.NewWord) + sb.Append('_'); + + sb.Append(value[i]); + state = SnakeCaseState.Lower; + } + } + + return sb.ToString(); + } + + private enum SnakeCaseState + { + Start, + Lower, + Upper, + NewWord + } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseMigrationOptions.cs b/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseMigrationOptions.cs new file mode 100644 index 0000000..32a44b6 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseMigrationOptions.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Options for the database migration. + /// + public class DatabaseMigrationOptions + { + /// + /// Gets or sets a logger of the type . + /// + public ILogger Logger { get; set; } + + /// + /// Gets or sets the migrations table name. + /// + public string MigrationsTableName { get; set; } = "__migrations"; + + /// + /// Gets or sets the absolute path to the migration files. + /// + public string Path { get; set; } + + /// + /// Gets or sets the source assembly for embedded files. + /// + public Assembly SourceAssembly { get; set; } + } +} diff --git a/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseProviderOptions.cs b/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseProviderOptions.cs new file mode 100644 index 0000000..7c36c22 --- /dev/null +++ b/AMWD.Common.EntityFrameworkCore/Utilities/DatabaseProviderOptions.cs @@ -0,0 +1,13 @@ +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Options for the database provider. + /// + public class DatabaseProviderOptions + { + /// + /// Gets or sets the absolute path to the database directory. + /// + public string AbsoluteBasePath { get; set; } + } +} diff --git a/AMWD.Common/AMWD.Common.csproj b/AMWD.Common/AMWD.Common.csproj new file mode 100644 index 0000000..fe68e1d --- /dev/null +++ b/AMWD.Common/AMWD.Common.csproj @@ -0,0 +1,38 @@ + + + + netstandard2.0 + 9.0 + + AMWD.Common + AMWD.Common + {semvertag:master:+chash}{!:-dirty} + + true + false + true + false + + true + true + snupkg + AMWD.Common + + AM.WD Common Library + Library with classes and extensions used frequently on AM.WD projects. + AM.WD + Andreas Müller + © {copyright:2020-} AM.WD + MIT + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/AMWD.Common/Extensions/CryptographyHelperExtensions.cs b/AMWD.Common/Extensions/CryptographyHelperExtensions.cs new file mode 100644 index 0000000..9a8ec49 --- /dev/null +++ b/AMWD.Common/Extensions/CryptographyHelperExtensions.cs @@ -0,0 +1,84 @@ +namespace System.Security.Cryptography +{ + /// + /// Provides extension methods for the class. + /// + public static class CryptographyHelperExtensions + { + #region Hashing + + #region MD5 + + /// + /// Computes a hash value from a string using the MD5 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The MD5 hash value, in hexadecimal notation. + public static string Md5(this string str) => CryptographyHelper.Md5(str); + + /// + /// Computes a hash from a byte array value using the MD5 algorithm. + /// + /// The byte array. + /// The MD5 hash value, in hexadecimal notation. + public static string Md5(this byte[] bytes) => CryptographyHelper.Md5(bytes); + + #endregion MD5 + + #region SHA-1 + + /// + /// Computes a hash value from a string using the SHA-1 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-1 hash value, in hexadecimal notation. + public static string Sha1(this string str) => CryptographyHelper.Sha1(str); + + /// + /// Computes a hash from a byte array value using the SHA-1 algorithm. + /// + /// The byte array. + /// The SHA-1 hash value, in hexadecimal notation. + public static string Sha1(this byte[] bytes) => CryptographyHelper.Sha1(bytes); + + #endregion SHA-1 + + #region SHA-256 + + /// + /// Computes a hash value from a string using the SHA-256 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-256 hash value, in hexadecimal notation. + public static string Sha256(string str) => CryptographyHelper.Sha256(str); + + /// + /// Computes a hash from a byte array value using the SHA-256 algorithm. + /// + /// The byte array. + /// The SHA-256 hash value, in hexadecimal notation. + public static string Sha256(byte[] bytes) => CryptographyHelper.Sha256(bytes); + + #endregion SHA-256 + + #region SHA-512 + + /// + /// Computes a hash value from a string using the SHA-512 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-512 hash value, in hexadecimal notation. + public static string Sha512(this string str) => CryptographyHelper.Sha512(str); + + /// + /// Computes a hash from a byte array value using the SHA-512 algorithm. + /// + /// The byte array. + /// The SHA-512 hash value, in hexadecimal notation. + public static string Sha512(this byte[] bytes) => CryptographyHelper.Sha512(bytes); + + #endregion SHA-512 + + #endregion Hashing + } +} diff --git a/AMWD.Common/Extensions/DateTimeExtensions.cs b/AMWD.Common/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..7f229d5 --- /dev/null +++ b/AMWD.Common/Extensions/DateTimeExtensions.cs @@ -0,0 +1,182 @@ +using System.Text; + +namespace System +{ + /// + /// Provides extension methods for date and time manipulation. + /// + public static class DateTimeExtensions + { + #region Kind + + /// + /// Specifies the as UTC. + /// + /// The instance. + /// A with correct . + public static DateTime AsUtc(this DateTime dt) + { + return dt.Kind switch + { + DateTimeKind.Local => dt.ToUniversalTime(), + DateTimeKind.Utc => dt, + _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + }; + } + + /// + /// Specifies the as local time. + /// + /// The instance. + /// A with correct . + public static DateTime AsLocal(this DateTime dt) + { + return dt.Kind switch + { + DateTimeKind.Local => dt, + DateTimeKind.Utc => dt.ToLocalTime(), + _ => DateTime.SpecifyKind(dt, DateTimeKind.Local), + }; + } + + #endregion Kind + + /// + /// Aligns the to the clock. + /// + /// The timespan to align. + /// A specific offset to the timespan. + /// The timespan until the aligned time. + public static TimeSpan GetAlignedInterval(this TimeSpan timeSpan, TimeSpan offset = default) + { + var now = DateTime.UtcNow; + var nextTime = new DateTime(now.Ticks / timeSpan.Ticks * timeSpan.Ticks) + offset; + + if (nextTime <= now) + nextTime += timeSpan; + + return nextTime - now; + } + + /// + /// Prints the timespan as shortended string. + /// + /// The timespan + /// A value indicating whether to show milliseconds. + /// The timespan as string. + public static string ToShortString(this TimeSpan timeSpan, bool withMilliseconds = false) + { + var sb = new StringBuilder(); + + if (timeSpan.TotalDays >= 1) + sb.Append(timeSpan.Days).Append("d "); + + if (timeSpan.TotalHours >= 1) + sb.Append(timeSpan.Hours).Append("h "); + + if (timeSpan.TotalMinutes >= 1) + sb.Append(timeSpan.Minutes).Append("m "); + + sb.Append(timeSpan.Seconds).Append("s "); + + if (withMilliseconds) + sb.Append(timeSpan.Milliseconds).Append("ms"); + + return sb.ToString().Trim(); + } + + #region Round DateTime + + /// + /// Rounds the to full seconds. + /// + /// The time value to round. + /// + + public static DateTime RoundToSecond(this DateTime dt) + { + return new DateTime(RoundTicks(dt.Ticks, TimeSpan.TicksPerSecond), dt.Kind); + } + + /// + /// Rounds the to full minutes. + /// + /// The time value to round. + /// + public static DateTime RoundToMinute(this DateTime dt) + { + return new DateTime(RoundTicks(dt.Ticks, TimeSpan.TicksPerMinute), dt.Kind); + } + + /// + /// Rounds the to full hours. + /// + /// The time value to round. + /// + public static DateTime RoundToHour(this DateTime dt) + { + return new DateTime(RoundTicks(dt.Ticks, TimeSpan.TicksPerHour), dt.Kind); + } + + /// + /// Rounds the to full days. + /// + /// The time value to round. + /// + public static DateTime RoundToDay(this DateTime dt) + { + return new DateTime(RoundTicks(dt.Ticks, TimeSpan.TicksPerDay), dt.Kind); + } + + #endregion Round DateTime + + #region Round TimeSpan + + /// + /// Rounds the to full seconds. + /// + /// The time value to round. + /// + public static TimeSpan RoundToSecond(this TimeSpan timeSpan) + { + return new TimeSpan(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerSecond)); + } + + /// + /// Rounds the to full minutes. + /// + /// The time value to round. + /// + public static TimeSpan RoundToMinute(this TimeSpan timeSpan) + { + return new TimeSpan(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerMinute)); + } + + /// + /// Rounds the to full hours. + /// + /// The time value to round. + /// + public static TimeSpan RoundToHour(this TimeSpan timeSpan) + { + return new TimeSpan(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerHour)); + } + + /// + /// Rounds the to full days. + /// + /// The time value to round. + /// + public static TimeSpan RoundToDay(this TimeSpan timeSpan) + { + return new TimeSpan(RoundTicks(timeSpan.Ticks, TimeSpan.TicksPerDay)); + } + + #endregion Round TimeSpan + + private static long RoundTicks(long ticks, long value) + { + return (ticks + value / 2) / value * value; + } + } +} diff --git a/AMWD.Common/Extensions/EnumExtensions.cs b/AMWD.Common/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..1a5cece --- /dev/null +++ b/AMWD.Common/Extensions/EnumExtensions.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace System +{ + /// + /// Extend the enum values by attribute driven methods. + /// + public static class EnumExtensions + { + /// + /// Returns a list of specific attribute type from a enum-value. + /// + /// The attribute type. + /// The enum value. + /// The attributes or null. + public static IEnumerable GetAttributes(this Enum value) + { + var fieldInfo = value.GetType().GetField(value.ToString()); + if (fieldInfo == null) + return Array.Empty(); + + return fieldInfo.GetCustomAttributes(typeof(TAttribute), inherit: false).Cast(); + } + + /// + /// Returns a specific attribute from a enum-value. + /// + /// The attribute type. + /// The enum value. + /// The attribute or null. + public static TAttribute GetAttribute(this Enum value) + => value.GetAttributes().FirstOrDefault(); + + /// + /// Returns the description from . + /// + /// The enum value. + /// The description or the string representation of the value. + public static string GetDescription(this Enum value) + => value.GetAttribute()?.Description ?? value.ToString(); + } +} diff --git a/AMWD.Common/Extensions/ExceptionExtensions.cs b/AMWD.Common/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..d1a0aa3 --- /dev/null +++ b/AMWD.Common/Extensions/ExceptionExtensions.cs @@ -0,0 +1,41 @@ +using System.Linq; + +namespace System +{ + /// + /// Provides extension methods for exceptions. + /// + public static class ExceptionExtensions + { + /// + /// Returns the message of the inner exception if exists otherwise the message of the exception itself. + /// + /// The exception. + /// The message of the inner exception or the exception itself. + public static string GetMessage(this Exception exception) + => exception.InnerException?.Message ?? exception.Message; + + /// + /// Returns the message of the exception and its inner exceptions. + /// + /// The exception. + /// The message of the and all its inner exceptions. + public static string GetRecursiveMessage(this Exception exception) + { + if (exception is AggregateException aggregateEx) + { + return aggregateEx.InnerExceptions + .Take(3) + .Select(ex => ex.GetRecursiveMessage()) + .Aggregate((a, b) => a + " " + b); + } + if (exception.InnerException != null) + { + string message = exception.Message; + message = message.ReplaceEnd(" See the inner exception for details.", ""); + return message + " " + exception.InnerException.GetRecursiveMessage(); + } + return exception.Message; + } + } +} diff --git a/AMWD.Common/Extensions/JsonExtensions.cs b/AMWD.Common/Extensions/JsonExtensions.cs new file mode 100644 index 0000000..80fcc8e --- /dev/null +++ b/AMWD.Common/Extensions/JsonExtensions.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections; +using System.IO; +using System.Text; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Unclassified.Util; + +namespace Newtonsoft.Json +{ + /// + /// Provides extension methods to serialize and deserialize JSON values to/from objects using + /// common naming conventions. + /// + public static class JsonExtensions + { + /// + /// Common JSON serializer settings. + /// + private static readonly JsonSerializerSettings jsonSerializerSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + + /// + /// Populates an instance with values deserialized from a JSON string. + /// + /// The type of the instance to populate. + /// The instance to populate. + /// The JSON string to read the values from. + public static void DeserializeJson(this T target, string json) + { + if (!string.IsNullOrWhiteSpace(json)) + JsonConvert.PopulateObject(json, target, jsonSerializerSettings); + } + + /// + /// Serializes an instance to a JSON string. + /// + /// The type of the instance to serialize. + /// The instance to serialize. + /// Indicates whether the JSON string is indented to make it better readable. + /// Indicates whether the JSON string uses single quotes instead of double quotes. + /// Indicates whether the camelCase conversion should be used. + /// Indicates whether to include the instance type of if it is not . + /// The JSON-serialized string. + public static string SerializeJson(this T source, bool indented = false, bool useSingleQuotes = false, bool useCamelCase = true, bool includeType = false) + { + var sb = new StringBuilder(); + using (var sw = new StringWriter(sb)) + using (var jw = new JsonTextWriter(sw)) + { + if (useSingleQuotes) + jw.QuoteChar = '\''; + + jw.Formatting = indented ? Formatting.Indented : Formatting.None; + var serializer = useCamelCase ? JsonSerializer.Create(jsonSerializerSettings) : JsonSerializer.CreateDefault(); + + serializer.Error += (s, a) => + { + a.ErrorContext.Handled = true; + }; + + if (includeType) + serializer.TypeNameHandling = TypeNameHandling.Auto; + + serializer.Serialize(jw, source, typeof(T)); + } + return sb.ToString().Trim(); + } + + /// + /// Deserializes a JSON string into a new instance. + /// + /// The type of the instance to deserialize. + /// The JSON string to read the values from. + /// A new instance of with the deserialized values. + public static T DeserializeJson(this string json) + { + if (!string.IsNullOrWhiteSpace(json)) + return JsonConvert.DeserializeObject(json, jsonSerializerSettings); + + return default; + } + + /// + /// Converts an object into a JObject using the custom serializer settings. + /// + /// The object to convert. + /// A JObject representing the . + public static JObject ConvertToJObject(this object obj) + { + if (obj == null) + return null; + + var serializer = JsonSerializer.Create(jsonSerializerSettings); + return JObject.FromObject(obj, serializer); + } + + /// + /// Converts an enumerable into a JArray using the custom serializer settings. + /// + /// The enumerable to convert. + /// A JArray representing the . + public static JArray ConvertToJArray(this IEnumerable array) + { + if (array == null) + return null; + + var serializer = JsonSerializer.Create(jsonSerializerSettings); + return JArray.FromObject(array, serializer); + } + + /// + /// Gets a value from the object using multiple levels. + /// + /// The type to convert the data to. + /// The object. + /// The key to the value. + /// The default value when the key was not found. + /// The character to split the key in levels (default: colon). + /// The converted value. + public static T GetValue(this JObject jObj, string key, T defaultValue, char keySplit = ':') + { + if (jObj == null) + return defaultValue; + + string[] levels = key.Split(keySplit); + JToken lvlObj = jObj; + foreach (string level in levels) + { + if (lvlObj == null) + return defaultValue; + + lvlObj = lvlObj[level]; + } + + if (lvlObj == null) + return defaultValue; + + if (typeof(T) == typeof(string)) + return (T)Convert.ChangeType(lvlObj, typeof(T)); + + return DeepConvert.ChangeType(lvlObj); + } + + /// + /// Gets a value from the object using multiple levels. + /// + /// The type to convert the data to. + /// The object. + /// The key to the value. + /// The character to split the key in levels (default: colon). + /// The converted value. + public static T GetValue(this JObject jObj, string key, char keySplit = ':') + => jObj.GetValue(key, default(T), keySplit); + } +} diff --git a/AMWD.Common/Extensions/ReaderWriterLockSlimExtensions.cs b/AMWD.Common/Extensions/ReaderWriterLockSlimExtensions.cs new file mode 100644 index 0000000..75158bc --- /dev/null +++ b/AMWD.Common/Extensions/ReaderWriterLockSlimExtensions.cs @@ -0,0 +1,84 @@ +namespace System.Threading +{ + /// + /// Provides extension methods for the . + /// + public static class ReaderWriterLockSlimExtensions + { + /// + /// Acquires a read lock on a lock object that can be released with an + /// instance. + /// + /// The lock object. + /// The number of milliseconds to wait, or -1 + /// () to wait indefinitely. + /// An instance to release the lock. + public static IDisposable GetReadLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1) + { + if (!rwLock.TryEnterReadLock(timeoutMilliseconds)) + throw new TimeoutException("The read lock could not be acquired."); + + return new RWLockDisposable(rwLock, 1); + } + + /// + /// Acquires a upgradeable read lock on a lock object that can be released with an + /// instance. The lock can be upgraded to a write lock temporarily + /// with or until the lock is released with + /// alone. + /// + /// The lock object. + /// The number of milliseconds to wait, or -1 + /// () to wait indefinitely. + /// An instance to release the lock. If the lock was + /// upgraded to a write lock, that will be released as well. + public static IDisposable GetUpgradeableReadLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1) + { + if (!rwLock.TryEnterUpgradeableReadLock(timeoutMilliseconds)) + throw new TimeoutException("The upgradeable read lock could not be acquired."); + + return new RWLockDisposable(rwLock, 2); + } + + /// + /// Acquires a write lock on a lock object that can be released with an + /// instance. + /// + /// The lock object. + /// The number of milliseconds to wait, or -1 + /// () to wait indefinitely. + /// An instance to release the lock. + public static IDisposable GetWriteLock(this ReaderWriterLockSlim rwLock, int timeoutMilliseconds = -1) + { + if (!rwLock.TryEnterWriteLock(timeoutMilliseconds)) + throw new TimeoutException("The write lock could not be acquired."); + + return new RWLockDisposable(rwLock, 3); + } + + private struct RWLockDisposable : IDisposable + { + private readonly ReaderWriterLockSlim rwLock; + private int lockMode; + + public RWLockDisposable(ReaderWriterLockSlim rwLock, int lockMode) + { + this.rwLock = rwLock; + this.lockMode = lockMode; + } + + public void Dispose() + { + if (lockMode == 1) + rwLock.ExitReadLock(); + if (lockMode == 2 && rwLock.IsWriteLockHeld) // Upgraded with EnterWriteLock alone + rwLock.ExitWriteLock(); + if (lockMode == 2) + rwLock.ExitUpgradeableReadLock(); + if (lockMode == 3) + rwLock.ExitWriteLock(); + lockMode = 0; + } + } + } +} diff --git a/AMWD.Common/Extensions/StringExtensions.cs b/AMWD.Common/Extensions/StringExtensions.cs new file mode 100644 index 0000000..c5f012b --- /dev/null +++ b/AMWD.Common/Extensions/StringExtensions.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace System +{ + /// + /// String extensions. + /// + public static class StringExtensions + { + /// + /// Converts a hex string into a byte array. + /// + /// The hex encoded string. + /// A delimiter between the bytes (e.g. for MAC address). + /// The bytes. + public static IEnumerable HexToBytes(this string hexString, string delimiter = "") + { + if (string.IsNullOrWhiteSpace(hexString)) + yield break; + + string str = string.IsNullOrEmpty(delimiter) ? hexString : hexString.Replace(delimiter, ""); + if (str.Length % 2 == 1) + yield break; + + for (int i = 0; i < str.Length; i += 2) + yield return Convert.ToByte(str.Substring(i, 2), 16); + } + + /// + /// Converts a byte collection into a hex string. + /// + /// The bytes. + /// A delimiter to set between the bytes (e.g. for MAC address). + /// The hex encoded string. + public static string BytesToHex(this IEnumerable bytes, string delimiter = "") + { + if (bytes?.Any() != true) + return null; + + return string.Join(delimiter, bytes.Select(b => b.ToString("x2"))); + } + + /// + /// Encodes a string to the hexadecimal system (base 16). + /// + /// + /// + /// + public static string HexEncode(this string str, Encoding encoding = null) + { + if (string.IsNullOrEmpty(str)) + return str; + + return (encoding ?? Encoding.Default).GetBytes(str).BytesToHex(); + } + + /// + /// Decodes a string from the hexadecimal system (base 16). + /// + /// + /// + /// + public static string HexDecode(this string str, Encoding encoding = null) + { + if (string.IsNullOrEmpty(str)) + return str; + + return (encoding ?? Encoding.Default).GetString(str.HexToBytes().ToArray()); + } + + /// + /// Encodes a string to base64. + /// + /// + /// + /// + public static string Base64Encode(this string str, Encoding encoding = null) + => Convert.ToBase64String((encoding ?? Encoding.Default).GetBytes(str)); + + /// + /// Decodes a string from base64. + /// + /// + /// + /// + public static string Base64Decode(this string str, Encoding encoding = null) + => (encoding ?? Encoding.Default).GetString(Convert.FromBase64String(str)); + + /// + /// Replaces the search substring with the replacement when it was found at the beginning of the string. + /// + /// The string. + /// The searched substring. + /// The replacement. + /// + public static string ReplaceStart(this string str, string search, string replacement) + { + if (str.StartsWith(search)) + return replacement + str.Substring(search.Length); + + return str; + } + + /// + /// Replaces the search substring with the replacement when it was found at the end of the string. + /// + /// The string. + /// The searched substring. + /// The replacement. + /// + public static string ReplaceEnd(this string str, string search, string replacement) + { + if (str.EndsWith(search)) + return str.Substring(0, str.Length - search.Length) + replacement; + + return str; + } + } +} diff --git a/AMWD.Common/Utilities/CryptographyHelper.cs b/AMWD.Common/Utilities/CryptographyHelper.cs new file mode 100644 index 0000000..ab43fa3 --- /dev/null +++ b/AMWD.Common/Utilities/CryptographyHelper.cs @@ -0,0 +1,486 @@ +using System.IO; +using System.Reflection; +using System.Text; + +namespace System.Security.Cryptography +{ + /// + /// Provides cryptographic functions ready-to-use. + /// + public class CryptographyHelper + { + private static readonly int saltLength = 8; + + private readonly string masterKeyFile; + + /// + /// Initializes a new instance of the class. + /// + /// The (absolute) path to the crypto key file. On null the file 'crypto.key' at the executing assembly location will be used. + public CryptographyHelper(string keyFile = null) + { + if (string.IsNullOrWhiteSpace(keyFile)) + keyFile = "crypto.key"; + + if (!Path.IsPathRooted(keyFile)) + { + string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + keyFile = Path.Combine(dir, keyFile); + } + masterKeyFile = keyFile; + + string pw = File.Exists(masterKeyFile) ? File.ReadAllText(masterKeyFile) : null; + if (string.IsNullOrWhiteSpace(pw)) + File.WriteAllText(masterKeyFile, GetRandomString(64)); + } + + #region Instance methods + + #region AES + + /// + /// Decrypts data using the AES algorithm and a password. + /// + /// + /// When the parameter is null, the key from the file (set on initialize) is used instead. + /// + /// The encrypted data (cipher). + /// The password to use for decryption (optional). + /// The decrypted data. + public byte[] DecryptAes(byte[] cipher, string password = null) + { + if (password == null) + password = File.ReadAllText(masterKeyFile); + + return AesDecrypt(cipher, password); + } + + /// + /// Encrypts data using the AES algorithm and a password. + /// + /// + /// When the parameter is null, the key from the file (set on initialize) is used instead. + /// + /// The data to encrypt. + /// The password to use for encryption (optional). + /// The encrypted data (cipher). + public byte[] EncryptAes(byte[] plain, string password = null) + { + if (password == null) + password = File.ReadAllText(masterKeyFile); + + return AesEncrypt(plain, password); + } + + #endregion AES + + #region Triple DES + + /// + /// Decrypts data using the triple DES algorithm and a password. + /// + /// + /// When the parameter is null, the key from the file (set on initialize) is used instead. + /// + /// The encrypted data (cipher). + /// The password to use for decryption (optional). + /// The decrypted data. + public byte[] DecryptTripleDes(byte[] cipher, string password = null) + { + if (password == null) + password = File.ReadAllText(masterKeyFile); + + return TripleDesDecrypt(cipher, password); + } + + /// + /// Encrypts data using the triple DES algorithm and a password. + /// + /// + /// When the parameter is null, the key from the file (set on initialize) is used instead. + /// + /// The data to encrypt. + /// The password to use for encryption (optional). + /// The encrypted data (cipher). + public byte[] EncryptTripleDes(byte[] plain, string password = null) + { + if (password == null) + password = File.ReadAllText(masterKeyFile); + + return TripleDesEncrypt(plain, password); + } + + #endregion Triple DES + + #endregion Instance methods + + #region Static methods + + #region Encryption + + #region AES + + /// + /// Decrypts data using the AES algorithm and a password. + /// + /// The encrypted data (cipher). + /// The password to use for decryption. + /// The decrypted data. + public static byte[] AesDecrypt(byte[] cipher, string password) + { + byte[] salt = new byte[saltLength]; + Array.Copy(cipher, salt, saltLength); + + using var gen = new Rfc2898DeriveBytes(password, salt); + using var aes = Aes.Create(); + + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = gen.GetBytes(aes.KeySize / 8); + aes.IV = gen.GetBytes(aes.BlockSize / 8); + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Write); + + cs.Write(cipher, saltLength, cipher.Length - saltLength); + cs.FlushFinalBlock(); + + return ms.ToArray(); + } + + /// + /// Encrypts data using the AES algorithm and a password. + /// + /// The data to encrypt. + /// The password to use for encryption. + /// The encrypted data (cipher). + public static byte[] AesEncrypt(byte[] plain, string password) + { + byte[] salt = GetRandomBytes(saltLength); + + using var gen = new Rfc2898DeriveBytes(password, salt); + using var aes = Aes.Create(); + + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = gen.GetBytes(aes.KeySize / 8); + aes.IV = gen.GetBytes(aes.BlockSize / 8); + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write); + + ms.Write(salt, 0, salt.Length); + cs.Write(plain, 0, plain.Length); + cs.FlushFinalBlock(); + + return ms.ToArray(); + } + + #endregion AES + + #region Triple DES + + /// + /// Decrypts data using the triple DES algorithm and a password. + /// + /// The encrypted data (cipher). + /// The password to use for decryption. + /// The decrypted data. + public static byte[] TripleDesDecrypt(byte[] cipher, string password) + { + byte[] salt = new byte[saltLength]; + Array.Copy(cipher, salt, saltLength); + + using var gen = new Rfc2898DeriveBytes(password, salt); + using var tdes = TripleDES.Create(); + + tdes.Mode = CipherMode.CBC; + tdes.Padding = PaddingMode.PKCS7; + tdes.Key = gen.GetBytes(tdes.KeySize / 8); + tdes.IV = gen.GetBytes(tdes.BlockSize / 8); + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, tdes.CreateDecryptor(), CryptoStreamMode.Write); + + cs.Write(cipher, saltLength, cipher.Length - saltLength); + cs.FlushFinalBlock(); + + return ms.ToArray(); + } + + /// + /// Encrypts data using the triple DES algorithm and a password. + /// + /// The data to encrypt. + /// The password to use for encryption. + /// The encrypted data (cipher). + public static byte[] TripleDesEncrypt(byte[] plain, string password) + { + byte[] salt = GetRandomBytes(saltLength); + + using var gen = new Rfc2898DeriveBytes(password, salt); + using var tdes = TripleDES.Create(); + + tdes.Mode = CipherMode.CBC; + tdes.Padding = PaddingMode.PKCS7; + tdes.Key = gen.GetBytes(tdes.KeySize / 8); + tdes.IV = gen.GetBytes(tdes.BlockSize / 8); + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, tdes.CreateEncryptor(), CryptoStreamMode.Write); + + ms.Write(salt, 0, salt.Length); + cs.Write(plain, 0, plain.Length); + cs.FlushFinalBlock(); + + return ms.ToArray(); + } + + #endregion Triple DES + + #endregion Encryption + + #region Hashing + + #region MD5 + + /// + /// Computes a hash value from a string using the MD5 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The MD5 hash value, in hexadecimal notation. + public static string Md5(string str) + { + return Md5(Encoding.UTF8.GetBytes(str)); + } + + /// + /// Computes a hash value from a file using the MD5 algorithm. + /// + /// The name of the file to read. + /// The MD5 hash value, in hexadecimal notation. + public static string Md5File(string fileName) + { + using var md5 = MD5.Create(); + using var fs = new FileStream(fileName, FileMode.Open); + return md5.ComputeHash(fs).BytesToHex(); + } + + /// + /// Computes a hash from a byte array value using the MD5 algorithm. + /// + /// The byte array. + /// The MD5 hash value, in hexadecimal notation. + public static string Md5(byte[] bytes) + { + using var md5 = MD5.Create(); + return md5.ComputeHash(bytes).BytesToHex(); + } + + #endregion MD5 + + #region SHA-1 + + /// + /// Computes a hash value from a string using the SHA-1 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-1 hash value, in hexadecimal notation. + public static string Sha1(string str) + { + return Sha1(Encoding.UTF8.GetBytes(str)); + } + + /// + /// Computes a hash value from a file using the SHA-1 algorithm. + /// + /// The name of the file to read. + /// The SHA-1 hash value, in hexadecimal notation. + public static string Sha1File(string fileName) + { + using var sha1 = SHA1.Create(); + using var fs = new FileStream(fileName, FileMode.Open); + return sha1.ComputeHash(fs).BytesToHex(); + } + + /// + /// Computes a hash from a byte array value using the SHA-1 algorithm. + /// + /// The byte array. + /// The SHA-1 hash value, in hexadecimal notation. + public static string Sha1(byte[] bytes) + { + using var sha1 = SHA1.Create(); + return sha1.ComputeHash(bytes).BytesToHex(); + } + + #endregion SHA-1 + + #region SHA-256 + + /// + /// Computes a hash value from a string using the SHA-256 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-256 hash value, in hexadecimal notation. + public static string Sha256(string str) + { + return Sha256(Encoding.UTF8.GetBytes(str)); + } + + /// + /// Computes a hash value from a file using the SHA-256 algorithm. + /// + /// The name of the file to read. + /// The SHA-256 hash value, in hexadecimal notation. + public static string Sha256File(string fileName) + { + using var sha256 = SHA256.Create(); + using var fs = new FileStream(fileName, FileMode.Open); + return sha256.ComputeHash(fs).BytesToHex(); + } + + /// + /// Computes a hash from a byte array value using the SHA-256 algorithm. + /// + /// The byte array. + /// The SHA-256 hash value, in hexadecimal notation. + public static string Sha256(byte[] bytes) + { + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(bytes).BytesToHex(); + } + + #endregion SHA-256 + + #region SHA-512 + + /// + /// Computes a hash value from a string using the SHA-512 algorithm. + /// + /// The string to hash, using UTF-8 encoding. + /// The SHA-512 hash value, in hexadecimal notation. + public static string Sha512(string str) + { + return Sha512(Encoding.UTF8.GetBytes(str)); + } + + /// + /// Computes a hash value from a file using the SHA-512 algorithm. + /// + /// The name of the file to read. + /// The SHA-512 hash value, in hexadecimal notation. + public static string Sha512File(string fileName) + { + using var sha512 = SHA512.Create(); + using var fs = new FileStream(fileName, FileMode.Open); + return sha512.ComputeHash(fs).BytesToHex(); + } + + /// + /// Computes a hash from a byte array value using the SHA-512 algorithm. + /// + /// The byte array. + /// The SHA-512 hash value, in hexadecimal notation. + public static string Sha512(byte[] bytes) + { + using var sha512 = SHA512.Create(); + return sha512.ComputeHash(bytes).BytesToHex(); + } + + #endregion SHA-512 + + #endregion Hashing + + #region Random + + /// + /// Generates an array with random (non-zero) bytes. + /// + /// The number of bytes to generate. + /// + public static byte[] GetRandomBytes(int count) + { + using var gen = RandomNumberGenerator.Create(); + byte[] bytes = new byte[count]; + gen.GetNonZeroBytes(bytes); + + return bytes; + } + + /// + /// Generates a string with random characters. + /// + /// The length of the string to generate. + /// The characters to use (Default: [a-zA-Z0-9]). + /// + public static string GetRandomString(int length, string pool = null) + { + if (string.IsNullOrWhiteSpace(pool)) + pool = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; + + var sb = new StringBuilder(length); + int multiply = sizeof(int) / sizeof(byte); + int len = length * multiply; + byte[] bytes = GetRandomBytes(len); + for (int i = 0; i < bytes.Length; i += multiply) + { + uint number = BitConverter.ToUInt32(bytes, i); + sb.Append(pool[(int)(number % pool.Length)]); + } + return sb.ToString(); + } + + #endregion Random + + #region Probing security + + /// + /// Determines whether two strings are equal in constant time. This method does not stop + /// early if a difference was detected, unless the length differs. + /// + /// The first string. + /// The second string. + /// true, if both strings are equal; otherwise, false. + public static bool SecureEquals(string a, string b) + { + if ((a == null) != (b == null)) + return false; + if (a.Length != b.Length) + return false; + + int differentBits = 0; + for (int i = 0; i < a.Length; i++) + { + differentBits |= a[i] ^ b[i]; + } + return differentBits == 0; + } + + /// + /// Determines whether two byte arrays are equal in constant time. This method does not stop + /// early if a difference was detected, unless the length differs. + /// + /// The first array. + /// The second array. + /// true, if both arrays are equal; otherwise, false. + public static bool SecureEquals(byte[] a, byte[] b) + { + if ((a == null) != (b == null)) + return false; + if (a.Length != b.Length) + return false; + + int differentBits = 0; + for (int i = 0; i < a.Length; i++) + { + differentBits |= a[i] ^ b[i]; + } + return differentBits == 0; + } + + #endregion Probing security + + #endregion Static methods + } +} diff --git a/AMWD.Common/Utilities/DelayedTask.cs b/AMWD.Common/Utilities/DelayedTask.cs new file mode 100644 index 0000000..a4b68aa --- /dev/null +++ b/AMWD.Common/Utilities/DelayedTask.cs @@ -0,0 +1,590 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Common.Utilities +{ + /// + /// Implements an awaitable task that runs after a specified delay. The delay can be reset + /// before and after the task has run. By resetting the delay, the task can be executed multiple + /// times. The scheduled or executing or last executed task can be awaited, until the delay is + /// reset. After that, the next execution can be awaited. + /// + public class DelayedTask + { + #region Data + + /// + /// The synchronisation object. + /// + protected readonly object syncObj = new(); + + /// + /// The exception handler. + /// + protected Action exceptionHandler; + + private Timer timer; + + /// + /// Gets a value indicating whether the timer is running and an execution is scheduled. This + /// is mutually exclusive to . + /// + public bool IsWaitingToRun { get; private set; } + + /// + /// Gets a value indicating whether the action is currently running. This is mutually + /// exclusive to . + /// + public bool IsRunning { get; private set; } + + /// + /// Indicates whether the action shall be executed again after the currently ongoing + /// execution has completed. + /// + private bool nextRunPending; + + /// + /// Provides the for the method. + /// + protected TaskCompletionSourceWrapper tcs; + + /// + /// Gets or sets the action to execute. + /// + protected Action Action { get; set; } + + /// + /// Gets or sets the delay to wait before executing the action. + /// + public TimeSpan Delay { get; protected set; } + + #endregion Data + + #region Static methods + + /// + /// Creates a new task instance that executes the specified action after the delay, but does + /// not start it yet. Multiple executions are allowed when calling after + /// the executed was started. + /// + /// The action to execute. + /// The delay. + /// + public static DelayedTask Create(Action action, TimeSpan delay) + { + return new DelayedTask { Action = action, Delay = delay }; + } + + /// + /// Creates a new task instance that executes the specified action after the delay, but does + /// not start it yet. Multiple executions are allowed when calling after + /// the executed was started. + /// + /// The action to execute. + /// The delay. + /// + public static DelayedTaskWithResult Create(Func action, TimeSpan delay) + { + return DelayedTaskWithResult.Create(action, delay); + } + + /// + /// Executes the specified action after the delay. Multiple executions are allowed when + /// calling after the executed was started. + /// + /// The action to execute. + /// The delay. + /// + public static DelayedTask Run(Action action, TimeSpan delay) + { + return new DelayedTask { Action = action, Delay = delay }.Start(); + } + + /// + /// Executes the specified action after the delay. Multiple executions are allowed when + /// calling after the executed was started. + /// + /// The action to execute. + /// The delay. + /// + public static DelayedTaskWithResult Run(Func action, TimeSpan delay) + { + return DelayedTaskWithResult.Run(action, delay); + } + + #endregion Static methods + + #region Constructors + + /// + /// Initializes a new instance of the class. + /// + protected DelayedTask() + { + tcs = CreateTcs(); + SetLastResult(tcs); + } + + #endregion Constructors + + #region Public instance methods + + /// + /// Resets the delay and restarts the timer. If an execution is currently pending, it is + /// postponed until the full delay has elapsed again. If no execution is pending, the action + /// will be executed again after the delay. + /// + public void Reset() + { + lock (syncObj) + { + if (!IsWaitingToRun && !IsRunning) + { + // Let callers wait for the next execution + tcs = CreateTcs(); + } + IsWaitingToRun = true; + if (timer != null) + { + timer.Change(Delay, Timeout.InfiniteTimeSpan); + } + else + { + timer = new Timer(OnTimerCallback, null, Delay, Timeout.InfiniteTimeSpan); + } + } + } + + /// + /// Cancels the delay. Any pending executions are cleared. If the action was pending but not + /// yet executing, this task is cancelled. If the action was not pending or is already + /// executing, this task will be completed successfully after the action has completed. + /// + public void Cancel() + { + TaskCompletionSourceWrapper localTcs = null; + lock (syncObj) + { + IsWaitingToRun = false; + nextRunPending = false; + timer?.Dispose(); + timer = null; + if (!IsRunning) + { + localTcs = tcs; + } + } + + // Complete the task (as cancelled) so that nobody needs to wait for an execution that + // isn't currently scheduled + localTcs?.TrySetCanceled(); + } + + /// + /// Starts a pending execution immediately, not waiting for the timer to elapse. + /// + /// true, if an execution was started; otherwise, false. + /// + /// A new execution is only started if one is currently waiting to run, or already running. + /// In the former case, the execution is scheduled immediately with the timer; in the latter + /// case, it is scheduled for when the currently running execution has completed. If an + /// execution has been started (the method returned true), it can be awaited normally. + /// + public bool ExecutePending() + { + lock (syncObj) + { + if (!IsWaitingToRun && !IsRunning) + { + return false; + } + IsWaitingToRun = true; + if (timer != null) + { + timer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } + else + { + timer = new Timer(OnTimerCallback, null, TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } + return true; + } + } + + /// + /// Gets an awaiter used to await this . + /// + /// An awaiter instance. + public TaskAwaiter GetAwaiter() + { + lock (syncObj) + { + return tcs.Task.GetAwaiter(); + } + } + + /// + /// Gets a that represents the current awaitable + /// operation. + /// + public Task Task + { + get + { + lock (syncObj) + { + return tcs.Task; + } + } + } + + /// + /// Performs an implicit conversion from to + /// . + /// + /// The instance to cast. + /// A that represents the current + /// awaitable operation. + public static implicit operator Task(DelayedTask delayedTask) => delayedTask.Task; + + /// + /// Gets the exception of the last execution. If the action has not yet thrown any + /// exceptions, this will return null. + /// + public Exception Exception => Task.Exception; + + /// + /// Adds an unhandled exception handler to this instance. + /// + /// The action that handles an exception. + /// The current instance. + public DelayedTask WithExceptionHandler(Action exceptionHandler) + { + this.exceptionHandler = exceptionHandler; + return this; + } + + #endregion Public instance methods + + #region Non-public methods + + /// + /// Starts the current instance after creating it. + /// + /// The current instance. + protected DelayedTask Start() + { + tcs = CreateTcs(); + IsWaitingToRun = true; + timer = new Timer(OnTimerCallback, null, Delay, Timeout.InfiniteTimeSpan); + return this; + } + + /// + /// Creates a instance. + /// + /// + protected virtual TaskCompletionSourceWrapper CreateTcs() + { + return new TaskCompletionSourceWrapper(); + } + + /// + /// Called when the timer has elapsed. + /// + /// Unused. + protected void OnTimerCallback(object state) + { + lock (syncObj) + { + if (!IsWaitingToRun) + { + // Already cancelled, do nothing + return; + } + + IsWaitingToRun = false; + if (IsRunning) + { + // Currently running, remember and do nothing for now + nextRunPending = true; + return; + } + IsRunning = true; + } + + // Run as long as there are pending executions and the instance has not been disposed of + bool runAgain; + TaskCompletionSourceWrapper localTcs = null; + Exception exception = null; + do + { + try + { + Run(); + } + catch (Exception ex) + { + exception = ex; + lock (syncObj) + { + runAgain = false; + IsRunning = false; + nextRunPending = false; + localTcs = tcs; + if (!IsWaitingToRun) + { + timer?.Dispose(); + timer = null; + } + } + exceptionHandler?.Invoke(ex); + } + finally + { + lock (syncObj) + { + runAgain = nextRunPending; + IsRunning = runAgain; + nextRunPending = false; + if (!runAgain) + { + if (!IsWaitingToRun) + { + localTcs = tcs; + timer?.Dispose(); + timer = null; + } + } + } + } + } + while (runAgain); + + // Unblock waiters if not already waiting for the next execution. + // This task can be awaited again after the Reset method has been called. + if (exception != null) + localTcs?.TrySetException(exception); + else + SetLastResult(localTcs); + } + + /// + /// Runs the action of the task. + /// + protected virtual void Run() + { + Action(); + } + + /// + /// Sets the result from the last action. + /// + /// The to set the result of. + protected virtual void SetLastResult(TaskCompletionSourceWrapper tcs) + { + var myTcs = (TaskCompletionSourceWrapper)tcs; + myTcs?.TrySetResult(default); + } + + #endregion Non-public methods + + #region Internal TaskCompletionSourceWrapper classes + + /// + /// Wraps a instance in a non-generic way to + /// allow sharing it in the non-generic base class. + /// + protected abstract class TaskCompletionSourceWrapper + { + /// + /// Gets the of the . + /// + public abstract Task Task { get; } + + /// + /// Attempts to transition the underlying into the + /// state and binds it to a specified exception. + /// + /// The exception to bind to this . + /// + public abstract void TrySetException(Exception exception); + + /// + /// Attempts to transition the underlying into the + /// state. + /// + /// + public abstract void TrySetCanceled(); + } + + /// + /// A that provides a result value. + /// + /// The type of the result value. + protected class TaskCompletionSourceWrapper : TaskCompletionSourceWrapper + { + private readonly TaskCompletionSource tcs; + + /// + /// Gets the of the . + /// + public override Task Task => tcs.Task; + + /// + /// Initializes a new instance of the class. + /// + public TaskCompletionSourceWrapper() + { + tcs = new TaskCompletionSource(); + } + + /// + /// Attempts to transition the underlying into the + /// state. + /// + /// The result value to bind to this . + /// + public void TrySetResult(TResult result) + { + tcs.TrySetResult(result); + } + + /// + /// Attempts to transition the underlying into the + /// state and binds it to a specified exception. + /// + /// The exception to bind to this . + /// + public override void TrySetException(Exception exception) + { + tcs.TrySetException(exception); + } + + /// + /// Attempts to transition the underlying into the + /// state. + /// + /// + public override void TrySetCanceled() + { + tcs.TrySetCanceled(); + } + } + + #endregion Internal TaskCompletionSourceWrapper classes + } + + #region Generic derived classes + + /// + /// Implements an awaitable task that runs after a specified delay. The delay can be reset + /// before and after the task has run. + /// + /// The type of the return value of the action. + public class DelayedTaskWithResult : DelayedTask + { + /// + /// The result of the last execution of the action. + /// + protected TResult lastResult; + + /// + /// Gets or sets the action to execute. + /// + protected new Func Action { get; set; } + + internal static DelayedTaskWithResult Create(Func action, TimeSpan delay) + { + return new DelayedTaskWithResult { Action = action, Delay = delay }; + } + + internal static DelayedTaskWithResult Run(Func action, TimeSpan delay) + { + return (DelayedTaskWithResult)new DelayedTaskWithResult { Action = action, Delay = delay }.Start(); + } + + /// + /// Creates a instance. + /// + /// + protected override TaskCompletionSourceWrapper CreateTcs() + { + return new TaskCompletionSourceWrapper(); + } + + /// + /// Runs the action of the task. + /// + protected override void Run() + { + lastResult = Action(); + } + + /// + /// Sets the result from the last action. + /// + /// The to set the result of. + protected override void SetLastResult(TaskCompletionSourceWrapper tcs) + { + var myTcs = (TaskCompletionSourceWrapper)tcs; + myTcs?.TrySetResult(lastResult); + } + + /// + /// Gets an awaiter used to await this . + /// + /// An awaiter instance. + public new TaskAwaiter GetAwaiter() + { + lock (syncObj) + { + var myTcs = (TaskCompletionSourceWrapper)tcs; + var myTask = (Task)myTcs.Task; + return myTask.GetAwaiter(); + } + } + + /// + /// Gets a that represents the current awaitable operation. + /// + public new Task Task + { + get + { + lock (syncObj) + { + var myTcs = (TaskCompletionSourceWrapper)tcs; + var myTask = (Task)myTcs.Task; + return myTask; + } + } + } + + /// + /// Performs an implicit conversion from to + /// . + /// + /// The instance to cast. + /// A that represents the current awaitable operation. + public static implicit operator Task(DelayedTaskWithResult delayedTask) + { + return delayedTask.Task; + } + + /// + /// Adds an unhandled exception handler to this instance. + /// + /// The action that handles an exception. + /// The current instance. + public new DelayedTaskWithResult WithExceptionHandler(Action exceptionHandler) + { + this.exceptionHandler = exceptionHandler; + return this; + } + } + + #endregion Generic derived classes +} diff --git a/AMWD.Common/Utilities/NetworkHelper.cs b/AMWD.Common/Utilities/NetworkHelper.cs new file mode 100644 index 0000000..edab132 --- /dev/null +++ b/AMWD.Common/Utilities/NetworkHelper.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace AMWD.Common.Utilities +{ + /// + /// Provides some network utils. + /// + public static class NetworkHelper + { + /// + /// Tries to resolve a into an to connect to. + /// + /// The hostname to resolve. + /// An address family to use (available: and ). + /// The fallback ip address when resolving failed. + /// The resolved to connect to or value. + public static IPAddress ResolveHost(string hostname, AddressFamily addressFamily = AddressFamily.Unspecified, IPAddress fallback = null) + { + if (IPAddress.TryParse(hostname, out var ipAddress)) + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork && ipAddress.AddressFamily != AddressFamily.InterNetworkV6) + return fallback; + + if (addressFamily != AddressFamily.Unspecified && ipAddress.AddressFamily != addressFamily) + return fallback; + + return ipAddress; + } + + return Dns.GetHostAddresses(hostname) + .Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6) + .Where(a => addressFamily == AddressFamily.Unspecified || a.AddressFamily == addressFamily) + .OrderBy(a => a.AddressFamily) + .FirstOrDefault() ?? fallback; + } + + /// + /// Tries to resolve a into an to bind (listen) on. + /// + /// The interface name to resolve. + /// An address family to use (available: and ). + /// The fallback ip address when resolving failed. + /// The resolved to bind on or value. + public static IPAddress ResolveInterface(string iface, AddressFamily addressFamily = AddressFamily.Unspecified, IPAddress fallback = null) + { + if (IPAddress.TryParse(iface, out var ipAddress)) + { + if (ipAddress.AddressFamily != AddressFamily.InterNetwork && ipAddress.AddressFamily != AddressFamily.InterNetworkV6) + return fallback; + + if (addressFamily != AddressFamily.Unspecified && ipAddress.AddressFamily != addressFamily) + return fallback; + + return ipAddress; + } + + try + { + return Dns.GetHostAddresses(iface) + .Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6) + .Where(a => addressFamily == AddressFamily.Unspecified || a.AddressFamily == addressFamily) + .OrderBy(a => a.AddressFamily) + .FirstOrDefault() ?? fallback; + } + catch (SocketException) + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(nic => nic.Name.Equals(iface, StringComparison.OrdinalIgnoreCase)) + .SelectMany(nic => nic.GetIPProperties().UnicastAddresses.Select(ai => ai.Address)) + .Where(a => a.AddressFamily == AddressFamily.InterNetwork || a.AddressFamily == AddressFamily.InterNetworkV6) + .Where(a => addressFamily == AddressFamily.Unspecified || a.AddressFamily == addressFamily) + .OrderBy(a => a.AddressFamily) + .FirstOrDefault() ?? fallback; + } + } + } +} diff --git a/CodeMaid.config b/CodeMaid.config new file mode 100644 index 0000000..02255f6 --- /dev/null +++ b/CodeMaid.config @@ -0,0 +1,337 @@ + + + + +
+ + + + + + False + + + True + + + .*\.Designer\.cs||.*\.resx||packages.config||.*\.min\.js||.*\.min\.css + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + True + + + False + + + True + + + True + + + True + + + False + + + False + + + True + + + True + + + False + + + False + + + True + + + True + + + True + + + False + + + True + + + False + + + True + + + True + + + True + + + False + + + False + + + True + + + False + + + True + + + False + + + False + + + False + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + True + + + True + + + 0 + + + True + + + True + + + True + + + True + + + True + + + True + + + False + + + + + + + False + + + False + + + True + + + 100 + + + False + + + False + + + False + + + False + + + False + + + False + + + 0 + + + False + + + False + + + False + + + + \ No newline at end of file diff --git a/Common.sln b/Common.sln new file mode 100644 index 0000000..b9a880d --- /dev/null +++ b/Common.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31729.503 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMWD.Common", "AMWD.Common\AMWD.Common.csproj", "{F512C474-B670-4E47-911E-7C0674AA8E7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMWD.Common.AspNetCore", "AMWD.Common.AspNetCore\AMWD.Common.AspNetCore.csproj", "{725F40C9-8172-487F-B3D0-D7E38B4DB197}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMWD.Common.EntityFrameworkCore", "AMWD.Common.EntityFrameworkCore\AMWD.Common.EntityFrameworkCore.csproj", "{7091CECF-C981-4FB9-9CC6-91C4E65A6356}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{AFBF83AE-FE7D-48C1-B7E7-31BF3E17C6FB}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + .gitlab-ci.yml = .gitlab-ci.yml + CodeMaid.config = CodeMaid.config + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F512C474-B670-4E47-911E-7C0674AA8E7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F512C474-B670-4E47-911E-7C0674AA8E7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F512C474-B670-4E47-911E-7C0674AA8E7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F512C474-B670-4E47-911E-7C0674AA8E7E}.Release|Any CPU.Build.0 = Release|Any CPU + {725F40C9-8172-487F-B3D0-D7E38B4DB197}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {725F40C9-8172-487F-B3D0-D7E38B4DB197}.Debug|Any CPU.Build.0 = Debug|Any CPU + {725F40C9-8172-487F-B3D0-D7E38B4DB197}.Release|Any CPU.ActiveCfg = Release|Any CPU + {725F40C9-8172-487F-B3D0-D7E38B4DB197}.Release|Any CPU.Build.0 = Release|Any CPU + {7091CECF-C981-4FB9-9CC6-91C4E65A6356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7091CECF-C981-4FB9-9CC6-91C4E65A6356}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7091CECF-C981-4FB9-9CC6-91C4E65A6356}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7091CECF-C981-4FB9-9CC6-91C4E65A6356}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {961E8DF8-DDF5-4D10-A510-CE409E9962AC} + EndGlobalSection +EndGlobal diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..60e9765 --- /dev/null +++ b/build.bat @@ -0,0 +1,30 @@ +@echo off +set Configuration=Release + +cd "%~dp0" +cd "AMWD.Common" +rmdir /S /Q bin +dotnet build -c %Configuration% --nologo --no-incremental + +cd "%~dp0" +cd "AMWD.Common.AspNetCore" +rmdir /S /Q bin +dotnet build -c %Configuration% --nologo --no-incremental + +cd "%~dp0" +cd "AMWD.Common.EntityFrameworkCore" +rmdir /S /Q bin +dotnet build -c %Configuration% --nologo --no-incremental + +cd "%~dp0" +rmdir /S /Q build +mkdir build + +move AMWD.Common\bin\%Configuration%\*.nupkg build +move AMWD.Common\bin\%Configuration%\*.snupkg build + +move AMWD.Common.AspNetCore\bin\%Configuration%\*.nupkg build +move AMWD.Common.AspNetCore\bin\%Configuration%\*.snupkg build + +move AMWD.Common.EntityFrameworkCore\bin\%Configuration%\*.nupkg build +move AMWD.Common.EntityFrameworkCore\bin\%Configuration%\*.snupkg build diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..2f0a0e2 --- /dev/null +++ b/build.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +CONFIGURATION=Release + +pushd AMWD.Common +rm -rf bin +dotnet build -c ${CONFIGURATION} --nologo --no-incremental +popd + +pushd AMWD.Common.AspNetCore +rm -rf bin +dotnet build -c ${CONFIGURATION} --nologo --no-incremental +popd + +pushd AMWD.Common.EntityFrameworkCore +rm -rf bin +dotnet build -c ${CONFIGURATION} --nologo --no-incremental +popd + +rm -rf build +mkdir build +mv AMWD.Common/bin/${CONFIGURATION}/*.nupkg build +mv AMWD.Common/bin/${CONFIGURATION}/*.snupkg build + +mv AMWD.Common.AspNetCore/bin/${CONFIGURATION}/*.nupkg build +mv AMWD.Common.AspNetCore/bin/${CONFIGURATION}/*.snupkg build + +mv AMWD.Common.EntityFrameworkCore/bin/${CONFIGURATION}/*.nupkg build +mv AMWD.Common.EntityFrameworkCore/bin/${CONFIGURATION}/*.snupkg build