Files
Deal/src/core/Deal.Api/Telegram/TelegramKeysService.cs
T
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00

168 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной (системной)
/// настройки оператора (ТЗ §4.1/§8.1).
/// </summary>
/// <remarks>
/// Источник ключей — операторский уровень: <c>public.global_settings</c> (ключ
/// <see cref="GlobalSettingsKeys.TelegramKeys"/>), единый для всех тенантов; тенант ключи не задаёт.
/// apiHash хранится зашифрованным (<c>enc:</c> — Ruling 2), apiId — открытым (не секрет). Наружу
/// (командам входа/статусу) отдаётся расшифрованный снимок, операторским ручкам — маскированный
/// (apiHash не раскрывается). Повреждённая строка/сбой расшифровки — пустые ключи (мягкая семантика).
/// Scoped: IGlobalSettingsStore живёт на системном DealDbContext запроса.
/// </remarks>
/// <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 = "…";
// Префикс зашифрованного значения (маркер формата в хранилище, Ruling 2).
private const string EncryptedPrefix = "enc:";
// Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра.
private static readonly JsonSerializerOptions KeysJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <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>
/// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <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>
/// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным.
/// </summary>
/// <param name="apiId">api_id приложения (5..9 цифр).</param>
/// <param name="apiHash">api_hash приложения (непустой секрет).</param>
/// <param name="ct">Токен отмены.</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: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1).
/// </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: непустой, без маски (…), без префикса <c>enc:</c>.
/// </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)),
};
}
}