Mejoras en elevaciones, equilibrado y callejero inverso

Añadida visualización de pendientes en los tramos a pie con puntos coloreados: llano, subida y bajada.
Añadidos umbrales configurables para subida y bajada junto al interruptor de elevaciones.
Añadido callejero inverso en el detalle de la solución, mostrando el lugar del callejero más cercano al origen y al destino.
Ajustado el modo Equilib. A para limpiar la tarjeta visualmente y guardar/restaurar el factor usado desde el historial.
Mejoras menores en el refresco del resumen técnico de rutas y validación de compilación.
This commit is contained in:
2026-07-10 14:00:22 +02:00
parent 1fec0ae0f5
commit 2414fbe80c
10 changed files with 792 additions and 108 deletions

View File

@@ -304,7 +304,7 @@ public sealed class AuthController : ControllerBase
: session.Username.Trim(); : session.Username.Trim();
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username); _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); _logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));

View File

@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using ApiDenuncias.Services; using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -64,8 +65,9 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken)); return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken));
} }
[AllowAnonymous]
[HttpGet("{denunciaId:int}/gestiona-fields")] [HttpGet("{denunciaId:int}/gestiona-fields")]
public async Task<ActionResult<GestionaComplaintFieldsResponse>> GetGestionaFields( public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
int denunciaId, int denunciaId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -80,7 +82,19 @@ public sealed class DenunciasController : ControllerBase
return NotFound(new ApiError("No se ha encontrado la denuncia solicitada.")); 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] [HttpPost]
@@ -234,18 +248,9 @@ public sealed class DenunciasController : ControllerBase
private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia) private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia)
{ {
var preferenciaNotificacion = ResolveReportField( var preferenciaNotificacion = ResolveNotificationPreference(denuncia);
denuncia, var seguimiento = ResolveSeguimientoDenuncia(denuncia);
denuncia.Notificacion_Preferencia, var sms = ResolveSmsNotification(denuncia);
"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");
return new GestionaComplaintFieldsResponse( return new GestionaComplaintFieldsResponse(
FechaDenuncia: ToNullableDate(denuncia.Fecha), 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, string.Empty, "resumen de la denuncia", "resumen denuncia"),
ResolveReportField(denuncia, denuncia.Descripcion_Denuncia, "describa su denuncia", "descripcion de la denuncia")), ResolveReportField(denuncia, denuncia.Descripcion_Denuncia, "describa su denuncia", "descripcion de la denuncia")),
FechaHechos: ResolveFechaHechos(denuncia), FechaHechos: ResolveFechaHechos(denuncia),
LugarHechos: ResolveReportField( LugarHechos: ResolveLugarHechos(denuncia),
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"),
AmbitoCompetencias: ResolveAmbitoCompetencias(denuncia), AmbitoCompetencias: ResolveAmbitoCompetencias(denuncia),
SolicitaProteccion: ResolveReportField(denuncia, denuncia.SolicitaProteccion, "solicita medidas concretas de proteccion", "solicita proteccion"), SolicitaProteccion: ResolveReportField(denuncia, denuncia.SolicitaProteccion, "solicita medidas concretas de proteccion", "solicita proteccion"),
SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"), SexoDenunciante: ResolveReportField(denuncia, denuncia.Sexo, "sexo"),
AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"), AutorizaRemisionDenuncia: ResolveReportField(denuncia, denuncia.AutorizaRemision, "autorizacion para remitir su denuncia", "autoriza remision de la denuncia"),
AutorizaNotificacionesViaSms: ResolveReportField( AutorizaNotificacionesViaSms: sms,
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"),
PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento)); PreferenciaNotificacionSeguimientoDenuncia: JoinDistinct(preferenciaNotificacion, seguimiento));
} }
private static GestionaExternalFieldsResponse ToExternalGestionaFields(GestionaComplaintFieldsResponse source)
{
return new GestionaExternalFieldsResponse(
new Dictionary<string, GestionaExternalFieldValue>(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( private async Task TryRegisterGestionaHistoryFromComplaintAsync(
DenunciasGestiona denuncia, DenunciasGestiona denuncia,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -319,6 +333,32 @@ public sealed class DenunciasController : ControllerBase
private static DateTime? ToNullableDate(DateTime value) private static DateTime? ToNullableDate(DateTime value)
=> value == DateTime.MinValue ? null : value; => value == DateTime.MinValue ? null : value;
private static IReadOnlyList<string> GetMissingGestionaFields(GestionaComplaintFieldsResponse response)
{
var missing = new List<string>();
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<string> missing, string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
missing.Add(fieldName);
}
}
private static string ResolveFechaHechos(DenunciasGestiona denuncia) private static string ResolveFechaHechos(DenunciasGestiona denuncia)
{ {
var literalValue = ResolveReportField( var literalValue = ResolveReportField(
@@ -332,16 +372,125 @@ public sealed class DenunciasController : ControllerBase
return literalValue; 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 return denuncia.Fecha_Hechos == DateTime.MinValue
? string.Empty ? string.Empty
: denuncia.Fecha_Hechos.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture); : 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) private static string ResolveAmbitoCompetencias(DenunciasGestiona denuncia)
{ {
var directValue = ResolveReportField( var directValue = ResolveReportField(
denuncia, denuncia,
denuncia.Modalidad_Informacion, FirstNonEmpty(denuncia.Modalidad_Informacion, denuncia.Asunto),
"asunto",
"categoria",
"tipo de denuncia",
"ambito de competencias", "ambito de competencias",
"ambito competencial", "ambito competencial",
"ambito", "ambito",
@@ -388,9 +537,41 @@ public sealed class DenunciasController : ControllerBase
} }
} }
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; 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( private static string FindReportFieldValue(
DenunciasGestiona denuncia, DenunciasGestiona denuncia,
Func<string, bool> labelPredicate) Func<string, bool> labelPredicate)
@@ -407,6 +588,176 @@ public sealed class DenunciasController : ControllerBase
return string.Empty; return string.Empty;
} }
private static string ExtractDateAfterReportLabel(string? reportText, Func<string, bool> 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<string> normalizedLabels)
=> ExtractReportTextValue(
reportText,
normalizedLabel => normalizedLabels.Contains(normalizedLabel));
private static string ExtractReportTextValue(
string? reportText,
IReadOnlySet<string> normalizedLabels,
bool stopAtLikelyLabels)
=> ExtractReportTextValue(
reportText,
normalizedLabel => normalizedLabels.Contains(normalizedLabel),
stopAtLikelyLabels);
private static string ExtractReportTextValue(string? reportText, Func<string, bool> labelPredicate)
=> ExtractReportTextValue(reportText, labelPredicate, stopAtLikelyLabels: true);
private static string ExtractReportTextValue(
string? reportText,
Func<string, bool> 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) private static string JoinDistinct(params string[] values)
{ {
var result = new List<string>(); var result = new List<string>();

View File

@@ -131,7 +131,7 @@ public sealed class InboxController : ControllerBase
cancellationToken); 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); var stored = await _sessionStore.GetAsync(username, cancellationToken);
return Ok(ToDto(stored)); return Ok(ToDto(stored));
} }
@@ -173,13 +173,21 @@ public sealed class InboxController : ControllerBase
{ {
var state = await _trackingService.GetUserStateAsync(username, cancellationToken); var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, 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 enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync( var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync(
session.SessionId!, session.SessionId!,
enrichedReports, enrichedReports,
state.LastDownloadedReportMomentUtc, state.LastDownloadedReportMomentUtc,
cancellationToken); cancellationToken,
session.DpopPrivateKey);
return Ok(new InboxSnapshotResponse(contexts, activityReports, state)); return Ok(new InboxSnapshotResponse(contexts, activityReports, state));
} }
@@ -229,7 +237,8 @@ public sealed class InboxController : ControllerBase
session.SessionId!, session.SessionId!,
report.Id, report.Id,
report.LastAccess, report.LastAccess,
cancellationToken); cancellationToken,
session.DpopPrivateKey);
} }
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity || catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity ||
ex.StatusCode is >= 500 and <= 504) ex.StatusCode is >= 500 and <= 504)
@@ -240,12 +249,16 @@ public sealed class InboxController : ControllerBase
report.Id); report.Id);
} }
var reportPackage = await DownloadReportPackageWithRetryAsync(session.SessionId!, report, cancellationToken); var reportPackage = await DownloadReportPackageWithRetryAsync(session, report, cancellationToken);
FileDownloadResult? json = null; FileDownloadResult? json = null;
try 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) catch (GlobalLeaksValidationException ex) when (ex.StatusCode == 422)
{ {
@@ -297,7 +310,12 @@ public sealed class InboxController : ControllerBase
try 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) catch (GlobalLeaksSessionExpiredException)
{ {
@@ -318,7 +336,7 @@ public sealed class InboxController : ControllerBase
} }
private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync( private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
string sessionId, GlobalLeaksStoredSession session,
ReportDto report, ReportDto report,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -326,7 +344,11 @@ public sealed class InboxController : ControllerBase
{ {
try 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) catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length)
{ {
@@ -353,11 +375,37 @@ public sealed class InboxController : ControllerBase
private static bool IsExportNotReady(GlobalLeaksValidationException ex) private static bool IsExportNotReady(GlobalLeaksValidationException ex)
=> ex.StatusCode is >= 500 and <= 504; => 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 || if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
ex.StatusCode == StatusCodes.Status403Forbidden) 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( 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.")) "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<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken) private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
{ {
var session = await _sessionStore.GetAsync(username, cancellationToken); var session = await _sessionStore.GetAsync(username, cancellationToken);

View File

@@ -121,15 +121,26 @@ public static class GlobalLeaksJsonEnricher
} }
} }
if (element.TryGetProperty("children", out var children) && foreach (var property in element.EnumerateObject())
children.ValueKind == JsonValueKind.Array)
{ {
foreach (var child in children.EnumerateArray()) if (property.NameEquals("options"))
{
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); CollectDefinitions(child, definitions);
} }
} }
} }
}
private static Dictionary<string, string> ParseOptions(JsonElement element) private static Dictionary<string, string> ParseOptions(JsonElement element)
{ {
@@ -163,19 +174,35 @@ public static class GlobalLeaksJsonEnricher
private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition) private static string ResolveAnswer(JsonElement answerArray, FieldDefinition definition)
{ {
if (answerArray.ValueKind == JsonValueKind.Object)
{
return ResolveAnswerObject(answerArray, definition);
}
if (answerArray.ValueKind != JsonValueKind.Array) if (answerArray.ValueKind != JsonValueKind.Array)
{ {
return string.Empty; return ResolveValue(answerArray, definition.Options);
} }
var values = new List<string>(); var values = new List<string>();
foreach (var answer in answerArray.EnumerateArray()) 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))
{ {
continue; values.Add(resolved);
} }
}
return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase));
}
private static string ResolveAnswerObject(JsonElement answer, FieldDefinition definition)
{
var values = new List<string>();
if (answer.TryGetProperty("value", out var valueElement)) if (answer.TryGetProperty("value", out var valueElement))
{ {
@@ -200,7 +227,6 @@ public static class GlobalLeaksJsonEnricher
values.Add(label); values.Add(label);
} }
} }
}
return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase)); return string.Join("; ", values.Distinct(StringComparer.OrdinalIgnoreCase));
} }
@@ -213,10 +239,95 @@ public static class GlobalLeaksJsonEnricher
JsonValueKind.True => "Sí", JsonValueKind.True => "Sí",
JsonValueKind.False => "No", JsonValueKind.False => "No",
JsonValueKind.Number => valueElement.GetRawText(), JsonValueKind.Number => valueElement.GetRawText(),
JsonValueKind.Array => ResolveArrayValue(valueElement, options),
JsonValueKind.Object => ResolveObjectValue(valueElement, options),
_ => string.Empty, _ => string.Empty,
}; };
} }
private static string ResolveArrayValue(JsonElement valueElement, Dictionary<string, string> 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<string, string> 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<string>();
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<string, string> options) private static string ResolveStringValue(string? rawValue, Dictionary<string, string> options)
{ {
if (string.IsNullOrWhiteSpace(rawValue)) 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.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.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.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.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.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.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.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.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil"); 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"); SetIfMissing(() => denuncia.Pais, value => denuncia.Pais = value, answers, "pais");
if (denuncia.Fecha_Hechos == DateTime.MinValue && if (denuncia.Fecha_Hechos == DateTime.MinValue &&
TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia") && TryGetAnswer(answers, out var fechaHechos, "fecha de los hechos que denuncia", "fecha de los hechos") &&
DateTime.TryParse(fechaHechos, CultureInfo.CurrentCulture, DateTimeStyles.None, out var parsedDate)) TryParseReportDate(fechaHechos, out var parsedDate))
{ {
denuncia.Fecha_Hechos = parsedDate; denuncia.Fecha_Hechos = parsedDate;
} }
@@ -371,6 +483,38 @@ public static class GlobalLeaksJsonEnricher
return false; 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) private static string Normalize(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))

