- Añade GET /api/denuncias/{id}/gestiona-fields para exponer los campos diarios requeridos por la integración con Gestiona.
- Devuelve un DTO cerrado con fecha, canal, resumen, datos de hechos, protección, sexo y preferencias de notificación.
- Adapta el login contra GlobalLeaks al nuevo flujo DPoP exigido desde la versión 5.0.94.
- Genera proof DPoP con clave EC P-256 efímera y lo envía en la cabecera DPoP junto a X-Token.
- Mejora el mensaje de error cuando GlobalLeaks rechaza el proof DPoP.
400 lines
16 KiB
C#
400 lines
16 KiB
C#
using GestionaDenuncias.Shared.Models;
|
|
using ApiDenuncias.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ApiDenuncias.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/inbox")]
|
|
public sealed class InboxController : ControllerBase
|
|
{
|
|
private static readonly TimeSpan[] ExportRetryDelays =
|
|
[
|
|
TimeSpan.FromSeconds(2),
|
|
TimeSpan.FromSeconds(5),
|
|
TimeSpan.FromSeconds(10)
|
|
];
|
|
|
|
private readonly GlobalLeaksSessionStore _sessionStore;
|
|
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
|
private readonly GlobalLeaksClient _globalLeaksClient;
|
|
private readonly DenunciaInboxService _inboxService;
|
|
private readonly IInboxTrackingService _trackingService;
|
|
private readonly ILogger<InboxController> _logger;
|
|
|
|
public InboxController(
|
|
GlobalLeaksSessionStore sessionStore,
|
|
PendingGlobalLeaksLoginStore pendingLoginStore,
|
|
GlobalLeaksClient globalLeaksClient,
|
|
DenunciaInboxService inboxService,
|
|
IInboxTrackingService trackingService,
|
|
ILogger<InboxController> logger)
|
|
{
|
|
_sessionStore = sessionStore;
|
|
_pendingLoginStore = pendingLoginStore;
|
|
_globalLeaksClient = globalLeaksClient;
|
|
_inboxService = inboxService;
|
|
_trackingService = trackingService;
|
|
_logger = logger;
|
|
}
|
|
|
|
[HttpGet("session")]
|
|
public async Task<ActionResult<ApiGlobalLeaksSessionDto?>> GetSession(CancellationToken cancellationToken)
|
|
{
|
|
var session = await _sessionStore.GetAsync(GetUsername(), cancellationToken);
|
|
return Ok(ToDto(session));
|
|
}
|
|
|
|
[HttpPost("session/renew/prepare")]
|
|
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareRenewSession(CancellationToken cancellationToken)
|
|
{
|
|
var username = GetUsername();
|
|
var current = await _sessionStore.GetAsync(username, cancellationToken);
|
|
if (current is null || string.IsNullOrWhiteSpace(current.Password))
|
|
{
|
|
return BadRequest(new ApiError("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar."));
|
|
}
|
|
|
|
try
|
|
{
|
|
var prepared = await _globalLeaksClient.PrepareLoginAsync(
|
|
current.Username,
|
|
current.Password,
|
|
cancellationToken);
|
|
|
|
var pending = _pendingLoginStore.Create(
|
|
prepared.Username,
|
|
current.Password,
|
|
prepared.FinalPassword,
|
|
prepared.TokenAnswer);
|
|
|
|
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
|
|
}
|
|
catch (GlobalLeaksValidationException ex)
|
|
{
|
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
|
|
return StatusCode(
|
|
StatusCodes.Status500InternalServerError,
|
|
new ApiError("No se ha podido preparar la renovacion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
|
|
}
|
|
}
|
|
|
|
[HttpPost("session/renew")]
|
|
public async Task<ActionResult<ApiGlobalLeaksSessionDto>> RenewSession(
|
|
RenewGlobalLeaksSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var username = GetUsername();
|
|
var current = await _sessionStore.GetAsync(username, cancellationToken);
|
|
if (current is null || string.IsNullOrWhiteSpace(current.Password))
|
|
{
|
|
return BadRequest(new ApiError("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar."));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Authcode) || request.Authcode.Trim().Length != 6)
|
|
{
|
|
return BadRequest(new ApiError("Introduce un codigo 2FA valido de 6 digitos."));
|
|
}
|
|
|
|
try
|
|
{
|
|
GlSession session;
|
|
if (!string.IsNullOrWhiteSpace(request.PendingLoginId))
|
|
{
|
|
var pending = _pendingLoginStore.Get(request.PendingLoginId);
|
|
if (!string.Equals(pending.Username, current.Username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return BadRequest(new ApiError("La preparacion del login no corresponde al usuario actual."));
|
|
}
|
|
|
|
session = await _globalLeaksClient.CompleteLoginAsync(
|
|
pending.Username,
|
|
pending.FinalPassword,
|
|
pending.TokenAnswer,
|
|
request.Authcode.Trim(),
|
|
cancellationToken);
|
|
|
|
_pendingLoginStore.Remove(pending.Id);
|
|
}
|
|
else
|
|
{
|
|
session = await _globalLeaksClient.LoginAsync(
|
|
current.Username,
|
|
current.Password,
|
|
request.Authcode.Trim(),
|
|
cancellationToken);
|
|
}
|
|
|
|
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken);
|
|
var stored = await _sessionStore.GetAsync(username, cancellationToken);
|
|
return Ok(ToDto(stored));
|
|
}
|
|
catch (GlobalLeaksValidationException ex)
|
|
{
|
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return BadRequest(new ApiError(ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
|
|
return StatusCode(
|
|
StatusCodes.Status500InternalServerError,
|
|
new ApiError("No se ha podido renovar la sesion de GlobalLeaks. Intentalo de nuevo en unos segundos."));
|
|
}
|
|
}
|
|
|
|
[HttpPost("session/clear")]
|
|
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
|
|
{
|
|
await _sessionStore.ClearSessionAsync(GetUsername(), cancellationToken);
|
|
return Ok(new { ok = true });
|
|
}
|
|
|
|
[HttpGet("reports")]
|
|
public async Task<ActionResult<InboxSnapshotResponse>> GetReports(CancellationToken cancellationToken)
|
|
{
|
|
var username = GetUsername();
|
|
var session = await RequireActiveSessionAsync(username, cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
|
|
try
|
|
{
|
|
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 enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
|
|
var activityReports = await _globalLeaksClient.EnrichReportsWithActivityAsync(
|
|
session.SessionId!,
|
|
enrichedReports,
|
|
state.LastDownloadedReportMomentUtc,
|
|
cancellationToken);
|
|
|
|
return Ok(new InboxSnapshotResponse(contexts, activityReports, state));
|
|
}
|
|
catch (GlobalLeaksSessionExpiredException)
|
|
{
|
|
await _sessionStore.ClearSessionAsync(username, cancellationToken);
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
catch (GlobalLeaksValidationException ex)
|
|
{
|
|
return ToGlobalLeaksApiError(ex, "cargar la bandeja de GlobalLeaks");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
|
|
return StatusCode(
|
|
StatusCodes.Status500InternalServerError,
|
|
new ApiError("No se ha podido cargar la bandeja de GlobalLeaks. Intentalo de nuevo en unos segundos."));
|
|
}
|
|
}
|
|
|
|
[HttpPost("reports/{reportId}/import")]
|
|
public async Task<ActionResult<ImportSummary>> ImportReport(
|
|
string reportId,
|
|
ImportReportRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var username = GetUsername();
|
|
var session = await RequireActiveSessionAsync(username, cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
|
|
var report = string.IsNullOrWhiteSpace(request.Report.Id)
|
|
? request.Report with { Id = reportId }
|
|
: request.Report;
|
|
|
|
try
|
|
{
|
|
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
|
|
|
|
ReportDetailDto? reportDetail = null;
|
|
try
|
|
{
|
|
reportDetail = await _globalLeaksClient.GetReportDetailAsync(
|
|
session.SessionId!,
|
|
report.Id,
|
|
report.LastAccess,
|
|
cancellationToken);
|
|
}
|
|
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == StatusCodes.Status422UnprocessableEntity ||
|
|
ex.StatusCode is >= 500 and <= 504)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"No se ha podido obtener el detalle de fechas de adjuntos para la denuncia {ReportId}. Se usara la fecha del paquete exportado.",
|
|
report.Id);
|
|
}
|
|
|
|
var reportPackage = await DownloadReportPackageWithRetryAsync(session.SessionId!, report, cancellationToken);
|
|
|
|
FileDownloadResult? json = null;
|
|
try
|
|
{
|
|
json = await _globalLeaksClient.ExportReportJsonAsync(session.SessionId!, report.Id, cancellationToken);
|
|
}
|
|
catch (GlobalLeaksValidationException ex) when (ex.StatusCode == 422)
|
|
{
|
|
json = null;
|
|
}
|
|
|
|
var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
|
|
if (result.ImportedCount > 0)
|
|
{
|
|
await _trackingService.MarkReportImportedAsync(
|
|
username,
|
|
report,
|
|
result.ImportedComplaintIds?.FirstOrDefault(),
|
|
cancellationToken);
|
|
}
|
|
|
|
return Ok(result);
|
|
}
|
|
catch (GlobalLeaksSessionExpiredException)
|
|
{
|
|
await _sessionStore.ClearSessionAsync(username, cancellationToken);
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado durante la importacion. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
catch (GlobalLeaksValidationException ex)
|
|
{
|
|
return ToGlobalLeaksApiError(ex, "importar la denuncia");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
|
|
return StatusCode(
|
|
StatusCodes.Status500InternalServerError,
|
|
new ApiError("No se ha podido importar la denuncia. Intentalo de nuevo en unos segundos."));
|
|
}
|
|
}
|
|
|
|
[HttpGet("reports/{reportId}/detail")]
|
|
public async Task<ActionResult<ReportDetailDto>> GetReportDetail(
|
|
string reportId,
|
|
[FromQuery] string? lastAccess,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var username = GetUsername();
|
|
var session = await RequireActiveSessionAsync(username, cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
|
|
try
|
|
{
|
|
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken));
|
|
}
|
|
catch (GlobalLeaksSessionExpiredException)
|
|
{
|
|
await _sessionStore.ClearSessionAsync(username, cancellationToken);
|
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
|
}
|
|
catch (GlobalLeaksValidationException ex)
|
|
{
|
|
return ToGlobalLeaksApiError(ex, "leer el detalle de la denuncia");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
|
|
return StatusCode(
|
|
StatusCodes.Status500InternalServerError,
|
|
new ApiError("No se ha podido abrir el detalle de la denuncia. Intentalo de nuevo en unos segundos."));
|
|
}
|
|
}
|
|
|
|
private async Task<FileDownloadResult> DownloadReportPackageWithRetryAsync(
|
|
string sessionId,
|
|
ReportDto report,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 0; ; attempt++)
|
|
{
|
|
try
|
|
{
|
|
return await _globalLeaksClient.DownloadReportPackageAsync(sessionId, report.Id, cancellationToken);
|
|
}
|
|
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex) && attempt < ExportRetryDelays.Length)
|
|
{
|
|
var delay = ExportRetryDelays[attempt];
|
|
_logger.LogWarning(
|
|
ex,
|
|
"GlobalLeaks aun no ha preparado la exportacion de la denuncia {ReportId}. Reintento {Attempt}/{Total} en {DelaySeconds}s.",
|
|
report.Id,
|
|
attempt + 1,
|
|
ExportRetryDelays.Length,
|
|
delay.TotalSeconds);
|
|
|
|
await Task.Delay(delay, cancellationToken);
|
|
}
|
|
catch (GlobalLeaksValidationException ex) when (IsExportNotReady(ex))
|
|
{
|
|
throw new GlobalLeaksValidationException(
|
|
"GlobalLeaks todavia esta preparando la exportacion de esta denuncia. Espera unos segundos y vuelve a importarla.",
|
|
StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool IsExportNotReady(GlobalLeaksValidationException ex)
|
|
=> ex.StatusCode is >= 500 and <= 504;
|
|
|
|
private static ActionResult ToGlobalLeaksApiError(GlobalLeaksValidationException ex, string operation)
|
|
{
|
|
if (ex.StatusCode == StatusCodes.Status401Unauthorized ||
|
|
ex.StatusCode == StatusCodes.Status403Forbidden)
|
|
{
|
|
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."))
|
|
{
|
|
StatusCode = StatusCodes.Status403Forbidden
|
|
};
|
|
}
|
|
|
|
if (ex.StatusCode is >= 500 and <= 504)
|
|
{
|
|
return new ObjectResult(new ApiError(
|
|
$"GlobalLeaks no ha podido {operation} en este momento. Intentalo de nuevo en unos segundos."))
|
|
{
|
|
StatusCode = StatusCodes.Status503ServiceUnavailable
|
|
};
|
|
}
|
|
|
|
return new ObjectResult(new ApiError(ex.Message))
|
|
{
|
|
StatusCode = ex.StatusCode
|
|
};
|
|
}
|
|
|
|
private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
|
|
{
|
|
var session = await _sessionStore.GetAsync(username, cancellationToken);
|
|
return session?.HasActiveSession == true ? session : null;
|
|
}
|
|
|
|
private string GetUsername()
|
|
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
|
|
|
|
private static ApiGlobalLeaksSessionDto? ToDto(GlobalLeaksStoredSession? session)
|
|
=> session is null
|
|
? null
|
|
: new ApiGlobalLeaksSessionDto(session.Username, session.Role, session.HasActiveSession, session.UpdatedAt);
|
|
|
|
|
|
|
|
}
|