SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Api.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище глобальных настроек оператора (таблица public.global_settings).</param>
|
||||
/// <param name="cipher">Шифр секретов (AES-256-GCM, формат <c>enc:</c>).</param>
|
||||
public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCipher cipher)
|
||||
{
|
||||
/// <summary>
|
||||
/// Минимальная длина api_id приложения Telegram
|
||||
/// </summary>
|
||||
public const int ApiIdMinDigits = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Максимальная длина api_id приложения Telegram
|
||||
/// </summary>
|
||||
public const int ApiIdMaxDigits = 9;
|
||||
|
||||
// Символ-заполнитель маски секрета (U+2026, «1234…5678»).
|
||||
private const string MaskEllipsis = "…";
|
||||
|
||||
private const string EncryptedPrefix = "enc:";
|
||||
|
||||
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
|
||||
private static readonly JsonSerializerOptions KeysJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Читает текущие ключи приложения
|
||||
/// </summary>
|
||||
/// <returns>Снимок ключей (пустые — настройка не задана/повреждена).</returns>
|
||||
public async Task<TgKeysSnapshot> GetAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, ct).ConfigureAwait(false);
|
||||
if (row is null)
|
||||
{
|
||||
return new TgKeysSnapshot(string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
JsonElement root = document.RootElement;
|
||||
string apiId = ReadString(root, "apiId");
|
||||
string apiHash = ReadString(root, "apiHash");
|
||||
return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка telegramKeys — пустые ключи (мягкая семантика, как SettingsService).
|
||||
return new TgKeysSnapshot(string.Empty, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Маскированная форма ключей для операторской ручки
|
||||
/// </summary>
|
||||
/// <returns>DTO с флагом keysSet (оба ключа заданы) и маской apiHash.</returns>
|
||||
public async Task<TelegramKeysMaskedDto> GetMaskedAsync(CancellationToken ct)
|
||||
{
|
||||
TgKeysSnapshot snapshot = await GetAsync(ct).ConfigureAwait(false);
|
||||
return new TelegramKeysMaskedDto(
|
||||
ApiId: snapshot.ApiId,
|
||||
ApiHash: MaskSecret(snapshot.ApiHash),
|
||||
KeysSet: snapshot.KeysSet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет глобальные ключи приложения
|
||||
/// </summary>
|
||||
/// <param name="apiId">api_id приложения (5..9 цифр).</param>
|
||||
/// <param name="apiHash">api_hash приложения (непустой секрет).</param>
|
||||
/// <exception cref="ArgumentException">Значения не прошли валидацию (см. <see cref="IsValidApiId"/>/<see cref="IsValidApiHash"/>).</exception>
|
||||
public async Task SaveAsync(
|
||||
string apiId,
|
||||
string apiHash,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string trimmedApiId = (apiId ?? string.Empty).Trim();
|
||||
string trimmedApiHash = (apiHash ?? string.Empty).Trim();
|
||||
if (!IsValidApiId(trimmedApiId))
|
||||
{
|
||||
throw new ArgumentException("api_id должен состоять из 5–9 цифр.", nameof(apiId));
|
||||
}
|
||||
|
||||
if (!IsValidApiHash(trimmedApiHash))
|
||||
{
|
||||
throw new ArgumentException("api_hash непустой, без маски и без префикса enc:.", nameof(apiHash));
|
||||
}
|
||||
|
||||
var value = new TelegramKeysValue(trimmedApiId, cipher.Encrypt(trimmedApiHash));
|
||||
string valueJson = JsonSerializer.Serialize(value, KeysJsonOptions);
|
||||
await store.SetAsync(GlobalSettingsKeys.TelegramKeys, valueJson, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Валиден ли api_id
|
||||
/// </summary>
|
||||
/// <param name="apiId">Проверяемое значение (уже без пробелов).</param>
|
||||
/// <returns>True — значение допустимо.</returns>
|
||||
public static bool IsValidApiId(string apiId) =>
|
||||
apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit);
|
||||
|
||||
/// <summary>
|
||||
/// Валиден ли api_hash
|
||||
/// </summary>
|
||||
/// <param name="apiHash">Проверяемое значение (уже без пробелов).</param>
|
||||
/// <returns>True — значение допустимо к шифрованию и сохранению.</returns>
|
||||
public static bool IsValidApiHash(string apiHash) =>
|
||||
apiHash.Length > 0
|
||||
&& !apiHash.Contains(MaskEllipsis, StringComparison.Ordinal)
|
||||
&& !apiHash.StartsWith(EncryptedPrefix, StringComparison.Ordinal);
|
||||
|
||||
// Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке).
|
||||
// root: Корень JSON значения telegramKeys.
|
||||
// propertyName: Имя поля (camelCase).
|
||||
// Возвращает: Значение поля (trim) или пустая строка.
|
||||
private static string ReadString(JsonElement root, string propertyName)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty(propertyName, out JsonElement element)
|
||||
|| element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return (element.GetString() ?? string.Empty).Trim();
|
||||
}
|
||||
|
||||
// Маска СЕКРЕТА (apiHash): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678».
|
||||
// value: Открытый секрет (не null).
|
||||
// Возвращает: Маскированная строка.
|
||||
private static string MaskSecret(string value)
|
||||
{
|
||||
return value.Length switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
<= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis),
|
||||
_ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user