denuncias
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
<system.Web>
|
<system.Web>
|
||||||
<httpRuntime targetFramework="4.8" />
|
<httpRuntime targetFramework="4.8" />
|
||||||
</system.Web>
|
</system.Web>
|
||||||
|
|
||||||
-->
|
-->
|
||||||
<system.web>
|
<system.web>
|
||||||
<sessionState timeout="300000">
|
<sessionState timeout="300000">
|
||||||
@@ -103,14 +104,8 @@
|
|||||||
<errors callbackErrorRedirectUrl="" />
|
<errors callbackErrorRedirectUrl="" />
|
||||||
</devExpress>
|
</devExpress>
|
||||||
<appSettings>
|
<appSettings>
|
||||||
<add key="UrlCertLogin" value="https://cwe-antifraude.tecnosis.online/LoginCertBridge.aspx" />
|
<add key="vs:EnableBrowserLink" value="false" />
|
||||||
<add key="CertHeaderName" value="X-ARR-ClientCert" />
|
<add key="RutaRes" value="https://localhost:44300" />
|
||||||
<add key="AdditionalCertHeaders" value="X-Client-Cert|X-Client-Certificate|X-SSL-CERT|Ssl-Client-Cert" />
|
|
||||||
<add key="CertLoginEndpoint" value="api/Auth/login-cert-proxy" />
|
|
||||||
<add key="AllowedParentOrigins" value="https://we-antifraude.tecnosis.online" />
|
|
||||||
|
|
||||||
<add key="vs:EnableBrowserLink" value="false" />
|
|
||||||
<!--<add key="RutaRes" value="https://localhost:44300" />-->
|
|
||||||
<!--<add key="RutaRes" value="http://192.168.41.122:888" />-->
|
<!--<add key="RutaRes" value="http://192.168.41.122:888" />-->
|
||||||
<add key="SwaggerVB" value="http://localhost:103/" />
|
<add key="SwaggerVB" value="http://localhost:103/" />
|
||||||
<!--produccion-->
|
<!--produccion-->
|
||||||
|
|||||||
@@ -18,25 +18,184 @@ public sealed class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly GlobalLeaksClient _globalLeaksClient;
|
private readonly GlobalLeaksClient _globalLeaksClient;
|
||||||
private readonly GlobalLeaksSessionStore _sessionStore;
|
private readonly GlobalLeaksSessionStore _sessionStore;
|
||||||
|
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
||||||
private readonly LoginRateLimiter _rateLimiter;
|
private readonly LoginRateLimiter _rateLimiter;
|
||||||
private readonly JwtOptions _jwtOptions;
|
private readonly JwtOptions _jwtOptions;
|
||||||
|
private readonly GlobalLeaksOptions _globalLeaksOptions;
|
||||||
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
GlobalLeaksClient globalLeaksClient,
|
GlobalLeaksClient globalLeaksClient,
|
||||||
GlobalLeaksSessionStore sessionStore,
|
GlobalLeaksSessionStore sessionStore,
|
||||||
|
PendingGlobalLeaksLoginStore pendingLoginStore,
|
||||||
LoginRateLimiter rateLimiter,
|
LoginRateLimiter rateLimiter,
|
||||||
IOptions<JwtOptions> jwtOptions)
|
IOptions<JwtOptions> jwtOptions,
|
||||||
|
IOptions<GlobalLeaksOptions> globalLeaksOptions,
|
||||||
|
ILogger<AuthController> logger)
|
||||||
{
|
{
|
||||||
_globalLeaksClient = globalLeaksClient;
|
_globalLeaksClient = globalLeaksClient;
|
||||||
_sessionStore = sessionStore;
|
_sessionStore = sessionStore;
|
||||||
|
_pendingLoginStore = pendingLoginStore;
|
||||||
_rateLimiter = rateLimiter;
|
_rateLimiter = rateLimiter;
|
||||||
_jwtOptions = jwtOptions.Value;
|
_jwtOptions = jwtOptions.Value;
|
||||||
|
_globalLeaksOptions = globalLeaksOptions.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/prepare")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareLogin(
|
||||||
|
ApiLoginPrepareRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
|
||||||
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
if (!_rateLimiter.AllowAttempt(ip))
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status429TooManyRequests, new ApiError("Demasiados intentos. Espera un minuto."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return BadRequest(new ApiError("Debes indicar usuario y contrasena."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
|
||||||
|
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Preparando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
|
||||||
|
usernameForLogs,
|
||||||
|
_globalLeaksOptions.BaseUrl,
|
||||||
|
loginTimeoutSeconds);
|
||||||
|
|
||||||
|
var prepared = await _globalLeaksClient.PrepareLoginAsync(
|
||||||
|
request.Username.Trim(),
|
||||||
|
request.Password,
|
||||||
|
loginCancellation.Token);
|
||||||
|
|
||||||
|
var pending = _pendingLoginStore.Create(
|
||||||
|
prepared.Username,
|
||||||
|
request.Password,
|
||||||
|
prepared.FinalPassword);
|
||||||
|
|
||||||
|
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
|
||||||
|
}
|
||||||
|
catch (GlobalLeaksValidationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"GlobalLeaks rechazo la preparacion del login de {Username}. Status={StatusCode}. Mensaje={Message}",
|
||||||
|
usernameForLogs,
|
||||||
|
ex.StatusCode,
|
||||||
|
ex.Message);
|
||||||
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status504GatewayTimeout,
|
||||||
|
new ApiError($"GlobalLeaks no ha preparado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status502BadGateway,
|
||||||
|
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error no controlado preparando login de {Username}", usernameForLogs);
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status500InternalServerError,
|
||||||
|
new ApiError($"No se ha podido preparar el inicio de sesion en la API: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/complete")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<ApiLoginResponse>> CompleteLogin(
|
||||||
|
ApiLoginCompleteRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.PendingLoginId))
|
||||||
|
{
|
||||||
|
return BadRequest(new ApiError("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Authcode) || !Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
|
||||||
|
{
|
||||||
|
return BadRequest(new ApiError("El codigo 2FA debe tener exactamente 6 digitos."));
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingGlobalLeaksLogin pending;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pending = _pendingLoginStore.Get(request.PendingLoginId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new ApiError(ex.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
|
||||||
|
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
|
var session = await _globalLeaksClient.CompleteLoginAsync(
|
||||||
|
pending.Username,
|
||||||
|
pending.FinalPassword,
|
||||||
|
request.Authcode.Trim(),
|
||||||
|
loginCancellation.Token);
|
||||||
|
|
||||||
|
var response = await CreateLoginResponseAsync(
|
||||||
|
pending.Username,
|
||||||
|
pending.Password,
|
||||||
|
session,
|
||||||
|
loginCancellation.Token);
|
||||||
|
|
||||||
|
_pendingLoginStore.Remove(pending.Id);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
catch (GlobalLeaksValidationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"GlobalLeaks rechazo el 2FA de {Username}. Status={StatusCode}. Mensaje={Message}",
|
||||||
|
pending.Username,
|
||||||
|
ex.StatusCode,
|
||||||
|
ex.Message);
|
||||||
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status504GatewayTimeout,
|
||||||
|
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status502BadGateway,
|
||||||
|
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error no controlado completando login de {Username}", pending.Username);
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status500InternalServerError,
|
||||||
|
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult<ApiLoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<ApiLoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
|
||||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
if (!_rateLimiter.AllowAttempt(ip))
|
if (!_rateLimiter.AllowAttempt(ip))
|
||||||
{
|
{
|
||||||
@@ -57,30 +216,65 @@ public sealed class AuthController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
|
||||||
|
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Iniciando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
|
||||||
|
usernameForLogs,
|
||||||
|
_globalLeaksOptions.BaseUrl,
|
||||||
|
loginTimeoutSeconds);
|
||||||
|
|
||||||
var session = await _globalLeaksClient.LoginAsync(
|
var session = await _globalLeaksClient.LoginAsync(
|
||||||
request.Username.Trim(),
|
request.Username.Trim(),
|
||||||
request.Password,
|
request.Password,
|
||||||
request.Authcode.Trim(),
|
request.Authcode.Trim(),
|
||||||
cancellationToken);
|
loginCancellation.Token);
|
||||||
|
|
||||||
var username = string.IsNullOrWhiteSpace(session.Username)
|
return Ok(await CreateLoginResponseAsync(
|
||||||
? request.Username.Trim()
|
request.Username.Trim(),
|
||||||
: session.Username.Trim();
|
request.Password,
|
||||||
|
session,
|
||||||
await _sessionStore.SaveAsync(username, request.Password, session.Id, session.Role, cancellationToken);
|
loginCancellation.Token));
|
||||||
|
|
||||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
|
||||||
var token = CreateJwt(username, session.Role, expiresAtUtc);
|
|
||||||
|
|
||||||
return Ok(new ApiLoginResponse(username, token, expiresAtUtc, session.Role));
|
|
||||||
}
|
}
|
||||||
catch (GlobalLeaksValidationException ex)
|
catch (GlobalLeaksValidationException ex)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"GlobalLeaks rechazo el login de {Username}. Status={StatusCode}. Mensaje={Message}",
|
||||||
|
usernameForLogs,
|
||||||
|
ex.StatusCode,
|
||||||
|
ex.Message);
|
||||||
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
||||||
}
|
}
|
||||||
catch
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return StatusCode(StatusCodes.Status502BadGateway, new ApiError("No se ha podido conectar con GlobalLeaks."));
|
_logger.LogWarning(
|
||||||
|
"Timeout de GlobalLeaks al iniciar sesion para {Username}. BaseUrl={BaseUrl}. Timeout={TimeoutSeconds}s",
|
||||||
|
usernameForLogs,
|
||||||
|
_globalLeaksOptions.BaseUrl,
|
||||||
|
Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140));
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status504GatewayTimeout,
|
||||||
|
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}). Revisa el visor de eventos de ApiDenuncias para ver el paso exacto."));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"No se ha podido conectar con GlobalLeaks para {Username}. BaseUrl={BaseUrl}",
|
||||||
|
usernameForLogs,
|
||||||
|
_globalLeaksOptions.BaseUrl);
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status502BadGateway,
|
||||||
|
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error no controlado durante el login de {Username}", usernameForLogs);
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status500InternalServerError,
|
||||||
|
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +291,25 @@ public sealed class AuthController : ControllerBase
|
|||||||
return Ok(new { ok = true });
|
return Ok(new { ok = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ApiLoginResponse> CreateLoginResponseAsync(
|
||||||
|
string fallbackUsername,
|
||||||
|
string password,
|
||||||
|
GlSession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var username = string.IsNullOrWhiteSpace(session.Username)
|
||||||
|
? fallbackUsername.Trim()
|
||||||
|
: session.Username.Trim();
|
||||||
|
|
||||||
|
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
|
||||||
|
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken);
|
||||||
|
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
|
||||||
|
|
||||||
|
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
||||||
|
var token = CreateJwt(username, session.Role, expiresAtUtc);
|
||||||
|
return new ApiLoginResponse(username, token, expiresAtUtc, session.Role);
|
||||||
|
}
|
||||||
|
|
||||||
private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc)
|
private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey))
|
if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey))
|
||||||
|
|||||||
@@ -186,6 +186,40 @@ public sealed class InboxController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports/{reportId}/detail")]
|
||||||
|
public async Task<ActionResult<ReportDetailDto>> GetReportDetail(
|
||||||
|
string reportId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var username = GetUsername();
|
||||||
|
var session = await RequireActiveSessionAsync(username, cancellationToken);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (GlobalLeaksSessionExpiredException)
|
||||||
|
{
|
||||||
|
await _sessionStore.ClearSessionAsync(username, cancellationToken);
|
||||||
|
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
||||||
|
}
|
||||||
|
catch (GlobalLeaksValidationException ex)
|
||||||
|
{
|
||||||
|
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
|
||||||
|
return StatusCode(
|
||||||
|
StatusCodes.Status500InternalServerError,
|
||||||
|
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("local/ensure-storage")]
|
[HttpPost("local/ensure-storage")]
|
||||||
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
|
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ builder.Services.AddDataProtection()
|
|||||||
|
|
||||||
builder.Services.AddSingleton<LoginRateLimiter>();
|
builder.Services.AddSingleton<LoginRateLimiter>();
|
||||||
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
||||||
|
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
|
||||||
builder.Services.AddScoped<GlobalLeaksClient>();
|
builder.Services.AddScoped<GlobalLeaksClient>();
|
||||||
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
||||||
builder.Services.AddScoped<MySqlDenunciaStore>();
|
builder.Services.AddScoped<MySqlDenunciaStore>();
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
_configuration["Gestiona:AccessToken"]
|
_configuration["Gestiona:AccessToken"]
|
||||||
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
|
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
|
||||||
|
|
||||||
private string? PreferredCircuitTemplateName =>
|
|
||||||
_configuration["Gestiona:PreferredCircuitTemplateName"];
|
|
||||||
|
|
||||||
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
|
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
|
||||||
{
|
{
|
||||||
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
|
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
|
||||||
@@ -55,7 +52,8 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||||
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||||
metaReq.Headers.Accept.Clear();
|
metaReq.Headers.Accept.Clear();
|
||||||
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
metaReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||||
|
|
||||||
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
|
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
|
||||||
metaReq.Content.Headers.ContentType =
|
metaReq.Content.Headers.ContentType =
|
||||||
@@ -92,7 +90,7 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location.
|
// Si Gestiona devuelve cuerpo vacío por Prefer:return=minimal, usamos Location del documento.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +211,8 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
{
|
{
|
||||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
|
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
|
||||||
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||||
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
createReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||||
|
|
||||||
using var createResp = await CreateRawHttp().SendAsync(createReq);
|
using var createResp = await CreateRawHttp().SendAsync(createReq);
|
||||||
var createBody = await createResp.Content.ReadAsStringAsync();
|
var createBody = await createResp.Content.ReadAsStringAsync();
|
||||||
@@ -230,7 +229,9 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
|
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
|
||||||
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
||||||
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||||
|
putReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||||
putReq.Content = new ByteArrayContent(contentBytes);
|
putReq.Content = new ByteArrayContent(contentBytes);
|
||||||
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||||
|
|
||||||
@@ -242,11 +243,14 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
if (!string.IsNullOrWhiteSpace(infoJson))
|
||||||
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null;
|
|
||||||
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
|
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : "READY";
|
||||||
|
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uploadUri;
|
return uploadUri;
|
||||||
@@ -260,177 +264,6 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
: $"{normalized}/documents-and-folders";
|
: $"{normalized}/documents-and-folders";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CircuitTemplateCandidate> ObtenerTemplateCircuitoFirmaAsync(string documentUrl)
|
|
||||||
{
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"{documentUrl.TrimEnd('/')}/circuit/templates");
|
|
||||||
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
|
||||||
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.circuits.templates-filedoc-page");
|
|
||||||
|
|
||||||
using var resp = await CreateRawHttp().SendAsync(req);
|
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"ObtenerTemplateCircuitoFirmaAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("No se ha podido leer el listado de plantillas de circuito.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var templates = new List<CircuitTemplateCandidate>();
|
|
||||||
foreach (var item in content.EnumerateArray())
|
|
||||||
{
|
|
||||||
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
|
|
||||||
var href = GetSelfHref(item);
|
|
||||||
if (string.IsNullOrWhiteSpace(href))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = JsonNode.Parse(item.GetRawText()) as JsonObject ?? new JsonObject();
|
|
||||||
templates.Add(new CircuitTemplateCandidate(
|
|
||||||
Name: name,
|
|
||||||
Href: href!,
|
|
||||||
Payload: payload,
|
|
||||||
SignersCount: CountArrayItems(payload, "signers"),
|
|
||||||
BlockEdit: GetBoolean(payload, "block_edit")));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (templates.Count == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("No hay plantillas de circuito disponibles para el documento.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Plantillas de circuito para {DocumentUrl}: {Templates}",
|
|
||||||
documentUrl,
|
|
||||||
string.Join(
|
|
||||||
" | ",
|
|
||||||
templates.Select(template =>
|
|
||||||
$"{template.Name ?? "(sin nombre)"} [firmantes={template.SignersCount}, candado={template.BlockEdit}]")));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(PreferredCircuitTemplateName))
|
|
||||||
{
|
|
||||||
var configuredExact = templates.FirstOrDefault(template =>
|
|
||||||
string.Equals(template.Name, PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (configuredExact is not null)
|
|
||||||
{
|
|
||||||
return configuredExact;
|
|
||||||
}
|
|
||||||
|
|
||||||
var configuredContains = templates.FirstOrDefault(template =>
|
|
||||||
!string.IsNullOrWhiteSpace(template.Name) &&
|
|
||||||
template.Name.Contains(PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (configuredContains is not null)
|
|
||||||
{
|
|
||||||
return configuredContains;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] preferredNames =
|
|
||||||
[
|
|
||||||
"CT-Actualizacion de denuncia",
|
|
||||||
"CT-Actualizaci\u00f3n de denuncia",
|
|
||||||
"Firma automatizada",
|
|
||||||
"Firma Sello de \u00D3rgano",
|
|
||||||
"Firma Sello de Organo"
|
|
||||||
];
|
|
||||||
|
|
||||||
var preferredTemplate = templates.FirstOrDefault(template =>
|
|
||||||
preferredNames.Any(preferred =>
|
|
||||||
string.Equals(template.Name, preferred, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
if (preferredTemplate is not null)
|
|
||||||
{
|
|
||||||
return preferredTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
var templatesWithSigners = templates
|
|
||||||
.Where(template => template.SignersCount > 0)
|
|
||||||
.OrderByDescending(template => template.BlockEdit)
|
|
||||||
.ThenByDescending(template => template.SignersCount)
|
|
||||||
.ToList();
|
|
||||||
if (templatesWithSigners.Count > 0)
|
|
||||||
{
|
|
||||||
return templatesWithSigners[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JsonObject BuildCircuitPayloadFromTemplate(
|
|
||||||
CircuitTemplateCandidate template,
|
|
||||||
string assignedGroupHref,
|
|
||||||
int? complaintId)
|
|
||||||
{
|
|
||||||
_ = assignedGroupHref;
|
|
||||||
_ = complaintId;
|
|
||||||
|
|
||||||
var payload = (JsonObject)template.Payload.DeepClone();
|
|
||||||
EnsureTemplateSelfLink(payload, template.Href);
|
|
||||||
payload.Remove("assigneds_can_use");
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureTemplateSelfLink(JsonObject payload, string templateSelfHref)
|
|
||||||
{
|
|
||||||
if (payload["links"] is not JsonArray links)
|
|
||||||
{
|
|
||||||
links = new JsonArray();
|
|
||||||
payload["links"] = links;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var node in links)
|
|
||||||
{
|
|
||||||
if (node is not JsonObject link)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rel = link["rel"]?.GetValue<string>();
|
|
||||||
var href = link["href"]?.GetValue<string>();
|
|
||||||
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.IsNullOrWhiteSpace(href))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
links.Add(JsonSerializer.SerializeToNode(new { rel = "self", href = templateSelfHref }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountArrayItems(JsonObject payload, string propertyName)
|
|
||||||
{
|
|
||||||
return payload[propertyName] is JsonArray array ? array.Count : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool GetBoolean(JsonObject payload, string propertyName)
|
|
||||||
{
|
|
||||||
return payload[propertyName]?.GetValue<bool?>() ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? GetSelfHref(JsonElement item)
|
|
||||||
{
|
|
||||||
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var link in links.EnumerateArray())
|
|
||||||
{
|
|
||||||
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
|
|
||||||
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
|
|
||||||
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(href))
|
|
||||||
{
|
|
||||||
return href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EnsureAbsoluteGestionaUrl(string url, string apiBase)
|
private static string EnsureAbsoluteGestionaUrl(string url, string apiBase)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
@@ -458,10 +291,4 @@ public sealed class GestionaDocumentWorkflowService
|
|||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record CircuitTemplateCandidate(
|
|
||||||
string? Name,
|
|
||||||
string Href,
|
|
||||||
JsonObject Payload,
|
|
||||||
int SignersCount,
|
|
||||||
bool BlockEdit);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using GestionaDenuncias.Shared.Models;
|
using GestionaDenuncias.Shared.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -19,11 +20,16 @@ namespace ApiDenuncias.Services
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly GestionaOptions _opts;
|
private readonly GestionaOptions _opts;
|
||||||
|
private readonly ILogger<GestionaService> _logger;
|
||||||
|
|
||||||
public GestionaService(HttpClient http, IOptions<GestionaOptions> optsAccessor)
|
public GestionaService(
|
||||||
|
HttpClient http,
|
||||||
|
IOptions<GestionaOptions> optsAccessor,
|
||||||
|
ILogger<GestionaService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_opts = optsAccessor.Value;
|
_opts = optsAccessor.Value;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -58,7 +64,7 @@ namespace ApiDenuncias.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reemplaza este helper si quieres controlar la versi<EFBFBD>n en Accept:
|
// Reemplaza este helper si quieres controlar la versión en Accept:
|
||||||
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
|
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
|
||||||
{
|
{
|
||||||
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||||
@@ -83,6 +89,17 @@ namespace ApiDenuncias.Services
|
|||||||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
|
||||||
|
{
|
||||||
|
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
|
||||||
|
operation,
|
||||||
|
string.Join(" | ", deprecated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
@@ -113,65 +130,20 @@ namespace ApiDenuncias.Services
|
|||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
var fileUrl = GetLinkHref(doc.RootElement, "file")
|
var fileUrl = GetLinkHref(doc.RootElement, "file")
|
||||||
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
|
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
|
||||||
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open");
|
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open")
|
||||||
|
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'.");
|
||||||
|
|
||||||
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
|
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
|
private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
|
||||||
{
|
{
|
||||||
if (Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId))
|
var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)
|
||||||
{
|
? configuredExternalProcedureId
|
||||||
return $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{configuredExternalProcedureId}/create-file";
|
: procedureId;
|
||||||
}
|
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/catalog-2015/procedures/{procedureId}/external-procedures");
|
return Task.FromResult(
|
||||||
AddTokenAndAccept(req, "application/vnd.gestiona.external-procedures-page+json");
|
$"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file");
|
||||||
|
|
||||||
using var resp = await _http.SendAsync(req);
|
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"El procedimiento {procedureId} no tiene tramites externos configurados en Gestiona.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"ResolveExternalProcedureCreateFileUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"El procedimiento {procedureId} no ha devuelto tramites externos validos en Gestiona.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var createFileCandidates = new List<(string? Id, string Href)>();
|
|
||||||
foreach (var item in content.EnumerateArray())
|
|
||||||
{
|
|
||||||
var createFileHref = GetLinkHref(item, "create-file");
|
|
||||||
if (!string.IsNullOrWhiteSpace(createFileHref))
|
|
||||||
{
|
|
||||||
var externalProcedureId = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
|
|
||||||
? idProp.GetString()
|
|
||||||
: null;
|
|
||||||
createFileCandidates.Add((externalProcedureId, createFileHref!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createFileCandidates.Count == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"El procedimiento {procedureId} no tiene ningun tramite externo con link create-file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createFileCandidates
|
|
||||||
.FirstOrDefault(candidate => string.Equals(candidate.Id, procedureId.ToString(), StringComparison.OrdinalIgnoreCase))
|
|
||||||
.Href
|
|
||||||
?? createFileCandidates[0].Href;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OpenFileAsync(
|
public async Task OpenFileAsync(
|
||||||
@@ -183,9 +155,13 @@ namespace ApiDenuncias.Services
|
|||||||
string freeTitle,
|
string freeTitle,
|
||||||
string siaCode)
|
string siaCode)
|
||||||
{
|
{
|
||||||
var url = string.IsNullOrWhiteSpace(fileOpenUrl)
|
if (string.IsNullOrWhiteSpace(fileOpenUrl))
|
||||||
? $"{fileUrl.TrimEnd('/')}/open"
|
{
|
||||||
: fileOpenUrl;
|
throw new InvalidOperationException(
|
||||||
|
"OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = fileOpenUrl;
|
||||||
|
|
||||||
var payload = new
|
var payload = new
|
||||||
{
|
{
|
||||||
@@ -228,7 +204,8 @@ namespace ApiDenuncias.Services
|
|||||||
{
|
{
|
||||||
Content = content
|
Content = content
|
||||||
};
|
};
|
||||||
AddTokenAndAccept(req, "application/json");
|
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||||
|
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1");
|
||||||
|
|
||||||
using var resp = await _http.SendAsync(req);
|
using var resp = await _http.SendAsync(req);
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
@@ -248,7 +225,8 @@ namespace ApiDenuncias.Services
|
|||||||
{
|
{
|
||||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
|
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
|
||||||
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||||
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
createReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||||
|
|
||||||
using var createResp = await _http.SendAsync(createReq);
|
using var createResp = await _http.SendAsync(createReq);
|
||||||
var createBody = await createResp.Content.ReadAsStringAsync();
|
var createBody = await createResp.Content.ReadAsStringAsync();
|
||||||
@@ -256,7 +234,7 @@ namespace ApiDenuncias.Services
|
|||||||
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
|
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
|
||||||
|
|
||||||
var uploadUri = createResp.Headers.Location?.ToString()
|
var uploadUri = createResp.Headers.Location?.ToString()
|
||||||
?? throw new InvalidOperationException("No se devolvi<EFBFBD> Location en /rest/uploads");
|
?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
|
||||||
|
|
||||||
string md5Hex;
|
string md5Hex;
|
||||||
using (var md5 = MD5.Create())
|
using (var md5 = MD5.Create())
|
||||||
@@ -269,8 +247,9 @@ namespace ApiDenuncias.Services
|
|||||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
|
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
|
||||||
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
||||||
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||||
|
putReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||||
putReq.Content = new ByteArrayContent(contentBytes);
|
putReq.Content = new ByteArrayContent(contentBytes);
|
||||||
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||||
|
|
||||||
@@ -279,11 +258,15 @@ namespace ApiDenuncias.Services
|
|||||||
if (!putResp.IsSuccessStatusCode)
|
if (!putResp.IsSuccessStatusCode)
|
||||||
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
||||||
|
|
||||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
if (!string.IsNullOrWhiteSpace(infoJson))
|
||||||
var status = infoDoc.RootElement.GetProperty("status").GetString();
|
{
|
||||||
if (status != "READY")
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp)
|
||||||
|
? statusProp.GetString()
|
||||||
|
: "READY";
|
||||||
|
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||||
|
}
|
||||||
return uploadUri;
|
return uploadUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +292,10 @@ namespace ApiDenuncias.Services
|
|||||||
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
|
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
|
||||||
{ Content = metaContent };
|
{ Content = metaContent };
|
||||||
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||||
|
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||||
metaReq.Headers.Accept.Clear();
|
metaReq.Headers.Accept.Clear();
|
||||||
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
metaReq.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||||
|
|
||||||
using var metaResp = await _http.SendAsync(metaReq);
|
using var metaResp = await _http.SendAsync(metaReq);
|
||||||
var body = await metaResp.Content.ReadAsStringAsync();
|
var body = await metaResp.Content.ReadAsStringAsync();
|
||||||
@@ -386,7 +371,7 @@ namespace ApiDenuncias.Services
|
|||||||
if (thirdParty.IsLegalEntity)
|
if (thirdParty.IsLegalEntity)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
|
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
|
||||||
throw new ArgumentException("La raz<EFBFBD>n social es obligatoria para terceros jur<EFBFBD>dicos.", nameof(thirdParty));
|
throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -561,12 +546,15 @@ namespace ApiDenuncias.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CONSULTAS DE EXPEDIENTES (sin recorrer hist<EFBFBD>rico paginado) ---
|
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
|
||||||
|
|
||||||
private async Task<string> GetFilesAsync(object? filter = null)
|
private async Task<string> GetFilesAsync(object? filter = null)
|
||||||
{
|
{
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
|
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
|
||||||
AddBasicHeaders(req);
|
AddBasicHeaders(req);
|
||||||
|
req.Headers.Accept.Clear();
|
||||||
|
req.Headers.Accept.Add(
|
||||||
|
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json"));
|
||||||
|
|
||||||
if (filter is not null)
|
if (filter is not null)
|
||||||
{
|
{
|
||||||
@@ -575,6 +563,7 @@ namespace ApiDenuncias.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var resp = await _http.SendAsync(req);
|
using var resp = await _http.SendAsync(req);
|
||||||
|
LogDeprecatedHeaders(resp, "GET /rest/files");
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
|
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||||
{
|
{
|
||||||
return "{\"content\":[]}";
|
return "{\"content\":[]}";
|
||||||
@@ -612,7 +601,7 @@ namespace ApiDenuncias.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages p<EFBFBD>ginas.
|
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
|
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
|
||||||
{
|
{
|
||||||
@@ -683,9 +672,10 @@ namespace ApiDenuncias.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
|
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
|
||||||
AddTokenAndAccept(req, "application/json");
|
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
|
||||||
|
|
||||||
using var resp = await _http.SendAsync(req);
|
using var resp = await _http.SendAsync(req);
|
||||||
|
LogDeprecatedHeaders(resp, $"GET {fileUrl}");
|
||||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
|
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
|
||||||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
@@ -910,7 +900,7 @@ namespace ApiDenuncias.Services
|
|||||||
using var resp = await _http.SendAsync(req);
|
using var resp = await _http.SendAsync(req);
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
throw new InvalidOperationException($"Error actualizando direcci<EFBFBD>n del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
|
throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
|
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
|
||||||
@@ -1160,7 +1150,7 @@ namespace ApiDenuncias.Services
|
|||||||
return value switch
|
return value switch
|
||||||
{
|
{
|
||||||
"" => "ESP",
|
"" => "ESP",
|
||||||
"es" or "esp" or "espana" or "espa<EFBFBD>a" or "spain" => "ESP",
|
"es" or "esp" or "espana" or "españa" or "spain" => "ESP",
|
||||||
"prt" or "pt" or "portugal" => "PRT",
|
"prt" or "pt" or "portugal" => "PRT",
|
||||||
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
|
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
|
||||||
_ => "ESP",
|
_ => "ESP",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
@@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace ApiDenuncias.Services;
|
namespace ApiDenuncias.Services;
|
||||||
|
|
||||||
|
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
|
||||||
|
|
||||||
public sealed class GlobalLeaksClient
|
public sealed class GlobalLeaksClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
@@ -50,37 +53,90 @@ public sealed class GlobalLeaksClient
|
|||||||
string authcode,
|
string authcode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
|
||||||
using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken);
|
return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
|
||||||
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
|
}
|
||||||
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
|
|
||||||
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502);
|
public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
|
||||||
|
string username,
|
||||||
|
string password,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var loginWatch = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}",
|
||||||
|
username,
|
||||||
|
_options.BaseUrl);
|
||||||
|
|
||||||
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt);
|
|
||||||
|
|
||||||
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
|
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
|
||||||
typeRequest.Content = CreateJsonContent(new { username });
|
typeRequest.Content = CreateJsonContent(new { username });
|
||||||
using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken);
|
using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
|
||||||
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
|
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
|
||||||
|
|
||||||
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
|
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
|
||||||
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
|
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
|
||||||
|
|
||||||
|
var passwordWatch = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
|
||||||
|
username,
|
||||||
|
authType.Type);
|
||||||
var finalPassword = authType.Type == "key"
|
var finalPassword = authType.Type == "key"
|
||||||
? DerivePassword(password, authType.Salt)
|
? DerivePassword(password, authType.Salt)
|
||||||
: password;
|
: password;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
|
||||||
|
username,
|
||||||
|
passwordWatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication");
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
|
||||||
|
username,
|
||||||
|
loginWatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
return new PreparedGlobalLeaksCredentials(username, finalPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GlSession> CompleteLoginAsync(
|
||||||
|
string username,
|
||||||
|
string finalPassword,
|
||||||
|
string authcode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var loginWatch = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}",
|
||||||
|
username,
|
||||||
|
authcode?.Length ?? 0);
|
||||||
|
|
||||||
|
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
||||||
|
using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
|
||||||
|
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
|
||||||
|
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
|
||||||
|
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502);
|
||||||
|
|
||||||
|
var proofWatch = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username);
|
||||||
|
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
|
||||||
|
username,
|
||||||
|
proofWatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
using var authRequest = CreateRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}");
|
||||||
authRequest.Content = CreateJsonContent(new
|
authRequest.Content = CreateJsonContent(new
|
||||||
{
|
{
|
||||||
tid = 1,
|
tid = 1,
|
||||||
username,
|
username,
|
||||||
password = finalPassword,
|
password = finalPassword,
|
||||||
authcode,
|
authcode = authcode?.Trim() ?? string.Empty,
|
||||||
});
|
});
|
||||||
authRequest.Headers.Add("X-Token", tokenAnswer);
|
authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
|
||||||
|
|
||||||
using var authResponse = await _httpClient.SendAsync(authRequest, cancellationToken);
|
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
|
||||||
if (!authResponse.IsSuccessStatusCode)
|
if (!authResponse.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
|
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
|
||||||
@@ -92,6 +148,10 @@ public sealed class GlobalLeaksClient
|
|||||||
(HttpStatusCode)429 => new GlobalLeaksValidationException(
|
(HttpStatusCode)429 => new GlobalLeaksValidationException(
|
||||||
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
|
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
|
||||||
StatusCodes.Status429TooManyRequests),
|
StatusCodes.Status429TooManyRequests),
|
||||||
|
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
|
||||||
|
new GlobalLeaksValidationException(
|
||||||
|
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
|
||||||
|
StatusCodes.Status406NotAcceptable),
|
||||||
_ => new GlobalLeaksValidationException(
|
_ => new GlobalLeaksValidationException(
|
||||||
string.IsNullOrWhiteSpace(body)
|
string.IsNullOrWhiteSpace(body)
|
||||||
? $"Login fallido (código {(int)authResponse.StatusCode})."
|
? $"Login fallido (código {(int)authResponse.StatusCode})."
|
||||||
@@ -102,7 +162,11 @@ public sealed class GlobalLeaksClient
|
|||||||
|
|
||||||
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
|
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var session = ParseAuthSession(authBody, username);
|
var session = ParseAuthSession(authBody, username);
|
||||||
_logger.LogInformation("Login GlobalLeaks correcto para {Username}. Rol: {Role}", session.Username, session.Role ?? "(sin rol)");
|
_logger.LogInformation(
|
||||||
|
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
|
||||||
|
session.Username,
|
||||||
|
session.Role ?? "(sin rol)",
|
||||||
|
loginWatch.ElapsedMilliseconds);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +260,40 @@ public sealed class GlobalLeaksClient
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ReportDetailDto> GetReportDetailAsync(
|
||||||
|
string sessionId,
|
||||||
|
string reportId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidateUuid(reportId);
|
||||||
|
|
||||||
|
using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
|
||||||
|
using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken);
|
||||||
|
var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var metadata = ParseReports(listBody)
|
||||||
|
.FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (metadata is null)
|
||||||
|
{
|
||||||
|
throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
|
||||||
|
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
|
||||||
|
|
||||||
|
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
||||||
|
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
|
||||||
|
{
|
||||||
|
throw new GlobalLeaksValidationException(
|
||||||
|
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
|
||||||
|
422);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(content);
|
||||||
|
return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<FileDownloadResult> DownloadReportZipAsync(
|
public async Task<FileDownloadResult> DownloadReportZipAsync(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
string reportId,
|
string reportId,
|
||||||
@@ -253,6 +351,43 @@ public sealed class GlobalLeaksClient
|
|||||||
return new FileDownloadResult(content, $"report-{progressive}.json");
|
return new FileDownloadResult(content, $"report-{progressive}.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendLoginRequestAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
string endpoint,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stepWatch = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms",
|
||||||
|
endpoint,
|
||||||
|
(int)response.StatusCode,
|
||||||
|
stepWatch.ElapsedMilliseconds);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms",
|
||||||
|
endpoint,
|
||||||
|
stepWatch.ElapsedMilliseconds);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms",
|
||||||
|
endpoint,
|
||||||
|
stepWatch.ElapsedMilliseconds);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> SendGlRequestAsync(
|
private async Task<HttpResponseMessage> SendGlRequestAsync(
|
||||||
HttpRequestMessage request,
|
HttpRequestMessage request,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
@@ -303,7 +438,7 @@ public sealed class GlobalLeaksClient
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SolveProofOfWork(string tokenId, string tokenSalt)
|
private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var idBytes = Encoding.UTF8.GetBytes(tokenId);
|
var idBytes = Encoding.UTF8.GetBytes(tokenId);
|
||||||
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
|
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
|
||||||
@@ -311,6 +446,11 @@ public sealed class GlobalLeaksClient
|
|||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
|
if ((n & 15) == 0)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
|
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
|
||||||
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
|
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
|
||||||
if (hash[^1] == 0)
|
if (hash[^1] == 0)
|
||||||
@@ -477,6 +617,74 @@ public sealed class GlobalLeaksClient
|
|||||||
return reports;
|
return reports;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
|
||||||
|
{
|
||||||
|
var lastAccessDate = ParseDate(lastAccess);
|
||||||
|
|
||||||
|
bool IsNew(string? value)
|
||||||
|
{
|
||||||
|
if (lastAccessDate is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemDate = ParseDate(value);
|
||||||
|
return itemDate is not null && itemDate > lastAccessDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var comments = EnumerateArray(root, "comments")
|
||||||
|
.Select(item => new ReportCommentDto(
|
||||||
|
GetString(item, "id"),
|
||||||
|
GetString(item, "type"),
|
||||||
|
GetString(item, "content", "text", "message"),
|
||||||
|
GetString(item, "creation_date", "creationDate"),
|
||||||
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files")
|
||||||
|
.Select(item => new ReportFileDto(
|
||||||
|
GetString(item, "id"),
|
||||||
|
GetLocalizedString(item, "name", "file_name", "filename"),
|
||||||
|
GetInt64(item, "size"),
|
||||||
|
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
||||||
|
GetString(item, "creation_date", "creationDate"),
|
||||||
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var receiverFiles = EnumerateArray(root, "rfiles")
|
||||||
|
.Select(item => new ReportFileDto(
|
||||||
|
GetString(item, "id"),
|
||||||
|
GetLocalizedString(item, "name", "file_name", "filename"),
|
||||||
|
GetInt64(item, "size"),
|
||||||
|
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
||||||
|
GetString(item, "creation_date", "creationDate"),
|
||||||
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
|
||||||
|
{
|
||||||
|
if (root.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in property.EnumerateArray())
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
|
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
|
||||||
{
|
{
|
||||||
if (element.ValueKind != JsonValueKind.Object)
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
@@ -513,6 +721,30 @@ public sealed class GlobalLeaksClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? GetLocalizedString(JsonElement element, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (!element.TryGetProperty(name, out var property))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
var value = ExtractName(property, string.Empty);
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static int? GetInt32(JsonElement element, params string[] names)
|
private static int? GetInt32(JsonElement element, params string[] names)
|
||||||
{
|
{
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
@@ -535,6 +767,28 @@ public sealed class GlobalLeaksClient
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long? GetInt64(JsonElement element, params string[] names)
|
||||||
|
{
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(name, out var property))
|
||||||
|
{
|
||||||
|
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.ValueKind == JsonValueKind.String &&
|
||||||
|
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool GetBool(JsonElement element, params string[] names)
|
private static bool GetBool(JsonElement element, params string[] names)
|
||||||
{
|
{
|
||||||
foreach (var name in names)
|
foreach (var name in names)
|
||||||
@@ -621,6 +875,18 @@ public sealed class GlobalLeaksClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsTwoFactorRequiredError(string body)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record TokenResponse(string Id, string Salt);
|
private sealed record TokenResponse(string Id, string Salt);
|
||||||
private sealed record AuthTypeResponse(string Type, string Salt);
|
private sealed record AuthTypeResponse(string Type, string Salt);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace ApiDenuncias.Services;
|
||||||
|
|
||||||
|
public sealed record PendingGlobalLeaksLogin(
|
||||||
|
string Id,
|
||||||
|
string Username,
|
||||||
|
string Password,
|
||||||
|
string FinalPassword,
|
||||||
|
DateTimeOffset ExpiresAtUtc);
|
||||||
|
|
||||||
|
public sealed class PendingGlobalLeaksLoginStore
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly ConcurrentDictionary<string, PendingGlobalLeaksLogin> _items = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword)
|
||||||
|
{
|
||||||
|
CleanupExpired();
|
||||||
|
|
||||||
|
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||||
|
var pending = new PendingGlobalLeaksLogin(
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
finalPassword,
|
||||||
|
DateTimeOffset.UtcNow.Add(Lifetime));
|
||||||
|
|
||||||
|
_items[id] = pending;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PendingGlobalLeaksLogin Get(string id)
|
||||||
|
{
|
||||||
|
CleanupExpired();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || !_items.TryGetValue(id, out var pending))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
_items.TryRemove(id, out _);
|
||||||
|
throw new InvalidOperationException("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(string id)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
_items.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupExpired()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var item in _items)
|
||||||
|
{
|
||||||
|
if (item.Value.ExpiresAtUtc <= now)
|
||||||
|
{
|
||||||
|
_items.TryRemove(item.Key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"Gestiona": {
|
"Gestiona": {
|
||||||
"ApiBase": "https://02.g3stiona.com",
|
"ApiBase": "https://02.g3stiona.com",
|
||||||
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
|
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
|
||||||
|
"ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b",
|
||||||
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
|
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
|
||||||
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
|
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
|
||||||
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
|
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
|
||||||
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
|
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
|
||||||
"CircuitVersion": "2",
|
"CircuitVersion": "2",
|
||||||
"PreferredCircuitTemplateName": "CT-Actualización de denuncia",
|
|
||||||
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
|
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
|
||||||
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
|
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
|
||||||
"Location": "2.02.01"
|
"Location": "2.02.01"
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
namespace GestionaDenuncias.Shared.Models;
|
namespace GestionaDenuncias.Shared.Models;
|
||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password, string Authcode);
|
public sealed record LoginRequest(string Username, string Password, string Authcode);
|
||||||
|
|
||||||
|
public sealed record ApiLoginPrepareRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public sealed record ApiLoginPrepareResponse(
|
||||||
|
string PendingLoginId,
|
||||||
|
string Username,
|
||||||
|
DateTimeOffset ExpiresAtUtc);
|
||||||
|
|
||||||
|
public sealed record ApiLoginCompleteRequest(string PendingLoginId, string Authcode);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace GestionaDenuncias.Shared.Models;
|
||||||
|
|
||||||
|
public sealed record ReportDetailDto(
|
||||||
|
string ReportId,
|
||||||
|
string? LastAccess,
|
||||||
|
IReadOnlyList<ReportCommentDto> Comments,
|
||||||
|
IReadOnlyList<ReportFileDto> WhistleblowerFiles,
|
||||||
|
IReadOnlyList<ReportFileDto> ReceiverFiles);
|
||||||
|
|
||||||
|
public sealed record ReportCommentDto(
|
||||||
|
string? Id,
|
||||||
|
string? Type,
|
||||||
|
string? Content,
|
||||||
|
string? CreationDate,
|
||||||
|
bool IsNew);
|
||||||
|
|
||||||
|
public sealed record ReportFileDto(
|
||||||
|
string? Id,
|
||||||
|
string? Name,
|
||||||
|
long? Size,
|
||||||
|
string? ContentType,
|
||||||
|
string? CreationDate,
|
||||||
|
bool IsNew);
|
||||||
185
Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor
Normal file
185
Antifraude.Net/GestionaDenunciasAN/Components/BusyOverlay.razor
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
|
@if (Busy.IsVisible)
|
||||||
|
{
|
||||||
|
<div class="busy-overlay" role="alert" aria-live="assertive">
|
||||||
|
<div class="busy-overlay__panel">
|
||||||
|
<div class="busy-overlay__spinner" aria-hidden="true"></div>
|
||||||
|
<div class="busy-overlay__content">
|
||||||
|
<div class="busy-overlay__eyebrow">Operacion en curso</div>
|
||||||
|
<h2 class="busy-overlay__title">@Busy.Title</h2>
|
||||||
|
<p class="busy-overlay__message">@Busy.Message</p>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Busy.Detail))
|
||||||
|
{
|
||||||
|
<div class="busy-overlay__detail">@Busy.Detail</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="busy-overlay__progress" aria-hidden="true">
|
||||||
|
<div class="@ProgressBarCss" style="@ProgressStyle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Busy.IsIndeterminate && Busy.Total is > 0)
|
||||||
|
{
|
||||||
|
<div class="busy-overlay__counter">
|
||||||
|
@Busy.Current de @Busy.Total
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.busy-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(41, 101, 194, 0.28), transparent 32%),
|
||||||
|
rgba(6, 22, 41, 0.46);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__panel {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: min(520px, 100%);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 26px 80px rgba(5, 27, 54, 0.28);
|
||||||
|
color: #0d345f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__spinner {
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 4px solid rgba(42, 82, 152, 0.16);
|
||||||
|
border-top-color: #2865c2;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: busy-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__content {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__eyebrow {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: #4f6c8e;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__title {
|
||||||
|
margin: 0;
|
||||||
|
color: #0a315c;
|
||||||
|
font-size: clamp(1.25rem, 3vw, 1.65rem);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__message {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
color: #385c80;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__detail {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #264f79;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__progress {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 0.55rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbe8f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #2865c2, #1fa47f);
|
||||||
|
transition: width 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__progress-bar--indeterminate {
|
||||||
|
position: absolute;
|
||||||
|
width: 42%;
|
||||||
|
animation: busy-progress 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy-overlay__counter {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #5d7187;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes busy-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes busy-progress {
|
||||||
|
0% {
|
||||||
|
left: -45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
left: 105%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 575.98px) {
|
||||||
|
.busy-overlay__panel {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string ProgressBarCss => Busy.IsIndeterminate
|
||||||
|
? "busy-overlay__progress-bar busy-overlay__progress-bar--indeterminate"
|
||||||
|
: "busy-overlay__progress-bar";
|
||||||
|
|
||||||
|
private string ProgressStyle => Busy.IsIndeterminate
|
||||||
|
? string.Empty
|
||||||
|
: $"width: {Busy.ProgressPercent}%;";
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Busy.Changed += HandleBusyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Busy.Changed -= HandleBusyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleBusyChanged()
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@
|
|||||||
@Body
|
@Body
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BusyOverlay />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject IHttpContextAccessor HttpContextAccessor
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BusyOverlay />
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
<a href="" class="reload">Reload</a>
|
<a href="" class="reload">Reload</a>
|
||||||
@@ -140,9 +143,19 @@
|
|||||||
|
|
||||||
private async Task CerrarSesionAsync()
|
private async Task CerrarSesionAsync()
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
|
using var busy = Busy.Show(
|
||||||
userState.Token = string.Empty;
|
"Cerrando sesion",
|
||||||
userState.NombreUsu = string.Empty;
|
"Invalidando la sesion interna y limpiando el acceso del usuario.");
|
||||||
Navigation.NavigateTo("/", true);
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
userState.Token = string.Empty;
|
||||||
|
userState.NombreUsu = string.Empty;
|
||||||
|
Navigation.NavigateTo("/", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
@inject IHostEnvironment HostEnvironment
|
@inject IHostEnvironment HostEnvironment
|
||||||
@inject IDenunciaStore DenunciaStore
|
@inject IDenunciaStore DenunciaStore
|
||||||
@inject ApiDenunciasClient ApiDenuncias
|
@inject ApiDenunciasClient ApiDenuncias
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
<PageTitle>Actualizaciones</PageTitle>
|
<PageTitle>Actualizaciones</PageTitle>
|
||||||
|
|
||||||
@@ -72,27 +73,6 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay de carga */
|
|
||||||
.upload-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
z-index: 2000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
backdrop-filter: blur(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-box {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: .75rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
|
||||||
min-width: 260px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Estética de modal igual que en Pendientes === */
|
/* === Estética de modal igual que en Pendientes === */
|
||||||
|
|
||||||
.custom-modal {
|
.custom-modal {
|
||||||
@@ -594,17 +574,6 @@ else
|
|||||||
<div class="modal-backdrop fade show"></div>
|
<div class="modal-backdrop fade show"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (isUploading)
|
|
||||||
{
|
|
||||||
<div class="upload-overlay">
|
|
||||||
<div class="upload-box">
|
|
||||||
<div class="spinner-border" role="status" aria-hidden="true"></div>
|
|
||||||
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
|
|
||||||
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const string reportTxt = "report.txt";
|
private const string reportTxt = "report.txt";
|
||||||
|
|
||||||
@@ -810,10 +779,14 @@ else
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
isUploading = true;
|
isUploading = true;
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Enviando actualizacion",
|
||||||
|
"Preparando expediente, carpeta de actualizacion y documentos.");
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
|
|
||||||
// 1) Ficheros a subir
|
// 1) Ficheros a subir
|
||||||
|
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
|
||||||
var existentesF = await CargarFicherosJsonAsync();
|
var existentesF = await CargarFicherosJsonAsync();
|
||||||
var fDenuncia = GetPendingUpdateFiles(
|
var fDenuncia = GetPendingUpdateFiles(
|
||||||
existentesF
|
existentesF
|
||||||
@@ -843,6 +816,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8");
|
||||||
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
||||||
selectedDenuncias.ProcedureId,
|
selectedDenuncias.ProcedureId,
|
||||||
nuevoAsunto,
|
nuevoAsunto,
|
||||||
@@ -850,6 +824,7 @@ else
|
|||||||
"3109963"
|
"3109963"
|
||||||
);
|
);
|
||||||
fileUrl = createdFile.FileUrl;
|
fileUrl = createdFile.FileUrl;
|
||||||
|
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8");
|
||||||
await ApiDenuncias.OpenGestionaFileAsync(
|
await ApiDenuncias.OpenGestionaFileAsync(
|
||||||
fileUrl,
|
fileUrl,
|
||||||
createdFile.FileOpenUrl,
|
createdFile.FileOpenUrl,
|
||||||
@@ -869,14 +844,17 @@ else
|
|||||||
selectedDenuncias.EnGestiona = true;
|
selectedDenuncias.EnGestiona = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8");
|
||||||
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
||||||
|
|
||||||
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
|
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
|
||||||
|
|
||||||
|
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8");
|
||||||
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
|
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
|
||||||
|
|
||||||
var ahoraUtc = DateTime.UtcNow;
|
var ahoraUtc = DateTime.UtcNow;
|
||||||
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}");
|
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}");
|
||||||
|
Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8");
|
||||||
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
|
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
|
||||||
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
|
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
|
||||||
var nombresOriginalesSubidos = new List<string>();
|
var nombresOriginalesSubidos = new List<string>();
|
||||||
@@ -888,6 +866,7 @@ else
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(report.FileName))
|
if (!string.IsNullOrWhiteSpace(report.FileName))
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8");
|
||||||
var reportPdfBytes = PdfHelper.MergeFilesToPdf(
|
var reportPdfBytes = PdfHelper.MergeFilesToPdf(
|
||||||
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
|
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
|
||||||
var reportFinalName = FixFileName("Denuncia.pdf");
|
var reportFinalName = FixFileName("Denuncia.pdf");
|
||||||
@@ -905,6 +884,7 @@ else
|
|||||||
|
|
||||||
if (adjuntos.Count > 0 && uploadMode == "merge")
|
if (adjuntos.Count > 0 && uploadMode == "merge")
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8");
|
||||||
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
|
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
|
||||||
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
||||||
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
|
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
|
||||||
@@ -927,6 +907,12 @@ else
|
|||||||
|
|
||||||
foreach (var t in adjuntos)
|
foreach (var t in adjuntos)
|
||||||
{
|
{
|
||||||
|
Busy.Update(
|
||||||
|
message: $"Subiendo adjunto nuevo {i} de {adjuntos.Count}.",
|
||||||
|
detail: "Paso 7 de 8",
|
||||||
|
current: i,
|
||||||
|
total: adjuntos.Count);
|
||||||
|
|
||||||
var origName = t.FileName;
|
var origName = t.FileName;
|
||||||
var content = t.Content;
|
var content = t.Content;
|
||||||
var ext = Path.GetExtension(origName).ToLowerInvariant();
|
var ext = Path.GetExtension(origName).ToLowerInvariant();
|
||||||
@@ -961,6 +947,7 @@ else
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8");
|
||||||
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
||||||
documentoParaTramitar,
|
documentoParaTramitar,
|
||||||
GetAssignedGroupLinkBySelectedGroup(),
|
GetAssignedGroupLinkBySelectedGroup(),
|
||||||
@@ -979,6 +966,8 @@ else
|
|||||||
f.FechaSubida = ahoraUtc;
|
f.FechaSubida = ahoraUtc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
|
||||||
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
||||||
selectedDenuncias.Id_Denuncia,
|
selectedDenuncias.Id_Denuncia,
|
||||||
nombresOriginalesSubidos,
|
nombresOriginalesSubidos,
|
||||||
|
|||||||
@@ -1,13 +1,80 @@
|
|||||||
@page "/GestionZip"
|
@page "/GestionZip"
|
||||||
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@using GestionaDenunciasAN.Models
|
@using GestionaDenunciasAN.Models
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject ApiDenunciasClient ApiDenuncias
|
@inject ApiDenunciasClient ApiDenuncias
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
<PageTitle>Entrada de denuncias</PageTitle>
|
<PageTitle>Entrada de denuncias</PageTitle>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.report-detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
background: rgba(6, 22, 41, 0.62);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-dialog {
|
||||||
|
width: min(820px, 100%);
|
||||||
|
max-height: min(88vh, 860px);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-card {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(215, 228, 241, 0.92);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 28px 90px rgba(6, 22, 41, 0.38);
|
||||||
|
color: var(--app-ink, #12395f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-header,
|
||||||
|
.report-detail-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.35rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--app-border, #d7e4f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.35rem;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 1.35rem;
|
||||||
|
border-top: 1px solid var(--app-border, #d7e4f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -166,13 +233,14 @@
|
|||||||
<th>Ultima actualizacion</th>
|
<th>Ultima actualizacion</th>
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
<th>Seguimiento</th>
|
<th>Seguimiento</th>
|
||||||
|
<th style="width: 7rem;">Detalle</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (!CanUseGlobalLeaks)
|
@if (!CanUseGlobalLeaks)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-muted">
|
<td colspan="8" class="text-muted">
|
||||||
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
|
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,13 +248,13 @@
|
|||||||
else if (ReportsBusy)
|
else if (ReportsBusy)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-muted">Cargando denuncias...</td>
|
<td colspan="8" class="text-muted">Cargando denuncias...</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
else if (!VisibleReports.Any())
|
else if (!VisibleReports.Any())
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-muted">No hay denuncias con los filtros actuales.</td>
|
<td colspan="8" class="text-muted">No hay denuncias con los filtros actuales.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -217,6 +285,15 @@
|
|||||||
<div class="small text-muted mt-1">@report.TrackingNote</div>
|
<div class="small text-muted mt-1">@report.TrackingNote</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida."
|
||||||
|
@onclick="@(() => OpenReportDetailAsync(report))"
|
||||||
|
disabled="@DetailBusy">
|
||||||
|
Ver detalle
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +306,117 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (DetailModalVisible)
|
||||||
|
{
|
||||||
|
<div class="report-detail-overlay" role="dialog" aria-modal="true" aria-labelledby="report-detail-title">
|
||||||
|
<div class="report-detail-dialog">
|
||||||
|
<div class="report-detail-card">
|
||||||
|
<div class="report-detail-header">
|
||||||
|
<div>
|
||||||
|
<h5 id="report-detail-title" class="mb-0">Contenido de denuncia #@(DetailReport?.Progressive ?? 0)</h5>
|
||||||
|
<small class="text-muted">Mensajes y ficheros comparados contra el ultimo acceso registrado.</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close report-detail-close" aria-label="Cerrar" @onclick="CloseReportDetail"></button>
|
||||||
|
</div>
|
||||||
|
<div class="report-detail-body">
|
||||||
|
<div class="alert alert-warning small">
|
||||||
|
Esta consulta abre el detalle en GlobalLeaks y puede marcar la denuncia como leida.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (DetailBusy)
|
||||||
|
{
|
||||||
|
<div class="text-muted">Cargando detalle de GlobalLeaks...</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(DetailError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@DetailError</div>
|
||||||
|
}
|
||||||
|
else if (DetailData is not null)
|
||||||
|
{
|
||||||
|
<p class="small text-muted">
|
||||||
|
Ultimo acceso previo:
|
||||||
|
@(string.IsNullOrWhiteSpace(DetailData.LastAccess) ? "Sin acceso previo" : FormatDate(DetailData.LastAccess)).
|
||||||
|
Los elementos posteriores se marcan como nuevos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small fw-bold mt-4">Mensajes (@DetailData.Comments.Count)</h6>
|
||||||
|
@if (DetailData.Comments.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small">Sin mensajes.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var comment in DetailData.Comments)
|
||||||
|
{
|
||||||
|
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
|
||||||
|
<strong>@GetCommentAuthorLabel(comment.Type)</strong>
|
||||||
|
<span>@FormatDate(comment.CreationDate)</span>
|
||||||
|
</div>
|
||||||
|
@if (comment.IsNew)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success mb-2">Nuevo</span>
|
||||||
|
}
|
||||||
|
<div style="white-space: pre-wrap;">@(string.IsNullOrWhiteSpace(comment.Content) ? "-" : comment.Content)</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros del denunciante (@DetailData.WhistleblowerFiles.Count)</h6>
|
||||||
|
@if (DetailData.WhistleblowerFiles.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small">Sin ficheros.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var file in DetailData.WhistleblowerFiles)
|
||||||
|
{
|
||||||
|
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||||
|
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
|
||||||
|
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
|
||||||
|
@if (file.IsNew)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success mt-2">Nuevo</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros internos/receptor (@DetailData.ReceiverFiles.Count)</h6>
|
||||||
|
@if (DetailData.ReceiverFiles.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small">Sin ficheros.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var file in DetailData.ReceiverFiles)
|
||||||
|
{
|
||||||
|
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||||
|
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
|
||||||
|
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
|
||||||
|
@if (file.IsNew)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success mt-2">Nuevo</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="report-detail-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="CloseReportDetail">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private readonly List<ContextDto> Contexts = [];
|
private readonly List<ContextDto> Contexts = [];
|
||||||
private readonly List<ReportDto> Reports = [];
|
private readonly List<ReportDto> Reports = [];
|
||||||
@@ -252,6 +440,11 @@
|
|||||||
private bool ReportsBusy { get; set; }
|
private bool ReportsBusy { get; set; }
|
||||||
private bool RenewBusy { get; set; }
|
private bool RenewBusy { get; set; }
|
||||||
private bool ImportBusy { get; set; }
|
private bool ImportBusy { get; set; }
|
||||||
|
private bool DetailBusy { get; set; }
|
||||||
|
private bool DetailModalVisible { get; set; }
|
||||||
|
private string DetailError { get; set; } = string.Empty;
|
||||||
|
private ReportDto? DetailReport { get; set; }
|
||||||
|
private ReportDetailDto? DetailData { get; set; }
|
||||||
|
|
||||||
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
|
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
|
||||||
private int SelectedReportsCount => SelectedIds.Count;
|
private int SelectedReportsCount => SelectedIds.Count;
|
||||||
@@ -311,8 +504,13 @@
|
|||||||
RenewBusy = true;
|
RenewBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Renovando sesion GlobalLeaks",
|
||||||
|
"Validando el nuevo codigo 2FA para continuar descargando denuncias.");
|
||||||
|
|
||||||
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
|
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
|
||||||
RenewAuthcode = string.Empty;
|
RenewAuthcode = string.Empty;
|
||||||
|
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
|
||||||
await LoadReportsAsync();
|
await LoadReportsAsync();
|
||||||
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
|
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
|
||||||
}
|
}
|
||||||
@@ -336,6 +534,10 @@
|
|||||||
ReportsBusy = true;
|
ReportsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Actualizando bandeja",
|
||||||
|
"Consultando GlobalLeaks y cruzando el seguimiento con la base de datos.");
|
||||||
|
|
||||||
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
|
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
|
||||||
|
|
||||||
Contexts.Clear();
|
Contexts.Clear();
|
||||||
@@ -383,8 +585,21 @@
|
|||||||
.OrderBy(report => report.Progressive ?? 0)
|
.OrderBy(report => report.Progressive ?? 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Importando denuncias",
|
||||||
|
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
|
||||||
|
selectedReports.Count);
|
||||||
|
|
||||||
|
var processed = 0;
|
||||||
foreach (var report in selectedReports)
|
foreach (var report in selectedReports)
|
||||||
{
|
{
|
||||||
|
processed++;
|
||||||
|
Busy.Update(
|
||||||
|
message: $"Procesando denuncia #{report.Progressive ?? 0}.",
|
||||||
|
detail: "Descarga, analisis del report y guardado seguro en la base de datos.",
|
||||||
|
current: processed,
|
||||||
|
total: selectedReports.Count);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
|
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
|
||||||
@@ -442,6 +657,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OpenReportDetailAsync(ReportDto report)
|
||||||
|
{
|
||||||
|
if (!CanUseGlobalLeaks)
|
||||||
|
{
|
||||||
|
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailModalVisible = true;
|
||||||
|
DetailReport = report;
|
||||||
|
DetailData = null;
|
||||||
|
DetailError = string.Empty;
|
||||||
|
DetailBusy = true;
|
||||||
|
await SetBodyScrollLockAsync(true);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Leyendo detalle de denuncia",
|
||||||
|
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
|
||||||
|
|
||||||
|
DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
await LoadSessionStateAsync();
|
||||||
|
DetailError = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DetailError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DetailBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseReportDetail()
|
||||||
|
{
|
||||||
|
DetailModalVisible = false;
|
||||||
|
DetailReport = null;
|
||||||
|
DetailData = null;
|
||||||
|
DetailError = string.Empty;
|
||||||
|
await SetBodyScrollLockAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await SetBodyScrollLockAsync(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetBodyScrollLockAsync(bool locked)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Si el circuito se esta cerrando, solo aseguramos que no reviente el componente.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyFilters()
|
private void ApplyFilters()
|
||||||
{
|
{
|
||||||
IEnumerable<ReportDto> filtered = Reports;
|
IEnumerable<ReportDto> filtered = Reports;
|
||||||
@@ -652,6 +931,34 @@
|
|||||||
: "-";
|
: "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatBytes(long? value)
|
||||||
|
{
|
||||||
|
if (value is null or <= 0)
|
||||||
|
{
|
||||||
|
return "Tamano no disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = value.Value;
|
||||||
|
if (bytes < 1024)
|
||||||
|
{
|
||||||
|
return $"{bytes} B";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024)
|
||||||
|
{
|
||||||
|
return $"{bytes / 1024d:0.#} KB";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{bytes / 1024d / 1024d:0.#} MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCommentAuthorLabel(string? type)
|
||||||
|
=> string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Denunciante"
|
||||||
|
: string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "Receptor"
|
||||||
|
: "Comentario";
|
||||||
|
|
||||||
private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
|
private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
|
||||||
{
|
{
|
||||||
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
|
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
@layout EmptyLayout
|
@layout EmptyLayout
|
||||||
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
||||||
@using System.Text.Json
|
@using System.Text.Json
|
||||||
|
@using GestionaDenuncias.Shared.Models
|
||||||
@using GestionaDenunciasAN.Models
|
@using GestionaDenunciasAN.Models
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
<PageTitle>Portal de denuncias</PageTitle>
|
<PageTitle>Portal de denuncias</PageTitle>
|
||||||
|
|
||||||
@@ -87,12 +89,12 @@
|
|||||||
<div class="row g-5 align-items-center">
|
<div class="row g-5 align-items-center">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<div class="brand-panel">
|
<div class="brand-panel">
|
||||||
<span class="brand-kicker">Oficina Antifraude de Andalucía</span>
|
<span class="brand-kicker">Oficina Antifraude de Andalucia</span>
|
||||||
<h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1>
|
<h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1>
|
||||||
<p class="brand-copy mb-4">
|
<p class="brand-copy mb-4">
|
||||||
El acceso de la aplicación ya se apoya en GlobalLeaks. La sesión interna de esta app se mantiene
|
El acceso de la aplicacion ya se apoya en GlobalLeaks. La sesion interna de esta app se mantiene
|
||||||
activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que
|
activa de forma persistente, y cuando caduque la sesion de obtencion de denuncias solo habra que
|
||||||
renovar el código 2FA desde la bandeja de entrada.
|
renovar el codigo 2FA desde la bandeja de entrada.
|
||||||
</p>
|
</p>
|
||||||
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
|
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
|
||||||
alt="Logo Oficina Antifraude"
|
alt="Logo Oficina Antifraude"
|
||||||
@@ -104,7 +106,7 @@
|
|||||||
<div class="login-panel p-4 p-md-5">
|
<div class="login-panel p-4 p-md-5">
|
||||||
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
|
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
|
||||||
<p class="text-muted mb-4">
|
<p class="text-muted mb-4">
|
||||||
Introduce tu usuario, contraseña y el código 2FA actual para dejar la app iniciada.
|
Primero validamos usuario y contrasena. Despues te pediremos un codigo 2FA fresco para completar el acceso.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||||
@@ -116,33 +118,54 @@
|
|||||||
<label class="login-label mb-2">Usuario</label>
|
<label class="login-label mb-2">Usuario</label>
|
||||||
<input class="form-control login-input"
|
<input class="form-control login-input"
|
||||||
@bind="Username"
|
@bind="Username"
|
||||||
|
@bind:event="oninput"
|
||||||
|
disabled="@LoginPrepared"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
placeholder="usuario de GlobalLeaks" />
|
placeholder="usuario de GlobalLeaks" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="login-label mb-2">Contraseña</label>
|
<label class="login-label mb-2">Contrasena</label>
|
||||||
<input class="form-control login-input"
|
<input class="form-control login-input"
|
||||||
type="password"
|
type="password"
|
||||||
@bind="Password"
|
@bind="Password"
|
||||||
|
@bind:event="oninput"
|
||||||
|
disabled="@LoginPrepared"
|
||||||
autocomplete="current-password" />
|
autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
@if (LoginPrepared)
|
||||||
<label class="login-label mb-2">Código 2FA</label>
|
{
|
||||||
<input class="form-control login-input"
|
<div class="alert alert-info mb-4">
|
||||||
@bind="Authcode"
|
Credenciales preparadas. Introduce ahora el codigo 2FA actual para completar el acceso.
|
||||||
@onkeydown="HandleAuthcodeKeyDown"
|
</div>
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="6"
|
<div class="mb-4">
|
||||||
placeholder="123456" />
|
<label class="login-label mb-2">Codigo 2FA</label>
|
||||||
</div>
|
<input class="form-control login-input"
|
||||||
|
@bind="Authcode"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="HandleAuthcodeKeyDown"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="123456" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<button class="btn btn-primary login-button w-100"
|
<button class="btn btn-primary login-button w-100"
|
||||||
@onclick="LoginAsync"
|
@onclick="LoginAsync"
|
||||||
disabled="@IsBusy">
|
disabled="@IsBusy">
|
||||||
@(IsBusy ? "Conectando..." : "Entrar")
|
@LoginButtonText
|
||||||
</button>
|
</button>
|
||||||
|
@if (LoginPrepared)
|
||||||
|
{
|
||||||
|
<button class="btn btn-link w-100 mt-3"
|
||||||
|
type="button"
|
||||||
|
disabled="@IsBusy"
|
||||||
|
@onclick="ResetPreparedLogin">
|
||||||
|
Cambiar usuario o contrasena
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,9 +179,14 @@
|
|||||||
private string Username { get; set; } = string.Empty;
|
private string Username { get; set; } = string.Empty;
|
||||||
private string Password { get; set; } = string.Empty;
|
private string Password { get; set; } = string.Empty;
|
||||||
private string Authcode { get; set; } = string.Empty;
|
private string Authcode { get; set; } = string.Empty;
|
||||||
|
private string PendingLoginId { get; set; } = string.Empty;
|
||||||
private string StatusMessage { get; set; } = string.Empty;
|
private string StatusMessage { get; set; } = string.Empty;
|
||||||
private string StatusCss { get; set; } = "alert-info";
|
private string StatusCss { get; set; } = "alert-info";
|
||||||
private bool IsBusy { get; set; }
|
private bool IsBusy { get; set; }
|
||||||
|
private bool LoginPrepared => !string.IsNullOrWhiteSpace(PendingLoginId);
|
||||||
|
private string LoginButtonText => IsBusy
|
||||||
|
? (LoginPrepared ? "Validando 2FA..." : "Preparando acceso...")
|
||||||
|
: (LoginPrepared ? "Validar 2FA y entrar" : "Preparar acceso");
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -173,11 +201,21 @@
|
|||||||
{
|
{
|
||||||
StatusMessage = string.Empty;
|
StatusMessage = string.Empty;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Username) ||
|
if (!LoginPrepared)
|
||||||
string.IsNullOrWhiteSpace(Password) ||
|
|
||||||
string.IsNullOrWhiteSpace(Authcode))
|
|
||||||
{
|
{
|
||||||
SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning");
|
await PrepareLoginAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CompleteLoginAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrepareLoginAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Username) ||
|
||||||
|
string.IsNullOrWhiteSpace(Password))
|
||||||
|
{
|
||||||
|
SetStatus("Debes rellenar usuario y contrasena.", "alert-warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,27 +223,105 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Preparando acceso",
|
||||||
|
"Validando usuario y contrasena con GlobalLeaks. Cuando termine pediremos un codigo 2FA nuevo.");
|
||||||
|
|
||||||
|
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
|
||||||
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
|
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
|
||||||
"appAuthPostJson",
|
"appAuthPostJson",
|
||||||
"/api/auth/login",
|
loginTimeout.Token,
|
||||||
new LoginRequest(Username.Trim(), Password, Authcode.Trim()));
|
[
|
||||||
|
"/api/auth/prepare",
|
||||||
|
new ApiLoginPrepareRequest(Username.Trim(), Password)
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.Ok)
|
if (!response.Ok)
|
||||||
{
|
{
|
||||||
var error = ReadData<ApiError>(response);
|
var error = ReadData<ApiError>(response);
|
||||||
SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger");
|
SetStatus(error?.Error ?? "No se ha podido preparar el acceso.", "alert-danger");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var prepared = ReadData<ApiLoginPrepareResponse>(response);
|
||||||
|
if (prepared is null || string.IsNullOrWhiteSpace(prepared.PendingLoginId))
|
||||||
|
{
|
||||||
|
SetStatus("La API no ha devuelto una preparacion de login valida.", "alert-danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingLoginId = prepared.PendingLoginId;
|
||||||
|
Username = prepared.Username;
|
||||||
|
Password = string.Empty;
|
||||||
|
Authcode = string.Empty;
|
||||||
|
SetStatus("Credenciales preparadas. Introduce el codigo 2FA que este activo ahora mismo.", "alert-info");
|
||||||
|
}
|
||||||
|
catch (JSException ex)
|
||||||
|
{
|
||||||
|
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
SetStatus("La preparacion del acceso ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus($"No se ha podido preparar el acceso: {ex.Message}", "alert-danger");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteLoginAsync()
|
||||||
|
{
|
||||||
|
var authcode = Authcode.Trim();
|
||||||
|
if (authcode.Length != 6 || authcode.Any(ch => !char.IsDigit(ch)))
|
||||||
|
{
|
||||||
|
SetStatus("El codigo 2FA debe tener exactamente 6 digitos.", "alert-warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Validando 2FA",
|
||||||
|
"Completando el acceso con el codigo actual de GlobalLeaks.");
|
||||||
|
|
||||||
|
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
|
||||||
|
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
|
||||||
|
"appAuthPostJson",
|
||||||
|
loginTimeout.Token,
|
||||||
|
[
|
||||||
|
"/api/auth/complete",
|
||||||
|
new ApiLoginCompleteRequest(PendingLoginId, authcode)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!response.Ok)
|
||||||
|
{
|
||||||
|
var error = ReadData<ApiError>(response);
|
||||||
|
SetStatus(error?.Error ?? "No se ha podido completar el inicio de sesion.", "alert-danger");
|
||||||
|
Authcode = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Busy.Update(message: "Creando la sesion interna segura de la aplicacion.");
|
||||||
Navigation.NavigateTo(GetTargetUrl(), true);
|
Navigation.NavigateTo(GetTargetUrl(), true);
|
||||||
}
|
}
|
||||||
catch (JSException ex)
|
catch (JSException ex)
|
||||||
{
|
{
|
||||||
SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger");
|
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
|
||||||
}
|
}
|
||||||
catch
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
SetStatus("No se ha podido conectar con el servidor.", "alert-danger");
|
SetStatus("La validacion del 2FA ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus($"No se ha podido completar el inicio de sesion: {ex.Message}", "alert-danger");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -214,7 +330,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
|
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
|
||||||
=> args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
|
=> LoginPrepared && args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
|
||||||
|
|
||||||
|
private void ResetPreparedLogin()
|
||||||
|
{
|
||||||
|
PendingLoginId = string.Empty;
|
||||||
|
Password = string.Empty;
|
||||||
|
Authcode = string.Empty;
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private void SetStatus(string message, string cssClass)
|
private void SetStatus(string message, string cssClass)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,30 +18,11 @@
|
|||||||
@inject IHostEnvironment HostEnvironment
|
@inject IHostEnvironment HostEnvironment
|
||||||
@inject IDenunciaStore DenunciaStore
|
@inject IDenunciaStore DenunciaStore
|
||||||
@inject ApiDenunciasClient ApiDenuncias
|
@inject ApiDenunciasClient ApiDenuncias
|
||||||
|
@inject UiBusyService Busy
|
||||||
|
|
||||||
<PageTitle>Denuncias Pendientes</PageTitle>
|
<PageTitle>Denuncias Pendientes</PageTitle>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.upload-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.35);
|
|
||||||
z-index: 2000; /* por encima de todo, también de los modales */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
backdrop-filter: blur(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-box {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: .75rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
|
||||||
min-width: 260px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seleccionar-col {
|
.seleccionar-col {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -720,7 +701,7 @@ else
|
|||||||
<button type="button" class="btn btn-secondary" @onclick="CloseModal">
|
<button type="button" class="btn btn-secondary" @onclick="CloseModal">
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary" @onclick="ConfirmarEnvio">
|
<button type="button" class="btn btn-primary" disabled="@isUploading" @onclick="ConfirmarEnvio">
|
||||||
Confirmar
|
Confirmar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,17 +809,6 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (isUploading)
|
|
||||||
{
|
|
||||||
<div class="upload-overlay">
|
|
||||||
<div class="upload-box">
|
|
||||||
<div class="spinner-border" role="status" aria-hidden="true"></div>
|
|
||||||
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
|
|
||||||
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string nombreDocumentos = string.Empty;
|
private string nombreDocumentos = string.Empty;
|
||||||
private bool isUploading = false;
|
private bool isUploading = false;
|
||||||
@@ -970,7 +940,13 @@ else
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
isUploading = true;
|
isUploading = true;
|
||||||
|
using var busy = Busy.Show(
|
||||||
|
"Enviando a Gestiona",
|
||||||
|
"Preparando expediente, tercero y documentos de la denuncia.");
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
Busy.Update(message: "Cargando ficheros de la denuncia.", detail: "Paso 1 de 7");
|
||||||
var existentesF = await CargarFicherosJsonAsync();
|
var existentesF = await CargarFicherosJsonAsync();
|
||||||
var todos = existentesF
|
var todos = existentesF
|
||||||
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
|
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
|
||||||
@@ -987,6 +963,7 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
|
||||||
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
||||||
selectedDenuncias.ProcedureId,
|
selectedDenuncias.ProcedureId,
|
||||||
nuevoAsunto,
|
nuevoAsunto,
|
||||||
@@ -994,6 +971,7 @@ else
|
|||||||
"3109963"
|
"3109963"
|
||||||
);
|
);
|
||||||
fileUrl = createdFile.FileUrl;
|
fileUrl = createdFile.FileUrl;
|
||||||
|
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7");
|
||||||
await ApiDenuncias.OpenGestionaFileAsync(
|
await ApiDenuncias.OpenGestionaFileAsync(
|
||||||
fileUrl,
|
fileUrl,
|
||||||
createdFile.FileOpenUrl,
|
createdFile.FileOpenUrl,
|
||||||
@@ -1013,9 +991,11 @@ else
|
|||||||
selectedDenuncias.EnGestiona = true;
|
selectedDenuncias.EnGestiona = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 7");
|
||||||
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
||||||
|
|
||||||
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
|
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
|
||||||
|
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
|
||||||
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
|
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
|
||||||
|
|
||||||
var nombresOriginalesSubidos = new List<string>();
|
var nombresOriginalesSubidos = new List<string>();
|
||||||
@@ -1028,6 +1008,7 @@ else
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(report.FileName))
|
if (!string.IsNullOrWhiteSpace(report.FileName))
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 5 de 7");
|
||||||
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[]
|
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[]
|
||||||
{
|
{
|
||||||
(FileName: report.FileName, Content: report.Content)
|
(FileName: report.FileName, Content: report.Content)
|
||||||
@@ -1047,6 +1028,7 @@ else
|
|||||||
|
|
||||||
if (adjuntos.Count > 0 && uploadMode == "merge")
|
if (adjuntos.Count > 0 && uploadMode == "merge")
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7");
|
||||||
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
|
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
|
||||||
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
||||||
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
|
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
|
||||||
@@ -1069,6 +1051,12 @@ else
|
|||||||
|
|
||||||
foreach (var (origName, content) in adjuntos)
|
foreach (var (origName, content) in adjuntos)
|
||||||
{
|
{
|
||||||
|
Busy.Update(
|
||||||
|
message: $"Subiendo adjunto {i} de {adjuntos.Count}.",
|
||||||
|
detail: "Paso 6 de 7",
|
||||||
|
current: i,
|
||||||
|
total: adjuntos.Count);
|
||||||
|
|
||||||
var ext = Path.GetExtension(origName).ToLowerInvariant();
|
var ext = Path.GetExtension(origName).ToLowerInvariant();
|
||||||
byte[] bytesParaSubir = content;
|
byte[] bytesParaSubir = content;
|
||||||
string finalName;
|
string finalName;
|
||||||
@@ -1102,6 +1090,7 @@ else
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
||||||
{
|
{
|
||||||
|
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7");
|
||||||
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
||||||
documentoParaTramitar,
|
documentoParaTramitar,
|
||||||
GetAssignedGroupLinkBySelectedGroup(),
|
GetAssignedGroupLinkBySelectedGroup(),
|
||||||
@@ -1119,6 +1108,8 @@ else
|
|||||||
f.FechaSubida = ahoraUtc;
|
f.FechaSubida = ahoraUtc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
|
||||||
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
||||||
selectedDenuncias.Id_Denuncia,
|
selectedDenuncias.Id_Denuncia,
|
||||||
nombresOriginalesSubidos,
|
nombresOriginalesSubidos,
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ public sealed class ApiDenunciasOptions
|
|||||||
public const string SectionName = "ApiDenuncias";
|
public const string SectionName = "ApiDenuncias";
|
||||||
|
|
||||||
public string BaseUrl { get; set; } = "https://localhost:7093";
|
public string BaseUrl { get; set; } = "https://localhost:7093";
|
||||||
|
public int LoginTimeoutSeconds { get; set; } = 150;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("es-ES");
|
|||||||
builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName));
|
builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents(options =>
|
||||||
|
{
|
||||||
|
options.DetailedErrors = true;
|
||||||
|
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
|
||||||
|
});
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
@@ -37,12 +41,17 @@ builder.Services
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddDataProtection();
|
builder.Services.AddDataProtection();
|
||||||
builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; });
|
builder.Services.AddServerSideBlazor().AddCircuitOptions(option =>
|
||||||
|
{
|
||||||
|
option.DetailedErrors = true;
|
||||||
|
option.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
|
||||||
|
});
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddAntiforgery();
|
builder.Services.AddAntiforgery();
|
||||||
builder.Services.AddScoped<UserState>();
|
builder.Services.AddScoped<UserState>();
|
||||||
builder.Services.AddSingleton<AppSessionLifetime>();
|
builder.Services.AddSingleton<AppSessionLifetime>();
|
||||||
builder.Services.AddSingleton<LoginRateLimiter>();
|
builder.Services.AddSingleton<LoginRateLimiter>();
|
||||||
|
builder.Services.AddScoped<UiBusyService>();
|
||||||
builder.Services.AddScoped<ApiDenunciasClient>();
|
builder.Services.AddScoped<ApiDenunciasClient>();
|
||||||
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
|
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
|
||||||
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
|
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
|
||||||
@@ -140,11 +149,60 @@ app.UseAntiforgery();
|
|||||||
|
|
||||||
var api = app.MapGroup("/api");
|
var api = app.MapGroup("/api");
|
||||||
|
|
||||||
|
api.MapPost("/auth/prepare", async (
|
||||||
|
ApiLoginPrepareRequest request,
|
||||||
|
ApiDenunciasClient apiClient,
|
||||||
|
IOptions<ApiDenunciasOptions> apiOptions,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) ||
|
||||||
|
string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError("Debes indicar usuario y contrasena."),
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
||||||
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
|
var prepared = await apiClient.PrepareLoginAsync(
|
||||||
|
request with { Username = request.Username.Trim() },
|
||||||
|
loginTimeout.Token);
|
||||||
|
|
||||||
|
return Results.Ok(prepared);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
|
||||||
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
api.MapPost("/auth/login", async (
|
api.MapPost("/auth/login", async (
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
ApiDenunciasClient apiClient,
|
ApiDenunciasClient apiClient,
|
||||||
LoginRateLimiter rateLimiter,
|
LoginRateLimiter rateLimiter,
|
||||||
|
IOptions<ApiDenunciasOptions> apiOptions,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
@@ -174,13 +232,17 @@ api.MapPost("/auth/login", async (
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
||||||
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
var login = await apiClient.LoginAsync(
|
var login = await apiClient.LoginAsync(
|
||||||
request with
|
request with
|
||||||
{
|
{
|
||||||
Username = request.Username.Trim(),
|
Username = request.Username.Trim(),
|
||||||
Authcode = request.Authcode.Trim()
|
Authcode = request.Authcode.Trim()
|
||||||
},
|
},
|
||||||
cancellationToken);
|
loginTimeout.Token);
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
@@ -220,6 +282,104 @@ api.MapPost("/auth/login", async (
|
|||||||
new ApiError(ex.Message),
|
new ApiError(ex.Message),
|
||||||
statusCode: StatusCodes.Status400BadRequest);
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl}). Comprueba los logs de ApiDenuncias: probablemente esta esperando a GlobalLeaks o a una dependencia externa."),
|
||||||
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
api.MapPost("/auth/complete", async (
|
||||||
|
ApiLoginCompleteRequest request,
|
||||||
|
HttpContext httpContext,
|
||||||
|
ApiDenunciasClient apiClient,
|
||||||
|
IOptions<ApiDenunciasOptions> apiOptions,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.PendingLoginId) ||
|
||||||
|
string.IsNullOrWhiteSpace(request.Authcode))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError("Debes indicar el codigo 2FA."),
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError("El codigo 2FA debe tener exactamente 6 digitos."),
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
|
||||||
|
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||||
|
|
||||||
|
var login = await apiClient.CompleteLoginAsync(
|
||||||
|
request with { Authcode = request.Authcode.Trim() },
|
||||||
|
loginTimeout.Token);
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.Name, login.Username),
|
||||||
|
new("app_startup_stamp", appSessionLifetime.StartupStamp),
|
||||||
|
new(ApiDenunciasClient.AccessTokenClaim, login.AccessToken),
|
||||||
|
new(ApiDenunciasClient.TokenExpiresAtClaim, login.ExpiresAtUtc.ToString("O", CultureInfo.InvariantCulture)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(login.Role))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("gl_role", login.Role));
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var authProperties = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = false,
|
||||||
|
AllowRefresh = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await httpContext.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
principal,
|
||||||
|
authProperties);
|
||||||
|
|
||||||
|
return Results.Ok(new LoginResponse(login.Username));
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError(ex.Message),
|
||||||
|
statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
|
||||||
|
statusCode: StatusCodes.Status504GatewayTimeout);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
api.MapPost("/auth/logout", async (
|
api.MapPost("/auth/logout", async (
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public sealed class ApiDenunciasClient
|
|||||||
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
|
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
|
||||||
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login", request, authorize: false, cancellationToken);
|
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login", request, authorize: false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ApiLoginPrepareResponse> PrepareLoginAsync(ApiLoginPrepareRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> SendAsync<ApiLoginPrepareResponse>(HttpMethod.Post, "api/auth/login/prepare", request, authorize: false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ApiLoginResponse> CompleteLoginAsync(ApiLoginCompleteRequest request, CancellationToken cancellationToken = default)
|
||||||
|
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login/complete", request, authorize: false, cancellationToken);
|
||||||
|
|
||||||
public Task LogoutAsync(CancellationToken cancellationToken = default)
|
public Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||||
=> SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken);
|
=> SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken);
|
||||||
|
|
||||||
@@ -63,6 +69,14 @@ public sealed class ApiDenunciasClient
|
|||||||
authorize: true,
|
authorize: true,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<ReportDetailDto> GetReportDetailAsync(string reportId, CancellationToken cancellationToken = default)
|
||||||
|
=> SendAsync<ReportDetailDto>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail",
|
||||||
|
body: null,
|
||||||
|
authorize: true,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
|
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
|
||||||
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
|
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
140
Antifraude.Net/GestionaDenunciasAN/Services/UiBusyService.cs
Normal file
140
Antifraude.Net/GestionaDenunciasAN/Services/UiBusyService.cs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
namespace GestionaDenunciasAN.Services;
|
||||||
|
|
||||||
|
public sealed class UiBusyService
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private long _scopeCounter;
|
||||||
|
private long _activeScope;
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public bool IsVisible { get; private set; }
|
||||||
|
public string Title { get; private set; } = string.Empty;
|
||||||
|
public string Message { get; private set; } = string.Empty;
|
||||||
|
public string? Detail { get; private set; }
|
||||||
|
public int? Current { get; private set; }
|
||||||
|
public int? Total { get; private set; }
|
||||||
|
|
||||||
|
public bool IsIndeterminate => Total is not > 0 || Current is null;
|
||||||
|
|
||||||
|
public int ProgressPercent
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Total is not > 0 || Current is null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp((int)Math.Round(Current.Value * 100d / Total.Value), 0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Show(string title, string message, int? total = null, string? detail = null)
|
||||||
|
{
|
||||||
|
var scopeId = Interlocked.Increment(ref _scopeCounter);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_activeScope = scopeId;
|
||||||
|
IsVisible = true;
|
||||||
|
Title = title;
|
||||||
|
Message = message;
|
||||||
|
Detail = detail;
|
||||||
|
Total = total;
|
||||||
|
Current = total is > 0 ? 0 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyChanged();
|
||||||
|
return new BusyScope(this, scopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(
|
||||||
|
string? title = null,
|
||||||
|
string? message = null,
|
||||||
|
string? detail = null,
|
||||||
|
int? current = null,
|
||||||
|
int? total = null)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!IsVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title is not null)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
Message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail is not null)
|
||||||
|
{
|
||||||
|
Detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total is not null)
|
||||||
|
{
|
||||||
|
Total = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is not null)
|
||||||
|
{
|
||||||
|
Current = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide()
|
||||||
|
{
|
||||||
|
Hide(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Hide(long? scopeId)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (scopeId is not null && scopeId.Value != _activeScope)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsVisible = false;
|
||||||
|
Title = string.Empty;
|
||||||
|
Message = string.Empty;
|
||||||
|
Detail = null;
|
||||||
|
Current = null;
|
||||||
|
Total = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyChanged()
|
||||||
|
{
|
||||||
|
Changed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BusyScope(UiBusyService owner, long scopeId) : IDisposable
|
||||||
|
{
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
owner.Hide(scopeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ForceHttpsRedirection": false,
|
"ForceHttpsRedirection": false,
|
||||||
"ApiDenuncias": {
|
"ApiDenuncias": {
|
||||||
"BaseUrl": "http://localhost:7093"
|
"BaseUrl": "http://localhost:7093",
|
||||||
|
"LoginTimeoutSeconds": 150
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.app-scroll-locked,
|
||||||
|
body.app-scroll-locked {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
.btn-link {
|
.btn-link {
|
||||||
color: var(--app-accent);
|
color: var(--app-accent);
|
||||||
@@ -197,7 +202,6 @@ pre {
|
|||||||
border: 1px solid rgba(90, 155, 213, 0.14);
|
border: 1px solid rgba(90, 155, 213, 0.14);
|
||||||
box-shadow: var(--app-shadow-soft);
|
box-shadow: var(--app-shadow-soft);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content > .container,
|
.app-content > .container,
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
window.appAuthPostJson = async function (url, body) {
|
window.appAuthPostJson = async function (url, body) {
|
||||||
const response = await fetch(url, {
|
let response;
|
||||||
method: "POST",
|
try {
|
||||||
credentials: "same-origin",
|
response = await fetch(url, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json"
|
credentials: "same-origin",
|
||||||
},
|
headers: {
|
||||||
body: JSON.stringify(body)
|
"Content-Type": "application/json"
|
||||||
});
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
data: {
|
||||||
|
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -23,10 +34,21 @@ window.appAuthPostJson = async function (url, body) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.appAuthPost = async function (url) {
|
window.appAuthPost = async function (url) {
|
||||||
const response = await fetch(url, {
|
let response;
|
||||||
method: "POST",
|
try {
|
||||||
credentials: "same-origin"
|
response = await fetch(url, {
|
||||||
});
|
method: "POST",
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
data: {
|
||||||
|
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -41,3 +63,8 @@ window.appAuthPost = async function (url) {
|
|||||||
data
|
data
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.appSetBodyScrollLock = function (locked) {
|
||||||
|
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
|
||||||
|
document.body.classList.toggle("app-scroll-locked", Boolean(locked));
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user