using System.Text.Json; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Api.Services; using Deal.Api.Dtos; namespace Deal.Api.Telegram; /// /// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной (системной) /// настройки оператора (ТЗ §4.1/§8.1). /// /// /// Источник ключей — операторский уровень: public.global_settings (ключ /// ), единый для всех тенантов; тенант ключи не задаёт. /// apiHash хранится зашифрованным (enc: — Ruling 2), apiId — открытым (не секрет). Наружу /// (командам входа/статусу) отдаётся расшифрованный снимок, операторским ручкам — маскированный /// (apiHash не раскрывается). Повреждённая строка/сбой расшифровки — пустые ключи (мягкая семантика). /// Scoped: IGlobalSettingsStore живёт на системном DealDbContext запроса. /// /// KV-хранилище глобальных настроек оператора (таблица public.global_settings). /// Шифр секретов (AES-256-GCM, формат enc:). public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCipher cipher) { /// /// Минимальная длина api_id приложения Telegram (только цифры). /// public const int ApiIdMinDigits = 5; /// /// Максимальная длина api_id приложения Telegram (только цифры). /// public const int ApiIdMaxDigits = 9; // Символ-заполнитель маски секрета (U+2026, «1234…5678»). private const string MaskEllipsis = "…"; // Префикс зашифрованного значения (маркер формата в хранилище, Ruling 2). private const string EncryptedPrefix = "enc:"; // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. private static readonly JsonSerializerOptions KeysJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; /// /// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash. /// /// Токен отмены. /// Снимок ключей (пустые — настройка не задана/повреждена). public async Task 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); } } /// /// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска. /// /// Токен отмены. /// DTO с флагом keysSet (оба ключа заданы) и маской apiHash. public async Task GetMaskedAsync(CancellationToken ct) { TgKeysSnapshot snapshot = await GetAsync(ct).ConfigureAwait(false); return new TelegramKeysMaskedDto( ApiId: snapshot.ApiId, ApiHash: MaskSecret(snapshot.ApiHash), KeysSet: snapshot.KeysSet); } /// /// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным. /// /// api_id приложения (5..9 цифр). /// api_hash приложения (непустой секрет). /// Токен отмены. /// Значения не прошли валидацию (см. /). 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); } /// /// Валиден ли api_id: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1). /// /// Проверяемое значение (уже без пробелов). /// True — значение допустимо. public static bool IsValidApiId(string apiId) => apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); /// /// Валиден ли api_hash: непустой, без маски (…), без префикса enc:. /// /// Проверяемое значение (уже без пробелов). /// True — значение допустимо к шифрованию и сохранению. 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)), }; } }