From b62cfd46c1a7c24b769b4f6a87e55401297af032 Mon Sep 17 00:00:00 2001 From: Pedro Date: Thu, 21 May 2026 12:07:51 +0200 Subject: [PATCH] denuncias --- .codex-links/WebIntranet/Web.config | 11 +- .../Controllers/AuthController.cs | 241 +++++++++++++- .../Controllers/InboxController.cs | 34 ++ Antifraude.Net/ApiDenuncias/Program.cs | 1 + .../GestionaDocumentWorkflowService.cs | 203 +----------- .../ApiDenuncias/Services/GestionaService.cs | 142 ++++---- .../Services/GlobalLeaksClient.cs | 292 +++++++++++++++- .../Services/PendingGlobalLeaksLoginStore.cs | 71 ++++ Antifraude.Net/ApiDenuncias/appsettings.json | 4 +- .../Models/LoginRequest.cs | 9 + .../Models/ReportDetailDto.cs | 23 ++ .../Components/BusyOverlay.razor | 185 +++++++++++ .../Components/Layout/EmptyLayout.razor | 4 +- .../Components/Layout/MainLayout.razor | 21 +- .../Components/Pages/Actualizaciones.razor | 53 ++- .../Components/Pages/GestionZip.razor | 313 +++++++++++++++++- .../Components/Pages/Login.razor | 180 ++++++++-- .../Components/Pages/Pendientes.razor | 55 ++- .../Configuration/ApiDenunciasOptions.cs | 1 + Antifraude.Net/GestionaDenunciasAN/Program.cs | 166 +++++++++- .../Services/ApiDenunciasClient.cs | 14 + .../Services/UiBusyService.cs | 140 ++++++++ .../GestionaDenunciasAN/appsettings.json | 3 +- .../GestionaDenunciasAN/wwwroot/app.css | 6 +- .../GestionaDenunciasAN/wwwroot/js/appAuth.js | 51 ++- 25 files changed, 1805 insertions(+), 418 deletions(-) create mode 100644 Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs create mode 100644 Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs create mode 100644 Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor create mode 100644 Antifraude.Net/GestionaDenunciasAN/Services/UiBusyService.cs diff --git a/.codex-links/WebIntranet/Web.config b/.codex-links/WebIntranet/Web.config index 97a0c6b..f7d05b1 100644 --- a/.codex-links/WebIntranet/Web.config +++ b/.codex-links/WebIntranet/Web.config @@ -21,6 +21,7 @@ + --> @@ -103,14 +104,8 @@ - - - - - - - - + + diff --git a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs index 631ae31..715c74e 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs @@ -18,25 +18,184 @@ public sealed class AuthController : ControllerBase { private readonly GlobalLeaksClient _globalLeaksClient; private readonly GlobalLeaksSessionStore _sessionStore; + private readonly PendingGlobalLeaksLoginStore _pendingLoginStore; private readonly LoginRateLimiter _rateLimiter; private readonly JwtOptions _jwtOptions; + private readonly GlobalLeaksOptions _globalLeaksOptions; + private readonly ILogger _logger; public AuthController( GlobalLeaksClient globalLeaksClient, GlobalLeaksSessionStore sessionStore, + PendingGlobalLeaksLoginStore pendingLoginStore, LoginRateLimiter rateLimiter, - IOptions jwtOptions) + IOptions jwtOptions, + IOptions globalLeaksOptions, + ILogger logger) { _globalLeaksClient = globalLeaksClient; _sessionStore = sessionStore; + _pendingLoginStore = pendingLoginStore; _rateLimiter = rateLimiter; _jwtOptions = jwtOptions.Value; + _globalLeaksOptions = globalLeaksOptions.Value; + _logger = logger; + } + + [HttpPost("login/prepare")] + [AllowAnonymous] + public async Task> PrepareLogin( + ApiLoginPrepareRequest request, + CancellationToken cancellationToken) + { + var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim(); + var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!_rateLimiter.AllowAttempt(ip)) + { + return StatusCode(StatusCodes.Status429TooManyRequests, new ApiError("Demasiados intentos. Espera un minuto.")); + } + + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + { + return BadRequest(new ApiError("Debes indicar usuario y contrasena.")); + } + + try + { + var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140); + using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds)); + + _logger.LogInformation( + "Preparando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s", + usernameForLogs, + _globalLeaksOptions.BaseUrl, + loginTimeoutSeconds); + + var prepared = await _globalLeaksClient.PrepareLoginAsync( + request.Username.Trim(), + request.Password, + loginCancellation.Token); + + var pending = _pendingLoginStore.Create( + prepared.Username, + request.Password, + prepared.FinalPassword); + + return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc)); + } + catch (GlobalLeaksValidationException ex) + { + _logger.LogWarning( + "GlobalLeaks rechazo la preparacion del login de {Username}. Status={StatusCode}. Mensaje={Message}", + usernameForLogs, + ex.StatusCode, + ex.Message); + return StatusCode(ex.StatusCode, new ApiError(ex.Message)); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return StatusCode( + StatusCodes.Status504GatewayTimeout, + new ApiError($"GlobalLeaks no ha preparado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}).")); + } + catch (HttpRequestException ex) + { + return StatusCode( + StatusCodes.Status502BadGateway, + new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error no controlado preparando login de {Username}", usernameForLogs); + return StatusCode( + StatusCodes.Status500InternalServerError, + new ApiError($"No se ha podido preparar el inicio de sesion en la API: {ex.Message}")); + } + } + + [HttpPost("login/complete")] + [AllowAnonymous] + public async Task> CompleteLogin( + ApiLoginCompleteRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.PendingLoginId)) + { + return BadRequest(new ApiError("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena.")); + } + + if (string.IsNullOrWhiteSpace(request.Authcode) || !Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$")) + { + return BadRequest(new ApiError("El codigo 2FA debe tener exactamente 6 digitos.")); + } + + PendingGlobalLeaksLogin pending; + try + { + pending = _pendingLoginStore.Get(request.PendingLoginId); + } + catch (InvalidOperationException ex) + { + return BadRequest(new ApiError(ex.Message)); + } + + try + { + var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140); + using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds)); + + var session = await _globalLeaksClient.CompleteLoginAsync( + pending.Username, + pending.FinalPassword, + request.Authcode.Trim(), + loginCancellation.Token); + + var response = await CreateLoginResponseAsync( + pending.Username, + pending.Password, + session, + loginCancellation.Token); + + _pendingLoginStore.Remove(pending.Id); + return Ok(response); + } + catch (GlobalLeaksValidationException ex) + { + _logger.LogWarning( + "GlobalLeaks rechazo el 2FA de {Username}. Status={StatusCode}. Mensaje={Message}", + pending.Username, + ex.StatusCode, + ex.Message); + return StatusCode(ex.StatusCode, new ApiError(ex.Message)); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return StatusCode( + StatusCodes.Status504GatewayTimeout, + new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}).")); + } + catch (HttpRequestException ex) + { + return StatusCode( + StatusCodes.Status502BadGateway, + new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error no controlado completando login de {Username}", pending.Username); + return StatusCode( + StatusCodes.Status500InternalServerError, + new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}")); + } } [HttpPost("login")] [AllowAnonymous] public async Task> Login(LoginRequest request, CancellationToken cancellationToken) { + var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim(); var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; if (!_rateLimiter.AllowAttempt(ip)) { @@ -57,30 +216,65 @@ public sealed class AuthController : ControllerBase try { + var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140); + using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds)); + + _logger.LogInformation( + "Iniciando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s", + usernameForLogs, + _globalLeaksOptions.BaseUrl, + loginTimeoutSeconds); + var session = await _globalLeaksClient.LoginAsync( request.Username.Trim(), request.Password, request.Authcode.Trim(), - cancellationToken); + loginCancellation.Token); - var username = string.IsNullOrWhiteSpace(session.Username) - ? request.Username.Trim() - : session.Username.Trim(); - - await _sessionStore.SaveAsync(username, request.Password, session.Id, session.Role, cancellationToken); - - var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); - var token = CreateJwt(username, session.Role, expiresAtUtc); - - return Ok(new ApiLoginResponse(username, token, expiresAtUtc, session.Role)); + return Ok(await CreateLoginResponseAsync( + request.Username.Trim(), + request.Password, + session, + loginCancellation.Token)); } catch (GlobalLeaksValidationException ex) { + _logger.LogWarning( + "GlobalLeaks rechazo el login de {Username}. Status={StatusCode}. Mensaje={Message}", + usernameForLogs, + ex.StatusCode, + ex.Message); return StatusCode(ex.StatusCode, new ApiError(ex.Message)); } - catch + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return StatusCode(StatusCodes.Status502BadGateway, new ApiError("No se ha podido conectar con GlobalLeaks.")); + _logger.LogWarning( + "Timeout de GlobalLeaks al iniciar sesion para {Username}. BaseUrl={BaseUrl}. Timeout={TimeoutSeconds}s", + usernameForLogs, + _globalLeaksOptions.BaseUrl, + Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)); + return StatusCode( + StatusCodes.Status504GatewayTimeout, + new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}). Revisa el visor de eventos de ApiDenuncias para ver el paso exacto.")); + } + catch (HttpRequestException ex) + { + _logger.LogError( + ex, + "No se ha podido conectar con GlobalLeaks para {Username}. BaseUrl={BaseUrl}", + usernameForLogs, + _globalLeaksOptions.BaseUrl); + return StatusCode( + StatusCodes.Status502BadGateway, + new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error no controlado durante el login de {Username}", usernameForLogs); + return StatusCode( + StatusCodes.Status500InternalServerError, + new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}")); } } @@ -97,6 +291,25 @@ public sealed class AuthController : ControllerBase return Ok(new { ok = true }); } + private async Task CreateLoginResponseAsync( + string fallbackUsername, + string password, + GlSession session, + CancellationToken cancellationToken) + { + var username = string.IsNullOrWhiteSpace(session.Username) + ? fallbackUsername.Trim() + : session.Username.Trim(); + + _logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username); + await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken); + _logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username); + + var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); + var token = CreateJwt(username, session.Role, expiresAtUtc); + return new ApiLoginResponse(username, token, expiresAtUtc, session.Role); + } + private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc) { if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey)) diff --git a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs index 560adc3..e224219 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs @@ -186,6 +186,40 @@ public sealed class InboxController : ControllerBase } } + [HttpGet("reports/{reportId}/detail")] + public async Task> GetReportDetail( + string reportId, + CancellationToken cancellationToken) + { + var username = GetUsername(); + var session = await RequireActiveSessionAsync(username, cancellationToken); + if (session is null) + { + return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true)); + } + + try + { + return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken)); + } + catch (GlobalLeaksSessionExpiredException) + { + await _sessionStore.ClearSessionAsync(username, cancellationToken); + return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true)); + } + catch (GlobalLeaksValidationException ex) + { + return StatusCode(ex.StatusCode, new ApiError(ex.Message)); + } + catch (Exception ex) + { + _logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username); + return StatusCode( + StatusCodes.Status500InternalServerError, + new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}")); + } + } + [HttpPost("local/ensure-storage")] public async Task EnsureStorage(CancellationToken cancellationToken) { diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs index 9c90734..b7fca4e 100644 --- a/Antifraude.Net/ApiDenuncias/Program.cs +++ b/Antifraude.Net/ApiDenuncias/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddDataProtection() builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs b/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs index 0b97d5d..1abb992 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GestionaDocumentWorkflowService.cs @@ -30,9 +30,6 @@ public sealed class GestionaDocumentWorkflowService _configuration["Gestiona:AccessToken"] ?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings."); - private string? PreferredCircuitTemplateName => - _configuration["Gestiona:PreferredCircuitTemplateName"]; - public async Task UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName) { var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase); @@ -55,7 +52,8 @@ public sealed class GestionaDocumentWorkflowService metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); metaReq.Headers.Accept.Clear(); - metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + metaReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4")); metaReq.Content = new StringContent(metaJson, Encoding.UTF8); metaReq.Content.Headers.ContentType = @@ -92,7 +90,7 @@ public sealed class GestionaDocumentWorkflowService } catch { - // Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location. + // Si Gestiona devuelve cuerpo vacío por Prefer:return=minimal, usamos Location del documento. } } @@ -213,7 +211,8 @@ public sealed class GestionaDocumentWorkflowService { using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads"); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); - createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + createReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); using var createResp = await CreateRawHttp().SendAsync(createReq); var createBody = await createResp.Content.ReadAsStringAsync(); @@ -230,7 +229,9 @@ public sealed class GestionaDocumentWorkflowService putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes)); putReq.Headers.TryAddWithoutValidation("Slug", fileName); - putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); + putReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); putReq.Content = new ByteArrayContent(contentBytes); putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); @@ -242,11 +243,14 @@ public sealed class GestionaDocumentWorkflowService $"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}"); } - using var infoDoc = JsonDocument.Parse(infoJson); - var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null; - if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(infoJson)) { - throw new InvalidOperationException($"Upload no READY: {status}"); + using var infoDoc = JsonDocument.Parse(infoJson); + var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : "READY"; + if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Upload no READY: {status}"); + } } return uploadUri; @@ -260,177 +264,6 @@ public sealed class GestionaDocumentWorkflowService : $"{normalized}/documents-and-folders"; } - private async Task ObtenerTemplateCircuitoFirmaAsync(string documentUrl) - { - using var req = new HttpRequestMessage(HttpMethod.Get, $"{documentUrl.TrimEnd('/')}/circuit/templates"); - req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); - req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.circuits.templates-filedoc-page"); - - using var resp = await CreateRawHttp().SendAsync(req); - var body = await resp.Content.ReadAsStringAsync(); - if (!resp.IsSuccessStatusCode) - { - throw new InvalidOperationException( - $"ObtenerTemplateCircuitoFirmaAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); - } - - using var doc = JsonDocument.Parse(body); - if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) - { - throw new InvalidOperationException("No se ha podido leer el listado de plantillas de circuito."); - } - - var templates = new List(); - foreach (var item in content.EnumerateArray()) - { - var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null; - var href = GetSelfHref(item); - if (string.IsNullOrWhiteSpace(href)) - { - continue; - } - - var payload = JsonNode.Parse(item.GetRawText()) as JsonObject ?? new JsonObject(); - templates.Add(new CircuitTemplateCandidate( - Name: name, - Href: href!, - Payload: payload, - SignersCount: CountArrayItems(payload, "signers"), - BlockEdit: GetBoolean(payload, "block_edit"))); - } - - if (templates.Count == 0) - { - throw new InvalidOperationException("No hay plantillas de circuito disponibles para el documento."); - } - - _logger.LogInformation( - "Plantillas de circuito para {DocumentUrl}: {Templates}", - documentUrl, - string.Join( - " | ", - templates.Select(template => - $"{template.Name ?? "(sin nombre)"} [firmantes={template.SignersCount}, candado={template.BlockEdit}]"))); - - if (!string.IsNullOrWhiteSpace(PreferredCircuitTemplateName)) - { - var configuredExact = templates.FirstOrDefault(template => - string.Equals(template.Name, PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase)); - if (configuredExact is not null) - { - return configuredExact; - } - - var configuredContains = templates.FirstOrDefault(template => - !string.IsNullOrWhiteSpace(template.Name) && - template.Name.Contains(PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase)); - if (configuredContains is not null) - { - return configuredContains; - } - } - - string[] preferredNames = - [ - "CT-Actualizacion de denuncia", - "CT-Actualizaci\u00f3n de denuncia", - "Firma automatizada", - "Firma Sello de \u00D3rgano", - "Firma Sello de Organo" - ]; - - var preferredTemplate = templates.FirstOrDefault(template => - preferredNames.Any(preferred => - string.Equals(template.Name, preferred, StringComparison.OrdinalIgnoreCase))); - if (preferredTemplate is not null) - { - return preferredTemplate; - } - - var templatesWithSigners = templates - .Where(template => template.SignersCount > 0) - .OrderByDescending(template => template.BlockEdit) - .ThenByDescending(template => template.SignersCount) - .ToList(); - if (templatesWithSigners.Count > 0) - { - return templatesWithSigners[0]; - } - - return templates[0]; - } - - private static JsonObject BuildCircuitPayloadFromTemplate( - CircuitTemplateCandidate template, - string assignedGroupHref, - int? complaintId) - { - _ = assignedGroupHref; - _ = complaintId; - - var payload = (JsonObject)template.Payload.DeepClone(); - EnsureTemplateSelfLink(payload, template.Href); - payload.Remove("assigneds_can_use"); - return payload; - } - - private static void EnsureTemplateSelfLink(JsonObject payload, string templateSelfHref) - { - if (payload["links"] is not JsonArray links) - { - links = new JsonArray(); - payload["links"] = links; - } - - foreach (var node in links) - { - if (node is not JsonObject link) - { - continue; - } - - var rel = link["rel"]?.GetValue(); - var href = link["href"]?.GetValue(); - if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(href)) - { - return; - } - } - - links.Add(JsonSerializer.SerializeToNode(new { rel = "self", href = templateSelfHref })); - } - - private static int CountArrayItems(JsonObject payload, string propertyName) - { - return payload[propertyName] is JsonArray array ? array.Count : 0; - } - - private static bool GetBoolean(JsonObject payload, string propertyName) - { - return payload[propertyName]?.GetValue() ?? false; - } - - private static string? GetSelfHref(JsonElement item) - { - if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array) - { - return null; - } - - foreach (var link in links.EnumerateArray()) - { - var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null; - var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null; - if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(href)) - { - return href; - } - } - - return null; - } - private static string EnsureAbsoluteGestionaUrl(string url, string apiBase) { if (string.IsNullOrWhiteSpace(url)) @@ -458,10 +291,4 @@ public sealed class GestionaDocumentWorkflowService return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } - private sealed record CircuitTemplateCandidate( - string? Name, - string Href, - JsonObject Payload, - int SignersCount, - bool BlockEdit); } diff --git a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs index e1b6f2f..bf577bb 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs @@ -1,4 +1,5 @@ using GestionaDenuncias.Shared.Models; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; @@ -19,11 +20,16 @@ namespace ApiDenuncias.Services { private readonly HttpClient _http; private readonly GestionaOptions _opts; + private readonly ILogger _logger; - public GestionaService(HttpClient http, IOptions optsAccessor) + public GestionaService( + HttpClient http, + IOptions optsAccessor, + ILogger logger) { _http = http; _opts = optsAccessor.Value; + _logger = logger; } // ========================================================= @@ -58,7 +64,7 @@ namespace ApiDenuncias.Services return null; } - // Reemplaza este helper si quieres controlar la versión en Accept: + // Reemplaza este helper si quieres controlar la versión en Accept: private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null) { req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); @@ -83,6 +89,17 @@ namespace ApiDenuncias.Services req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } + private void LogDeprecatedHeaders(HttpResponseMessage response, string operation) + { + if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated)) + { + _logger.LogWarning( + "Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}", + operation, + string.Join(" | ", deprecated)); + } + } + // ========================================================= @@ -113,65 +130,20 @@ namespace ApiDenuncias.Services using var doc = JsonDocument.Parse(body); var fileUrl = GetLinkHref(doc.RootElement, "file") ?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'."); - var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open"); + var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open") + ?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'."); return new GestionaCreateFileResponse(fileUrl, fileOpenUrl); } - private async Task ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId) + private Task ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId) { - if (Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)) - { - return $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{configuredExternalProcedureId}/create-file"; - } + var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId) + ? configuredExternalProcedureId + : procedureId; - using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/catalog-2015/procedures/{procedureId}/external-procedures"); - AddTokenAndAccept(req, "application/vnd.gestiona.external-procedures-page+json"); - - using var resp = await _http.SendAsync(req); - if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) - { - throw new InvalidOperationException( - $"El procedimiento {procedureId} no tiene tramites externos configurados en Gestiona."); - } - - var body = await resp.Content.ReadAsStringAsync(); - if (!resp.IsSuccessStatusCode) - { - throw new InvalidOperationException( - $"ResolveExternalProcedureCreateFileUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}"); - } - - using var doc = JsonDocument.Parse(body); - if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) - { - throw new InvalidOperationException( - $"El procedimiento {procedureId} no ha devuelto tramites externos validos en Gestiona."); - } - - var createFileCandidates = new List<(string? Id, string Href)>(); - foreach (var item in content.EnumerateArray()) - { - var createFileHref = GetLinkHref(item, "create-file"); - if (!string.IsNullOrWhiteSpace(createFileHref)) - { - var externalProcedureId = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() - : null; - createFileCandidates.Add((externalProcedureId, createFileHref!)); - } - } - - if (createFileCandidates.Count == 0) - { - throw new InvalidOperationException( - $"El procedimiento {procedureId} no tiene ningun tramite externo con link create-file."); - } - - return createFileCandidates - .FirstOrDefault(candidate => string.Equals(candidate.Id, procedureId.ToString(), StringComparison.OrdinalIgnoreCase)) - .Href - ?? createFileCandidates[0].Href; + return Task.FromResult( + $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file"); } public async Task OpenFileAsync( @@ -183,9 +155,13 @@ namespace ApiDenuncias.Services string freeTitle, string siaCode) { - var url = string.IsNullOrWhiteSpace(fileOpenUrl) - ? $"{fileUrl.TrimEnd('/')}/open" - : fileOpenUrl; + if (string.IsNullOrWhiteSpace(fileOpenUrl)) + { + throw new InvalidOperationException( + "OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated."); + } + + var url = fileOpenUrl; var payload = new { @@ -228,7 +204,8 @@ namespace ApiDenuncias.Services { Content = content }; - AddTokenAndAccept(req, "application/json"); + req.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); + AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1"); using var resp = await _http.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); @@ -248,7 +225,8 @@ namespace ApiDenuncias.Services { using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads"); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); - createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + createReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); using var createResp = await _http.SendAsync(createReq); var createBody = await createResp.Content.ReadAsStringAsync(); @@ -256,7 +234,7 @@ namespace ApiDenuncias.Services throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}"); var uploadUri = createResp.Headers.Location?.ToString() - ?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads"); + ?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads"); string md5Hex; using (var md5 = MD5.Create()) @@ -269,8 +247,9 @@ namespace ApiDenuncias.Services putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex); putReq.Headers.TryAddWithoutValidation("Slug", fileName); - putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - + putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); + putReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json")); putReq.Content = new ByteArrayContent(contentBytes); putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); @@ -279,11 +258,15 @@ namespace ApiDenuncias.Services if (!putResp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}"); - using var infoDoc = JsonDocument.Parse(infoJson); - var status = infoDoc.RootElement.GetProperty("status").GetString(); - if (status != "READY") - throw new InvalidOperationException($"Upload no READY: {status}"); - + if (!string.IsNullOrWhiteSpace(infoJson)) + { + using var infoDoc = JsonDocument.Parse(infoJson); + var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp) + ? statusProp.GetString() + : "READY"; + if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Upload no READY: {status}"); + } return uploadUri; } @@ -309,8 +292,10 @@ namespace ApiDenuncias.Services using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders") { Content = metaContent }; metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); + metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); metaReq.Headers.Accept.Clear(); - metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + metaReq.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4")); using var metaResp = await _http.SendAsync(metaReq); var body = await metaResp.Content.ReadAsStringAsync(); @@ -386,7 +371,7 @@ namespace ApiDenuncias.Services if (thirdParty.IsLegalEntity) { if (string.IsNullOrWhiteSpace(thirdParty.BusinessName)) - throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty)); + throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty)); } else { @@ -485,7 +470,7 @@ namespace ApiDenuncias.Services { foreach (var item in content.EnumerateArray()) { - if (item.TryGetProperty("links", out var links)) + if (item.TryGetProperty("links", out var links)) { var third = links.EnumerateArray().FirstOrDefault(l => l.GetProperty("rel").GetString() == "third"); if (third.ValueKind != JsonValueKind.Undefined) @@ -561,12 +546,15 @@ namespace ApiDenuncias.Services }; } - // --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) --- + // --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) --- private async Task GetFilesAsync(object? filter = null) { using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files"); AddBasicHeaders(req); + req.Headers.Accept.Clear(); + req.Headers.Accept.Add( + MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json")); if (filter is not null) { @@ -575,6 +563,7 @@ namespace ApiDenuncias.Services } using var resp = await _http.SendAsync(req); + LogDeprecatedHeaders(resp, "GET /rest/files"); if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) { return "{\"content\":[]}"; @@ -612,7 +601,7 @@ namespace ApiDenuncias.Services } /// - /// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas. + /// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas. /// public async Task ListarExpedientesJsonAsyncBasico(int maxPages = 1) { @@ -683,9 +672,10 @@ namespace ApiDenuncias.Services } using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl); - AddTokenAndAccept(req, "application/json"); + AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2"); using var resp = await _http.SendAsync(req); + LogDeprecatedHeaders(resp, $"GET {fileUrl}"); if (resp.StatusCode == System.Net.HttpStatusCode.NoContent || resp.StatusCode == System.Net.HttpStatusCode.NotFound) { @@ -910,7 +900,7 @@ namespace ApiDenuncias.Services using var resp = await _http.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) - throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); + throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); } private async Task ThirdHasAddressesAsync(string thirdSelfHref) @@ -1160,7 +1150,7 @@ namespace ApiDenuncias.Services return value switch { "" => "ESP", - "es" or "esp" or "espana" or "españa" or "spain" => "ESP", + "es" or "esp" or "espana" or "españa" or "spain" => "ESP", "prt" or "pt" or "portugal" => "PRT", _ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3], _ => "ESP", diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs index 5c608dd..6573089 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Net; using System.Net.Http.Headers; @@ -12,6 +13,8 @@ using Microsoft.Extensions.Options; namespace ApiDenuncias.Services; +public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword); + public sealed class GlobalLeaksClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -50,37 +53,90 @@ public sealed class GlobalLeaksClient string authcode, CancellationToken cancellationToken) { - using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token"); - using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken); - await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken); - var tokenData = await tokenResponse.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) - ?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502); + var prepared = await PrepareLoginAsync(username, password, cancellationToken); + return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken); + } + + public async Task PrepareLoginAsync( + string username, + string password, + CancellationToken cancellationToken) + { + var loginWatch = Stopwatch.StartNew(); + _logger.LogInformation( + "GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}", + username, + _options.BaseUrl); - var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt); using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type"); typeRequest.Content = CreateJsonContent(new { username }); - using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken); + using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken); await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken); var authType = await typeResponse.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) ?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502); + var passwordWatch = Stopwatch.StartNew(); + _logger.LogInformation( + "GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}", + username, + authType.Type); var finalPassword = authType.Type == "key" ? DerivePassword(password, authType.Salt) : password; + _logger.LogInformation( + "GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms", + username, + passwordWatch.ElapsedMilliseconds); - using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication"); + _logger.LogInformation( + "GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms", + username, + loginWatch.ElapsedMilliseconds); + + return new PreparedGlobalLeaksCredentials(username, finalPassword); + } + + public async Task CompleteLoginAsync( + string username, + string finalPassword, + string authcode, + CancellationToken cancellationToken) + { + var loginWatch = Stopwatch.StartNew(); + _logger.LogInformation( + "GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}", + username, + authcode?.Length ?? 0); + + using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token"); + using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken); + await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502); + + var proofWatch = Stopwatch.StartNew(); + _logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username); + var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken); + _logger.LogInformation( + "GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms", + username, + proofWatch.ElapsedMilliseconds); + + using var authRequest = CreateRequest( + HttpMethod.Post, + $"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}"); authRequest.Content = CreateJsonContent(new { tid = 1, username, password = finalPassword, - authcode, + authcode = authcode?.Trim() ?? string.Empty, }); - authRequest.Headers.Add("X-Token", tokenAnswer); + authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer); - using var authResponse = await _httpClient.SendAsync(authRequest, cancellationToken); + using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken); if (!authResponse.IsSuccessStatusCode) { var body = await ReadBodySafeAsync(authResponse, cancellationToken); @@ -92,6 +148,10 @@ public sealed class GlobalLeaksClient (HttpStatusCode)429 => new GlobalLeaksValidationException( "Demasiados intentos en GlobalLeaks. Espera unos minutos.", StatusCodes.Status429TooManyRequests), + HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) => + new GlobalLeaksValidationException( + "Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.", + StatusCodes.Status406NotAcceptable), _ => new GlobalLeaksValidationException( string.IsNullOrWhiteSpace(body) ? $"Login fallido (código {(int)authResponse.StatusCode})." @@ -102,7 +162,11 @@ public sealed class GlobalLeaksClient var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); var session = ParseAuthSession(authBody, username); - _logger.LogInformation("Login GlobalLeaks correcto para {Username}. Rol: {Role}", session.Username, session.Role ?? "(sin rol)"); + _logger.LogInformation( + "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms", + session.Username, + session.Role ?? "(sin rol)", + loginWatch.ElapsedMilliseconds); return session; } @@ -196,6 +260,40 @@ public sealed class GlobalLeaksClient .ToArray(); } + public async Task GetReportDetailAsync( + string sessionId, + string reportId, + CancellationToken cancellationToken) + { + ValidateUuid(reportId); + + using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId); + using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken); + var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken); + var metadata = ParseReports(listBody) + .FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase)); + + if (metadata is null) + { + throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404); + } + + using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); + using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken); + + var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty; + var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken); + if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0) + { + throw new GlobalLeaksValidationException( + "No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.", + 422); + } + + using var document = JsonDocument.Parse(content); + return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement); + } + public async Task DownloadReportZipAsync( string sessionId, string reportId, @@ -253,6 +351,43 @@ public sealed class GlobalLeaksClient return new FileDownloadResult(content, $"report-{progressive}.json"); } + private async Task SendLoginRequestAsync( + HttpRequestMessage request, + string endpoint, + CancellationToken cancellationToken) + { + var stepWatch = Stopwatch.StartNew(); + _logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint); + + try + { + var response = await _httpClient.SendAsync(request, cancellationToken); + _logger.LogInformation( + "GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms", + endpoint, + (int)response.StatusCode, + stepWatch.ElapsedMilliseconds); + return response; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + "GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms", + endpoint, + stepWatch.ElapsedMilliseconds); + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms", + endpoint, + stepWatch.ElapsedMilliseconds); + throw; + } + } + private async Task SendGlRequestAsync( HttpRequestMessage request, CancellationToken cancellationToken, @@ -303,7 +438,7 @@ public sealed class GlobalLeaksClient return content; } - private static string SolveProofOfWork(string tokenId, string tokenSalt) + private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken) { var idBytes = Encoding.UTF8.GetBytes(tokenId); var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray(); @@ -311,6 +446,11 @@ public sealed class GlobalLeaksClient while (true) { + if ((n & 15) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + } + var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray(); var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024); if (hash[^1] == 0) @@ -477,6 +617,74 @@ public sealed class GlobalLeaksClient return reports; } + private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root) + { + var lastAccessDate = ParseDate(lastAccess); + + bool IsNew(string? value) + { + if (lastAccessDate is null) + { + return true; + } + + var itemDate = ParseDate(value); + return itemDate is not null && itemDate > lastAccessDate; + } + + var comments = EnumerateArray(root, "comments") + .Select(item => new ReportCommentDto( + GetString(item, "id"), + GetString(item, "type"), + GetString(item, "content", "text", "message"), + GetString(item, "creation_date", "creationDate"), + IsNew(GetString(item, "creation_date", "creationDate")))) + .ToArray(); + + var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files") + .Select(item => new ReportFileDto( + GetString(item, "id"), + GetLocalizedString(item, "name", "file_name", "filename"), + GetInt64(item, "size"), + GetString(item, "content_type", "contentType", "mime_type", "mimetype"), + GetString(item, "creation_date", "creationDate"), + IsNew(GetString(item, "creation_date", "creationDate")))) + .ToArray(); + + var receiverFiles = EnumerateArray(root, "rfiles") + .Select(item => new ReportFileDto( + GetString(item, "id"), + GetLocalizedString(item, "name", "file_name", "filename"), + GetInt64(item, "size"), + GetString(item, "content_type", "contentType", "mime_type", "mimetype"), + GetString(item, "creation_date", "creationDate"), + IsNew(GetString(item, "creation_date", "creationDate")))) + .ToArray(); + + return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles); + } + + private static IEnumerable EnumerateArray(JsonElement root, params string[] names) + { + if (root.ValueKind != JsonValueKind.Object) + { + yield break; + } + + foreach (var name in names) + { + if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array) + { + foreach (var item in property.EnumerateArray()) + { + yield return item; + } + + yield break; + } + } + } + private static JsonElement? FindArrayProperty(JsonElement element, params string[] names) { if (element.ValueKind != JsonValueKind.Object) @@ -513,6 +721,30 @@ public sealed class GlobalLeaksClient return null; } + private static string? GetLocalizedString(JsonElement element, params string[] names) + { + foreach (var name in names) + { + if (!element.TryGetProperty(name, out var property)) + { + continue; + } + + if (property.ValueKind == JsonValueKind.String) + { + return property.GetString(); + } + + if (property.ValueKind == JsonValueKind.Object) + { + var value = ExtractName(property, string.Empty); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + return null; + } + private static int? GetInt32(JsonElement element, params string[] names) { foreach (var name in names) @@ -535,6 +767,28 @@ public sealed class GlobalLeaksClient return null; } + private static long? GetInt64(JsonElement element, params string[] names) + { + foreach (var name in names) + { + if (element.TryGetProperty(name, out var property)) + { + if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value)) + { + return value; + } + + if (property.ValueKind == JsonValueKind.String && + long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + return value; + } + } + } + + return null; + } + private static bool GetBool(JsonElement element, params string[] names) { foreach (var name in names) @@ -621,6 +875,18 @@ public sealed class GlobalLeaksClient } } + private static bool IsTwoFactorRequiredError(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) || + body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) || + body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase); + } + private sealed record TokenResponse(string Id, string Salt); private sealed record AuthTypeResponse(string Type, string Salt); diff --git a/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs b/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs new file mode 100644 index 0000000..86e30c5 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/PendingGlobalLeaksLoginStore.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace ApiDenuncias.Services; + +public sealed record PendingGlobalLeaksLogin( + string Id, + string Username, + string Password, + string FinalPassword, + DateTimeOffset ExpiresAtUtc); + +public sealed class PendingGlobalLeaksLoginStore +{ + private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + private readonly ConcurrentDictionary _items = new(StringComparer.Ordinal); + + public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword) + { + CleanupExpired(); + + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var pending = new PendingGlobalLeaksLogin( + id, + username, + password, + finalPassword, + DateTimeOffset.UtcNow.Add(Lifetime)); + + _items[id] = pending; + return pending; + } + + public PendingGlobalLeaksLogin Get(string id) + { + CleanupExpired(); + + if (string.IsNullOrWhiteSpace(id) || !_items.TryGetValue(id, out var pending)) + { + throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena."); + } + + if (pending.ExpiresAtUtc <= DateTimeOffset.UtcNow) + { + _items.TryRemove(id, out _); + throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena."); + } + + return pending; + } + + public void Remove(string id) + { + if (!string.IsNullOrWhiteSpace(id)) + { + _items.TryRemove(id, out _); + } + } + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var item in _items) + { + if (item.Value.ExpiresAtUtc <= now) + { + _items.TryRemove(item.Key, out _); + } + } + } +} diff --git a/Antifraude.Net/ApiDenuncias/appsettings.json b/Antifraude.Net/ApiDenuncias/appsettings.json index 6d06087..e0e8bf3 100644 --- a/Antifraude.Net/ApiDenuncias/appsettings.json +++ b/Antifraude.Net/ApiDenuncias/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Information", @@ -27,12 +27,12 @@ "Gestiona": { "ApiBase": "https://02.g3stiona.com", "AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c", + "ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b", "CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa", "CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36", "CircuitSignerStampTitle": "oaaf-complaints-tramit", "CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004", "CircuitVersion": "2", - "PreferredCircuitTemplateName": "CT-Actualización de denuncia", "UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63", "GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", "Location": "2.02.01" diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/LoginRequest.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/LoginRequest.cs index 0830bcd..cd7fc59 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/LoginRequest.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/LoginRequest.cs @@ -1,3 +1,12 @@ namespace GestionaDenuncias.Shared.Models; public sealed record LoginRequest(string Username, string Password, string Authcode); + +public sealed record ApiLoginPrepareRequest(string Username, string Password); + +public sealed record ApiLoginPrepareResponse( + string PendingLoginId, + string Username, + DateTimeOffset ExpiresAtUtc); + +public sealed record ApiLoginCompleteRequest(string PendingLoginId, string Authcode); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs new file mode 100644 index 0000000..d1e53ad --- /dev/null +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ReportDetailDto.cs @@ -0,0 +1,23 @@ +namespace GestionaDenuncias.Shared.Models; + +public sealed record ReportDetailDto( + string ReportId, + string? LastAccess, + IReadOnlyList Comments, + IReadOnlyList WhistleblowerFiles, + IReadOnlyList ReceiverFiles); + +public sealed record ReportCommentDto( + string? Id, + string? Type, + string? Content, + string? CreationDate, + bool IsNew); + +public sealed record ReportFileDto( + string? Id, + string? Name, + long? Size, + string? ContentType, + string? CreationDate, + bool IsNew); diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor b/Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor new file mode 100644 index 0000000..c2bc9dc --- /dev/null +++ b/Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor @@ -0,0 +1,185 @@ +@implements IDisposable +@inject UiBusyService Busy + +@if (Busy.IsVisible) +{ + +} + + + +@code { + private string ProgressBarCss => Busy.IsIndeterminate + ? "busy-overlay__progress-bar busy-overlay__progress-bar--indeterminate" + : "busy-overlay__progress-bar"; + + private string ProgressStyle => Busy.IsIndeterminate + ? string.Empty + : $"width: {Busy.ProgressPercent}%;"; + + protected override void OnInitialized() + { + Busy.Changed += HandleBusyChanged; + } + + public void Dispose() + { + Busy.Changed -= HandleBusyChanged; + } + + private void HandleBusyChanged() + { + _ = InvokeAsync(StateHasChanged); + } +} diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/EmptyLayout.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/EmptyLayout.razor index a852abe..f423066 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/EmptyLayout.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/EmptyLayout.razor @@ -4,4 +4,6 @@
@Body
- \ No newline at end of file + + + diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor index 69f881a..ac38fc1 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor @@ -4,6 +4,7 @@ @inject IHttpContextAccessor HttpContextAccessor @inject IJSRuntime JSRuntime @inject NavigationManager Navigation +@inject UiBusyService Busy
+ +
An unhandled error has occurred. Reload @@ -140,9 +143,19 @@ private async Task CerrarSesionAsync() { - await JSRuntime.InvokeAsync("appAuthPost", "/api/auth/logout"); - userState.Token = string.Empty; - userState.NombreUsu = string.Empty; - Navigation.NavigateTo("/", true); + using var busy = Busy.Show( + "Cerrando sesion", + "Invalidando la sesion interna y limpiando el acceso del usuario."); + + try + { + await JSRuntime.InvokeAsync("appAuthPost", "/api/auth/logout"); + } + finally + { + userState.Token = string.Empty; + userState.NombreUsu = string.Empty; + Navigation.NavigateTo("/", true); + } } } diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor index f89210d..9ad2ffc 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Actualizaciones.razor @@ -15,6 +15,7 @@ @inject IHostEnvironment HostEnvironment @inject IDenunciaStore DenunciaStore @inject ApiDenunciasClient ApiDenuncias +@inject UiBusyService Busy Actualizaciones @@ -72,27 +73,6 @@ vertical-align: middle; } - /* Overlay de carga */ - .upload-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.35); - z-index: 2000; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(1px); - } - - .upload-box { - background: #fff; - border-radius: .75rem; - padding: 1.25rem 1.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,.25); - min-width: 260px; - text-align: center; - } - /* === Estética de modal igual que en Pendientes === */ .custom-modal { @@ -594,17 +574,6 @@ else } -@if (isUploading) -{ -
-
- -
Subiendo documentos…
-
Por favor, espera
-
-
-} - @code { private const string reportTxt = "report.txt"; @@ -810,10 +779,14 @@ else try { isUploading = true; + using var busy = Busy.Show( + "Enviando actualizacion", + "Preparando expediente, carpeta de actualizacion y documentos."); StateHasChanged(); await Task.Yield(); // 1) Ficheros a subir + Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8"); var existentesF = await CargarFicherosJsonAsync(); var fDenuncia = GetPendingUpdateFiles( existentesF @@ -843,6 +816,7 @@ else } else { + Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8"); var createdFile = await ApiDenuncias.CreateGestionaFileAsync( selectedDenuncias.ProcedureId, nuevoAsunto, @@ -850,6 +824,7 @@ else "3109963" ); fileUrl = createdFile.FileUrl; + Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8"); await ApiDenuncias.OpenGestionaFileAsync( fileUrl, createdFile.FileOpenUrl, @@ -869,14 +844,17 @@ else selectedDenuncias.EnGestiona = true; } + Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8"); await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl); var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias); + Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8"); await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty); var ahoraUtc = DateTime.UtcNow; var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}"); + Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8"); var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion); var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl; var nombresOriginalesSubidos = new List(); @@ -888,6 +866,7 @@ else if (!string.IsNullOrWhiteSpace(report.FileName)) { + Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8"); var reportPdfBytes = PdfHelper.MergeFilesToPdf( new (string FileName, byte[] Content)[] { (report.FileName, report.Content) }); var reportFinalName = FixFileName("Denuncia.pdf"); @@ -905,6 +884,7 @@ else if (adjuntos.Count > 0 && uploadMode == "merge") { + Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8"); var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos); var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf"); var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName); @@ -927,6 +907,12 @@ else foreach (var t in adjuntos) { + Busy.Update( + message: $"Subiendo adjunto nuevo {i} de {adjuntos.Count}.", + detail: "Paso 7 de 8", + current: i, + total: adjuntos.Count); + var origName = t.FileName; var content = t.Content; var ext = Path.GetExtension(origName).ToLowerInvariant(); @@ -961,6 +947,7 @@ else if (!string.IsNullOrWhiteSpace(documentoParaTramitar)) { + Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8"); await ApiDenuncias.TramitarGestionaDocumentAsync( documentoParaTramitar, GetAssignedGroupLinkBySelectedGroup(), @@ -979,6 +966,8 @@ else f.FechaSubida = ahoraUtc; } } + + Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando"); await DenunciaStore.MarkFicherosAsUploadedAsync( selectedDenuncias.Id_Denuncia, nombresOriginalesSubidos, diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/GestionZip.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/GestionZip.razor index 13af21b..f0bb5d1 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/GestionZip.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/GestionZip.razor @@ -1,13 +1,80 @@ @page "/GestionZip" @rendermode @(new InteractiveServerRenderMode(prerender: false)) @attribute [Authorize] +@implements IAsyncDisposable @using System.Globalization @using GestionaDenunciasAN.Models @inject AuthenticationStateProvider AuthenticationStateProvider @inject ApiDenunciasClient ApiDenuncias +@inject IJSRuntime JSRuntime +@inject UiBusyService Busy Entrada de denuncias + +
@@ -166,13 +233,14 @@ Ultima actualizacion Estado Seguimiento + Detalle @if (!CanUseGlobalLeaks) { - + Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja. @@ -180,13 +248,13 @@ else if (ReportsBusy) { - Cargando denuncias... + Cargando denuncias... } else if (!VisibleReports.Any()) { - No hay denuncias con los filtros actuales. + No hay denuncias con los filtros actuales. } else @@ -217,6 +285,15 @@
@report.TrackingNote
} + + + } } @@ -229,6 +306,117 @@
+@if (DetailModalVisible) +{ + +} + @code { private readonly List Contexts = []; private readonly List Reports = []; @@ -252,6 +440,11 @@ private bool ReportsBusy { get; set; } private bool RenewBusy { get; set; } private bool ImportBusy { get; set; } + private bool DetailBusy { get; set; } + private bool DetailModalVisible { get; set; } + private string DetailError { get; set; } = string.Empty; + private ReportDto? DetailReport { get; set; } + private ReportDetailDto? DetailData { get; set; } private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true; private int SelectedReportsCount => SelectedIds.Count; @@ -311,8 +504,13 @@ RenewBusy = true; try { + using var busy = Busy.Show( + "Renovando sesion GlobalLeaks", + "Validando el nuevo codigo 2FA para continuar descargando denuncias."); + SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None); RenewAuthcode = string.Empty; + Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada."); await LoadReportsAsync(); SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success"); } @@ -336,6 +534,10 @@ ReportsBusy = true; try { + using var busy = Busy.Show( + "Actualizando bandeja", + "Consultando GlobalLeaks y cruzando el seguimiento con la base de datos."); + var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None); Contexts.Clear(); @@ -383,8 +585,21 @@ .OrderBy(report => report.Progressive ?? 0) .ToList(); + using var busy = Busy.Show( + "Importando denuncias", + $"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.", + selectedReports.Count); + + var processed = 0; foreach (var report in selectedReports) { + processed++; + Busy.Update( + message: $"Procesando denuncia #{report.Progressive ?? 0}.", + detail: "Descarga, analisis del report y guardado seguro en la base de datos.", + current: processed, + total: selectedReports.Count); + try { var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None); @@ -442,6 +657,70 @@ } } + private async Task OpenReportDetailAsync(ReportDto report) + { + if (!CanUseGlobalLeaks) + { + SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning"); + return; + } + + DetailModalVisible = true; + DetailReport = report; + DetailData = null; + DetailError = string.Empty; + DetailBusy = true; + await SetBodyScrollLockAsync(true); + + try + { + using var busy = Busy.Show( + "Leyendo detalle de denuncia", + "Consultando mensajes y ficheros para identificar que contenido es nuevo."); + + DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None); + } + catch (UnauthorizedAccessException ex) + { + await LoadSessionStateAsync(); + DetailError = ex.Message; + } + catch (Exception ex) + { + DetailError = ex.Message; + } + finally + { + DetailBusy = false; + } + } + + private async Task CloseReportDetail() + { + DetailModalVisible = false; + DetailReport = null; + DetailData = null; + DetailError = string.Empty; + await SetBodyScrollLockAsync(false); + } + + public async ValueTask DisposeAsync() + { + await SetBodyScrollLockAsync(false); + } + + private async Task SetBodyScrollLockAsync(bool locked) + { + try + { + await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked); + } + catch + { + // Si el circuito se esta cerrando, solo aseguramos que no reviente el componente. + } + } + private void ApplyFilters() { IEnumerable filtered = Reports; @@ -652,6 +931,34 @@ : "-"; } + private static string FormatBytes(long? value) + { + if (value is null or <= 0) + { + return "Tamano no disponible"; + } + + var bytes = value.Value; + if (bytes < 1024) + { + return $"{bytes} B"; + } + + if (bytes < 1024 * 1024) + { + return $"{bytes / 1024d:0.#} KB"; + } + + return $"{bytes / 1024d / 1024d:0.#} MB"; + } + + private static string GetCommentAuthorLabel(string? type) + => string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase) + ? "Denunciante" + : string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase) + ? "Receptor" + : "Comentario"; + private static DateTimeOffset? GetEffectiveMoment(ReportDto report) { return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate); diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Login.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Login.razor index 4d934ee..81f31ff 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Login.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Login.razor @@ -2,10 +2,12 @@ @layout EmptyLayout @rendermode @(new InteractiveServerRenderMode(prerender: false)) @using System.Text.Json +@using GestionaDenuncias.Shared.Models @using GestionaDenunciasAN.Models @inject AuthenticationStateProvider AuthenticationStateProvider @inject IJSRuntime JSRuntime @inject NavigationManager Navigation +@inject UiBusyService Busy Portal de denuncias @@ -87,12 +89,12 @@
- Oficina Antifraude de Andalucía + Oficina Antifraude de Andalucia

Una sola puerta para entrar, importar y tramitar denuncias.

- El acceso de la aplicación ya se apoya en GlobalLeaks. La sesión interna de esta app se mantiene - activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que - renovar el código 2FA desde la bandeja de entrada. + El acceso de la aplicacion ya se apoya en GlobalLeaks. La sesion interna de esta app se mantiene + activa de forma persistente, y cuando caduque la sesion de obtencion de denuncias solo habra que + renovar el codigo 2FA desde la bandeja de entrada.

Acceso con GlobalLeaks

- Introduce tu usuario, contraseña y el código 2FA actual para dejar la app iniciada. + Primero validamos usuario y contrasena. Despues te pediremos un codigo 2FA fresco para completar el acceso.

@if (!string.IsNullOrWhiteSpace(StatusMessage)) @@ -116,33 +118,54 @@
- +
-
- - -
+ @if (LoginPrepared) + { +
+ Credenciales preparadas. Introduce ahora el codigo 2FA actual para completar el acceso. +
+ +
+ + +
+ } + @if (LoginPrepared) + { + + }
@@ -156,9 +179,14 @@ private string Username { get; set; } = string.Empty; private string Password { get; set; } = string.Empty; private string Authcode { get; set; } = string.Empty; + private string PendingLoginId { get; set; } = string.Empty; private string StatusMessage { get; set; } = string.Empty; private string StatusCss { get; set; } = "alert-info"; private bool IsBusy { get; set; } + private bool LoginPrepared => !string.IsNullOrWhiteSpace(PendingLoginId); + private string LoginButtonText => IsBusy + ? (LoginPrepared ? "Validando 2FA..." : "Preparando acceso...") + : (LoginPrepared ? "Validar 2FA y entrar" : "Preparar acceso"); protected override async Task OnInitializedAsync() { @@ -166,18 +194,28 @@ if (authState.User.Identity?.IsAuthenticated == true) { Navigation.NavigateTo(GetTargetUrl(), true); - } + } } private async Task LoginAsync() { StatusMessage = string.Empty; - if (string.IsNullOrWhiteSpace(Username) || - string.IsNullOrWhiteSpace(Password) || - string.IsNullOrWhiteSpace(Authcode)) + if (!LoginPrepared) { - SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning"); + await PrepareLoginAsync(); + return; + } + + await CompleteLoginAsync(); + } + + private async Task PrepareLoginAsync() + { + if (string.IsNullOrWhiteSpace(Username) || + string.IsNullOrWhiteSpace(Password)) + { + SetStatus("Debes rellenar usuario y contrasena.", "alert-warning"); return; } @@ -185,27 +223,105 @@ try { + using var busy = Busy.Show( + "Preparando acceso", + "Validando usuario y contrasena con GlobalLeaks. Cuando termine pediremos un codigo 2FA nuevo."); + + using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170)); var response = await JSRuntime.InvokeAsync( "appAuthPostJson", - "/api/auth/login", - new LoginRequest(Username.Trim(), Password, Authcode.Trim())); + loginTimeout.Token, + [ + "/api/auth/prepare", + new ApiLoginPrepareRequest(Username.Trim(), Password) + ]); if (!response.Ok) { var error = ReadData(response); - SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger"); + SetStatus(error?.Error ?? "No se ha podido preparar el acceso.", "alert-danger"); return; } + var prepared = ReadData(response); + if (prepared is null || string.IsNullOrWhiteSpace(prepared.PendingLoginId)) + { + SetStatus("La API no ha devuelto una preparacion de login valida.", "alert-danger"); + return; + } + + PendingLoginId = prepared.PendingLoginId; + Username = prepared.Username; + Password = string.Empty; + Authcode = string.Empty; + SetStatus("Credenciales preparadas. Introduce el codigo 2FA que este activo ahora mismo.", "alert-info"); + } + catch (JSException ex) + { + SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger"); + } + catch (OperationCanceledException) + { + SetStatus("La preparacion del acceso ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger"); + } + catch (Exception ex) + { + SetStatus($"No se ha podido preparar el acceso: {ex.Message}", "alert-danger"); + } + finally + { + IsBusy = false; + } + } + + private async Task CompleteLoginAsync() + { + var authcode = Authcode.Trim(); + if (authcode.Length != 6 || authcode.Any(ch => !char.IsDigit(ch))) + { + SetStatus("El codigo 2FA debe tener exactamente 6 digitos.", "alert-warning"); + return; + } + + IsBusy = true; + + try + { + using var busy = Busy.Show( + "Validando 2FA", + "Completando el acceso con el codigo actual de GlobalLeaks."); + + using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170)); + var response = await JSRuntime.InvokeAsync( + "appAuthPostJson", + loginTimeout.Token, + [ + "/api/auth/complete", + new ApiLoginCompleteRequest(PendingLoginId, authcode) + ]); + + if (!response.Ok) + { + var error = ReadData(response); + SetStatus(error?.Error ?? "No se ha podido completar el inicio de sesion.", "alert-danger"); + Authcode = string.Empty; + return; + } + + Busy.Update(message: "Creando la sesion interna segura de la aplicacion."); Navigation.NavigateTo(GetTargetUrl(), true); } catch (JSException ex) { - SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger"); + SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger"); } - catch + catch (OperationCanceledException) { - SetStatus("No se ha podido conectar con el servidor.", "alert-danger"); + SetStatus("La validacion del 2FA ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger"); + } + catch (Exception ex) + { + SetStatus($"No se ha podido completar el inicio de sesion: {ex.Message}", "alert-danger"); } finally { @@ -214,7 +330,15 @@ } private Task HandleAuthcodeKeyDown(KeyboardEventArgs args) - => args.Key == "Enter" ? LoginAsync() : Task.CompletedTask; + => LoginPrepared && args.Key == "Enter" ? LoginAsync() : Task.CompletedTask; + + private void ResetPreparedLogin() + { + PendingLoginId = string.Empty; + Password = string.Empty; + Authcode = string.Empty; + StatusMessage = string.Empty; + } private void SetStatus(string message, string cssClass) { diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor index 316c9e6..31290d2 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Pages/Pendientes.razor @@ -18,30 +18,11 @@ @inject IHostEnvironment HostEnvironment @inject IDenunciaStore DenunciaStore @inject ApiDenunciasClient ApiDenuncias +@inject UiBusyService Busy Denuncias Pendientes