View File

@@ -648,9 +648,20 @@ namespace ApiDenuncias.Services
"application/vnd.gestiona.filter.thirds+json"); "application/vnd.gestiona.filter.thirds+json");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(); 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); using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
return default; return default;
@@ -768,8 +779,13 @@ namespace ApiDenuncias.Services
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
return new HashSet<string>(StringComparer.Ordinal); return new HashSet<string>(StringComparer.Ordinal);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(); 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<string>(StringComparer.Ordinal);
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var set = new HashSet<string>(StringComparer.Ordinal); var set = new HashSet<string>(StringComparer.Ordinal);

View File

@@ -152,7 +152,7 @@ public sealed class GlobalLeaksClient
if (authResponse.IsSuccessStatusCode) if (authResponse.IsSuccessStatusCode)
{ {
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username); var session = ParseAuthSession(authBody, username, ExportDpopPrivateKey(dpopKey));
_logger.LogInformation( _logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms", "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username, session.Username,
@@ -233,12 +233,17 @@ public sealed class GlobalLeaksClient
string? dateFrom, string? dateFrom,
string? dateTo, string? dateTo,
CancellationToken cancellationToken, CancellationToken cancellationToken,
IReadOnlyList<ContextDto>? contexts = null) IReadOnlyList<ContextDto>? contexts = null,
string? dpopPrivateKey = null)
{ {
filter ??= "all"; filter ??= "all";
using var reportsRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId); using var reportsResponse = await SendAuthenticatedGlRequestAsync(
using var reportsResponse = await SendGlRequestAsync(reportsRequest, cancellationToken); HttpMethod.Get,
"/api/recipient/rtips",
sessionId,
dpopPrivateKey,
cancellationToken);
var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken); var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken);
var tips = ParseReports(body); var tips = ParseReports(body);
_logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count); _logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);
@@ -312,7 +317,8 @@ public sealed class GlobalLeaksClient
string sessionId, string sessionId,
IReadOnlyList<ReportDto> reports, IReadOnlyList<ReportDto> reports,
DateTimeOffset? fallbackReference, DateTimeOffset? fallbackReference,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{ {
if (reports.Count == 0) if (reports.Count == 0)
{ {
@@ -337,7 +343,7 @@ public sealed class GlobalLeaksClient
try try
{ {
var reference = GetActivityReference(item.Report, fallbackReference); 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); enriched[item.Index] = ApplyActivity(item.Report, activity);
} }
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException) catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
@@ -362,16 +368,17 @@ public sealed class GlobalLeaksClient
string sessionId, string sessionId,
string reportId, string reportId,
string? lastAccess, string? lastAccess,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{ {
ValidateUuid(reportId); ValidateUuid(reportId);
var reference = ParseDate(lastAccess); var reference = ParseDate(lastAccess);
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken); using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
IReadOnlyList<ReportCommentDto>? comments = null; IReadOnlyList<ReportCommentDto>? comments = null;
try 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) catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
{ {
@@ -387,17 +394,16 @@ public sealed class GlobalLeaksClient
public async Task<FileDownloadResult> DownloadReportPackageAsync( public async Task<FileDownloadResult> DownloadReportPackageAsync(
string sessionId, string sessionId,
string reportId, string reportId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{ {
ValidateUuid(reportId); ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest( using var response = await SendAuthenticatedGlRequestAsync(
HttpMethod.Get, HttpMethod.Get,
$"/api/recipient/rtips/{reportId}/export", $"/api/recipient/rtips/{reportId}/export",
sessionId); sessionId,
dpopPrivateKey,
using var response = await SendGlRequestAsync(
request,
cancellationToken, cancellationToken,
HttpCompletionOption.ResponseHeadersRead); HttpCompletionOption.ResponseHeadersRead);
@@ -417,12 +423,17 @@ public sealed class GlobalLeaksClient
public async Task<FileDownloadResult> ExportReportJsonAsync( public async Task<FileDownloadResult> ExportReportJsonAsync(
string sessionId, string sessionId,
string reportId, string reportId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? dpopPrivateKey = null)
{ {
ValidateUuid(reportId); ValidateUuid(reportId);
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); using var response = await SendAuthenticatedGlRequestAsync(
using var response = await SendGlRequestAsync(request, cancellationToken); HttpMethod.Get,
$"/api/recipient/rtips/{reportId}",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -444,10 +455,15 @@ public sealed class GlobalLeaksClient
private async Task<JsonDocument> ReadReportDetailDocumentAsync( private async Task<JsonDocument> ReadReportDetailDocumentAsync(
string sessionId, string sessionId,
string reportId, string reportId,
string? dpopPrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId); using var detailResponse = await SendAuthenticatedGlRequestAsync(
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken); HttpMethod.Get,
$"/api/recipient/rtips/{reportId}",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty; var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken); var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -466,10 +482,15 @@ public sealed class GlobalLeaksClient
string reportId, string reportId,
DateTimeOffset? reference, DateTimeOffset? reference,
bool defaultNewWhenNoReference, bool defaultNewWhenNoReference,
string? dpopPrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}/comments", sessionId); using var response = await SendAuthenticatedGlRequestAsync(
using var response = await SendGlRequestAsync(request, cancellationToken); HttpMethod.Get,
$"/api/recipient/rtips/{reportId}/comments",
sessionId,
dpopPrivateKey,
cancellationToken);
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty; var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken); var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -499,10 +520,11 @@ public sealed class GlobalLeaksClient
string sessionId, string sessionId,
ReportDto report, ReportDto report,
DateTimeOffset? reference, DateTimeOffset? reference,
string? dpopPrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var reportId = report.Id; var reportId = report.Id;
using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, cancellationToken); using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
IReadOnlyList<ReportCommentDto> comments; IReadOnlyList<ReportCommentDto> comments;
try try
{ {
@@ -511,6 +533,7 @@ public sealed class GlobalLeaksClient
reportId, reportId,
reference, reference,
defaultNewWhenNoReference: false, defaultNewWhenNoReference: false,
dpopPrivateKey,
cancellationToken); cancellationToken);
} }
catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException) catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
@@ -650,7 +673,16 @@ public sealed class GlobalLeaksClient
if (!response.IsSuccessStatusCode) 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(); response.Dispose();
throw new GlobalLeaksValidationException(message, (int)response.StatusCode); throw new GlobalLeaksValidationException(message, (int)response.StatusCode);
} }
@@ -658,6 +690,38 @@ public sealed class GlobalLeaksClient
return response; return response;
} }
private async Task<HttpResponseMessage> 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) private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
{ {
var request = new HttpRequestMessage(method, path) var request = new HttpRequestMessage(method, path)
@@ -671,10 +735,26 @@ public sealed class GlobalLeaksClient
return request; 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); var request = CreateRequest(method, path);
request.Headers.Add("X-Session", sessionId); 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; return request;
} }
@@ -717,7 +797,7 @@ public sealed class GlobalLeaksClient
return Convert.ToBase64String(hash); 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 publicParameters = privateKey.ExportParameters(includePrivateParameters: false);
var jwk = new Dictionary<string, object?> var jwk = new Dictionary<string, object?>
@@ -742,6 +822,10 @@ public sealed class GlobalLeaksClient
["htu"] = path, ["htu"] = path,
["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
}; };
if (!string.IsNullOrWhiteSpace(ath))
{
payload["ath"] = ath;
}
var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions)); var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions));
var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions)); var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions));
@@ -754,6 +838,25 @@ public sealed class GlobalLeaksClient
return $"{signingInput}.{Base64UrlEncode(signature)}"; 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) private static string Base64UrlEncode(byte[] bytes)
=> Convert.ToBase64String(bytes) => Convert.ToBase64String(bytes)
.TrimEnd('=') .TrimEnd('=')
@@ -1309,12 +1412,12 @@ public sealed class GlobalLeaksClient
private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition) private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition)
=> contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"'); => 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); using var document = JsonDocument.Parse(body);
var root = document.RootElement; 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); ?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502);
var username = GetString(root, "username", "name"); var username = GetString(root, "username", "name");
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
@@ -1324,7 +1427,7 @@ public sealed class GlobalLeaksClient
var role = GetString(root, "role", "user_role", "userRole"); 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( private static async Task EnsureSuccessOrThrowAsync(

View File

@@ -54,6 +54,7 @@ public sealed class GlobalLeaksSessionStore
string password, string password,
string sessionId, string sessionId,
string? role, string? role,
string? dpopPrivateKey,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var data = new GlobalLeaksStoredSession var data = new GlobalLeaksStoredSession
@@ -62,6 +63,7 @@ public sealed class GlobalLeaksSessionStore
Password = password, Password = password,
SessionId = sessionId, SessionId = sessionId,
Role = role, Role = role,
DpopPrivateKey = dpopPrivateKey,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
}; };
@@ -72,6 +74,7 @@ public sealed class GlobalLeaksSessionStore
string username, string username,
string sessionId, string sessionId,
string? role, string? role,
string? dpopPrivateKey,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var current = await GetAsync(username, cancellationToken) var current = await GetAsync(username, cancellationToken)
@@ -79,6 +82,7 @@ public sealed class GlobalLeaksSessionStore
current.SessionId = sessionId; current.SessionId = sessionId;
current.Role = role; current.Role = role;
current.DpopPrivateKey = dpopPrivateKey;
current.UpdatedAt = DateTimeOffset.UtcNow; current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken); await WriteAsync(current, cancellationToken);
@@ -93,6 +97,7 @@ public sealed class GlobalLeaksSessionStore
} }
current.SessionId = null; current.SessionId = null;
current.DpopPrivateKey = null;
current.UpdatedAt = DateTimeOffset.UtcNow; current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken); await WriteAsync(current, cancellationToken);
} }

View File

@@ -122,3 +122,10 @@ public sealed record GestionaComplaintFieldsResponse(
string AutorizaRemisionDenuncia, string AutorizaRemisionDenuncia,
string AutorizaNotificacionesViaSms, string AutorizaNotificacionesViaSms,
string PreferenciaNotificacionSeguimientoDenuncia); string PreferenciaNotificacionSeguimientoDenuncia);
public sealed record GestionaExternalFieldValue(
string Type,
string Value);
public sealed record GestionaExternalFieldsResponse(
IReadOnlyDictionary<string, GestionaExternalFieldValue> Data);

View File

@@ -1,3 +1,3 @@
namespace GestionaDenuncias.Shared.Models; 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);

View File

@@ -6,7 +6,10 @@ public sealed class GlobalLeaksStoredSession
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public string? SessionId { get; set; } public string? SessionId { get; set; }
public string? Role { get; set; } public string? Role { get; set; }
public string? DpopPrivateKey { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public bool HasActiveSession => !string.IsNullOrWhiteSpace(SessionId); public bool HasActiveSession =>
!string.IsNullOrWhiteSpace(SessionId) &&
!string.IsNullOrWhiteSpace(DpopPrivateKey);
} }