From 2414fbe80c0ce7a66fb31f4732b1bbdf755e758f Mon Sep 17 00:00:00 2001 From: Pedro Date: Fri, 10 Jul 2026 14:00:22 +0200 Subject: [PATCH] =?UTF-8?q?Mejoras=20en=20elevaciones,=20equilibrado=20y?= =?UTF-8?q?=20callejero=20inverso=20A=C3=B1adida=20visualizaci=C3=B3n=20de?= =?UTF-8?q?=20pendientes=20en=20los=20tramos=20a=20pie=20con=20puntos=20co?= =?UTF-8?q?loreados:=20llano,=20subida=20y=20bajada.=20A=C3=B1adidos=20umb?= =?UTF-8?q?rales=20configurables=20para=20subida=20y=20bajada=20junto=20al?= =?UTF-8?q?=20interruptor=20de=20elevaciones.=20A=C3=B1adido=20callejero?= =?UTF-8?q?=20inverso=20en=20el=20detalle=20de=20la=20soluci=C3=B3n,=20mos?= =?UTF-8?q?trando=20el=20lugar=20del=20callejero=20m=C3=A1s=20cercano=20al?= =?UTF-8?q?=20origen=20y=20al=20destino.=20Ajustado=20el=20modo=20Equilib.?= =?UTF-8?q?=20A=20para=20limpiar=20la=20tarjeta=20visualmente=20y=20guarda?= =?UTF-8?q?r/restaurar=20el=20factor=20usado=20desde=20el=20historial.=20M?= =?UTF-8?q?ejoras=20menores=20en=20el=20refresco=20del=20resumen=20t=C3=A9?= =?UTF-8?q?cnico=20de=20rutas=20y=20validaci=C3=B3n=20de=20compilaci=C3=B3?= =?UTF-8?q?n.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AuthController.cs | 2 +- .../Controllers/DenunciasController.cs | 415 ++++++++++++++++-- .../Controllers/InboxController.cs | 75 +++- .../Helpers/GlobalLeaksJsonEnricher.cs | 208 +++++++-- .../ApiDenuncias/Services/GestionaService.cs | 20 +- .../Services/GlobalLeaksClient.cs | 161 +++++-- .../Services/GlobalLeaksSessionStore.cs | 5 + .../Models/ApiDenunciasDtos.cs | 7 + .../Models/GlSession.cs | 2 +- .../Models/GlobalLeaksStoredSession.cs | 5 +- 10 files changed, 792 insertions(+), 108 deletions(-) diff --git a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs index c5a8664..083f3c6 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs @@ -304,7 +304,7 @@ public sealed class AuthController : ControllerBase : session.Username.Trim(); _logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username); - await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken); + await _sessionStore.SaveAsync(username, password, session.Id, session.Role, session.DpopPrivateKey, cancellationToken); _logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username); var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs index c175e4f..f810a1d 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; +using System.Text.RegularExpressions; using ApiDenuncias.Services; using GestionaDenuncias.Shared.Models; using Microsoft.AspNetCore.Authorization; @@ -64,8 +65,9 @@ public sealed class DenunciasController : ControllerBase return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken)); } + [AllowAnonymous] [HttpGet("{denunciaId:int}/gestiona-fields")] - public async Task> GetGestionaFields( + public async Task> GetGestionaFields( int denunciaId, CancellationToken cancellationToken) { @@ -80,7 +82,19 @@ public sealed class DenunciasController : ControllerBase return NotFound(new ApiError("No se ha encontrado la denuncia solicitada.")); } - return Ok(ToGestionaComplaintFields(denuncia)); + var response = ToGestionaComplaintFields(denuncia); + var missingFields = GetMissingGestionaFields(response); + if (missingFields.Count > 0) + { + _logger.LogInformation( + "Campos Gestiona denuncia {DenunciaId}: campos vacios={MissingFields}; rawReportLength={RawReportLength}; formFieldsCount={FormFieldsCount}.", + denunciaId, + string.Join(", ", missingFields), + denuncia.TextoOriginalReport?.Length ?? 0, + denuncia.GetCamposFormulario().Count); + } + + return Ok(ToExternalGestionaFields(response)); } [HttpPost] @@ -234,18 +248,9 @@ public sealed class DenunciasController : ControllerBase private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia) { - var preferenciaNotificacion = ResolveReportField( - denuncia, - denuncia.Notificacion_Preferencia, - "preferencia de notificacion", - "seleccione su preferencia de notificacion y seguimiento de su denuncia", - "notificaciones"); - - var seguimiento = ResolveReportField( - denuncia, - denuncia.SeguimientoOnline, - "seguimiento online", - "seguimiento de su denuncia"); + var preferenciaNotificacion = ResolveNotificationPreference(denuncia); + var seguimiento = ResolveSeguimientoDenuncia(denuncia); + var sms = ResolveSmsNotification(denuncia); return new GestionaComplaintFieldsResponse( FechaDenuncia: ToNullableDate(denuncia.Fecha), @@ -255,29 +260,38 @@ public sealed class DenunciasController : ControllerBase ResolveReportField(denuncia, string.Empty, "resumen de la denuncia", "resumen denuncia"), ResolveReportField(denuncia, denuncia.Descripcion_Denuncia, "describa su denuncia", "descripcion de la denuncia")), FechaHechos: ResolveFechaHechos(denuncia), - LugarHechos: ResolveReportField( - denuncia, - denuncia.Lugar_Hechos, - "lugar en el que ocurrieron los hechos que denuncia", - "lugar en la que ocurrieron los hechos que denuncia", - "lugar de los hechos"), + LugarHechos: ResolveLugarHechos(denuncia), AmbitoCompetencias: ResolveAmbitoCompetencias(denuncia), SolicitaProteccion: ResolveReportField(denuncia, denuncia.SolicitaProteccion, "solicita medidas concretas de proteccion", "solicita proteccion"), SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"), AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"), - AutorizaNotificacionesViaSms: ResolveReportField( - denuncia, - denuncia.Notificacion_Sms, - "autorizo recibir notificaciones via sms", - "autorizacion notificaciones via sms", - "autorizacion para recibir notificaciones via sms", - "autoriza notificaciones via sms", - "autorizo recibir notificaciones sms", - "notificaciones via sms", - "sms"), + AutorizaNotificacionesViaSms: sms, PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento)); } + private static GestionaExternalFieldsResponse ToExternalGestionaFields(GestionaComplaintFieldsResponse source) + { + return new GestionaExternalFieldsResponse( + new Dictionary(StringComparer.Ordinal) + { + ["fechaDenuncia"] = StringField(source.FechaDenuncia?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty), + ["numeroDenunciaCanal"] = StringField(source.NumeroDenunciaCanal.ToString(CultureInfo.InvariantCulture)), + ["aQuienDenuncia"] = StringField(source.AQuienDenuncia), + ["resumenDenuncia"] = StringField(source.ResumenDenuncia), + ["fechaHechos"] = StringField(source.FechaHechos), + ["lugarHechos"] = StringField(source.LugarHechos), + ["ambitoCompetencias"] = StringField(source.AmbitoCompetencias), + ["solicitaProteccion"] = StringField(source.SolicitaProteccion), + ["sexoDenunciante"] = StringField(source.SexoDenunciante), + ["autorizaRemisionDenuncia"] = StringField(source.AutorizaRemisionDenuncia), + ["autorizaNotificacionesViaSms"] = StringField(source.AutorizaNotificacionesViaSms), + ["preferenciaNotificacionSeguimientoDenuncia"] = StringField(source.PreferenciaNotificacionSeguimientoDenuncia) + }); + } + + private static GestionaExternalFieldValue StringField(string? value) + => new("STRING", value?.Trim() ?? string.Empty); + private async Task TryRegisterGestionaHistoryFromComplaintAsync( DenunciasGestiona denuncia, CancellationToken cancellationToken) @@ -319,6 +333,32 @@ public sealed class DenunciasController : ControllerBase private static DateTime? ToNullableDate(DateTime value) => value == DateTime.MinValue ? null : value; + private static IReadOnlyList GetMissingGestionaFields(GestionaComplaintFieldsResponse response) + { + var missing = new List(); + + AddIfMissing(missing, response.AQuienDenuncia, nameof(response.AQuienDenuncia)); + AddIfMissing(missing, response.ResumenDenuncia, nameof(response.ResumenDenuncia)); + AddIfMissing(missing, response.FechaHechos, nameof(response.FechaHechos)); + AddIfMissing(missing, response.LugarHechos, nameof(response.LugarHechos)); + AddIfMissing(missing, response.AmbitoCompetencias, nameof(response.AmbitoCompetencias)); + AddIfMissing(missing, response.SolicitaProteccion, nameof(response.SolicitaProteccion)); + AddIfMissing(missing, response.SexoDenunciante, nameof(response.SexoDenunciante)); + AddIfMissing(missing, response.AutorizaRemisionDenuncia, nameof(response.AutorizaRemisionDenuncia)); + AddIfMissing(missing, response.AutorizaNotificacionesViaSms, nameof(response.AutorizaNotificacionesViaSms)); + AddIfMissing(missing, response.PreferenciaNotificacionSeguimientoDenuncia, nameof(response.PreferenciaNotificacionSeguimientoDenuncia)); + + return missing; + } + + private static void AddIfMissing(List missing, string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + missing.Add(fieldName); + } + } + private static string ResolveFechaHechos(DenunciasGestiona denuncia) { var literalValue = ResolveReportField( @@ -332,16 +372,125 @@ public sealed class DenunciasController : ControllerBase return literalValue; } + var textDate = ExtractDateAfterReportLabel( + denuncia.TextoOriginalReport, + normalizedLabel => normalizedLabel.Contains("fecha", StringComparison.Ordinal) && + normalizedLabel.Contains("hechos", StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(textDate)) + { + return textDate; + } + return denuncia.Fecha_Hechos == DateTime.MinValue ? string.Empty : denuncia.Fecha_Hechos.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture); } + private static string ResolveLugarHechos(DenunciasGestiona denuncia) + { + var directValue = ResolveReportField( + denuncia, + denuncia.Lugar_Hechos, + "lugar en el que ocurrieron los hechos que denuncia", + "lugar en la que ocurrieron los hechos que denuncia", + "lugar de los hechos"); + + if (!string.IsNullOrWhiteSpace(directValue)) + { + return directValue; + } + + return ExtractReportTextValue( + denuncia.TextoOriginalReport, + normalizedLabel => normalizedLabel.Contains("lugar", StringComparison.Ordinal) && + normalizedLabel.Contains("hechos", StringComparison.Ordinal)); + } + + private static string ResolveSeguimientoDenuncia(DenunciasGestiona denuncia) + { + var directValue = ResolveReportField( + denuncia, + denuncia.SeguimientoOnline, + "seguimiento online", + "seguimiento de su denuncia"); + + if (!string.IsNullOrWhiteSpace(directValue)) + { + return directValue; + } + + return ReportContainsLabel(denuncia.TextoOriginalReport, "seguimiento online") + ? "Seguimiento Online" + : string.Empty; + } + + private static string ResolveNotificationPreference(DenunciasGestiona denuncia) + { + var directValue = ResolveReportField( + denuncia, + denuncia.Notificacion_Preferencia, + "preferencia de notificacion", + "seleccione su preferencia de notificacion y seguimiento de su denuncia", + "preferencia de notificacion y seguimiento de su denuncia", + "notificaciones"); + if (!string.IsNullOrWhiteSpace(directValue)) + { + return directValue; + } + + directValue = ResolveReportFieldAllowingLabelValues( + denuncia, + string.Empty, + "preferencia de notificacion", + "seleccione su preferencia de notificacion y seguimiento de su denuncia", + "preferencia de notificacion y seguimiento de su denuncia"); + if (!string.IsNullOrWhiteSpace(directValue)) + { + return directValue; + } + + var electronic = ResolveReportField( + denuncia, + denuncia.Notificacion_Electronica, + "notificaciones electronicas", + "notificacion electronica"); + if (!string.IsNullOrWhiteSpace(electronic) || + ReportContainsLabel(denuncia.TextoOriginalReport, "notificaciones electronicas")) + { + return "Notificaciones electronicas"; + } + + var postal = ResolveReportField( + denuncia, + denuncia.NotificacionPostal, + "autorizo recibir notificaciones via correo postal", + "notificaciones via correo postal", + "correo postal"); + return string.IsNullOrWhiteSpace(postal) ? string.Empty : "Correo postal"; + } + + private static string ResolveSmsNotification(DenunciasGestiona denuncia) + { + return ResolveReportField( + denuncia, + denuncia.Notificacion_Sms, + "autorizo recibir notificaciones via sms", + "autorizacion notificaciones via sms", + "autorizacion para recibir notificaciones via sms", + "autoriza notificaciones via sms", + "autorizo recibir notificaciones sms", + "notificaciones via sms", + "sms"); + } + private static string ResolveAmbitoCompetencias(DenunciasGestiona denuncia) { var directValue = ResolveReportField( denuncia, - denuncia.Modalidad_Informacion, + FirstNonEmpty(denuncia.Modalidad_Informacion, denuncia.Asunto), + "asunto", + "categoria", + "tipo de denuncia", "ambito de competencias", "ambito competencial", "ambito", @@ -388,7 +537,39 @@ public sealed class DenunciasController : ControllerBase } } - return string.Empty; + return ExtractReportTextValue(denuncia.TextoOriginalReport, labels); + } + + private static string ResolveReportFieldAllowingLabelValues( + DenunciasGestiona denuncia, + string? currentValue, + params string[] candidateLabels) + { + if (!string.IsNullOrWhiteSpace(currentValue)) + { + return currentValue.Trim(); + } + + var labels = candidateLabels + .Select(NormalizeLabel) + .Where(label => !string.IsNullOrWhiteSpace(label)) + .ToHashSet(StringComparer.Ordinal); + + if (labels.Count == 0) + { + return string.Empty; + } + + foreach (var field in denuncia.GetCamposFormulario()) + { + if (!string.IsNullOrWhiteSpace(field.Value) && + labels.Contains(NormalizeLabel(field.Label))) + { + return field.Value.Trim(); + } + } + + return ExtractReportTextValue(denuncia.TextoOriginalReport, labels, stopAtLikelyLabels: false); } private static string FindReportFieldValue( @@ -407,6 +588,176 @@ public sealed class DenunciasController : ControllerBase return string.Empty; } + private static string ExtractDateAfterReportLabel(string? reportText, Func labelPredicate) + { + if (string.IsNullOrWhiteSpace(reportText)) + { + return string.Empty; + } + + var lines = reportText + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .Select(line => line.Trim()) + .ToArray(); + + for (var index = 0; index < lines.Length; index++) + { + var normalized = NormalizeLabel(lines[index]); + if (!labelPredicate(normalized)) + { + continue; + } + + for (var valueIndex = index + 1; valueIndex < lines.Length && valueIndex <= index + 12; valueIndex++) + { + var candidate = lines[valueIndex].Trim(); + if (string.IsNullOrWhiteSpace(candidate) || IsReportStructuralLine(candidate)) + { + continue; + } + + var dateMatch = Regex.Match( + candidate, + @"\b\d{1,2}/\d{1,2}/\d{4}\b", + RegexOptions.CultureInvariant); + if (dateMatch.Success) + { + return dateMatch.Value; + } + + if (IsLikelyReportLabel(candidate)) + { + break; + } + } + } + + var rawMatch = Regex.Match( + reportText, + @"fecha\s+de\s+los\s+hechos[\s\S]{0,600}?(\b\d{1,2}/\d{1,2}/\d{4}\b)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return rawMatch.Success ? rawMatch.Groups[1].Value : string.Empty; + } + + private static string ExtractReportTextValue(string? reportText, IReadOnlySet normalizedLabels) + => ExtractReportTextValue( + reportText, + normalizedLabel => normalizedLabels.Contains(normalizedLabel)); + + private static string ExtractReportTextValue( + string? reportText, + IReadOnlySet normalizedLabels, + bool stopAtLikelyLabels) + => ExtractReportTextValue( + reportText, + normalizedLabel => normalizedLabels.Contains(normalizedLabel), + stopAtLikelyLabels); + + private static string ExtractReportTextValue(string? reportText, Func labelPredicate) + => ExtractReportTextValue(reportText, labelPredicate, stopAtLikelyLabels: true); + + private static string ExtractReportTextValue( + string? reportText, + Func labelPredicate, + bool stopAtLikelyLabels) + { + if (string.IsNullOrWhiteSpace(reportText)) + { + return string.Empty; + } + + var lines = reportText + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .Select(line => line.Trim()) + .ToArray(); + + for (var index = 0; index < lines.Length; index++) + { + if (!labelPredicate(NormalizeLabel(lines[index]))) + { + continue; + } + + for (var valueIndex = index + 1; valueIndex < lines.Length; valueIndex++) + { + var candidate = lines[valueIndex].Trim(); + if (string.IsNullOrWhiteSpace(candidate) || IsReportStructuralLine(candidate)) + { + continue; + } + + if (stopAtLikelyLabels && IsLikelyReportLabel(candidate)) + { + break; + } + + return candidate; + } + } + + return string.Empty; + } + + private static bool ReportContainsLabel(string? reportText, string label) + { + if (string.IsNullOrWhiteSpace(reportText)) + { + return false; + } + + var normalizedLabel = NormalizeLabel(label); + return reportText + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .Any(line => string.Equals(NormalizeLabel(line.Trim()), normalizedLabel, StringComparison.Ordinal)); + } + + private static bool IsReportStructuralLine(string value) + { + if (Regex.IsMatch(value, @"^\d+/\d+$", RegexOptions.CultureInvariant)) + { + return true; + } + + return value.StartsWith("REPORT ", StringComparison.OrdinalIgnoreCase) || + value.Equals("[CONFIDENTIAL]", StringComparison.OrdinalIgnoreCase) || + value.Equals("{Messages}", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Comments", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("De:", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Fecha:", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLikelyReportLabel(string value) + { + var normalized = NormalizeLabel(value); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + return normalized is "descripcion" + or "datos del denunciante" + or "condiciones y reglas de uso" + or "tratamiento de datos personales" + or "preferencias de notificacion" + or "notificaciones electronicas" || + normalized.StartsWith("indique ", StringComparison.Ordinal) || + normalized.StartsWith("describa ", StringComparison.Ordinal) || + normalized.StartsWith("autoriza ", StringComparison.Ordinal) || + normalized.StartsWith("autorizacion ", StringComparison.Ordinal) || + normalized.StartsWith("ha denunciado ", StringComparison.Ordinal) || + normalized.StartsWith("solicita ", StringComparison.Ordinal) || + normalized.StartsWith("por favor ", StringComparison.Ordinal) || + normalized.Contains(" denuncia", StringComparison.Ordinal) || + normalized.Contains(" hechos", StringComparison.Ordinal) || + normalized.Contains(" notificacion", StringComparison.Ordinal); + } + private static string JoinDistinct(params string[] values) { var result = new List(); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs index 48591a5..fc21766 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs @@ -131,7 +131,7 @@ public sealed class InboxController : ControllerBase cancellationToken); } - await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken); + await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, session.DpopPrivateKey, cancellationToken); var stored = await _sessionStore.GetAsync(username, cancellationToken); return Ok(ToDto(stored)); } @@ -173,13 +173,21 @@ public sealed class InboxController : ControllerBase { var state = await _trackingService.GetUserStateAsync(username, cancellationToken); var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken); - var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts); + var reports = await _globalLeaksClient.GetReportsAsync( + session.SessionId!, + "all", + null, + null, + cancellationToken, + contexts, + session.DpopPrivateKey); var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken); var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync( session.SessionId!, enrichedReports, state.LastDownloadedReportMomentUtc, - cancellationToken); + cancellationToken, + session.DpopPrivateKey); return Ok(new InboxSnapshotResponse(contexts, activityReports, state)); } @@ -229,7 +237,8 @@ public sealed class InboxController : ControllerBase session.SessionId!, report.Id, report.LastAccess, - cancellationToken); + cancellationToken, + session.DpopPrivateKey); } catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity || ex.StatusCode is >= 500 and <= 504) @@ -240,12 +249,16 @@ public sealed class InboxController : ControllerBase report.Id); } - var reportPackage = await DownloadReportPackageWithRetryAsync(session.SessionId!, report, cancellationToken); + var reportPackage = await DownloadReportPackageWithRetryAsync(session, report, cancellationToken); FileDownloadResult? json = null; try { - json = await _globalLeaksClient.ExportReportJsonAsync(session.SessionId!, report.Id, cancellationToken); + json = await _globalLeaksClient.ExportReportJsonAsync( + session.SessionId!, + report.Id, + cancellationToken, + session.DpopPrivateKey); } catch (GlobalLeaksValidationException ex) when (ex.StatusCode == 422) { @@ -297,7 +310,12 @@ public sealed class InboxController : ControllerBase try { - return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken)); + return Ok(await _globalLeaksClient.GetReportDetailAsync( + session.SessionId!, + reportId, + lastAccess, + cancellationToken, + session.DpopPrivateKey)); } catch (GlobalLeaksSessionExpiredException) { @@ -318,7 +336,7 @@ public sealed class InboxController : ControllerBase } private async Task DownloadReportPackageWithRetryAsync( - string sessionId, + GlobalLeaksStoredSession session, ReportDto report, CancellationToken cancellationToken) { @@ -326,7 +344,11 @@ public sealed class InboxController : ControllerBase { try { - return await _globalLeaksClient.DownloadReportPackageAsync(sessionId, report.Id, cancellationToken); + return await _globalLeaksClient.DownloadReportPackageAsync( + session.SessionId!, + report.Id, + cancellationToken, + session.DpopPrivateKey); } catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length) { @@ -353,11 +375,37 @@ public sealed class InboxController : ControllerBase private static bool IsExportNotReady(GlobalLeaksValidationException ex) => ex.StatusCode is >= 500 and <= 504; - private static ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation) + private ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation) { if (ex.StatusCode == StatusCodes.Status401Unauthorized || ex.StatusCode == StatusCodes.Status403Forbidden) { + _logger.LogWarning( + "GlobalLeaks rechazo la operacion '{Operation}'. Status={StatusCode}. Mensaje={Message}", + operation, + ex.StatusCode, + ex.Message); + + if (operation.Contains("bandeja", StringComparison.OrdinalIgnoreCase)) + { + return new ObjectResult(new ApiError( + "GlobalLeaks ha rechazado la sesion al cargar la bandeja. Renueva el 2FA y vuelve a intentarlo.", + SessionExpired: true)) + { + StatusCode = StatusCodes.Status401Unauthorized + }; + } + + if (IsSessionAuthorizationProblem(ex.Message)) + { + return new ObjectResult(new ApiError( + "La sesion de GlobalLeaks no es valida o ha caducado. Renueva el 2FA y vuelve a intentarlo.", + SessionExpired: true)) + { + StatusCode = StatusCodes.Status401Unauthorized + }; + } + return new ObjectResult(new ApiError( "No tienes permiso en GlobalLeaks para acceder a esa denuncia. Puede ser una denuncia anterior a la creacion de tu usuario; consulta con el administrador del buzon.")) { @@ -380,6 +428,13 @@ public sealed class InboxController : ControllerBase }; } + private static bool IsSessionAuthorizationProblem(string? message) + => !string.IsNullOrWhiteSpace(message) && + (message.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase) || + message.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) || + message.Contains("Invalid session", StringComparison.OrdinalIgnoreCase) || + message.Contains("session", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase)); + private async Task RequireActiveSessionAsync(string username, CancellationToken cancellationToken) { var session = await _sessionStore.GetAsync(username, cancellationToken); diff --git a/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs b/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs index 1029080..3c2c67f 100644 --- a/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs +++ b/Antifraude.Net/ApiDenuncias/Helpers/GlobalLeaksJsonEnricher.cs @@ -121,12 +121,23 @@ public static class GlobalLeaksJsonEnricher } } - if (element.TryGetProperty("children", out var children) && - children.ValueKind == JsonValueKind.Array) + foreach (var property in element.EnumerateObject()) { - foreach (var child in children.EnumerateArray()) + if (property.NameEquals("options")) { - CollectDefinitions(child, definitions); + continue; + } + + if (property.Value.ValueKind == JsonValueKind.Object) + { + CollectDefinitions(property.Value, definitions); + } + else if (property.Value.ValueKind == JsonValueKind.Array) + { + foreach (var child in property.Value.EnumerateArray()) + { + CollectDefinitions(child, definitions); + } } } } @@ -163,42 +174,57 @@ public static class GlobalLeaksJsonEnricher private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition) { + if (answerArray.ValueKind == JsonValueKind.Object) + { + return ResolveAnswerObject(answerArray, definition); + } + if (answerArray.ValueKind != JsonValueKind.Array) { - return string.Empty; + return ResolveValue(answerArray, definition.Options); } var values = new List(); foreach (var answer in answerArray.EnumerateArray()) { - if (answer.ValueKind != JsonValueKind.Object) + var resolved = answer.ValueKind == JsonValueKind.Object + ? ResolveAnswerObject(answer, definition) + : ResolveValue(answer, definition.Options); + if (!string.IsNullOrWhiteSpace(resolved)) + { + values.Add(resolved); + } + } + + return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase)); + } + + private static string ResolveAnswerObject(JsonElement answer, FieldDefinition definition) + { + var values = new List(); + + if (answer.TryGetProperty("value", out var valueElement)) + { + var resolvedValue = ResolveValue(valueElement, definition.Options); + if (!string.IsNullOrWhiteSpace(resolvedValue)) + { + values.Add(resolvedValue); + } + } + + foreach (var property in answer.EnumerateObject()) + { + if (property.NameEquals("value") || property.NameEquals("index") || property.NameEquals("required_status")) { continue; } - if (answer.TryGetProperty("value", out var valueElement)) + if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False && + property.Value.GetBoolean() && + definition.Options.TryGetValue(property.Name, out var label)) { - var resolvedValue = ResolveValue(valueElement, definition.Options); - if (!string.IsNullOrWhiteSpace(resolvedValue)) - { - values.Add(resolvedValue); - } - } - - foreach (var property in answer.EnumerateObject()) - { - if (property.NameEquals("value") || property.NameEquals("index") || property.NameEquals("required_status")) - { - continue; - } - - if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False && - property.Value.GetBoolean() && - definition.Options.TryGetValue(property.Name, out var label)) - { - values.Add(label); - } + values.Add(label); } } @@ -213,10 +239,95 @@ public static class GlobalLeaksJsonEnricher JsonValueKind.True => "Sí", JsonValueKind.False => "No", JsonValueKind.Number => valueElement.GetRawText(), + JsonValueKind.Array => ResolveArrayValue(valueElement, options), + JsonValueKind.Object => ResolveObjectValue(valueElement, options), _ => string.Empty, }; } + private static string ResolveArrayValue(JsonElement valueElement, Dictionary options) + { + var values = valueElement + .EnumerateArray() + .Select(item => ResolveValue(item, options)) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return string.Join("; ", values); + } + + private static string ResolveObjectValue(JsonElement valueElement, Dictionary options) + { + foreach (var propertyName in new[] { "label", "text", "value", "answer", "date", "formatted", "name" }) + { + if (!valueElement.TryGetProperty(propertyName, out var propertyValue)) + { + continue; + } + + var resolved = ResolveValue(propertyValue, options); + if (!string.IsNullOrWhiteSpace(resolved)) + { + return resolved; + } + } + + if (TryReadDateParts(valueElement, out var dateText)) + { + return dateText; + } + + var values = new List(); + foreach (var property in valueElement.EnumerateObject()) + { + if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False && + property.Value.GetBoolean() && + options.TryGetValue(property.Name, out var label)) + { + values.Add(label); + } + } + + return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase)); + } + + private static bool TryReadDateParts(JsonElement valueElement, out string dateText) + { + dateText = string.Empty; + if (!TryGetIntProperty(valueElement, "day", out var day) || + !TryGetIntProperty(valueElement, "month", out var month) || + !TryGetIntProperty(valueElement, "year", out var year)) + { + return false; + } + + try + { + dateText = new DateTime(year, month, day).ToString("dd/MM/yyyy", CultureInfo.InvariantCulture); + return true; + } + catch + { + return false; + } + } + + private static bool TryGetIntProperty(JsonElement valueElement, string propertyName, out int value) + { + value = 0; + if (!valueElement.TryGetProperty(propertyName, out var property)) + { + return false; + } + + return property.ValueKind switch + { + JsonValueKind.Number => property.TryGetInt32(out value), + JsonValueKind.String => int.TryParse(property.GetString(), CultureInfo.InvariantCulture, out value), + _ => false, + }; + } + private static string ResolveStringValue(string? rawValue, Dictionary options) { if (string.IsNullOrWhiteSpace(rawValue)) @@ -259,12 +370,13 @@ public static class GlobalLeaksJsonEnricher SetIfMissing(() => denuncia.OrganismoDenunciado, value => denuncia.OrganismoDenunciado = value, answers, "por favor indique el organismo o la institucion donde ha denunciado los hechos"); SetIfMissing(() => denuncia.SolicitaProteccion, value => denuncia.SolicitaProteccion = value, answers, "solicita medidas concretas de proteccion"); SetIfMissing(() => denuncia.MedidasProteccionSolicitadas, value => denuncia.MedidasProteccionSolicitadas = value, answers, "describa las medidas de proteccion solicitadas"); - SetIfMissing(() => denuncia.Lugar_Hechos, value => denuncia.Lugar_Hechos = value, answers, "lugar en la que ocurrieron los hechos que denuncia"); + SetIfMissing(() => denuncia.Lugar_Hechos, value => denuncia.Lugar_Hechos = value, answers, "lugar en el que ocurrieron los hechos que denuncia", "lugar en la que ocurrieron los hechos que denuncia", "lugar de los hechos"); SetIfMissing(() => denuncia.AutorizaRemision, value => denuncia.AutorizaRemision = value, answers, "autorizacion para remitir su denuncia"); SetIfMissing(() => denuncia.PreferenciaRemision, value => denuncia.PreferenciaRemision = value, answers, "en tal caso desea que su denuncia se remita anonimizada sin datos personales"); - SetIfMissing(() => denuncia.Notificacion_Preferencia, value => denuncia.Notificacion_Preferencia = value, answers, "seleccione su preferencia de notificacion y seguimiento de su denuncia", "notificaciones"); + SetIfMissing(() => denuncia.Notificacion_Preferencia, value => denuncia.Notificacion_Preferencia = value, answers, "preferencia de notificacion", "seleccione su preferencia de notificacion y seguimiento de su denuncia", "preferencia de notificacion y seguimiento de su denuncia", "notificaciones"); SetIfMissing(() => denuncia.Notificacion_Electronica, value => denuncia.Notificacion_Electronica = value, answers, "notificaciones electronicas"); - SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online"); + SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online", "seguimiento de su denuncia"); + SetIfMissing(() => denuncia.Notificacion_Sms, value => denuncia.Notificacion_Sms = value, answers, "autorizo recibir notificaciones via sms", "autorizacion notificaciones via sms", "autoriza notificaciones via sms", "notificaciones via sms", "sms"); SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal"); SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email"); SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil"); @@ -282,8 +394,8 @@ public static class GlobalLeaksJsonEnricher SetIfMissing(() => denuncia.Pais, value => denuncia.Pais = value, answers, "pais"); if (denuncia.Fecha_Hechos == DateTime.MinValue && - TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia") && - DateTime.TryParse(fechaHechos, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsedDate)) + TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia", "fecha de los hechos") && + TryParseReportDate(fechaHechos, out var parsedDate)) { denuncia.Fecha_Hechos = parsedDate; } @@ -371,6 +483,38 @@ public static class GlobalLeaksJsonEnricher return false; } + private static bool TryParseReportDate(string? value, out DateTime parsedDate) + { + parsedDate = DateTime.MinValue; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + foreach (var culture in new[] + { + CultureInfo.GetCultureInfo("es-ES"), + CultureInfo.InvariantCulture, + CultureInfo.CurrentCulture + }) + { + if (DateTime.TryParse(value, culture, DateTimeStyles.None, out parsedDate)) + { + return true; + } + } + + foreach (var format in new[] { "dd/MM/yyyy", "d/M/yyyy", "yyyy-MM-dd" }) + { + if (DateTime.TryParseExact(value, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsedDate)) + { + return true; + } + } + + return false; + } + private static string Normalize(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs index ef3204b..3c210fe 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GestionaService.cs @@ -648,9 +648,20 @@ namespace ApiDenuncias.Services "application/vnd.gestiona.filter.thirds+json"); using var resp = await _http.SendAsync(req); - resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadAsStringAsync(); + if (resp.StatusCode == System.Net.HttpStatusCode.NoContent || + string.IsNullOrWhiteSpace(body)) + { + return default; + } + + if (!resp.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Error BuscarTerceroPorNifAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); + } + using var doc = JsonDocument.Parse(body); if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return default; @@ -768,8 +779,13 @@ namespace ApiDenuncias.Services if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) return new HashSet(StringComparer.Ordinal); - resp.EnsureSuccessStatusCode(); var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + throw new InvalidOperationException( + $"Error ObtenerTercerosEnlazadosAsync: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); + + if (string.IsNullOrWhiteSpace(body)) + return new HashSet(StringComparer.Ordinal); using var doc = JsonDocument.Parse(body); var set = new HashSet(StringComparer.Ordinal); diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs index 35d3096..089847b 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs @@ -152,7 +152,7 @@ public sealed class GlobalLeaksClient if (authResponse.IsSuccessStatusCode) { var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); - var session = ParseAuthSession(authBody, username); + var session = ParseAuthSession(authBody, username, ExportDpopPrivateKey(dpopKey)); _logger.LogInformation( "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms", session.Username, @@ -233,12 +233,17 @@ public sealed class GlobalLeaksClient string? dateFrom, string? dateTo, CancellationToken cancellationToken, - IReadOnlyList? contexts = null) + IReadOnlyList? contexts = null, + string? dpopPrivateKey = null) { filter ??= "all"; - using var reportsRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId); - using var reportsResponse = await SendGlRequestAsync(reportsRequest, cancellationToken); + using var reportsResponse = await SendAuthenticatedGlRequestAsync( + HttpMethod.Get, + "/api/recipient/rtips", + sessionId, + dpopPrivateKey, + cancellationToken); var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken); var tips = ParseReports(body); _logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count); @@ -312,7 +317,8 @@ public sealed class GlobalLeaksClient string sessionId, IReadOnlyList reports, DateTimeOffset? fallbackReference, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? dpopPrivateKey = null) { if (reports.Count == 0) { @@ -337,7 +343,7 @@ public sealed class GlobalLeaksClient try { var reference = GetActivityReference(item.Report, fallbackReference); - var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, cancellationToken); + var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, dpopPrivateKey, cancellationToken); enriched[item.Index] = ApplyActivity(item.Report, activity); } catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException) @@ -362,16 +368,17 @@ public sealed class GlobalLeaksClient string sessionId, string reportId, string? lastAccess, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? dpopPrivateKey = null) { ValidateUuid(reportId); var reference = ParseDate(lastAccess); - using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken); + using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken); IReadOnlyList? comments = null; try { - comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, cancellationToken); + comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, dpopPrivateKey, cancellationToken); } catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException) { @@ -387,17 +394,16 @@ public sealed class GlobalLeaksClient public async Task DownloadReportPackageAsync( string sessionId, string reportId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? dpopPrivateKey = null) { ValidateUuid(reportId); - using var request = CreateAuthenticatedRequest( + using var response = await SendAuthenticatedGlRequestAsync( HttpMethod.Get, $"/api/recipient/rtips/{reportId}/export", - sessionId); - - using var response = await SendGlRequestAsync( - request, + sessionId, + dpopPrivateKey, cancellationToken, HttpCompletionOption.ResponseHeadersRead); @@ -417,12 +423,17 @@ public sealed class GlobalLeaksClient public async Task ExportReportJsonAsync( string sessionId, string reportId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? dpopPrivateKey = null) { ValidateUuid(reportId); - using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); - using var response = await SendGlRequestAsync(request, cancellationToken); + using var response = await SendAuthenticatedGlRequestAsync( + HttpMethod.Get, + $"/api/recipient/rtips/{reportId}", + sessionId, + dpopPrivateKey, + cancellationToken); var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); @@ -444,10 +455,15 @@ public sealed class GlobalLeaksClient private async Task ReadReportDetailDocumentAsync( string sessionId, string reportId, + string? dpopPrivateKey, CancellationToken cancellationToken) { - using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); - using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken); + using var detailResponse = await SendAuthenticatedGlRequestAsync( + HttpMethod.Get, + $"/api/recipient/rtips/{reportId}", + sessionId, + dpopPrivateKey, + cancellationToken); var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty; var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken); @@ -466,10 +482,15 @@ public sealed class GlobalLeaksClient string reportId, DateTimeOffset? reference, bool defaultNewWhenNoReference, + string? dpopPrivateKey, CancellationToken cancellationToken) { - using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}/comments", sessionId); - using var response = await SendGlRequestAsync(request, cancellationToken); + using var response = await SendAuthenticatedGlRequestAsync( + HttpMethod.Get, + $"/api/recipient/rtips/{reportId}/comments", + sessionId, + dpopPrivateKey, + cancellationToken); var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); @@ -499,10 +520,11 @@ public sealed class GlobalLeaksClient string sessionId, ReportDto report, DateTimeOffset? reference, + string? dpopPrivateKey, CancellationToken cancellationToken) { var reportId = report.Id; - using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken); + using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken); IReadOnlyList comments; try { @@ -511,6 +533,7 @@ public sealed class GlobalLeaksClient reportId, reference, defaultNewWhenNoReference: false, + dpopPrivateKey, cancellationToken); } catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException) @@ -650,7 +673,16 @@ public sealed class GlobalLeaksClient if (!response.IsSuccessStatusCode) { - var message = $"Error de GlobalLeaks (código {(int)response.StatusCode})."; + var body = await ReadBodySafeAsync(response, cancellationToken); + _logger.LogWarning( + "GlobalLeaks devolvio {StatusCode} en {Method} {Path}. Body={Body}", + (int)response.StatusCode, + request.Method.Method, + request.RequestUri?.OriginalString, + string.IsNullOrWhiteSpace(body) ? "(sin cuerpo)" : body); + var message = string.IsNullOrWhiteSpace(body) + ? $"Error de GlobalLeaks (código {(int)response.StatusCode})." + : $"Error de GlobalLeaks (código {(int)response.StatusCode}): {body}"; response.Dispose(); throw new GlobalLeaksValidationException(message, (int)response.StatusCode); } @@ -658,6 +690,38 @@ public sealed class GlobalLeaksClient return response; } + private async Task SendAuthenticatedGlRequestAsync( + HttpMethod method, + string path, + string sessionId, + string? dpopPrivateKey, + CancellationToken cancellationToken, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) + { + if (string.IsNullOrWhiteSpace(dpopPrivateKey)) + { + using var requestWithoutDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey: null); + return await SendGlRequestAsync(requestWithoutDpop, cancellationToken, completionOption); + } + + try + { + using var requestWithDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey); + return await SendGlRequestAsync(requestWithDpop, cancellationToken, completionOption); + } + catch (GlobalLeaksValidationException ex) when (ex.StatusCode is StatusCodes.Status401Unauthorized or StatusCodes.Status403Forbidden) + { + _logger.LogWarning( + ex, + "GlobalLeaks ha rechazado {Method} {Path} con DPoP. Se reintentara una vez solo con X-Session para compatibilidad.", + method.Method, + path); + + using var fallbackRequest = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey: null); + return await SendGlRequestAsync(fallbackRequest, cancellationToken, completionOption); + } + } + private static HttpRequestMessage CreateRequest(HttpMethod method, string path) { var request = new HttpRequestMessage(method, path) @@ -671,10 +735,26 @@ public sealed class GlobalLeaksClient return request; } - private static HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string path, string sessionId) + private static HttpRequestMessage CreateAuthenticatedRequest( + HttpMethod method, + string path, + string sessionId, + string? dpopPrivateKey) { var request = CreateRequest(method, path); request.Headers.Add("X-Session", sessionId); + if (!string.IsNullOrWhiteSpace(dpopPrivateKey)) + { + using var key = ImportDpopPrivateKey(dpopPrivateKey); + request.Headers.TryAddWithoutValidation( + "DPoP", + CreateDpopProof( + key, + method.Method.ToUpperInvariant(), + GetDpopPath(path), + ComputeAth(sessionId))); + } + return request; } @@ -717,7 +797,7 @@ public sealed class GlobalLeaksClient return Convert.ToBase64String(hash); } - private static string CreateDpopProof(ECDsa privateKey, string method, string path) + private static string CreateDpopProof(ECDsa privateKey, string method, string path, string? ath = null) { var publicParameters = privateKey.ExportParameters(includePrivateParameters: false); var jwk = new Dictionary @@ -742,6 +822,10 @@ public sealed class GlobalLeaksClient ["htu"] = path, ["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), }; + if (!string.IsNullOrWhiteSpace(ath)) + { + payload["ath"] = ath; + } var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions)); var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions)); @@ -754,6 +838,25 @@ public sealed class GlobalLeaksClient return $"{signingInput}.{Base64UrlEncode(signature)}"; } + private static string ExportDpopPrivateKey(ECDsa key) + => Convert.ToBase64String(key.ExportECPrivateKey()); + + private static ECDsa ImportDpopPrivateKey(string privateKey) + { + var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + key.ImportECPrivateKey(Convert.FromBase64String(privateKey), out _); + return key; + } + + private static string ComputeAth(string sessionId) + => Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(sessionId))); + + private static string GetDpopPath(string path) + { + var queryIndex = path.IndexOf('?', StringComparison.Ordinal); + return queryIndex < 0 ? path : path[..queryIndex]; + } + private static string Base64UrlEncode(byte[] bytes) => Convert.ToBase64String(bytes) .TrimEnd('=') @@ -1309,12 +1412,12 @@ public sealed class GlobalLeaksClient private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition) => contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"'); - private static GlSession ParseAuthSession(string body, string fallbackUsername) + private static GlSession ParseAuthSession(string body, string fallbackUsername, string? dpopPrivateKey) { using var document = JsonDocument.Parse(body); var root = document.RootElement; - var id = GetString(root, "id") + var id = GetString(root, "session_id", "sessionId", "session", "sid", "id") ?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502); var username = GetString(root, "username", "name"); if (string.IsNullOrWhiteSpace(username)) @@ -1324,7 +1427,7 @@ public sealed class GlobalLeaksClient var role = GetString(root, "role", "user_role", "userRole"); - return new GlSession(id, username, role); + return new GlSession(id, username, role, dpopPrivateKey); } private static async Task EnsureSuccessOrThrowAsync( diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs index 65c7972..586151a 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs @@ -54,6 +54,7 @@ public sealed class GlobalLeaksSessionStore string password, string sessionId, string? role, + string? dpopPrivateKey, CancellationToken cancellationToken = default) { var data = new GlobalLeaksStoredSession @@ -62,6 +63,7 @@ public sealed class GlobalLeaksSessionStore Password = password, SessionId = sessionId, Role = role, + DpopPrivateKey = dpopPrivateKey, UpdatedAt = DateTimeOffset.UtcNow, }; @@ -72,6 +74,7 @@ public sealed class GlobalLeaksSessionStore string username, string sessionId, string? role, + string? dpopPrivateKey, CancellationToken cancellationToken = default) { var current = await GetAsync(username, cancellationToken) @@ -79,6 +82,7 @@ public sealed class GlobalLeaksSessionStore current.SessionId = sessionId; current.Role = role; + current.DpopPrivateKey = dpopPrivateKey; current.UpdatedAt = DateTimeOffset.UtcNow; await WriteAsync(current, cancellationToken); @@ -93,6 +97,7 @@ public sealed class GlobalLeaksSessionStore } current.SessionId = null; + current.DpopPrivateKey = null; current.UpdatedAt = DateTimeOffset.UtcNow; await WriteAsync(current, cancellationToken); } diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs index 535ab81..586d48f 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/ApiDenunciasDtos.cs @@ -122,3 +122,10 @@ public sealed record GestionaComplaintFieldsResponse( string AutorizaRemisionDenuncia, string AutorizaNotificacionesViaSms, string PreferenciaNotificacionSeguimientoDenuncia); + +public sealed record GestionaExternalFieldValue( + string Type, + string Value); + +public sealed record GestionaExternalFieldsResponse( + IReadOnlyDictionary Data); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs index f5e1c25..cc20a2a 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs @@ -1,3 +1,3 @@ namespace GestionaDenuncias.Shared.Models; -public sealed record GlSession(string Id, string Username, string? Role = null); +public sealed record GlSession(string Id, string Username, string? Role = null, string? DpopPrivateKey = null); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs index f37c680..c214798 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs @@ -6,7 +6,10 @@ public sealed class GlobalLeaksStoredSession public string Password { get; set; } = string.Empty; public string? SessionId { get; set; } public string? Role { get; set; } + public string? DpopPrivateKey { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; - public bool HasActiveSession => !string.IsNullOrWhiteSpace(SessionId); + public bool HasActiveSession => + !string.IsNullOrWhiteSpace(SessionId) && + !string.IsNullOrWhiteSpace(DpopPrivateKey); }