Compare commits
55 Commits
1b210714fd
...
feat/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c64a3f9a | ||
|
|
1d600aa453 | ||
|
|
816d5835f1 | ||
|
|
4a64a31d47 | ||
|
|
e9b2ba788f | ||
|
|
e53813500a | ||
|
|
25e3855de2 | ||
|
|
dd3d6e70cc | ||
|
|
02a87309df | ||
|
|
0f7bdc9d0e | ||
|
|
f9df2fb29e | ||
|
|
ef7da0e52c | ||
|
|
f602a842be | ||
|
|
52a7664e57 | ||
|
|
ea3d1312b8 | ||
|
|
3b8b315fea | ||
|
|
c65eefb954 | ||
|
|
997fd533ac | ||
|
|
bcfb5a8a70 | ||
|
|
049e9977f4 | ||
|
|
0334fc4cdf | ||
|
|
0c2334cefb | ||
|
|
dd7f1c1ea0 | ||
|
|
4bb242a4cc | ||
|
|
b577067379 | ||
|
|
bd4d4856ea | ||
|
|
c3a12ba5b7 | ||
|
|
478bf13a4a | ||
|
|
d8849f48da | ||
|
|
c466c553dc | ||
|
|
48afa6b433 | ||
|
|
e44b2895c9 | ||
|
|
85e5fc4018 | ||
|
|
70ccec9fef | ||
|
|
f7193594b1 | ||
|
|
9c7319634a | ||
|
|
3becb208ec | ||
|
|
1b00f9afa2 | ||
|
|
b58d4aed2f | ||
|
|
5adc67edf2 | ||
|
|
0ff0de8159 | ||
|
|
49b49271f3 | ||
|
|
5c5a6bd181 | ||
|
|
6ab1777f7c | ||
|
|
103ddf5c2e | ||
|
|
f9c94e8464 | ||
|
|
cdb0009e7c | ||
|
|
5010224500 | ||
|
|
1ebdd7e5bb | ||
|
|
0e0513e640 | ||
|
|
683b95c205 | ||
|
|
f28b43cc06 | ||
|
|
777a8a73ac | ||
|
|
77fc06991b | ||
|
|
eeb50e837d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -409,3 +409,4 @@ FodyWeavers.xsd
|
||||
/DigitalData.Core.ConsoleApp/Program.cs
|
||||
/DigitalData.Core.ConsoleApp/FooHttpOptions.cs
|
||||
/DigitalData.Core.Tests/obj/
|
||||
/DigitalData.Core.Terminal
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace DigitalData.Core.API
|
||||
/// <param name="index">The key in the ViewData dictionary where the value will be stored.</param>
|
||||
/// <param name="value">The value to be stored in the ViewData dictionary.</param>
|
||||
/// <returns>The same ViewResult object with updated ViewData, allowing for additional chained operations.</returns>
|
||||
public static ViewResult WithData(this ViewResult viewResult, string index, object value)
|
||||
public static ViewResult WithData(this ViewResult viewResult, string index, object? value)
|
||||
{
|
||||
viewResult.ViewData[index] = value;
|
||||
return viewResult;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<OutputType>Library</OutputType>
|
||||
<Description>This package provides a comprehensive set of API controllers and related utilities for the DigitalData.Core library. It includes generic CRUD controllers, localization extensions, middleware for security policies, and application model conventions.</Description>
|
||||
<PackageId>DigitalData.Core.API</PackageId>
|
||||
<Version>2.0.0.0</Version>
|
||||
<Version>2.0.1</Version>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>DigitalData.Core.API</Product>
|
||||
@@ -16,6 +16,8 @@
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/WebCoreModules.git</RepositoryUrl>
|
||||
<PackageTags>digital data core api</PackageTags>
|
||||
<PackageIcon>core_icon.png</PackageIcon>
|
||||
<AssemblyVersion>2.0.1</AssemblyVersion>
|
||||
<FileVersion>2.0.1</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,15 +4,17 @@ namespace DigitalData.Core.Abstractions.Client
|
||||
{
|
||||
public interface IBaseHttpClientService
|
||||
{
|
||||
public string Uri { get; init; }
|
||||
|
||||
public CookieCollection GetCookies(string route = "");
|
||||
CookieCollection GetCookies(string path = "");
|
||||
|
||||
Task<HttpResponseMessage> FetchAsync(
|
||||
string route = "",
|
||||
string? scheme = null,
|
||||
int? port = null,
|
||||
string path = "",
|
||||
IEnumerable<KeyValuePair<string, object?>>? queryParams = null,
|
||||
HttpMethod? method = null,
|
||||
HttpContent? body = null,
|
||||
Dictionary<string, string>? form = null,
|
||||
IEnumerable<KeyValuePair<string, object>>? form = null,
|
||||
IEnumerable<KeyValuePair<string, object>>? headers = null,
|
||||
bool sendWithCookie = true,
|
||||
bool saveCookie = true
|
||||
);
|
||||
|
||||
13
DigitalData.Core.Abstractions/Client/IHttpClientOptions.cs
Normal file
13
DigitalData.Core.Abstractions/Client/IHttpClientOptions.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace DigitalData.Core.Abstractions.Client
|
||||
{
|
||||
public interface IHttpClientOptions
|
||||
{
|
||||
string Uri { get; init; }
|
||||
|
||||
string? Path { get; init; }
|
||||
|
||||
Dictionary<string, object>? Headers { get; init; }
|
||||
|
||||
Dictionary<string, object?>? QueryParams { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace DigitalData.Core.Abstractions.Client
|
||||
{
|
||||
public interface IHttpClientService<TClientOptions> : IBaseHttpClientService
|
||||
public interface IHttpClientService<TClientOptions> : IBaseHttpClientService where TClientOptions : IHttpClientOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,10 @@
|
||||
<PackageProjectUrl></PackageProjectUrl>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/WebCoreModules.git</RepositoryUrl>
|
||||
<PackAsTool>False</PackAsTool>
|
||||
<NeutralLanguage>aa-DJ</NeutralLanguage>
|
||||
<PackageIcon>core_icon.png</PackageIcon>
|
||||
<Version>2.0.0.0</Version>
|
||||
<Version>2.2.1</Version>
|
||||
<AssemblyVersion>2.2.1</AssemblyVersion>
|
||||
<FileVersion>2.2.1</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,19 +4,33 @@ namespace DigitalData.Core.Abstractions.Security
|
||||
{
|
||||
public interface ICryptFactory
|
||||
{
|
||||
public int KeySizeInBits { get; init; }
|
||||
int KeySizeInBits { get; init; }
|
||||
|
||||
public string PbePassword { init; }
|
||||
string PbePassword { init; }
|
||||
|
||||
public PbeEncryptionAlgorithm PbeEncryptionAlgorithm { get; init; }
|
||||
PbeEncryptionAlgorithm PbeEncryptionAlgorithm { get; init; }
|
||||
|
||||
public HashAlgorithmName PbeHashAlgorithmName { get; init; }
|
||||
HashAlgorithmName PbeHashAlgorithmName { get; init; }
|
||||
|
||||
public int PbeIterationCount { get; init; }
|
||||
int PbeIterationCount { get; init; }
|
||||
|
||||
public PbeParameters PbeParameters { get; }
|
||||
PbeParameters PbeParameters { get; }
|
||||
|
||||
public string EncryptedPrivateKeyPemLabel { get; init; }
|
||||
string EncryptedPrivateKeyPemLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the formatter function for generating RSA key names.
|
||||
/// This formatter takes an issuer, audience, isPrivate, and optional version and separator
|
||||
/// to produce a formatted string used for the key naming convention.
|
||||
/// </summary>
|
||||
/// <param name="issuer">A string representing the issuer of the key. It should not contain invalid file name characters or the separator.</param>
|
||||
/// <param name="audience">A string representing the audience for which the key is intended. It should not contain invalid file name characters or the separator.</param>
|
||||
/// <param name="isPrivate">An bool to check if the key is private.</param>
|
||||
/// <param name="version">An instance of the <see cref="Version?"/> interface, which is used to keep the version of Pbe password.</param>
|
||||
/// <param name="separator">An optional string separator used to separate the issuer and audience. The default is "-_-". It should not be included in the issuer or audience strings.</param>
|
||||
/// <returns>A formatted string combining the issuer, audience, and separator, which adheres to valid file naming rules.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the issuer, audience, or separator contains invalid characters or when the separator is present within the issuer or audience.</exception>
|
||||
Func<string, string, bool, Version?, string?, string> RSAKeyNameFormatter { get; }
|
||||
|
||||
string CreateRSAPrivateKeyPem(int? keySizeInBits = null);
|
||||
|
||||
@@ -26,5 +40,9 @@ namespace DigitalData.Core.Abstractions.Security
|
||||
PbeEncryptionAlgorithm? pbeEncryptionAlgorithm = null,
|
||||
HashAlgorithmName? hashAlgorithmName = null,
|
||||
int? iterationCount = null);
|
||||
|
||||
IRSADecryptor this[string key] { get; }
|
||||
|
||||
bool TryGetRSADecryptor(string key, out IRSADecryptor? decryptor);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
{
|
||||
public interface IRSADecryptor : IRSACryptographer
|
||||
{
|
||||
public string? Password { get; init; }
|
||||
(string Value, Version Version)? VersionedPassword { init; }
|
||||
|
||||
public IRSAEncryptor Encryptor { get; }
|
||||
Version? PasswordVersion { get; }
|
||||
|
||||
public byte[] Decrypt(byte[] data);
|
||||
bool HasEncryptedPem { get; }
|
||||
|
||||
public string Decrypt(string data);
|
||||
IRSAEncryptor Encryptor { get; }
|
||||
|
||||
byte[] Decrypt(byte[] data);
|
||||
|
||||
string Decrypt(string data);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,113 @@
|
||||
using DigitalData.Core.Abstractions.Client;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
|
||||
namespace DigitalData.Core.Client
|
||||
{
|
||||
public class BaseHttpClientService : IBaseHttpClientService
|
||||
{
|
||||
protected readonly HttpClient _client;
|
||||
protected readonly CookieContainer _cookies;
|
||||
protected readonly CookieContainer _cookies;
|
||||
|
||||
[StringSyntax("Uri")]
|
||||
public string Uri { get; init; }
|
||||
protected readonly string _uri;
|
||||
|
||||
public BaseHttpClientService(HttpClient client, CookieContainer cookieContainer, IOptions<HttpClientOptions> clientOptions)
|
||||
protected readonly string _path;
|
||||
|
||||
protected IEnumerable<KeyValuePair<string, object>>? _headers;
|
||||
|
||||
protected IEnumerable<KeyValuePair<string, object?>>? _queryParams;
|
||||
|
||||
internal BaseHttpClientService(HttpClient client, CookieContainer cookieContainer, IHttpClientOptions clientOptions)
|
||||
{
|
||||
_client = client;
|
||||
_cookies = cookieContainer;
|
||||
Uri = clientOptions.Value.Uri;
|
||||
_uri = clientOptions.Uri.Trim(URI_TRIM_CHARS);
|
||||
_path = clientOptions.Path?.Trim(URI_TRIM_CHARS) ?? string.Empty;
|
||||
_headers = clientOptions.Headers;
|
||||
_queryParams = clientOptions.QueryParams;
|
||||
}
|
||||
|
||||
public CookieCollection GetCookies(string route = "") => _cookies.GetCookies(uri: new Uri(Uri + route));
|
||||
public CookieCollection GetCookies(string path = "") => _cookies.GetCookies(uri: new Uri(UriCombine(_uri, path, path.Trim(URI_TRIM_CHARS))));
|
||||
|
||||
public async Task<HttpResponseMessage> FetchAsync(
|
||||
string route = "",
|
||||
string? scheme = null,
|
||||
int? port = null,
|
||||
string path = "",
|
||||
IEnumerable<KeyValuePair<string, object?>>? queryParams = null,
|
||||
HttpMethod? method = null,
|
||||
HttpContent? body = null,
|
||||
Dictionary<string, string>? form = null,
|
||||
IEnumerable<KeyValuePair<string, object>>? form = null,
|
||||
IEnumerable<KeyValuePair<string, object>>? headers = null,
|
||||
bool sendWithCookie = true,
|
||||
bool saveCookie = true
|
||||
)
|
||||
{
|
||||
// merge with default headers
|
||||
if(_headers is not null)
|
||||
{
|
||||
if (headers is null)
|
||||
headers = _headers;
|
||||
else
|
||||
{
|
||||
var mergedHeaders = headers.ToList();
|
||||
mergedHeaders.AddRange(_headers);
|
||||
headers = mergedHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
// Add default query parameters
|
||||
if(_queryParams is not null)
|
||||
{
|
||||
if (queryParams is null)
|
||||
queryParams = _queryParams;
|
||||
else
|
||||
{
|
||||
var mergedQueryParams = queryParams.ToList();
|
||||
mergedQueryParams.AddRange(_queryParams);
|
||||
queryParams = mergedQueryParams;
|
||||
}
|
||||
}
|
||||
|
||||
// set default HTTP method as GET
|
||||
method ??= HttpMethod.Get;
|
||||
|
||||
// create URL
|
||||
var requestUriStr = Uri + route;
|
||||
var requestUri = new Uri(requestUriStr);
|
||||
var uriBuilder = new UriBuilder(_uri);
|
||||
if (scheme is not null)
|
||||
uriBuilder.Scheme = scheme;
|
||||
if (port is int portInt)
|
||||
uriBuilder.Port = portInt;
|
||||
uriBuilder.Path = UriCombine(_path, path?.Trim(URI_TRIM_CHARS) ?? string.Empty);
|
||||
|
||||
var requestMessage = new HttpRequestMessage(method, requestUriStr);
|
||||
// Add query parameters if provided
|
||||
if (queryParams?.Any() ?? false)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
|
||||
var flagParams = queryParams.Where(param => param.Value is null).Select(param => param.Key);
|
||||
|
||||
var valueParams = queryParams.Where(param => param.Value is not null);
|
||||
|
||||
foreach (var param in valueParams)
|
||||
query[param.Key] = param.Value switch
|
||||
{
|
||||
bool b => b.ToString().ToLower(),
|
||||
_ => param.Value.ToString()
|
||||
};
|
||||
|
||||
if (flagParams.Any())
|
||||
uriBuilder.Query = string.Join(QUERY_SEPARATOR, query.ToString(), string.Join(QUERY_SEPARATOR, flagParams));
|
||||
else uriBuilder.Query = query.ToString();
|
||||
}
|
||||
|
||||
var requestUri = uriBuilder.Uri;
|
||||
|
||||
var requestMessage = new HttpRequestMessage(method, requestUri);
|
||||
|
||||
// Add headers if provided
|
||||
headers?.ForEach(header => requestMessage.Headers.Add(header.Key, header.Value.ToString()));
|
||||
|
||||
// Add cookie to request
|
||||
if (sendWithCookie)
|
||||
@@ -56,7 +125,7 @@ namespace DigitalData.Core.Client
|
||||
else if (body != null)
|
||||
requestMessage.Content = body;
|
||||
else if (form != null)
|
||||
requestMessage.Content = new FormUrlEncodedContent(form);
|
||||
requestMessage.Content = new FormUrlEncodedContent(form.Select(e => KeyValuePair.Create(e.Key, e.Value.ToString())));
|
||||
|
||||
var response = await _client.SendAsync(requestMessage);
|
||||
|
||||
@@ -68,5 +137,11 @@ namespace DigitalData.Core.Client
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
internal static readonly char[] URI_TRIM_CHARS = { '\\', '/', ' ' };
|
||||
|
||||
internal static string UriCombine(params string[] paths) => System.IO.Path.Combine(paths).Replace("\\", "/");
|
||||
|
||||
internal static readonly char QUERY_SEPARATOR = '&';
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,35 @@
|
||||
using DigitalData.Core.Abstractions.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net;
|
||||
|
||||
namespace DigitalData.Core.Client
|
||||
{
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static IServiceCollection AddHttpClientService(this IServiceCollection services, string uri)
|
||||
internal static IServiceCollection AddHttpClientServiceDefaults(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<HttpClient>();
|
||||
services.TryAddSingleton<CookieContainer>();
|
||||
services.AddSingleton<IBaseHttpClientService, BaseHttpClientService>();
|
||||
services.Configure<HttpClientOptions>(opt => opt.Uri = uri);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHttpClientService<TClientOptions>(this IServiceCollection services, Action<TClientOptions>? clientOptions = null, bool setAsDefaultBase = false)
|
||||
where TClientOptions : HttpClientOptions
|
||||
public static IServiceCollection AddHttpClientService<THttpClientOptions>(this IServiceCollection services, IConfigurationSection section)
|
||||
where THttpClientOptions : class, IHttpClientOptions
|
||||
{
|
||||
services.TryAddSingleton<HttpClient>();
|
||||
services.TryAddSingleton<CookieContainer>();
|
||||
services.AddSingleton<IHttpClientService<TClientOptions>, HttpClientService<TClientOptions>>();
|
||||
services.Configure(clientOptions ?? (_ => { }));
|
||||
|
||||
if (setAsDefaultBase)
|
||||
services.AddSingleton<IBaseHttpClientService, HttpClientService<TClientOptions>>();
|
||||
services.AddHttpClientServiceDefaults();
|
||||
services.TryAddSingleton<IHttpClientService<THttpClientOptions>, HttpClientService<THttpClientOptions>>();
|
||||
return services.Configure<THttpClientOptions>(section);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHttpClientService<THttpClientOptions>(this IServiceCollection services, THttpClientOptions options)
|
||||
where THttpClientOptions : class, IHttpClientOptions
|
||||
{
|
||||
services.AddHttpClientServiceDefaults();
|
||||
services.TryAddSingleton<IHttpClientService<THttpClientOptions>, HttpClientService<THttpClientOptions>>();
|
||||
services.TryAddSingleton(Options.Create(options));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net7.0;net8.0</TargetFrameworks>
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<Description>This package provides HTTP client extension methods for the DigitalData.Core library, offering simplified and asynchronous methods for fetching and handling HTTP responses. It includes utility methods for sending GET requests, reading response content as text or JSON, and deserializing JSON into dynamic or strongly-typed objects using Newtonsoft.Json. These extensions facilitate efficient and easy-to-read HTTP interactions in client applications.</Description>
|
||||
<PackageId>DigitalData.Core.Client</PackageId>
|
||||
<Version>1.0.1.1</Version>
|
||||
<Version>2.0.3</Version>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>Digital Data GmbH</Product>
|
||||
@@ -15,6 +15,8 @@
|
||||
<PackageIcon>core_icon.png</PackageIcon>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/WebCoreModules.git</RepositoryUrl>
|
||||
<PackageTags>digital data core http client json serilization</PackageTags>
|
||||
<AssemblyVersion>2.0.3</AssemblyVersion>
|
||||
<FileVersion>2.0.3</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,6 +29,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DigitalData.Core.Client
|
||||
{
|
||||
public class HttpClientOptions
|
||||
{
|
||||
public string Uri { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ using System.Net;
|
||||
namespace DigitalData.Core.Client
|
||||
{
|
||||
public class HttpClientService<TClientOptions> : BaseHttpClientService, IHttpClientService<TClientOptions>, IBaseHttpClientService
|
||||
where TClientOptions : HttpClientOptions
|
||||
where TClientOptions : class, IHttpClientOptions
|
||||
{
|
||||
public HttpClientService(HttpClient client, CookieContainer cookieContainer, IOptions<TClientOptions> clientOptions) : base(client, cookieContainer, clientOptions)
|
||||
public HttpClientService(HttpClient client, CookieContainer cookieContainer, IOptions<TClientOptions> clientOptions) : base(client, cookieContainer, clientOptions.Value)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace DigitalData.Core.Client
|
||||
|
||||
public static T Provide<T>() where T : notnull => _lazyProvider.Value.GetRequiredService<T>();
|
||||
|
||||
public static IHttpClientService<TOptions> ProvideHttpClientService<TOptions>() where TOptions : notnull
|
||||
public static IHttpClientService<TOptions> ProvideHttpClientService<TOptions>() where TOptions : IHttpClientOptions
|
||||
=> _lazyProvider.Value.GetRequiredService<IHttpClientService<TOptions>>();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DigitalData.Core.Security.Extensions
|
||||
@@ -12,10 +13,54 @@ namespace DigitalData.Core.Security.Extensions
|
||||
return rsa;
|
||||
}
|
||||
|
||||
public static bool TryGetEncryptor(this IDictionary<string, IRSAEncryptor> pairs, string issuer, string audience, out IRSAEncryptor? encryptor)
|
||||
=> pairs.TryGetValue($"{issuer}:{audience}", out encryptor);
|
||||
public static IRSADecryptor GetRSADecryptor(this ICryptFactory factory, string issuer, string audience, Version? version = null, string? seperator = null)
|
||||
=> factory[factory.RSAKeyNameFormatter(issuer, audience, true, version, seperator)];
|
||||
|
||||
public static IRSAEncryptor? GetEncryptor(this IDictionary<string, IRSAEncryptor> pairs, string issuer, string audience)
|
||||
=> pairs.TryGetEncryptor(issuer: issuer, audience: audience, out var encryptor) ? encryptor : null;
|
||||
public static bool TryGetRSADecryptor(this ICryptFactory factory, string issuer, string audience, out IRSADecryptor? decryptor, Version? version = null, string? seperator = null)
|
||||
=> factory.TryGetRSADecryptor(factory.RSAKeyNameFormatter(issuer, audience, true, version, seperator), out decryptor);
|
||||
|
||||
private static string CreatePath(string filename, string? directory = null)
|
||||
{
|
||||
directory ??= Environment.CurrentDirectory;
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
return Path.Combine(directory, $"{filename}.pem");
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
|
||||
|
||||
public static void SavePem(this IRSACryptographer decryptor, string key, string? directory = null)
|
||||
{
|
||||
var filePath = CreatePath(filename: key, directory : directory);
|
||||
var fileLock = FileLocks.GetOrAdd(filePath, _ => new (1, 1));
|
||||
fileLock.Wait();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(filePath, decryptor.Pem);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SavePemAsync(this IRSACryptographer decryptor, string key, string? directory = null)
|
||||
{
|
||||
var filePath = CreatePath(filename: key, directory: directory);
|
||||
var fileLock = FileLocks.GetOrAdd(filePath, _ => new (1, 1));
|
||||
await fileLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(filePath, decryptor.Pem);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,25 @@
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DigitalData.Core.Security
|
||||
{
|
||||
public class CryptFactory : ICryptFactory
|
||||
public class CryptFactory : RSAFactory, ICryptFactory
|
||||
{
|
||||
private static readonly Lazy<CryptFactory> LazyInstance = new (() => new ());
|
||||
private readonly IDictionary<string, IRSADecryptor> _decryptors;
|
||||
|
||||
public static CryptFactory Instance => LazyInstance.Value;
|
||||
public IRSADecryptor this[string key] { get => _decryptors[key]; set => _decryptors[key] = value; }
|
||||
|
||||
public int KeySizeInBits { get; init; } = 2048;
|
||||
public Func<string, string, bool, Version?, string?, string> RSAKeyNameFormatter { get; }
|
||||
|
||||
public string PbePassword { private get; init; } = Secrets.PBE_PASSWORD;
|
||||
|
||||
public PbeEncryptionAlgorithm PbeEncryptionAlgorithm { get; init; } = PbeEncryptionAlgorithm.Aes256Cbc;
|
||||
|
||||
public HashAlgorithmName PbeHashAlgorithmName { get; init; } = HashAlgorithmName.SHA256;
|
||||
|
||||
public int PbeIterationCount { get; init; } = 100_000;
|
||||
|
||||
private readonly Lazy<PbeParameters> _lazyPbeParameters;
|
||||
|
||||
public PbeParameters PbeParameters => _lazyPbeParameters.Value;
|
||||
|
||||
public string EncryptedPrivateKeyPemLabel { get; init; } = "ENCRYPTED PRIVATE KEY";
|
||||
|
||||
public CryptFactory(ILogger<CryptFactory>? logger = null)
|
||||
public CryptFactory(ILogger<CryptFactory> logger, IDictionary<string, IRSADecryptor> decryptors, Func<string, string, bool, Version?, string?, string> rsaKeyNameFormatter) : base()
|
||||
{
|
||||
_lazyPbeParameters = new(() => new PbeParameters(PbeEncryptionAlgorithm, PbeHashAlgorithmName, PbeIterationCount));
|
||||
_decryptors = decryptors ?? new Dictionary<string, IRSADecryptor>();
|
||||
|
||||
logger?.LogInformation("CryptFactory initialized. Core.Secrets version: {Version}, Created on: {CreationDate}.", Secrets.Version, Secrets.CreationDate.ToString("dd.MM.yyyy"));
|
||||
RSAKeyNameFormatter = rsaKeyNameFormatter;
|
||||
|
||||
logger?.LogInformation("Core.Secrets version: {Version}, Created on: {CreationDate}.", Secrets.Version, Secrets.CreationDate.ToString("dd.MM.yyyy"));
|
||||
}
|
||||
|
||||
public string CreateRSAPrivateKeyPem(int? keySizeInBits = null)
|
||||
=> RSA.Create(keySizeInBits ?? KeySizeInBits).ExportRSAPrivateKeyPem();
|
||||
|
||||
public string CreateEncryptedPrivateKeyPem(
|
||||
int? keySizeInBits = null,
|
||||
string? password = null,
|
||||
PbeEncryptionAlgorithm? pbeEncryptionAlgorithm = null,
|
||||
HashAlgorithmName? hashAlgorithmName = null,
|
||||
int? iterationCount = null)
|
||||
{
|
||||
password ??= PbePassword;
|
||||
|
||||
var pbeParameters = (pbeEncryptionAlgorithm is null && hashAlgorithmName is null && iterationCount is null)
|
||||
? new PbeParameters(
|
||||
pbeEncryptionAlgorithm ?? PbeEncryptionAlgorithm,
|
||||
hashAlgorithmName ?? PbeHashAlgorithmName,
|
||||
iterationCount ?? PbeIterationCount)
|
||||
: PbeParameters;
|
||||
|
||||
var encryptedPrivateKey = RSA.Create(keySizeInBits ?? KeySizeInBits).ExportEncryptedPkcs8PrivateKey(password.AsSpan(), pbeParameters);
|
||||
|
||||
var pemChars = PemEncoding.Write(EncryptedPrivateKeyPemLabel, encryptedPrivateKey);
|
||||
|
||||
return new string(pemChars);
|
||||
}
|
||||
public bool TryGetRSADecryptor(string key, out IRSADecryptor? decryptor) => _decryptors.TryGetValue(key, out decryptor);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace DigitalData.Core.Security
|
||||
{
|
||||
public static IServiceCollection AddSecurity(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<ICryptFactory>(_ => CryptFactory.Instance);
|
||||
services.TryAddScoped<ICryptFactory, CryptFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace DigitalData.Core.Security
|
||||
|
||||
public RSAEncryptionPadding Padding { get; init; } = RSAEncryptionPadding.OaepSHA256;
|
||||
|
||||
protected readonly RSA _rsa = RSA.Create();
|
||||
protected virtual RSA RSA { get; } = RSA.Create();
|
||||
|
||||
internal RSACryptographer() { }
|
||||
}
|
||||
|
||||
@@ -1,47 +1,58 @@
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using DigitalData.Core.Security.Extensions;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DigitalData.Core.Security
|
||||
{
|
||||
public class RSADecryptor : RSACryptographer, IRSADecryptor, IRSACryptographer
|
||||
{
|
||||
public string? Password { get; init; }
|
||||
|
||||
public bool IsEncrypted => Password is not null;
|
||||
|
||||
public IRSAEncryptor Encryptor
|
||||
public (string Value, Version Version)? VersionedPassword
|
||||
{
|
||||
get
|
||||
init
|
||||
{
|
||||
return new RSAEncryptor()
|
||||
{
|
||||
Pem = _rsa.ExportRSAPublicKeyPem(),
|
||||
Padding = Padding
|
||||
};
|
||||
_password = value?.Value;
|
||||
PasswordVersion = value?.Version;
|
||||
}
|
||||
}
|
||||
|
||||
internal RSADecryptor() { }
|
||||
private string? _password;
|
||||
|
||||
[OnDeserialized]
|
||||
private void OnDeserialized(StreamingContext context) => Init();
|
||||
public Version? PasswordVersion { get; private init; } = null;
|
||||
|
||||
private IRSADecryptor Init()
|
||||
public bool HasEncryptedPem => _password is not null;
|
||||
|
||||
public bool IsEncrypted => _password is not null;
|
||||
|
||||
private readonly Lazy<IRSAEncryptor> _lazyEncryptor;
|
||||
|
||||
public IRSAEncryptor Encryptor => _lazyEncryptor.Value;
|
||||
|
||||
private readonly Lazy<RSA> lazyRSA;
|
||||
|
||||
protected override RSA RSA => lazyRSA.Value;
|
||||
|
||||
public RSADecryptor()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Pem))
|
||||
throw new InvalidOperationException("Pem cannot be null or empty.");
|
||||
_lazyEncryptor = new(() => new RSAEncryptor()
|
||||
{
|
||||
Pem = RSA.ExportRSAPublicKeyPem(),
|
||||
Padding = Padding
|
||||
});
|
||||
|
||||
if (Password is null)
|
||||
_rsa.ImportFromPem(Pem);
|
||||
else
|
||||
_rsa.ImportFromEncryptedPem(Pem, Password.AsSpan());
|
||||
lazyRSA = new(() =>
|
||||
{
|
||||
var rsa = RSA.Create();
|
||||
if (_password is null)
|
||||
RSA.ImportFromPem(Pem);
|
||||
else
|
||||
RSA.ImportFromEncryptedPem(Pem, _password.AsSpan());
|
||||
|
||||
return this;
|
||||
return rsa;
|
||||
});
|
||||
}
|
||||
|
||||
public byte[] Decrypt(byte[] data) => RSA.Decrypt(data, Padding);
|
||||
|
||||
public byte[] Decrypt(byte[] data) => _rsa.Decrypt(data, Padding);
|
||||
|
||||
public string Decrypt(string data) => _rsa.Decrypt(data.Base64ToByte(), Padding).BytesToString();
|
||||
public string Decrypt(string data) => RSA.Decrypt(data.Base64ToByte(), Padding).BytesToString();
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,14 @@ namespace DigitalData.Core.Security
|
||||
get => base.Pem;
|
||||
init
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Pem))
|
||||
throw new InvalidOperationException("Pem cannot be null or empty.");
|
||||
|
||||
_rsa.ImportFromPem(base.Pem);
|
||||
RSA.ImportFromPem(base.Pem);
|
||||
base.Pem = value;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Encrypt(byte[] data) => _rsa.Encrypt(data, Padding);
|
||||
public byte[] Encrypt(byte[] data) => RSA.Encrypt(data, Padding);
|
||||
|
||||
public string Encrypt(string data) => _rsa.Encrypt(data.Base64ToByte(), Padding).BytesToString();
|
||||
public string Encrypt(string data) => RSA.Encrypt(data.Base64ToByte(), Padding).BytesToString();
|
||||
|
||||
public bool Verify(string data, string signature) => Encrypt(data) == signature;
|
||||
}
|
||||
|
||||
132
DigitalData.Core.Security/RSAFactory.cs
Normal file
132
DigitalData.Core.Security/RSAFactory.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DigitalData.Core.Security
|
||||
{
|
||||
public class RSAFactory
|
||||
{
|
||||
private static readonly Lazy<RSAFactory> LazyInstance = new(() => new());
|
||||
|
||||
public static RSAFactory Static => LazyInstance.Value;
|
||||
|
||||
public static readonly string DefaultEncryptedPrivateKeyFileTag = "enc-private";
|
||||
|
||||
public static readonly string DefaultPrivateKeyFileTag = "private";
|
||||
|
||||
public static readonly string DefaultPublicKeyFileTag = "public";
|
||||
|
||||
public static readonly IEnumerable<string> KeyFileTags = new string[] { DefaultEncryptedPrivateKeyFileTag, DefaultPrivateKeyFileTag, DefaultPublicKeyFileTag };
|
||||
|
||||
private static readonly Lazy<IEnumerable<string>> LazyLowerFileTags = new(() => KeyFileTags.Select(tag => tag.ToLower()));
|
||||
|
||||
public static readonly string DefaultRSAKeyNameSeparator = "-_-";
|
||||
|
||||
//TODO: make the validation using regex
|
||||
public static string DefaultRSAKeyNameFormatter(string issuer, string audience, bool isPrivate = true, Version? passwordVersion = null, string? separator = null)
|
||||
{
|
||||
separator ??= DefaultRSAKeyNameSeparator;
|
||||
|
||||
void ValidateForbidden(string value, string paramName)
|
||||
{
|
||||
if (Path.GetInvalidFileNameChars().Any(value.Contains) || LazyLowerFileTags.Value.Any(tag => value.ToLower().Contains(tag)))
|
||||
throw new ArgumentException($"RSA decryptor key name creation is forbidden. The {paramName} contains forbidden characters that are not allowed in file naming.", paramName);
|
||||
}
|
||||
|
||||
static void ValidateSeparator(string value, string paramName, string separator)
|
||||
{
|
||||
if (value.Contains(separator))
|
||||
throw new ArgumentException($"RSA decryptor key name creation is forbidden. The {paramName} contains separator characters ({separator}) that are not allowed in file naming.", paramName);
|
||||
}
|
||||
|
||||
ValidateForbidden(issuer, nameof(issuer));
|
||||
ValidateForbidden(audience, nameof(audience));
|
||||
ValidateForbidden(separator, nameof(separator));
|
||||
|
||||
ValidateSeparator(issuer, nameof(issuer), separator);
|
||||
ValidateSeparator(audience, nameof(audience), separator);
|
||||
|
||||
var sb = new StringBuilder(issuer.Length + audience.Length + separator.Length * 2 + 20);
|
||||
sb.Append(issuer).Append(separator).Append(audience).Append(separator);
|
||||
|
||||
if (passwordVersion is null && isPrivate)
|
||||
sb.Append(DefaultPrivateKeyFileTag);
|
||||
else if (isPrivate)
|
||||
sb.Append(DefaultEncryptedPrivateKeyFileTag).Append(separator).Append(passwordVersion);
|
||||
else if (passwordVersion is null)
|
||||
sb.Append(DefaultPublicKeyFileTag);
|
||||
else
|
||||
sb.Append(DefaultPublicKeyFileTag).Append(separator).Append(passwordVersion);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public int KeySizeInBits { get; init; } = 2048;
|
||||
|
||||
public string PbePassword { private get; init; } = Secrets.PBE_PASSWORD;
|
||||
|
||||
public PbeEncryptionAlgorithm PbeEncryptionAlgorithm { get; init; } = PbeEncryptionAlgorithm.Aes256Cbc;
|
||||
|
||||
public HashAlgorithmName PbeHashAlgorithmName { get; init; } = HashAlgorithmName.SHA256;
|
||||
|
||||
public int PbeIterationCount { get; init; } = 100_000;
|
||||
|
||||
private readonly Lazy<PbeParameters> _lazyPbeParameters;
|
||||
|
||||
public PbeParameters PbeParameters => _lazyPbeParameters.Value;
|
||||
|
||||
public string EncryptedPrivateKeyPemLabel { get; init; } = "ENCRYPTED PRIVATE KEY";
|
||||
|
||||
internal RSAFactory()
|
||||
{
|
||||
_lazyPbeParameters = new(() => new PbeParameters(PbeEncryptionAlgorithm, PbeHashAlgorithmName, PbeIterationCount));
|
||||
}
|
||||
|
||||
public string CreateRSAPrivateKeyPem(int? keySizeInBits = null)
|
||||
=> RSA.Create(keySizeInBits ?? KeySizeInBits).ExportRSAPrivateKeyPem();
|
||||
|
||||
public string CreateEncryptedPrivateKeyPem(
|
||||
int? keySizeInBits = null,
|
||||
string? password = null,
|
||||
PbeEncryptionAlgorithm? pbeEncryptionAlgorithm = null,
|
||||
HashAlgorithmName? hashAlgorithmName = null,
|
||||
int? iterationCount = null)
|
||||
{
|
||||
password ??= PbePassword;
|
||||
|
||||
var pbeParameters = (pbeEncryptionAlgorithm is null && hashAlgorithmName is null && iterationCount is null)
|
||||
? new PbeParameters(
|
||||
pbeEncryptionAlgorithm ?? PbeEncryptionAlgorithm,
|
||||
hashAlgorithmName ?? PbeHashAlgorithmName,
|
||||
iterationCount ?? PbeIterationCount)
|
||||
: PbeParameters;
|
||||
|
||||
var encryptedPrivateKey = RSA.Create(keySizeInBits ?? KeySizeInBits).ExportEncryptedPkcs8PrivateKey(password.AsSpan(), pbeParameters);
|
||||
|
||||
var pemChars = PemEncoding.Write(EncryptedPrivateKeyPemLabel, encryptedPrivateKey);
|
||||
|
||||
return new string(pemChars);
|
||||
}
|
||||
|
||||
public async Task<IRSADecryptor> ReadRSADecryptorAsync(string path, Version? version = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pem = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
|
||||
(string Value, Version Version)? versionedPassword = null;
|
||||
|
||||
if(version is not null)
|
||||
{
|
||||
if (version != Secrets.Version)
|
||||
throw new InvalidOperationException($"The provided version {version} does not match the expected version {Secrets.Version}.");
|
||||
|
||||
versionedPassword = (Secrets.PBE_PASSWORD, Secrets.Version);
|
||||
}
|
||||
|
||||
return new RSADecryptor()
|
||||
{
|
||||
Pem = pem,
|
||||
VersionedPassword = versionedPassword
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,22 +14,46 @@ namespace DigitalData.Core.Tests.Client
|
||||
public void SetUp()
|
||||
{
|
||||
_serviceProvider = new ServiceCollection()
|
||||
.AddHttpClientService("https://jsonplaceholder.typicode.com/todos")
|
||||
.AddHttpClientService("https://jsonplaceholder.typicode.com", "todos")
|
||||
.BuildServiceProvider();
|
||||
|
||||
_service = _serviceProvider.GetRequiredService<IBaseHttpClientService>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FetchJsonAsync_ShouldReturnJsonResponse()
|
||||
public async Task FetchJsonAsync_ShouldReturnJsonResponse_WithCorrectWithPath()
|
||||
{
|
||||
// Act
|
||||
var expectedUserId = (int) await _service.FetchAsync("/1", sendWithCookie: false, saveCookie: false)
|
||||
var expectedUserId = (int) await _service.FetchAsync(path: "/1", sendWithCookie: false, saveCookie: false)
|
||||
.ThenAsync(res => res.Json())
|
||||
.ThenAsync(todo => todo.userId);
|
||||
|
||||
// Assert
|
||||
Assert.That(expectedUserId, Is.EqualTo(1), "The userId of the fetched JSON object should be 1.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FetchJsonAsync_ShouldReturnJsonResponse_WithQueryParams()
|
||||
{
|
||||
var queryParams = new Dictionary<string, object?>
|
||||
{
|
||||
{ "id", "1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var dyn_id = await _service.FetchAsync(queryParams: queryParams, sendWithCookie: false, saveCookie: false)
|
||||
.ThenAsync(res => res.JsonList())
|
||||
.ThenAsync(todo => todo.FirstOrDefault()?.userId);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.That((int)dyn_id, Is.EqualTo(1), "The userId of the fetched JSON object should be 1.");
|
||||
}
|
||||
catch (InvalidCastException)
|
||||
{
|
||||
// Handle the case where the cast is not possible
|
||||
Assert.Fail("The id could not be cast to an integer.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.Core.Legacy.Cli
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigitalData.Core.Security", "DigitalData.Core.Security\DigitalData.Core.Security.csproj", "{47D80C65-74A2-4EB8-96A5-D571A9108FB3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.Core.Security.Extensions", "DigitalData.Core.Security.Extensions\DigitalData.Core.Security.Extensions.csproj", "{D740182D-82DA-480A-9F87-BFB4A8620A00}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigitalData.Core.Security.Extensions", "DigitalData.Core.Security.Extensions\DigitalData.Core.Security.Extensions.csproj", "{D740182D-82DA-480A-9F87-BFB4A8620A00}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.Core.Terminal", "DigitalData.Core.Terminal\DigitalData.Core.Terminal.csproj", "{0FA93730-8084-4907-B172-87D610323796}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -51,8 +53,8 @@ Global
|
||||
{0B051A5F-BD38-47D1-BAFF-D44BA30D3FB7}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{0B051A5F-BD38-47D1-BAFF-D44BA30D3FB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0B051A5F-BD38-47D1-BAFF-D44BA30D3FB7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A80FFEC-9B83-40A7-8C78-124440B48B33}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13E40DF1-6123-4838-9BF8-086C94E6ADF6}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -74,6 +76,10 @@ Global
|
||||
{D740182D-82DA-480A-9F87-BFB4A8620A00}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D740182D-82DA-480A-9F87-BFB4A8620A00}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D740182D-82DA-480A-9F87-BFB4A8620A00}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0FA93730-8084-4907-B172-87D610323796}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0FA93730-8084-4907-B172-87D610323796}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0FA93730-8084-4907-B172-87D610323796}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0FA93730-8084-4907-B172-87D610323796}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
Reference in New Issue
Block a user