using System.Security.Claims; using foodmarket.Application.Common.Email; using foodmarket.Domain.Organizations; using foodmarket.Domain.Platform; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.SuperAdmin; /// SuperAdmin: единая платформенная настройка SMTP. GET отдаёт всё /// КРОМЕ пароля (только has-password флаг). PUT принимает все поля + опционально /// новый пароль. Пароль шифруется через DataProtection (purpose="foodmarket.smtp") /// перед записью в БД. Все мутации пишутся в SuperAdminAuditLog с reason. [ApiController] [Authorize(Roles = "SuperAdmin")] [Route("api/super-admin/platform-settings")] public class PlatformSettingsController : ControllerBase { private readonly AppDbContext _db; private readonly IDataProtectionProvider _dpProvider; private readonly IEmailSender _email; public PlatformSettingsController(AppDbContext db, IDataProtectionProvider dpProvider, IEmailSender email) { _db = db; _dpProvider = dpProvider; _email = email; } public record PlatformSettingsDto( string? SmtpHost, int? SmtpPort, bool SmtpUseSsl, bool SmtpStartTls, string? SmtpUsername, bool HasSmtpPassword, string? FromEmail, string? FromName, DateTime? UpdatedAt); public record PlatformSettingsInput( string Reason, string? SmtpHost, int? SmtpPort, bool SmtpUseSsl, bool SmtpStartTls, string? SmtpUsername, // Если null/пусто — пароль не меняется. Если задан — шифруется и // записывается. Чтобы СНЯТЬ пароль (отправлять без auth), используем // спец-значение "__clear__". string? NewSmtpPassword, string? FromEmail, string? FromName); public record TestSendInput(string ToEmail, string Subject, string Body); [HttpGet] public async Task> Get(CancellationToken ct) { var s = await GetOrCreateAsync(ct); return new PlatformSettingsDto( s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls, s.SmtpUsername, !string.IsNullOrEmpty(s.SmtpPasswordEncrypted), s.FromEmail, s.FromName, s.UpdatedAt); } [HttpPut] public async Task Update([FromBody] PlatformSettingsInput input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10) return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." }); var s = await GetOrCreateAsync(ct); var prev = new { s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls, s.SmtpUsername, s.FromEmail, s.FromName, HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted), }; s.SmtpHost = input.SmtpHost?.Trim(); s.SmtpPort = input.SmtpPort; s.SmtpUseSsl = input.SmtpUseSsl; s.SmtpStartTls = input.SmtpStartTls; s.SmtpUsername = input.SmtpUsername?.Trim(); s.FromEmail = input.FromEmail?.Trim(); s.FromName = input.FromName?.Trim(); s.UpdatedAt = DateTime.UtcNow; if (input.NewSmtpPassword == "__clear__") { s.SmtpPasswordEncrypted = null; } else if (!string.IsNullOrEmpty(input.NewSmtpPassword)) { var protector = _dpProvider.CreateProtector("foodmarket.smtp"); s.SmtpPasswordEncrypted = protector.Protect(input.NewSmtpPassword); } await _db.SaveChangesAsync(ct); await LogAsync("PlatformSettingsUpdate", $"Обновлены SMTP-настройки платформы (host={s.SmtpHost} from={s.FromEmail})", input.Reason, System.Text.Json.JsonSerializer.Serialize(new { prev, next = new { s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls, s.SmtpUsername, s.FromEmail, s.FromName, HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted), } }), ct); return NoContent(); } [HttpPost("test-send")] public async Task TestSend([FromBody] TestSendInput input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input.ToEmail)) return BadRequest(new { error = "Адрес получателя обязателен." }); try { await _email.SendAsync(input.ToEmail.Trim(), string.IsNullOrWhiteSpace(input.Subject) ? "ALL MARKET — тестовое сообщение" : input.Subject, string.IsNullOrWhiteSpace(input.Body) ? "Тестовое письмо от ALL MARKET." : input.Body, ct); return Ok(new { ok = true, sentTo = input.ToEmail }); } catch (EmailNotConfiguredException ex) { return BadRequest(new { ok = false, error = ex.Message }); } catch (Exception ex) { // Полный текст ошибки SMTP пишем в response — это SuperAdmin-only, // diagnostic-info ему нужна. return StatusCode(StatusCodes.Status500InternalServerError, new { ok = false, error = ex.Message }); } } private async Task GetOrCreateAsync(CancellationToken ct) { var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct); if (s is null) { s = new PlatformSettings(); _db.PlatformSettings.Add(s); await _db.SaveChangesAsync(ct); } return s; } private async Task LogAsync(string actionType, string description, string? reason, string changesJson, CancellationToken ct) { var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); Guid.TryParse(userIdRaw, out var uid); _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog { SuperAdminUserId = uid, ActionType = actionType, OrganizationId = null, Description = description, Reason = reason, ChangesJson = changesJson, IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "", }); await _db.SaveChangesAsync(ct); } }