diff --git a/.github/workflows/dev_build_test.yml b/.github/workflows/dev_build_test.yml index b83c6ed5..ac32a052 100644 --- a/.github/workflows/dev_build_test.yml +++ b/.github/workflows/dev_build_test.yml @@ -30,6 +30,8 @@ jobs: DbConnect: "${{ secrets.DBCONNECT_TEST }}" AZ_Storage_ContainerName: "${{ secrets.STORAGE_CONTAINER_NAME_TEST }}" AZ_Storage_ConnectionString: "${{ secrets.STORAGE_CONNECTION_STRING }}" + Mailcow_API_Key: "${{ secrets.MAILCOW_API_KEY }}" + Mailcow_Status_Url: "${{ secrets.MAILCOW_STATUS_URL }}" - name: Setup dotnet uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/master_build_test_publish.yml b/.github/workflows/master_build_test_publish.yml index aed5a23d..a4c44a1f 100644 --- a/.github/workflows/master_build_test_publish.yml +++ b/.github/workflows/master_build_test_publish.yml @@ -60,6 +60,8 @@ jobs: DbConnect: "${{ secrets.DBCONNECT_PROD }}" AZ_Storage_ContainerName: "${{ secrets.STORAGE_CONTAINER_NAME_PROD }}" AZ_Storage_ConnectionString: "${{ secrets.STORAGE_CONNECTION_STRING }}" + Mailcow_API_Key: "${{ secrets.MAILCOW_API_KEY }}" + Mailcow_Status_Url: "${{ secrets.MAILCOW_STATUS_URL }}" - name: Production | Build Docker (prepare) uses: azure/docker-login@v1.0.1 @@ -71,6 +73,7 @@ jobs: - name: Production | Build Docker (execute with tests) run: | docker build . \ + --build-arg "ENV_VALUE=Production" \ -t ${{ secrets.DOCKER_REGISTRY_SERVER_URL }}/${{ secrets.DOCKER_REGISTRY_SERVER_USERNAME }}:backend-production-${{ github.sha }} - name: Production | Push Docker image (prepare) diff --git a/.github/workflows/stage_build_test_publish.yml b/.github/workflows/stage_build_test_publish.yml index bead69e0..bd32c2b9 100644 --- a/.github/workflows/stage_build_test_publish.yml +++ b/.github/workflows/stage_build_test_publish.yml @@ -23,6 +23,8 @@ jobs: DbConnect: "${{ secrets.DBCONNECT_STAGE }}" AZ_Storage_ContainerName: "${{ secrets.STORAGE_CONTAINER_NAME_STAGE }}" AZ_Storage_ConnectionString: "${{ secrets.STORAGE_CONNECTION_STRING }}" + Mailcow_API_Key: "${{ secrets.MAILCOW_API_KEY }}" + Mailcow_Status_Url: "${{ secrets.MAILCOW_STATUS_URL }}" - name: Staging | Build Docker (prepare) uses: azure/docker-login@v1.0.1 @@ -32,7 +34,10 @@ jobs: password: ${{ secrets.DOCKER_REGISTRY_SERVER_PASSWORD }} - name: Staging | Build Docker (execute with tests) - run: docker build . -t ${{ secrets.DOCKER_REGISTRY_SERVER_URL }}/${{ secrets.DOCKER_REGISTRY_SERVER_USERNAME }}:backend-staging-${{ github.sha }} + run: | + docker build . \ + --build-arg "ENV_VALUE=Staging" \ + -t ${{ secrets.DOCKER_REGISTRY_SERVER_URL }}/${{ secrets.DOCKER_REGISTRY_SERVER_USERNAME }}:backend-staging-${{ github.sha }} - name: Staging | Push Docker image (prepare) uses: azure/docker-login@v1.0.1 diff --git a/EmailSender.Backend/EmailSender.Backend.Application/EmailSender.Backend.Application.csproj b/EmailSender.Backend/EmailSender.Backend.Application/EmailSender.Backend.Application.csproj index 3bc51408..8891a5c4 100644 --- a/EmailSender.Backend/EmailSender.Backend.Application/EmailSender.Backend.Application.csproj +++ b/EmailSender.Backend/EmailSender.Backend.Application/EmailSender.Backend.Application.csproj @@ -9,6 +9,7 @@ + diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQuery.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQuery.cs deleted file mode 100644 index 8ebd0825..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQuery.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using MediatR; - -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFileContentQuery : IRequest -{ - public string LogFileName { get; set; } = ""; -} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryHandler.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryHandler.cs deleted file mode 100644 index f804d58e..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using EmailSender.Backend.Core.Exceptions; -using EmailSender.Backend.Shared.Resources; -using Microsoft.AspNetCore.Mvc; - -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFileContentQueryHandler : RequestHandler -{ - public override async Task Handle(GetLogFileContentQuery request, CancellationToken cancellationToken) - { - var pathToFolder = $"{AppDomain.CurrentDomain.BaseDirectory}logs"; - if (!Directory.Exists(pathToFolder)) - throw new BusinessException(nameof(ErrorCodes.ERROR_UNEXPECTED), ErrorCodes.ERROR_UNEXPECTED); - - var fullFilePath = $"{pathToFolder}{Path.DirectorySeparatorChar}{request.LogFileName}"; - if (!File.Exists(fullFilePath)) - throw new BusinessException(nameof(ErrorCodes.FILE_NOT_FOUND), ErrorCodes.FILE_NOT_FOUND); - - var fileContent = await File.ReadAllBytesAsync(fullFilePath, cancellationToken); - return new FileContentResult(fileContent, "text/plain"); - } -} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryValidator.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryValidator.cs deleted file mode 100644 index d23dc517..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFileContentQueryValidator.cs +++ /dev/null @@ -1,18 +0,0 @@ -using EmailSender.Backend.Shared.Resources; -using FluentValidation; - -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFileContentQueryValidator : AbstractValidator -{ - public GetLogFileContentQueryValidator() - { - RuleFor(query => query.LogFileName) - .NotEmpty() - .WithErrorCode(nameof(ValidationCodes.REQUIRED)) - .WithMessage(ValidationCodes.REQUIRED) - .MaximumLength(255) - .WithErrorCode(nameof(ValidationCodes.VALUE_TOO_LONG)) - .WithMessage(ValidationCodes.VALUE_TOO_LONG); - } -} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQuery.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQuery.cs deleted file mode 100644 index dd6970ae..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using MediatR; - -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFilesListQuery : IRequest { } \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryHandler.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryHandler.cs deleted file mode 100644 index 137b5b9b..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryHandler.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFilesListQueryHandler : RequestHandler -{ - public override async Task Handle(GetLogFilesListQuery request, CancellationToken cancellationToken) - { - var pathToFolder = $"{AppDomain.CurrentDomain.BaseDirectory}logs"; - if (!Directory.Exists(pathToFolder)) - return new GetLogFilesListQueryResult(); - - var fullPathFileList = Directory.EnumerateFiles(pathToFolder, "*.txt", SearchOption.TopDirectoryOnly); - var logFiles = new List(); - - foreach (var item in fullPathFileList) - { - logFiles.Add(Path.GetFileName(item)); - } - - return await Task.FromResult(new GetLogFilesListQueryResult - { - LogFiles = logFiles - }); - } -} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryResult.cs b/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryResult.cs deleted file mode 100644 index 4974e85d..00000000 --- a/EmailSender.Backend/EmailSender.Backend.Application/Logger/GetLogFilesListQueryResult.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace EmailSender.Backend.Application.Logger; - -public class GetLogFilesListQueryResult -{ - public List LogFiles { get; set; } = new(); -} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQuery.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQuery.cs new file mode 100644 index 00000000..4492f928 --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQuery.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace EmailSender.Backend.Application.Mailcow; + +public class GetMailcowStatusQuery : IRequest { } diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryHandler.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryHandler.cs new file mode 100644 index 00000000..e85851e2 --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryHandler.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using EmailSender.Backend.Application.Mailcow.Models; +using EmailSender.Backend.Core.Utilities.LoggerService; +using EmailSender.Services.HttpClientService.Abstractions; +using EmailSender.Services.HttpClientService.Models; +using Microsoft.Extensions.Configuration; + +namespace EmailSender.Backend.Application.Mailcow; + +public class GetMailcowStatusQueryHandler : RequestHandler +{ + private readonly IHttpClientServiceFactory _httpClientServiceFactory; + + private readonly ILoggerService _loggerService; + + private readonly IConfiguration _configuration; + + private const BindingFlags Flags = BindingFlags.IgnoreCase | BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetField; + + public GetMailcowStatusQueryHandler(IHttpClientServiceFactory httpClientServiceFactory, ILoggerService loggerService, IConfiguration configuration) + { + _httpClientServiceFactory = httpClientServiceFactory; + _loggerService = loggerService; + _configuration = configuration; + } + + public override async Task Handle(GetMailcowStatusQuery request, CancellationToken cancellationToken) + { + var headers = new Dictionary + { + ["X-API-Key"] = _configuration.GetValue("Mailcow_API_Key") + }; + + var configuration = new Configuration + { + Url = _configuration.GetValue("Mailcow_Status_Url"), + Method = "GET", + Headers = headers + }; + + var client = _httpClientServiceFactory.Create(false, _loggerService); + var result = await client.Execute(configuration, cancellationToken); + + var healthyCount = 0; + var unhealthyCount = 0; + var data = new List(); + + var resultType = result.GetType(); + var resultProps = resultType.GetProperties(Flags); + + foreach (var prop in resultProps) + { + var item = prop.GetValue(result, null); + if (item is null) + continue; + + var statusItem = item as StatusItem; + data.Add(statusItem!); + + if (statusItem?.State == "running") + { + healthyCount += 1; + } + else + { + unhealthyCount += 1; + } + } + + if (healthyCount == data.Count) + { + return new GetMailcowStatusQueryResult + { + Status = StatusTypes.Healthy, + Results = data + }; + } + + if (healthyCount != unhealthyCount) + { + return new GetMailcowStatusQueryResult + { + Status = StatusTypes.Degraded, + Results = data + }; + } + + if (healthyCount == 0) + { + return new GetMailcowStatusQueryResult + { + Status = StatusTypes.Unhealthy, + Results = data + }; + } + + return new GetMailcowStatusQueryResult + { + Status = StatusTypes.Unknown, + Results = data + }; + } +} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryResult.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryResult.cs new file mode 100644 index 00000000..d10ef28c --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/GetMailcowStatusQueryResult.cs @@ -0,0 +1,10 @@ +using EmailSender.Backend.Application.Mailcow.Models; + +namespace EmailSender.Backend.Application.Mailcow; + +public class GetMailcowStatusQueryResult +{ + public StatusTypes Status { get; set; } + + public IList? Results { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/MailcowStatus.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/MailcowStatus.cs new file mode 100644 index 00000000..da1ed5f3 --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/MailcowStatus.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json; + +namespace EmailSender.Backend.Application.Mailcow.Models; + +public class MailcowStatus +{ + [JsonProperty("ipv6nat-mailcow")] + public StatusItem? Ipv6Nat { get; set; } + + [JsonProperty("watchdog-mailcow")] + public StatusItem? Watchdog { get; set; } + + [JsonProperty("acme-mailcow")] + public StatusItem? Acme { get; set; } + + [JsonProperty("ofelia-mailcow")] + public StatusItem? Ofelia { get; set; } + + [JsonProperty("rspamd-mailcow")] + public StatusItem? Rspamd { get; set; } + + [JsonProperty("nginx-mailcow")] + public StatusItem? Nginx { get; set; } + + [JsonProperty("postfix-mailcow")] + public StatusItem? Postfix { get; set; } + + [JsonProperty("dovecot-mailcow")] + public StatusItem? DoveCot { get; set; } + + [JsonProperty("php-fpm-mailcow")] + public StatusItem? PhpFpm { get; set; } + + [JsonProperty("mysql-mailcow")] + public StatusItem? MySql { get; set; } + + [JsonProperty("redis-mailcow")] + public StatusItem? Redis { get; set; } + + [JsonProperty("solr-mailcow")] + public StatusItem? Solr { get; set; } + + [JsonProperty("clamd-mailcow")] + public StatusItem? Clamd { get; set; } + + [JsonProperty("dockerapi-mailcow")] + public StatusItem? DockerApi { get; set; } + + [JsonProperty("memcached-mailcow")] + public StatusItem? MemCached { get; set; } + + [JsonProperty("sogo-mailcow")] + public StatusItem? SoGo { get; set; } + + [JsonProperty("unbound-mailcow")] + public StatusItem? Unbound { get; set; } + + [JsonProperty("netfilter-mailcow")] + public StatusItem? NetFilter { get; set; } + + [JsonProperty("olefy-mailcow")] + public StatusItem? Olefy { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusItem.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusItem.cs new file mode 100644 index 00000000..bb9b79db --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusItem.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace EmailSender.Backend.Application.Mailcow.Models; + +public class StatusItem +{ + [JsonProperty("type")] + public string? Type { get; set; } + + [JsonProperty("container")] + public string? Container { get; set; } + + [JsonProperty("state")] + public string? State { get; set; } + + [JsonProperty("started_at")] + public DateTime? StartedAt { get; set; } + + [JsonProperty("image")] + public string? Image { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusTypes.cs b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusTypes.cs new file mode 100644 index 00000000..871eaf6b --- /dev/null +++ b/EmailSender.Backend/EmailSender.Backend.Application/Mailcow/Models/StatusTypes.cs @@ -0,0 +1,9 @@ +namespace EmailSender.Backend.Application.Mailcow.Models; + +public enum StatusTypes +{ + Unknown, + Healthy, + Degraded, + Unhealthy +} \ No newline at end of file diff --git a/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.Designer.cs b/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.Designer.cs index 794717ab..8bda3f1a 100644 --- a/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.Designer.cs +++ b/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.Designer.cs @@ -134,5 +134,23 @@ public static string INSUFFICIENT_PRIVILEGES { return ResourceManager.GetString("INSUFFICIENT_PRIVILEGES", resourceCulture); } } + + public static string HTTP_REQUEST_FAILED { + get { + return ResourceManager.GetString("HTTP_REQUEST_FAILED", resourceCulture); + } + } + + public static string CANNOT_PARSE { + get { + return ResourceManager.GetString("CANNOT_PARSE", resourceCulture); + } + } + + public static string ARGUMENT_EMPTY_OR_NULL { + get { + return ResourceManager.GetString("ARGUMENT_EMPTY_OR_NULL", resourceCulture); + } + } } } diff --git a/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.resx b/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.resx index 7d756ccc..02e5fa18 100644 --- a/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.resx +++ b/EmailSender.Backend/EmailSender.Backend.Shared/Resources/ErrorCodes.resx @@ -63,4 +63,13 @@ Cannot execute action. Insufficient privileges + + HTTP request has failed + + + Cannot parse given string + + + Argument cannot be null or empty + \ No newline at end of file diff --git a/EmailSender.Configuration/appsettings.Development.json b/EmailSender.Configuration/appsettings.Development.json index 66b31293..800464f2 100644 --- a/EmailSender.Configuration/appsettings.Development.json +++ b/EmailSender.Configuration/appsettings.Development.json @@ -2,5 +2,7 @@ "AllowedHosts": "*", "DbConnect": "", "AZ_Storage_ContainerName": "", - "AZ_Storage_ConnectionString": "" + "AZ_Storage_ConnectionString": "", + "Mailcow_API_Key": "", + "Mailcow_Status_Url": "" } \ No newline at end of file diff --git a/EmailSender.Configuration/appsettings.Production.json b/EmailSender.Configuration/appsettings.Production.json index 66b31293..800464f2 100644 --- a/EmailSender.Configuration/appsettings.Production.json +++ b/EmailSender.Configuration/appsettings.Production.json @@ -2,5 +2,7 @@ "AllowedHosts": "*", "DbConnect": "", "AZ_Storage_ContainerName": "", - "AZ_Storage_ConnectionString": "" + "AZ_Storage_ConnectionString": "", + "Mailcow_API_Key": "", + "Mailcow_Status_Url": "" } \ No newline at end of file diff --git a/EmailSender.Configuration/appsettings.Staging.json b/EmailSender.Configuration/appsettings.Staging.json index 66b31293..800464f2 100644 --- a/EmailSender.Configuration/appsettings.Staging.json +++ b/EmailSender.Configuration/appsettings.Staging.json @@ -2,5 +2,7 @@ "AllowedHosts": "*", "DbConnect": "", "AZ_Storage_ContainerName": "", - "AZ_Storage_ConnectionString": "" + "AZ_Storage_ConnectionString": "", + "Mailcow_API_Key": "", + "Mailcow_Status_Url": "" } \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IAuthentication.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IAuthentication.cs new file mode 100644 index 00000000..3c829dc8 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IAuthentication.cs @@ -0,0 +1,3 @@ +namespace EmailSender.Services.HttpClientService.Abstractions; + +public interface IAuthentication { } \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientService.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientService.cs new file mode 100644 index 00000000..c693bd23 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientService.cs @@ -0,0 +1,13 @@ +using EmailSender.Services.HttpClientService.Models; +using Microsoft.AspNetCore.Http; + +namespace EmailSender.Services.HttpClientService.Abstractions; + +public interface IHttpClientService +{ + Task ProxyRequest(Configuration configuration, HttpResponse response, CancellationToken cancellationToken = default); + + Task Execute(Configuration configuration, CancellationToken cancellationToken = default); + + Task Execute(Configuration configuration, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientServiceFactory.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientServiceFactory.cs new file mode 100644 index 00000000..9f54d2af --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IHttpClientServiceFactory.cs @@ -0,0 +1,8 @@ +using EmailSender.Backend.Core.Utilities.LoggerService; + +namespace EmailSender.Services.HttpClientService.Abstractions; + +public interface IHttpClientServiceFactory +{ + IHttpClientService Create(bool allowAutoRedirect, ILoggerService loggerService); +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IPayloadContent.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IPayloadContent.cs new file mode 100644 index 00000000..efaefd99 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Abstractions/IPayloadContent.cs @@ -0,0 +1,3 @@ +namespace EmailSender.Services.HttpClientService.Abstractions; + +public interface IPayloadContent { } diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/EmailSender.Services.HttpClientService.csproj b/EmailSender.Services/EmailSender.Services.HttpClientService/EmailSender.Services.HttpClientService.csproj new file mode 100644 index 00000000..cba3877f --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/EmailSender.Services.HttpClientService.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + 10 + enable + enable + true + true + true + + + + + + + + diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientService.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientService.cs new file mode 100644 index 00000000..5493a3d7 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientService.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using EmailSender.Backend.Core.Exceptions; +using EmailSender.Backend.Core.Utilities.LoggerService; +using EmailSender.Backend.Shared.Resources; +using EmailSender.Services.HttpClientService.Abstractions; +using EmailSender.Services.HttpClientService.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace EmailSender.Services.HttpClientService; + +public class HttpClientService : IHttpClientService +{ + private const string Header = "Authorization"; + + private const string Basic = "Basic"; + + private const string Bearer = "Bearer"; + + private readonly HttpClient _httpClient; + + private readonly ILoggerService _loggerService; + + public HttpClientService(HttpClient httpClient, ILoggerService loggerService) + { + _httpClient = httpClient; + _loggerService = loggerService; + } + + public async Task ProxyRequest(Configuration configuration, HttpResponse response, CancellationToken cancellationToken = default) + { + var preparedRequest = PrepareProxyRequest(configuration); + await ExecuteRangeRequest(response, preparedRequest, cancellationToken); + } + + public async Task Execute(Configuration configuration, CancellationToken cancellationToken = default) + { + VerifyConfigurationArgument(configuration); + + var response = await GetResponse(configuration, cancellationToken); + var contentType = response.Content.Headers.ContentType; + var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Redirect) + return new ExecutionResult + { + StatusCode = response.StatusCode, + ContentType = contentType, + Content = content + }; + + var stringContent = Encoding.ASCII.GetString(content); + _loggerService.LogError($"{ErrorCodes.HTTP_REQUEST_FAILED}. Full response: {stringContent}."); + throw new BusinessException(nameof(ErrorCodes.HTTP_REQUEST_FAILED), ErrorCodes.HTTP_REQUEST_FAILED); + } + + public async Task Execute(Configuration configuration, CancellationToken cancellationToken = default) + { + VerifyConfigurationArgument(configuration); + + var response = await GetResponse(configuration, cancellationToken); + var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var stringContent = Encoding.ASCII.GetString(content); + + try + { + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Redirect) + return JsonConvert.DeserializeObject(stringContent, GetSettings())!; + } + catch (Exception exception) + { + _loggerService.LogError($"{ErrorCodes.CANNOT_PARSE}. Exception: {exception}. Full response: {stringContent}."); + throw new BusinessException(nameof(ErrorCodes.CANNOT_PARSE), ErrorCodes.CANNOT_PARSE); + } + + _loggerService.LogError($"{ErrorCodes.HTTP_REQUEST_FAILED}. Full response: {stringContent}."); + throw new BusinessException(nameof(ErrorCodes.HTTP_REQUEST_FAILED), ErrorCodes.HTTP_REQUEST_FAILED); + } + + private static HttpContent PrepareContent(IPayloadContent content) + { + switch (content) + { + case ContentDictionary contentDictionary: + return new FormUrlEncodedContent(contentDictionary.Payload); + + case ContentString contentString: + var serialized = JsonConvert.SerializeObject(contentString.Payload, GetSettings()); + return new StringContent(serialized, Encoding.Default, "application/json"); + + default: + throw new BusinessException("Unsupported content type."); + } + } + + private async Task GetResponse(Configuration configuration, CancellationToken cancellationToken = default) + { + var requestUri = configuration.Url; + if (configuration.QueryParameters is not null && configuration.QueryParameters.Any()) + requestUri = QueryHelpers.AddQueryString(configuration.Url, configuration.QueryParameters); + + using var request = new HttpRequestMessage(new HttpMethod(configuration.Method), requestUri); + + if (configuration.Headers != null) + { + foreach (var (name, value) in configuration.Headers) + { + request.Headers.TryAddWithoutValidation(name, value); + } + } + + if (configuration.PayloadContent is not null) + request.Content = PrepareContent(configuration.PayloadContent); + + if (configuration.Authentication != null) + ApplyAuthentication(request, configuration.Authentication); + + return await _httpClient.SendAsync(request, cancellationToken); + } + + private static JsonSerializerSettings GetSettings() + { + return new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + NullValueHandling = NullValueHandling.Ignore + }; + } + + private static void ApplyAuthentication(HttpRequestMessage request, IAuthentication authentication) + { + switch (authentication) + { + case BasicAuthentication basicAuthentication: + var basic = SetAuthentication(basicAuthentication.Login, basicAuthentication.Password); + request.Headers.TryAddWithoutValidation(Header, basic); + break; + + case BearerAuthentication bearerAuthentication: + var bearer = SetAuthentication(bearerAuthentication.Token); + request.Headers.TryAddWithoutValidation(Header, bearer); + break; + } + } + + private static string SetAuthentication(string login, string password) + { + if (string.IsNullOrEmpty(login)) + { + const string message = $"Argument '{nameof(login)}' cannot be null or empty."; + throw new BusinessException(nameof(ErrorCodes.ARGUMENT_EMPTY_OR_NULL),message); + } + + var base64 = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{login}:{password}")); + return $"{Basic} {base64}"; + } + + private static string SetAuthentication(string token) + { + if (!string.IsNullOrEmpty(token)) return $"{Bearer} {token}"; + + const string message = $"Argument '{nameof(token)}' cannot be null or empty."; + throw new BusinessException(nameof(ErrorCodes.ARGUMENT_EMPTY_OR_NULL), message); + } + + private static void VerifyConfigurationArgument(Configuration configuration) + { + string message; + + if (string.IsNullOrEmpty(configuration.Method)) + { + message = $"Argument '{nameof(configuration.Method)}' cannot be null or empty."; + throw new BusinessException(nameof(ErrorCodes.ARGUMENT_EMPTY_OR_NULL), message); + } + + if (!string.IsNullOrEmpty(configuration.Url)) return; + + message = $"Argument '{nameof(configuration.Url)}' cannot be null or empty."; + throw new BusinessException(nameof(ErrorCodes.ARGUMENT_EMPTY_OR_NULL), message); + } + + private static HttpRequestMessage PrepareProxyRequest(Configuration configuration) + { + var url = configuration.Url; + if (configuration.QueryParameters is not null && configuration.QueryParameters.Any()) + url = QueryHelpers.AddQueryString(url, configuration.QueryParameters); + + var request = new HttpRequestMessage + { + Method = new HttpMethod(configuration.Method), + RequestUri = new Uri(url, UriKind.RelativeOrAbsolute), + }; + + if (configuration.Range is not null && configuration.Range.Value.Count != 0) + request.Headers.Range = RangeHeaderValue.Parse(configuration.Range); + + if (configuration.Authentication != null) + ApplyAuthentication(request, configuration.Authentication); + + return request; + } + + private async Task ExecuteRangeRequest(HttpResponse response, HttpRequestMessage request, CancellationToken cancellationToken = default) + { + var origin = await _httpClient.SendAsync(request, cancellationToken); + response.StatusCode = (int)origin.StatusCode; + + foreach (var header in origin.Headers) + { + response.Headers[header.Key] = header.Value.ToArray(); + } + + foreach (var header in origin.Content.Headers) + { + response.Headers[header.Key] = header.Value.ToArray(); + } + + response.Headers.Remove("server"); + response.Headers.Remove("transfer-encoding"); + + await origin.Content.CopyToAsync(response.Body, cancellationToken); + } +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientServiceFactory.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientServiceFactory.cs new file mode 100644 index 00000000..66a75b0f --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/HttpClientServiceFactory.cs @@ -0,0 +1,14 @@ +using EmailSender.Backend.Core.Utilities.LoggerService; +using EmailSender.Services.HttpClientService.Abstractions; + +namespace EmailSender.Services.HttpClientService; + +public class HttpClientServiceFactory : IHttpClientServiceFactory +{ + public IHttpClientService Create(bool allowAutoRedirect, ILoggerService loggerService) + { + var handler = new HttpClientHandler { AllowAutoRedirect = allowAutoRedirect }; + var client = new HttpClient(handler); + return new HttpClientService(client, loggerService); + } +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BasicAuthentication.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BasicAuthentication.cs new file mode 100644 index 00000000..15bbb566 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BasicAuthentication.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +using EmailSender.Services.HttpClientService.Abstractions; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class BasicAuthentication : IAuthentication +{ + public string Login { get; set; } = ""; + + public string Password { get; set; } = ""; +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BearerAuthentication.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BearerAuthentication.cs new file mode 100644 index 00000000..e54a95b7 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/BearerAuthentication.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using EmailSender.Services.HttpClientService.Abstractions; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class BearerAuthentication : IAuthentication +{ + public string Token { get; set; } = ""; +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/Configuration.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/Configuration.cs new file mode 100644 index 00000000..0f5e508b --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/Configuration.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using EmailSender.Services.HttpClientService.Abstractions; +using Microsoft.Extensions.Primitives; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class Configuration +{ + public string Url { get; set; } = ""; + + public string Method { get; set; } = ""; + + public StringValues? Range { get; set; } + + public IDictionary? Headers { get; set; } + + public IDictionary? QueryParameters { get; set; } + + public IAuthentication? Authentication { get; set; } + + public IPayloadContent? PayloadContent { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentDictionary.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentDictionary.cs new file mode 100644 index 00000000..039b4920 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentDictionary.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using EmailSender.Services.HttpClientService.Abstractions; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class ContentDictionary : IPayloadContent +{ + public IDictionary Payload { get; set; } = new Dictionary(); +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentString.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentString.cs new file mode 100644 index 00000000..b8682a0c --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ContentString.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using EmailSender.Services.HttpClientService.Abstractions; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class ContentString : IPayloadContent +{ + public object? Payload { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ExecutionResult.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ExecutionResult.cs new file mode 100644 index 00000000..965a2a5a --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/ExecutionResult.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class ExecutionResult : HttpContentResult +{ + public HttpStatusCode StatusCode { get; set; } +} \ No newline at end of file diff --git a/EmailSender.Services/EmailSender.Services.HttpClientService/Models/HttpContentResult.cs b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/HttpContentResult.cs new file mode 100644 index 00000000..80c8a789 --- /dev/null +++ b/EmailSender.Services/EmailSender.Services.HttpClientService/Models/HttpContentResult.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; + +namespace EmailSender.Services.HttpClientService.Models; + +[ExcludeFromCodeCoverage] +public class HttpContentResult +{ + public MediaTypeHeaderValue? ContentType { get; set; } + + public byte[]? Content { get; set; } +} \ No newline at end of file diff --git a/EmailSender.WebApi/Configuration/Dependencies.cs b/EmailSender.WebApi/Configuration/Dependencies.cs index f6f343a2..d4a31615 100644 --- a/EmailSender.WebApi/Configuration/Dependencies.cs +++ b/EmailSender.WebApi/Configuration/Dependencies.cs @@ -8,6 +8,8 @@ using EmailSender.Persistence.Database; using EmailSender.Persistence.Database.Initializer; using EmailSender.Services.BehaviourService; +using EmailSender.Services.HttpClientService; +using EmailSender.Services.HttpClientService.Abstractions; using EmailSender.Services.SenderService; using EmailSender.Services.SmtpService; using EmailSender.Services.UserService; @@ -53,8 +55,8 @@ private static void SetupDatabase(IServiceCollection services, IConfiguration co private static void SetupServices(IServiceCollection services) { services.AddHttpContextAccessor(); + services.AddSingleton(_ => new HttpClientServiceFactory()); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/EmailSender.WebApi/Controllers/LoggerController.cs b/EmailSender.WebApi/Controllers/LoggerController.cs deleted file mode 100644 index 76d17005..00000000 --- a/EmailSender.WebApi/Controllers/LoggerController.cs +++ /dev/null @@ -1,24 +0,0 @@ -using EmailSender.Backend.Application.Logger; -using EmailSender.Backend.Shared.Attributes; -using Microsoft.AspNetCore.Mvc; -using MediatR; - -namespace EmailSender.WebApi.Controllers; - -[ApiVersion("1.0")] -public class LoggerController : BaseController -{ - public LoggerController(IMediator mediator) : base(mediator) { } - - [HttpGet] - [RequireAdministrator] - [ProducesResponseType(typeof(GetLogFilesListQueryResult), StatusCodes.Status200OK)] - public async Task GetLogFilesList([FromHeader(Name = HeaderName)] string privateKey) - => await Mediator.Send(new GetLogFilesListQuery()); - - [HttpGet("{fileName}")] - [RequireAdministrator] - [ProducesResponseType(typeof(IActionResult), StatusCodes.Status200OK)] - public async Task GetLogFileContent([FromRoute] string fileName, [FromHeader(Name = HeaderName)] string privateKey) - => await Mediator.Send(new GetLogFileContentQuery { LogFileName = fileName }); -} \ No newline at end of file diff --git a/EmailSender.WebApi/Controllers/MailcowController.cs b/EmailSender.WebApi/Controllers/MailcowController.cs new file mode 100644 index 00000000..50c0a8d3 --- /dev/null +++ b/EmailSender.WebApi/Controllers/MailcowController.cs @@ -0,0 +1,18 @@ +using EmailSender.Backend.Application.Mailcow; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EmailSender.WebApi.Controllers; + +[ApiVersion("1.0")] +public class MailcowController : BaseController +{ + public MailcowController(IMediator mediator) : base(mediator) { } + + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(GetMailcowStatusQueryResult), StatusCodes.Status200OK)] + public async Task GetStatus() + => await Mediator.Send(new GetMailcowStatusQuery()); +} \ No newline at end of file diff --git a/EmailSender.WebApi/EmailSender.WebApi.csproj b/EmailSender.WebApi/EmailSender.WebApi.csproj index 1edef5a9..806e2fc6 100644 --- a/EmailSender.WebApi/EmailSender.WebApi.csproj +++ b/EmailSender.WebApi/EmailSender.WebApi.csproj @@ -28,6 +28,7 @@ + diff --git a/EmailSender.sln b/EmailSender.sln index 05f6d0a9..a1a06ab2 100644 --- a/EmailSender.sln +++ b/EmailSender.sln @@ -63,6 +63,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "4. Docker", "4. Docker", "{ dockerfile = dockerfile EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmailSender.Services.HttpClientService", "EmailSender.Services\EmailSender.Services.HttpClientService\EmailSender.Services.HttpClientService.csproj", "{4C4140B2-25EF-4E97-A767-46CAE4638853}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -121,6 +123,10 @@ Global {FE78528E-BAF5-4544-8CF9-F8C9C6B05F92}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE78528E-BAF5-4544-8CF9-F8C9C6B05F92}.Release|Any CPU.ActiveCfg = Release|Any CPU {FE78528E-BAF5-4544-8CF9-F8C9C6B05F92}.Release|Any CPU.Build.0 = Release|Any CPU + {4C4140B2-25EF-4E97-A767-46CAE4638853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C4140B2-25EF-4E97-A767-46CAE4638853}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C4140B2-25EF-4E97-A767-46CAE4638853}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C4140B2-25EF-4E97-A767-46CAE4638853}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {3891C8E1-909A-4D60-874B-1A149D8A3605} = {C2719A80-86A0-4E87-A498-3EEDD5C9179E} @@ -136,5 +142,6 @@ Global {ED5DD42A-5B36-4815-B817-9FAF068AEA54} = {21288831-4321-4486-94E9-F845B0686DEA} {FE78528E-BAF5-4544-8CF9-F8C9C6B05F92} = {C2719A80-86A0-4E87-A498-3EEDD5C9179E} {5E005153-349C-4653-B1F6-D6EBEFEFFB3A} = {14D3E1FE-C526-4670-BED4-5DED8546EA83} + {4C4140B2-25EF-4E97-A767-46CAE4638853} = {21288831-4321-4486-94E9-F845B0686DEA} EndGlobalSection EndGlobal diff --git a/__testrun.sh b/__testrun.sh index 594d3258..69f9038c 100644 --- a/__testrun.sh +++ b/__testrun.sh @@ -1,3 +1,5 @@ -APP_NAME="emailsender-webapi" -docker build . -t "$APP_NAME" -docker run --rm -it -p 5008:80 "$APP_NAME" \ No newline at end of file +ENV_VALUE="Development" +APP_NAME="emailsender-test" + +docker build . --build-arg "ENV_VALUE=$ENV_VALUE" -t "$APP_NAME" +docker run --rm -it -p 7008:80 "$APP_NAME" diff --git a/dockerfile b/dockerfile index 29c3a2b9..4325004d 100644 --- a/dockerfile +++ b/dockerfile @@ -30,6 +30,7 @@ COPY --from=PROJECTS "/app/EmailSender.Persistence/EmailSender.Persistence.Datab # SERVICES COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.BehaviourService/bin/Release/net6.0" . +COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.HttpClientService/bin/Release/net6.0" . COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.SenderService/bin/Release/net6.0" . COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.SmtpService/bin/Release/net6.0" . COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.UserService/bin/Release/net6.0" . @@ -38,6 +39,10 @@ COPY --from=PROJECTS "/app/EmailSender.Services/EmailSender.Services.UserService COPY --from=PROJECTS "/app/EmailSender.WebApi/bin/Release/net6.0" . COPY --from=PROJECTS "/app/EmailSender.WebApi.Dto/bin/Release/net6.0" . +# CONFIGURATION +ARG ENV_VALUE +ENV ASPNETCORE_ENVIRONMENT=${ENV_VALUE} ENV ASPNETCORE_URLS=http://+:80 + EXPOSE 80 ENTRYPOINT ["dotnet", "EmailSender.WebApi.dll"]