denuncias
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
<system.Web>
|
||||
<httpRuntime targetFramework="4.8" />
|
||||
</system.Web>
|
||||
|
||||
-->
|
||||
<system.web>
|
||||
<sessionState timeout="300000">
|
||||
@@ -103,14 +104,8 @@
|
||||
<errors callbackErrorRedirectUrl="" />
|
||||
</devExpress>
|
||||
<appSettings>
|
||||
<add key="UrlCertLogin" value="https://cwe-antifraude.tecnosis.online/LoginCertBridge.aspx" />
|
||||
<add key="CertHeaderName" value="X-ARR-ClientCert" />
|
||||
<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="vs:EnableBrowserLink" value="false" />
|
||||
<add key="RutaRes" value="https://localhost:44300" />
|
||||
<!--<add key="RutaRes" value="http://192.168.41.122:888" />-->
|
||||
<add key="SwaggerVB" value="http://localhost:103/" />
|
||||
<!--produccion-->
|
||||
|
||||
@@ -18,25 +18,184 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly GlobalLeaksClient _globalLeaksClient;
|
||||
private readonly GlobalLeaksSessionStore _sessionStore;
|
||||
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
||||
private readonly LoginRateLimiter _rateLimiter;
|
||||
private readonly JwtOptions _jwtOptions;
|
||||
private readonly GlobalLeaksOptions _globalLeaksOptions;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
GlobalLeaksClient globalLeaksClient,
|
||||
GlobalLeaksSessionStore sessionStore,
|
||||
PendingGlobalLeaksLoginStore pendingLoginStore,
|
||||
LoginRateLimiter rateLimiter,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IOptions<GlobalLeaksOptions> globalLeaksOptions,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_globalLeaksClient = globalLeaksClient;
|
||||
_sessionStore = sessionStore;
|
||||
_pendingLoginStore = pendingLoginStore;
|
||||
_rateLimiter = rateLimiter;
|
||||
_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")]
|
||||
[AllowAnonymous]
|
||||
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";
|
||||
if (!_rateLimiter.AllowAttempt(ip))
|
||||
{
|
||||
@@ -57,30 +216,65 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
|
||||
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Iniciando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
|
||||
usernameForLogs,
|
||||
_globalLeaksOptions.BaseUrl,
|
||||
loginTimeoutSeconds);
|
||||
|
||||
var session = await _globalLeaksClient.LoginAsync(
|
||||
request.Username.Trim(),
|
||||
request.Password,
|
||||
request.Authcode.Trim(),
|
||||
cancellationToken);
|
||||
loginCancellation.Token);
|
||||
|
||||
var username = string.IsNullOrWhiteSpace(session.Username)
|
||||
? request.Username.Trim()
|
||||
: session.Username.Trim();
|
||||
|
||||
await _sessionStore.SaveAsync(username, request.Password, session.Id, session.Role, cancellationToken);
|
||||
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
||||
var token = CreateJwt(username, session.Role, expiresAtUtc);
|
||||
|
||||
return Ok(new ApiLoginResponse(username, token, expiresAtUtc, session.Role));
|
||||
return Ok(await CreateLoginResponseAsync(
|
||||
request.Username.Trim(),
|
||||
request.Password,
|
||||
session,
|
||||
loginCancellation.Token));
|
||||
}
|
||||
catch (GlobalLeaksValidationException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GlobalLeaks rechazo el login de {Username}. Status={StatusCode}. Mensaje={Message}",
|
||||
usernameForLogs,
|
||||
ex.StatusCode,
|
||||
ex.Message);
|
||||
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new ApiError("No se ha podido conectar con GlobalLeaks."));
|
||||
_logger.LogWarning(
|
||||
"Timeout de GlobalLeaks al iniciar sesion para {Username}. BaseUrl={BaseUrl}. Timeout={TimeoutSeconds}s",
|
||||
usernameForLogs,
|
||||
_globalLeaksOptions.BaseUrl,
|
||||
Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140));
|
||||
return StatusCode(
|
||||
StatusCodes.Status504GatewayTimeout,
|
||||
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}). Revisa el visor de eventos de ApiDenuncias para ver el paso exacto."));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"No se ha podido conectar con GlobalLeaks para {Username}. BaseUrl={BaseUrl}",
|
||||
usernameForLogs,
|
||||
_globalLeaksOptions.BaseUrl);
|
||||
return StatusCode(
|
||||
StatusCodes.Status502BadGateway,
|
||||
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error no controlado durante el login de {Username}", usernameForLogs);
|
||||
return StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +291,25 @@ public sealed class AuthController : ControllerBase
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
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")]
|
||||
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ builder.Services.AddDataProtection()
|
||||
|
||||
builder.Services.AddSingleton<LoginRateLimiter>();
|
||||
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
||||
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
|
||||
builder.Services.AddScoped<GlobalLeaksClient>();
|
||||
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
||||
builder.Services.AddScoped<MySqlDenunciaStore>();
|
||||
|
||||
@@ -30,9 +30,6 @@ public sealed class GestionaDocumentWorkflowService
|
||||
_configuration["Gestiona:AccessToken"]
|
||||
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
|
||||
|
||||
private string? PreferredCircuitTemplateName =>
|
||||
_configuration["Gestiona:PreferredCircuitTemplateName"];
|
||||
|
||||
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
|
||||
{
|
||||
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
|
||||
@@ -55,7 +52,8 @@ public sealed class GestionaDocumentWorkflowService
|
||||
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
metaReq.Headers.Accept.Clear();
|
||||
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
metaReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||
|
||||
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
|
||||
metaReq.Content.Headers.ContentType =
|
||||
@@ -92,7 +90,7 @@ public sealed class GestionaDocumentWorkflowService
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location.
|
||||
// Si Gestiona devuelve cuerpo vacío por Prefer:return=minimal, usamos Location del documento.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +211,8 @@ public sealed class GestionaDocumentWorkflowService
|
||||
{
|
||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
|
||||
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
createReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||
|
||||
using var createResp = await CreateRawHttp().SendAsync(createReq);
|
||||
var createBody = await createResp.Content.ReadAsStringAsync();
|
||||
@@ -230,7 +229,9 @@ public sealed class GestionaDocumentWorkflowService
|
||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
|
||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
|
||||
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
||||
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
putReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||
putReq.Content = new ByteArrayContent(contentBytes);
|
||||
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
@@ -242,11 +243,14 @@ public sealed class GestionaDocumentWorkflowService
|
||||
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
||||
}
|
||||
|
||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null;
|
||||
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(infoJson))
|
||||
{
|
||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : "READY";
|
||||
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||
}
|
||||
}
|
||||
|
||||
return uploadUri;
|
||||
@@ -260,177 +264,6 @@ public sealed class GestionaDocumentWorkflowService
|
||||
: $"{normalized}/documents-and-folders";
|
||||
}
|
||||
|
||||
private async Task<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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
@@ -458,10 +291,4 @@ public sealed class GestionaDocumentWorkflowService
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record CircuitTemplateCandidate(
|
||||
string? Name,
|
||||
string Href,
|
||||
JsonObject Payload,
|
||||
int SignersCount,
|
||||
bool BlockEdit);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -19,11 +20,16 @@ namespace ApiDenuncias.Services
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly GestionaOptions _opts;
|
||||
private readonly ILogger<GestionaService> _logger;
|
||||
|
||||
public GestionaService(HttpClient http, IOptions<GestionaOptions> optsAccessor)
|
||||
public GestionaService(
|
||||
HttpClient http,
|
||||
IOptions<GestionaOptions> optsAccessor,
|
||||
ILogger<GestionaService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_opts = optsAccessor.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
@@ -58,7 +64,7 @@ namespace ApiDenuncias.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reemplaza este helper si quieres controlar la versi<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)
|
||||
{
|
||||
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
@@ -83,6 +89,17 @@ namespace ApiDenuncias.Services
|
||||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
|
||||
{
|
||||
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
|
||||
operation,
|
||||
string.Join(" | ", deprecated));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =========================================================
|
||||
@@ -113,65 +130,20 @@ namespace ApiDenuncias.Services
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var fileUrl = GetLinkHref(doc.RootElement, "file")
|
||||
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
|
||||
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open");
|
||||
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open")
|
||||
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'.");
|
||||
|
||||
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
|
||||
}
|
||||
|
||||
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
|
||||
private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
|
||||
{
|
||||
if (Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId))
|
||||
{
|
||||
return $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{configuredExternalProcedureId}/create-file";
|
||||
}
|
||||
var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)
|
||||
? configuredExternalProcedureId
|
||||
: procedureId;
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/catalog-2015/procedures/{procedureId}/external-procedures");
|
||||
AddTokenAndAccept(req, "application/vnd.gestiona.external-procedures-page+json");
|
||||
|
||||
using var resp = await _http.SendAsync(req);
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"El procedimiento {procedureId} no tiene tramites externos configurados en Gestiona.");
|
||||
}
|
||||
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"ResolveExternalProcedureCreateFileUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"El procedimiento {procedureId} no ha devuelto tramites externos validos en Gestiona.");
|
||||
}
|
||||
|
||||
var createFileCandidates = new List<(string? Id, string Href)>();
|
||||
foreach (var item in content.EnumerateArray())
|
||||
{
|
||||
var createFileHref = GetLinkHref(item, "create-file");
|
||||
if (!string.IsNullOrWhiteSpace(createFileHref))
|
||||
{
|
||||
var externalProcedureId = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
|
||||
? idProp.GetString()
|
||||
: null;
|
||||
createFileCandidates.Add((externalProcedureId, createFileHref!));
|
||||
}
|
||||
}
|
||||
|
||||
if (createFileCandidates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"El procedimiento {procedureId} no tiene ningun tramite externo con link create-file.");
|
||||
}
|
||||
|
||||
return createFileCandidates
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Id, procedureId.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
.Href
|
||||
?? createFileCandidates[0].Href;
|
||||
return Task.FromResult(
|
||||
$"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file");
|
||||
}
|
||||
|
||||
public async Task OpenFileAsync(
|
||||
@@ -183,9 +155,13 @@ namespace ApiDenuncias.Services
|
||||
string freeTitle,
|
||||
string siaCode)
|
||||
{
|
||||
var url = string.IsNullOrWhiteSpace(fileOpenUrl)
|
||||
? $"{fileUrl.TrimEnd('/')}/open"
|
||||
: fileOpenUrl;
|
||||
if (string.IsNullOrWhiteSpace(fileOpenUrl))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated.");
|
||||
}
|
||||
|
||||
var url = fileOpenUrl;
|
||||
|
||||
var payload = new
|
||||
{
|
||||
@@ -228,7 +204,8 @@ namespace ApiDenuncias.Services
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
AddTokenAndAccept(req, "application/json");
|
||||
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1");
|
||||
|
||||
using var resp = await _http.SendAsync(req);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
@@ -248,7 +225,8 @@ namespace ApiDenuncias.Services
|
||||
{
|
||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
|
||||
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
createReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||
|
||||
using var createResp = await _http.SendAsync(createReq);
|
||||
var createBody = await createResp.Content.ReadAsStringAsync();
|
||||
@@ -256,7 +234,7 @@ namespace ApiDenuncias.Services
|
||||
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
|
||||
|
||||
var uploadUri = createResp.Headers.Location?.ToString()
|
||||
?? throw new InvalidOperationException("No se devolvi<EFBFBD> Location en /rest/uploads");
|
||||
?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
|
||||
|
||||
string md5Hex;
|
||||
using (var md5 = MD5.Create())
|
||||
@@ -269,8 +247,9 @@ namespace ApiDenuncias.Services
|
||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
|
||||
putReq.Headers.TryAddWithoutValidation("Slug", fileName);
|
||||
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
putReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
|
||||
putReq.Content = new ByteArrayContent(contentBytes);
|
||||
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
@@ -279,11 +258,15 @@ namespace ApiDenuncias.Services
|
||||
if (!putResp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
|
||||
|
||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||
var status = infoDoc.RootElement.GetProperty("status").GetString();
|
||||
if (status != "READY")
|
||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(infoJson))
|
||||
{
|
||||
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||
var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp)
|
||||
? statusProp.GetString()
|
||||
: "READY";
|
||||
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"Upload no READY: {status}");
|
||||
}
|
||||
return uploadUri;
|
||||
}
|
||||
|
||||
@@ -309,8 +292,10 @@ namespace ApiDenuncias.Services
|
||||
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
|
||||
{ Content = metaContent };
|
||||
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
|
||||
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
|
||||
metaReq.Headers.Accept.Clear();
|
||||
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
metaReq.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
|
||||
|
||||
using var metaResp = await _http.SendAsync(metaReq);
|
||||
var body = await metaResp.Content.ReadAsStringAsync();
|
||||
@@ -386,7 +371,7 @@ namespace ApiDenuncias.Services
|
||||
if (thirdParty.IsLegalEntity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
|
||||
throw new ArgumentException("La raz<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
|
||||
{
|
||||
@@ -485,7 +470,7 @@ namespace ApiDenuncias.Services
|
||||
{
|
||||
foreach (var item in content.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("links", out var links))
|
||||
if (item.TryGetProperty("links", out var links))
|
||||
{
|
||||
var third = links.EnumerateArray().FirstOrDefault(l => l.GetProperty("rel").GetString() == "third");
|
||||
if (third.ValueKind != JsonValueKind.Undefined)
|
||||
@@ -561,12 +546,15 @@ namespace ApiDenuncias.Services
|
||||
};
|
||||
}
|
||||
|
||||
// --- CONSULTAS DE EXPEDIENTES (sin recorrer hist<EFBFBD>rico paginado) ---
|
||||
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
|
||||
|
||||
private async Task<string> GetFilesAsync(object? filter = null)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
|
||||
AddBasicHeaders(req);
|
||||
req.Headers.Accept.Clear();
|
||||
req.Headers.Accept.Add(
|
||||
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json"));
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
@@ -575,6 +563,7 @@ namespace ApiDenuncias.Services
|
||||
}
|
||||
|
||||
using var resp = await _http.SendAsync(req);
|
||||
LogDeprecatedHeaders(resp, "GET /rest/files");
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return "{\"content\":[]}";
|
||||
@@ -612,7 +601,7 @@ namespace ApiDenuncias.Services
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
|
||||
{
|
||||
@@ -683,9 +672,10 @@ namespace ApiDenuncias.Services
|
||||
}
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
|
||||
AddTokenAndAccept(req, "application/json");
|
||||
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
|
||||
|
||||
using var resp = await _http.SendAsync(req);
|
||||
LogDeprecatedHeaders(resp, $"GET {fileUrl}");
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
|
||||
resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
@@ -910,7 +900,7 @@ namespace ApiDenuncias.Services
|
||||
using var resp = await _http.SendAsync(req);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"Error actualizando direcci<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)
|
||||
@@ -1160,7 +1150,7 @@ namespace ApiDenuncias.Services
|
||||
return value switch
|
||||
{
|
||||
"" => "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",
|
||||
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
|
||||
_ => "ESP",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiDenuncias.Services;
|
||||
|
||||
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
|
||||
|
||||
public sealed class GlobalLeaksClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -50,37 +53,90 @@ public sealed class GlobalLeaksClient
|
||||
string authcode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
||||
using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken);
|
||||
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
|
||||
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
|
||||
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502);
|
||||
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
|
||||
return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<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");
|
||||
typeRequest.Content = CreateJsonContent(new { username });
|
||||
using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken);
|
||||
using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
|
||||
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
|
||||
|
||||
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
|
||||
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
|
||||
|
||||
var passwordWatch = Stopwatch.StartNew();
|
||||
_logger.LogInformation(
|
||||
"GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
|
||||
username,
|
||||
authType.Type);
|
||||
var finalPassword = authType.Type == "key"
|
||||
? DerivePassword(password, authType.Salt)
|
||||
: password;
|
||||
_logger.LogInformation(
|
||||
"GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
|
||||
username,
|
||||
passwordWatch.ElapsedMilliseconds);
|
||||
|
||||
using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication");
|
||||
_logger.LogInformation(
|
||||
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
|
||||
username,
|
||||
loginWatch.ElapsedMilliseconds);
|
||||
|
||||
return new PreparedGlobalLeaksCredentials(username, finalPassword);
|
||||
}
|
||||
|
||||
public async Task<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
|
||||
{
|
||||
tid = 1,
|
||||
username,
|
||||
password = finalPassword,
|
||||
authcode,
|
||||
authcode = authcode?.Trim() ?? string.Empty,
|
||||
});
|
||||
authRequest.Headers.Add("X-Token", tokenAnswer);
|
||||
authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
|
||||
|
||||
using var authResponse = await _httpClient.SendAsync(authRequest, cancellationToken);
|
||||
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
|
||||
if (!authResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
|
||||
@@ -92,6 +148,10 @@ public sealed class GlobalLeaksClient
|
||||
(HttpStatusCode)429 => new GlobalLeaksValidationException(
|
||||
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
|
||||
StatusCodes.Status429TooManyRequests),
|
||||
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
|
||||
new GlobalLeaksValidationException(
|
||||
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
|
||||
StatusCodes.Status406NotAcceptable),
|
||||
_ => new GlobalLeaksValidationException(
|
||||
string.IsNullOrWhiteSpace(body)
|
||||
? $"Login fallido (código {(int)authResponse.StatusCode})."
|
||||
@@ -102,7 +162,11 @@ public sealed class GlobalLeaksClient
|
||||
|
||||
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var session = ParseAuthSession(authBody, username);
|
||||
_logger.LogInformation("Login GlobalLeaks correcto para {Username}. Rol: {Role}", session.Username, session.Role ?? "(sin rol)");
|
||||
_logger.LogInformation(
|
||||
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
|
||||
session.Username,
|
||||
session.Role ?? "(sin rol)",
|
||||
loginWatch.ElapsedMilliseconds);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -196,6 +260,40 @@ public sealed class GlobalLeaksClient
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<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(
|
||||
string sessionId,
|
||||
string reportId,
|
||||
@@ -253,6 +351,43 @@ public sealed class GlobalLeaksClient
|
||||
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(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken,
|
||||
@@ -303,7 +438,7 @@ public sealed class GlobalLeaksClient
|
||||
return content;
|
||||
}
|
||||
|
||||
private static string SolveProofOfWork(string tokenId, string tokenSalt)
|
||||
private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
|
||||
{
|
||||
var idBytes = Encoding.UTF8.GetBytes(tokenId);
|
||||
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
|
||||
@@ -311,6 +446,11 @@ public sealed class GlobalLeaksClient
|
||||
|
||||
while (true)
|
||||
{
|
||||
if ((n & 15) == 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
|
||||
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
|
||||
if (hash[^1] == 0)
|
||||
@@ -477,6 +617,74 @@ public sealed class GlobalLeaksClient
|
||||
return reports;
|
||||
}
|
||||
|
||||
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
|
||||
{
|
||||
var lastAccessDate = ParseDate(lastAccess);
|
||||
|
||||
bool IsNew(string? value)
|
||||
{
|
||||
if (lastAccessDate is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var itemDate = ParseDate(value);
|
||||
return itemDate is not null && itemDate > lastAccessDate;
|
||||
}
|
||||
|
||||
var comments = EnumerateArray(root, "comments")
|
||||
.Select(item => new ReportCommentDto(
|
||||
GetString(item, "id"),
|
||||
GetString(item, "type"),
|
||||
GetString(item, "content", "text", "message"),
|
||||
GetString(item, "creation_date", "creationDate"),
|
||||
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||
.ToArray();
|
||||
|
||||
var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files")
|
||||
.Select(item => new ReportFileDto(
|
||||
GetString(item, "id"),
|
||||
GetLocalizedString(item, "name", "file_name", "filename"),
|
||||
GetInt64(item, "size"),
|
||||
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
||||
GetString(item, "creation_date", "creationDate"),
|
||||
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||
.ToArray();
|
||||
|
||||
var receiverFiles = EnumerateArray(root, "rfiles")
|
||||
.Select(item => new ReportFileDto(
|
||||
GetString(item, "id"),
|
||||
GetLocalizedString(item, "name", "file_name", "filename"),
|
||||
GetInt64(item, "size"),
|
||||
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
||||
GetString(item, "creation_date", "creationDate"),
|
||||
IsNew(GetString(item, "creation_date", "creationDate"))))
|
||||
.ToArray();
|
||||
|
||||
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
|
||||
}
|
||||
|
||||
private static IEnumerable<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)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
@@ -513,6 +721,30 @@ public sealed class GlobalLeaksClient
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetLocalizedString(JsonElement element, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var property))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return property.GetString();
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var value = ExtractName(property, string.Empty);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? GetInt32(JsonElement element, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
@@ -535,6 +767,28 @@ public sealed class GlobalLeaksClient
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? GetInt64(JsonElement element, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var property))
|
||||
{
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String &&
|
||||
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement element, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
@@ -621,6 +875,18 @@ public sealed class GlobalLeaksClient
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTwoFactorRequiredError(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
|
||||
body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
|
||||
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record TokenResponse(string Id, string Salt);
|
||||
private sealed record AuthTypeResponse(string Type, string Salt);
|
||||
|
||||
|
||||
@@ -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": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
@@ -27,12 +27,12 @@
|
||||
"Gestiona": {
|
||||
"ApiBase": "https://02.g3stiona.com",
|
||||
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
|
||||
"ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b",
|
||||
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
|
||||
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
|
||||
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
|
||||
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
|
||||
"CircuitVersion": "2",
|
||||
"PreferredCircuitTemplateName": "CT-Actualización de denuncia",
|
||||
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
|
||||
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
|
||||
"Location": "2.02.01"
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
namespace GestionaDenuncias.Shared.Models;
|
||||
|
||||
public sealed record LoginRequest(string Username, string Password, string Authcode);
|
||||
|
||||
public sealed record ApiLoginPrepareRequest(string Username, string Password);
|
||||
|
||||
public sealed record ApiLoginPrepareResponse(
|
||||
string PendingLoginId,
|
||||
string Username,
|
||||
DateTimeOffset ExpiresAtUtc);
|
||||
|
||||
public sealed record ApiLoginCompleteRequest(string PendingLoginId, string Authcode);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,6 @@
|
||||
<div class="">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BusyOverlay />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager Navigation
|
||||
@inject UiBusyService Busy
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar">
|
||||
@@ -40,6 +41,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<BusyOverlay />
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
@@ -140,9 +143,19 @@
|
||||
|
||||
private async Task CerrarSesionAsync()
|
||||
{
|
||||
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
|
||||
userState.Token = string.Empty;
|
||||
userState.NombreUsu = string.Empty;
|
||||
Navigation.NavigateTo("/", true);
|
||||
using var busy = Busy.Show(
|
||||
"Cerrando sesion",
|
||||
"Invalidando la sesion interna y limpiando el acceso del usuario.");
|
||||
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeAsync<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 IDenunciaStore DenunciaStore
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject UiBusyService Busy
|
||||
|
||||
<PageTitle>Actualizaciones</PageTitle>
|
||||
|
||||
@@ -72,27 +73,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Overlay de carga */
|
||||
.upload-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
background: #fff;
|
||||
border-radius: .75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||||
min-width: 260px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === Estética de modal igual que en Pendientes === */
|
||||
|
||||
.custom-modal {
|
||||
@@ -594,17 +574,6 @@ else
|
||||
<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 {
|
||||
private const string reportTxt = "report.txt";
|
||||
|
||||
@@ -810,10 +779,14 @@ else
|
||||
try
|
||||
{
|
||||
isUploading = true;
|
||||
using var busy = Busy.Show(
|
||||
"Enviando actualizacion",
|
||||
"Preparando expediente, carpeta de actualizacion y documentos.");
|
||||
StateHasChanged();
|
||||
await Task.Yield();
|
||||
|
||||
// 1) Ficheros a subir
|
||||
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
|
||||
var existentesF = await CargarFicherosJsonAsync();
|
||||
var fDenuncia = GetPendingUpdateFiles(
|
||||
existentesF
|
||||
@@ -843,6 +816,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8");
|
||||
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
||||
selectedDenuncias.ProcedureId,
|
||||
nuevoAsunto,
|
||||
@@ -850,6 +824,7 @@ else
|
||||
"3109963"
|
||||
);
|
||||
fileUrl = createdFile.FileUrl;
|
||||
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8");
|
||||
await ApiDenuncias.OpenGestionaFileAsync(
|
||||
fileUrl,
|
||||
createdFile.FileOpenUrl,
|
||||
@@ -869,14 +844,17 @@ else
|
||||
selectedDenuncias.EnGestiona = true;
|
||||
}
|
||||
|
||||
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8");
|
||||
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
||||
|
||||
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
|
||||
|
||||
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8");
|
||||
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
|
||||
|
||||
var ahoraUtc = DateTime.UtcNow;
|
||||
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}");
|
||||
Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8");
|
||||
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
|
||||
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
|
||||
var nombresOriginalesSubidos = new List<string>();
|
||||
@@ -888,6 +866,7 @@ else
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(report.FileName))
|
||||
{
|
||||
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8");
|
||||
var reportPdfBytes = PdfHelper.MergeFilesToPdf(
|
||||
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
|
||||
var reportFinalName = FixFileName("Denuncia.pdf");
|
||||
@@ -905,6 +884,7 @@ else
|
||||
|
||||
if (adjuntos.Count > 0 && uploadMode == "merge")
|
||||
{
|
||||
Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8");
|
||||
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
|
||||
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
||||
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
|
||||
@@ -927,6 +907,12 @@ else
|
||||
|
||||
foreach (var t in adjuntos)
|
||||
{
|
||||
Busy.Update(
|
||||
message: $"Subiendo adjunto nuevo {i} de {adjuntos.Count}.",
|
||||
detail: "Paso 7 de 8",
|
||||
current: i,
|
||||
total: adjuntos.Count);
|
||||
|
||||
var origName = t.FileName;
|
||||
var content = t.Content;
|
||||
var ext = Path.GetExtension(origName).ToLowerInvariant();
|
||||
@@ -961,6 +947,7 @@ else
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
||||
{
|
||||
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8");
|
||||
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
||||
documentoParaTramitar,
|
||||
GetAssignedGroupLinkBySelectedGroup(),
|
||||
@@ -979,6 +966,8 @@ else
|
||||
f.FechaSubida = ahoraUtc;
|
||||
}
|
||||
}
|
||||
|
||||
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
|
||||
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
||||
selectedDenuncias.Id_Denuncia,
|
||||
nombresOriginalesSubidos,
|
||||
|
||||
@@ -1,13 +1,80 @@
|
||||
@page "/GestionZip"
|
||||
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
||||
@attribute [Authorize]
|
||||
@implements IAsyncDisposable
|
||||
@using System.Globalization
|
||||
@using GestionaDenunciasAN.Models
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject UiBusyService Busy
|
||||
|
||||
<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="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
||||
<div>
|
||||
@@ -166,13 +233,14 @@
|
||||
<th>Ultima actualizacion</th>
|
||||
<th>Estado</th>
|
||||
<th>Seguimiento</th>
|
||||
<th style="width: 7rem;">Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!CanUseGlobalLeaks)
|
||||
{
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -180,13 +248,13 @@
|
||||
else if (ReportsBusy)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted">Cargando denuncias...</td>
|
||||
<td colspan="8" class="text-muted">Cargando denuncias...</td>
|
||||
</tr>
|
||||
}
|
||||
else if (!VisibleReports.Any())
|
||||
{
|
||||
<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>
|
||||
}
|
||||
else
|
||||
@@ -217,6 +285,15 @@
|
||||
<div class="small text-muted mt-1">@report.TrackingNote</div>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
@@ -229,6 +306,117 @@
|
||||
</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 {
|
||||
private readonly List<ContextDto> Contexts = [];
|
||||
private readonly List<ReportDto> Reports = [];
|
||||
@@ -252,6 +440,11 @@
|
||||
private bool ReportsBusy { get; set; }
|
||||
private bool RenewBusy { get; set; }
|
||||
private bool ImportBusy { get; set; }
|
||||
private bool DetailBusy { get; set; }
|
||||
private bool DetailModalVisible { get; set; }
|
||||
private string DetailError { get; set; } = string.Empty;
|
||||
private ReportDto? DetailReport { get; set; }
|
||||
private ReportDetailDto? DetailData { get; set; }
|
||||
|
||||
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
|
||||
private int SelectedReportsCount => SelectedIds.Count;
|
||||
@@ -311,8 +504,13 @@
|
||||
RenewBusy = true;
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Renovando sesion GlobalLeaks",
|
||||
"Validando el nuevo codigo 2FA para continuar descargando denuncias.");
|
||||
|
||||
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
|
||||
RenewAuthcode = string.Empty;
|
||||
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
|
||||
await LoadReportsAsync();
|
||||
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
|
||||
}
|
||||
@@ -336,6 +534,10 @@
|
||||
ReportsBusy = true;
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Actualizando bandeja",
|
||||
"Consultando GlobalLeaks y cruzando el seguimiento con la base de datos.");
|
||||
|
||||
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
|
||||
|
||||
Contexts.Clear();
|
||||
@@ -383,8 +585,21 @@
|
||||
.OrderBy(report => report.Progressive ?? 0)
|
||||
.ToList();
|
||||
|
||||
using var busy = Busy.Show(
|
||||
"Importando denuncias",
|
||||
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
|
||||
selectedReports.Count);
|
||||
|
||||
var processed = 0;
|
||||
foreach (var report in selectedReports)
|
||||
{
|
||||
processed++;
|
||||
Busy.Update(
|
||||
message: $"Procesando denuncia #{report.Progressive ?? 0}.",
|
||||
detail: "Descarga, analisis del report y guardado seguro en la base de datos.",
|
||||
current: processed,
|
||||
total: selectedReports.Count);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
|
||||
@@ -442,6 +657,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenReportDetailAsync(ReportDto report)
|
||||
{
|
||||
if (!CanUseGlobalLeaks)
|
||||
{
|
||||
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
|
||||
return;
|
||||
}
|
||||
|
||||
DetailModalVisible = true;
|
||||
DetailReport = report;
|
||||
DetailData = null;
|
||||
DetailError = string.Empty;
|
||||
DetailBusy = true;
|
||||
await SetBodyScrollLockAsync(true);
|
||||
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Leyendo detalle de denuncia",
|
||||
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
|
||||
|
||||
DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
await LoadSessionStateAsync();
|
||||
DetailError = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DetailError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DetailBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseReportDetail()
|
||||
{
|
||||
DetailModalVisible = false;
|
||||
DetailReport = null;
|
||||
DetailData = null;
|
||||
DetailError = string.Empty;
|
||||
await SetBodyScrollLockAsync(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await SetBodyScrollLockAsync(false);
|
||||
}
|
||||
|
||||
private async Task SetBodyScrollLockAsync(bool locked)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Si el circuito se esta cerrando, solo aseguramos que no reviente el componente.
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
IEnumerable<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)
|
||||
{
|
||||
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
@layout EmptyLayout
|
||||
@rendermode @(new InteractiveServerRenderMode(prerender: false))
|
||||
@using System.Text.Json
|
||||
@using GestionaDenuncias.Shared.Models
|
||||
@using GestionaDenunciasAN.Models
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager Navigation
|
||||
@inject UiBusyService Busy
|
||||
|
||||
<PageTitle>Portal de denuncias</PageTitle>
|
||||
|
||||
@@ -87,12 +89,12 @@
|
||||
<div class="row g-5 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<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>
|
||||
<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
|
||||
activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que
|
||||
renovar el código 2FA desde la bandeja de entrada.
|
||||
El acceso de la aplicacion ya se apoya en GlobalLeaks. La sesion interna de esta app se mantiene
|
||||
activa de forma persistente, y cuando caduque la sesion de obtencion de denuncias solo habra que
|
||||
renovar el codigo 2FA desde la bandeja de entrada.
|
||||
</p>
|
||||
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
|
||||
alt="Logo Oficina Antifraude"
|
||||
@@ -104,7 +106,7 @@
|
||||
<div class="login-panel p-4 p-md-5">
|
||||
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
|
||||
<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>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
@@ -116,33 +118,54 @@
|
||||
<label class="login-label mb-2">Usuario</label>
|
||||
<input class="form-control login-input"
|
||||
@bind="Username"
|
||||
@bind:event="oninput"
|
||||
disabled="@LoginPrepared"
|
||||
autocomplete="username"
|
||||
placeholder="usuario de GlobalLeaks" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
type="password"
|
||||
@bind="Password"
|
||||
@bind:event="oninput"
|
||||
disabled="@LoginPrepared"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="login-label mb-2">Código 2FA</label>
|
||||
<input class="form-control login-input"
|
||||
@bind="Authcode"
|
||||
@onkeydown="HandleAuthcodeKeyDown"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="123456" />
|
||||
</div>
|
||||
@if (LoginPrepared)
|
||||
{
|
||||
<div class="alert alert-info mb-4">
|
||||
Credenciales preparadas. Introduce ahora el codigo 2FA actual para completar el acceso.
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="login-label mb-2">Codigo 2FA</label>
|
||||
<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"
|
||||
@onclick="LoginAsync"
|
||||
disabled="@IsBusy">
|
||||
@(IsBusy ? "Conectando..." : "Entrar")
|
||||
@LoginButtonText
|
||||
</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>
|
||||
@@ -156,9 +179,14 @@
|
||||
private string Username { get; set; } = string.Empty;
|
||||
private string Password { get; set; } = string.Empty;
|
||||
private string Authcode { get; set; } = string.Empty;
|
||||
private string PendingLoginId { get; set; } = string.Empty;
|
||||
private string StatusMessage { get; set; } = string.Empty;
|
||||
private string StatusCss { get; set; } = "alert-info";
|
||||
private bool IsBusy { get; set; }
|
||||
private bool LoginPrepared => !string.IsNullOrWhiteSpace(PendingLoginId);
|
||||
private string LoginButtonText => IsBusy
|
||||
? (LoginPrepared ? "Validando 2FA..." : "Preparando acceso...")
|
||||
: (LoginPrepared ? "Validar 2FA y entrar" : "Preparar acceso");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -166,18 +194,28 @@
|
||||
if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
Navigation.NavigateTo(GetTargetUrl(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
StatusMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Username) ||
|
||||
string.IsNullOrWhiteSpace(Password) ||
|
||||
string.IsNullOrWhiteSpace(Authcode))
|
||||
if (!LoginPrepared)
|
||||
{
|
||||
SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning");
|
||||
await PrepareLoginAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteLoginAsync();
|
||||
}
|
||||
|
||||
private async Task PrepareLoginAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username) ||
|
||||
string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
SetStatus("Debes rellenar usuario y contrasena.", "alert-warning");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,27 +223,105 @@
|
||||
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Preparando acceso",
|
||||
"Validando usuario y contrasena con GlobalLeaks. Cuando termine pediremos un codigo 2FA nuevo.");
|
||||
|
||||
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
|
||||
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
|
||||
"appAuthPostJson",
|
||||
"/api/auth/login",
|
||||
new LoginRequest(Username.Trim(), Password, Authcode.Trim()));
|
||||
loginTimeout.Token,
|
||||
[
|
||||
"/api/auth/prepare",
|
||||
new ApiLoginPrepareRequest(Username.Trim(), Password)
|
||||
]);
|
||||
|
||||
if (!response.Ok)
|
||||
{
|
||||
var error = ReadData<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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger");
|
||||
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
SetStatus("No se ha podido conectar con el servidor.", "alert-danger");
|
||||
SetStatus("La validacion del 2FA ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"No se ha podido completar el inicio de sesion: {ex.Message}", "alert-danger");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -214,7 +330,15 @@
|
||||
}
|
||||
|
||||
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
|
||||
=> args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
|
||||
=> LoginPrepared && args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
|
||||
|
||||
private void ResetPreparedLogin()
|
||||
{
|
||||
PendingLoginId = string.Empty;
|
||||
Password = string.Empty;
|
||||
Authcode = string.Empty;
|
||||
StatusMessage = string.Empty;
|
||||
}
|
||||
|
||||
private void SetStatus(string message, string cssClass)
|
||||
{
|
||||
|
||||
@@ -18,30 +18,11 @@
|
||||
@inject IHostEnvironment HostEnvironment
|
||||
@inject IDenunciaStore DenunciaStore
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject UiBusyService Busy
|
||||
|
||||
<PageTitle>Denuncias Pendientes</PageTitle>
|
||||
|
||||
<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 {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
@@ -720,7 +701,7 @@ else
|
||||
<button type="button" class="btn btn-secondary" @onclick="CloseModal">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ConfirmarEnvio">
|
||||
<button type="button" class="btn btn-primary" disabled="@isUploading" @onclick="ConfirmarEnvio">
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
@@ -828,17 +809,6 @@ else
|
||||
</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 {
|
||||
private string nombreDocumentos = string.Empty;
|
||||
private bool isUploading = false;
|
||||
@@ -970,7 +940,13 @@ else
|
||||
try
|
||||
{
|
||||
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 todos = existentesF
|
||||
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
|
||||
@@ -987,6 +963,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
|
||||
var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
|
||||
selectedDenuncias.ProcedureId,
|
||||
nuevoAsunto,
|
||||
@@ -994,6 +971,7 @@ else
|
||||
"3109963"
|
||||
);
|
||||
fileUrl = createdFile.FileUrl;
|
||||
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7");
|
||||
await ApiDenuncias.OpenGestionaFileAsync(
|
||||
fileUrl,
|
||||
createdFile.FileOpenUrl,
|
||||
@@ -1013,9 +991,11 @@ else
|
||||
selectedDenuncias.EnGestiona = true;
|
||||
}
|
||||
|
||||
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 7");
|
||||
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
||||
|
||||
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);
|
||||
|
||||
var nombresOriginalesSubidos = new List<string>();
|
||||
@@ -1028,6 +1008,7 @@ else
|
||||
|
||||
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[]
|
||||
{
|
||||
(FileName: report.FileName, Content: report.Content)
|
||||
@@ -1047,6 +1028,7 @@ else
|
||||
|
||||
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 pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
|
||||
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
|
||||
@@ -1069,6 +1051,12 @@ else
|
||||
|
||||
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();
|
||||
byte[] bytesParaSubir = content;
|
||||
string finalName;
|
||||
@@ -1102,6 +1090,7 @@ else
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
|
||||
{
|
||||
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7");
|
||||
await ApiDenuncias.TramitarGestionaDocumentAsync(
|
||||
documentoParaTramitar,
|
||||
GetAssignedGroupLinkBySelectedGroup(),
|
||||
@@ -1119,6 +1108,8 @@ else
|
||||
f.FechaSubida = ahoraUtc;
|
||||
}
|
||||
}
|
||||
|
||||
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
|
||||
await DenunciaStore.MarkFicherosAsUploadedAsync(
|
||||
selectedDenuncias.Id_Denuncia,
|
||||
nombresOriginalesSubidos,
|
||||
|
||||
@@ -5,4 +5,5 @@ public sealed class ApiDenunciasOptions
|
||||
public const string SectionName = "ApiDenuncias";
|
||||
|
||||
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.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
.AddInteractiveServerComponents(options =>
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
|
||||
});
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services
|
||||
@@ -37,12 +41,17 @@ builder.Services
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
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.AddAntiforgery();
|
||||
builder.Services.AddScoped<UserState>();
|
||||
builder.Services.AddSingleton<AppSessionLifetime>();
|
||||
builder.Services.AddSingleton<LoginRateLimiter>();
|
||||
builder.Services.AddScoped<UiBusyService>();
|
||||
builder.Services.AddScoped<ApiDenunciasClient>();
|
||||
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
|
||||
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
|
||||
@@ -140,11 +149,60 @@ app.UseAntiforgery();
|
||||
|
||||
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 (
|
||||
LoginRequest request,
|
||||
HttpContext httpContext,
|
||||
ApiDenunciasClient apiClient,
|
||||
LoginRateLimiter rateLimiter,
|
||||
IOptions<ApiDenunciasOptions> apiOptions,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
@@ -174,13 +232,17 @@ api.MapPost("/auth/login", async (
|
||||
|
||||
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(
|
||||
request with
|
||||
{
|
||||
Username = request.Username.Trim(),
|
||||
Authcode = request.Authcode.Trim()
|
||||
},
|
||||
cancellationToken);
|
||||
loginTimeout.Token);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -220,6 +282,104 @@ api.MapPost("/auth/login", async (
|
||||
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}). 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();
|
||||
|
||||
api.MapPost("/auth/logout", async (
|
||||
|
||||
@@ -35,6 +35,12 @@ public sealed class ApiDenunciasClient
|
||||
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
|
||||
=> 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)
|
||||
=> SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken);
|
||||
|
||||
@@ -63,6 +69,14 @@ public sealed class ApiDenunciasClient
|
||||
authorize: true,
|
||||
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)
|
||||
=> 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": "*",
|
||||
"ForceHttpsRedirection": false,
|
||||
"ApiDenuncias": {
|
||||
"BaseUrl": "http://localhost:7093"
|
||||
"BaseUrl": "http://localhost:7093",
|
||||
"LoginTimeoutSeconds": 150
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html.app-scroll-locked,
|
||||
body.app-scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a,
|
||||
.btn-link {
|
||||
color: var(--app-accent);
|
||||
@@ -197,7 +202,6 @@ pre {
|
||||
border: 1px solid rgba(90, 155, 213, 0.14);
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.app-content > .container,
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
window.appAuthPostJson = async function (url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"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;
|
||||
try {
|
||||
@@ -23,10 +34,21 @@ window.appAuthPostJson = async function (url, body) {
|
||||
};
|
||||
|
||||
window.appAuthPost = async function (url) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
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;
|
||||
try {
|
||||
@@ -41,3 +63,8 @@ window.appAuthPost = async function (url) {
|
||||
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