Вынести условия-предикаты в extension-методы

HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser;
15 приватных предикатов заменены extension-методами с удалением
дублирующих приватных методов: IsCommunicationFailure,
IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue,
IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget,
HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint,
IsTypeLabel.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:08:47 +03:00
parent 9e07568ddd
commit 413eaac48c
40 changed files with 1091 additions and 993 deletions
@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
@@ -128,7 +127,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
if (IsPrivateEndpoint(modelsUri))
if (modelsUri.IsPrivateEndpoint())
{
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
}
@@ -247,50 +246,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
}
// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
// (полный egress-контроль с резолвом выполняется на сетевом периметре).
// uri: Абсолютный http(s)-адрес.
// Возвращает: True — адрес приватный/локальный (HTTP к нему запрещён).
private static bool IsPrivateEndpoint(Uri uri)
{
string host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!IPAddress.TryParse(host, out IPAddress? address))
{
return false; // DNS-имя — резолв вне этого слоя
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata)
|| bytes[0] == 0;
}
// IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10).
byte[] v6 = address.GetAddressBytes();
return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
}
/// <param name="exception">Исключение HTTP-слоя.</param>
/// <returns>Человекочитаемый текст причины.</returns>
// Исключение HTTP-слоя.
// Возвращает: Человекочитаемый текст причины.
private static string ConnectionErrorDetail(HttpRequestException exception)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
@@ -0,0 +1,17 @@
using Grpc.Core;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
/// </summary>
internal static class RpcExceptionExtensions
{
/// <summary>
/// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
/// </summary>
/// <param name="exception">Исключение RPC.</param>
/// <returns>True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона).</returns>
public static bool IsCommunicationFailure(this RpcException exception) =>
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
}
@@ -58,7 +58,7 @@ public sealed class ServiceHealthProbe
Reachable: true,
Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving);
}
catch (RpcException exception) when (IsCommunicationFailure(exception))
catch (RpcException exception) when (exception.IsCommunicationFailure())
{
return ServiceHealthResult.Unreachable;
}
@@ -73,10 +73,4 @@ public sealed class ServiceHealthProbe
return ServiceHealthResult.Unreachable;
}
}
// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
// exception: Исключение RPC.
// Возвращает: True — транспорта/контракта health нет (down), false — прикладной статус (не наша зона).
private static bool IsCommunicationFailure(RpcException exception) =>
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
}
@@ -64,7 +64,7 @@ public static class FileStorageRegistrar
// MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
if (MinioConfigured(options.Minio))
if (options.Minio.IsConfigured())
{
services.AddSingleton<IFileStorage>(serviceProvider =>
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
@@ -113,20 +113,12 @@ public static class FileStorageRegistrar
if (secureRaw is not null)
{
options.Minio.Secure = IsTrue(secureRaw);
options.Minio.Secure = secureRaw.IsTrue();
}
return options;
}
// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
private static bool MinioConfigured(MinioStorageOptions minio)
{
return !string.IsNullOrWhiteSpace(minio.Endpoint)
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
}
// Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть.
private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath)
{
@@ -172,8 +164,4 @@ public static class FileStorageRegistrar
};
}
private static bool IsTrue(string raw)
{
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
}
}
@@ -0,0 +1,19 @@
namespace Deal.Infrastructure.Integrations.Storage;
/// <summary>
/// Расширения <see cref="MinioStorageOptions"/> (выбор MinIO-адаптера по заполненности секции).
/// </summary>
internal static class MinioStorageOptionsExtensions
{
/// <summary>
/// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
/// </summary>
/// <param name="minio">Настройки MinIO из секции Storage:Minio.</param>
/// <returns>True — заданы Endpoint, AccessKey и SecretKey.</returns>
public static bool IsConfigured(this MinioStorageOptions minio)
{
return !string.IsNullOrWhiteSpace(minio.Endpoint)
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
}
}
@@ -0,0 +1,17 @@
namespace Deal.Infrastructure.Integrations.Storage;
/// <summary>
/// Расширения <see cref="string"/> для разбора конфигурационных значений.
/// </summary>
internal static class StringExtensions
{
/// <summary>
/// Разбирает строковое значение как булев флаг конфигурации: «true» (без учёта регистра) или «1».
/// </summary>
/// <param name="raw">Сырое значение настройки.</param>
/// <returns>True — значение распознано как включённое.</returns>
public static bool IsTrue(this string raw)
{
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
}
}
@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations;
/// <summary>
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций (проверка приватности адреса).
/// </summary>
internal static class UriExtensions
{
/// <summary>
/// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
/// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
/// (полный egress-контроль с резолвом выполняется на сетевом периметре).
/// </summary>
/// <param name="uri">Абсолютный http(s)-адрес.</param>
/// <returns>True — адрес приватный/локальный (HTTP к нему запрещён).</returns>
public static bool IsPrivateEndpoint(this Uri uri)
{
string host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!IPAddress.TryParse(host, out IPAddress? address))
{
return false; // DNS-имя — резолв вне этого слоя
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata)
|| bytes[0] == 0;
}
// IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10).
byte[] v6 = address.GetAddressBytes();
return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
}
}