denuncias

This commit is contained in:
2026-05-21 12:07:51 +02:00
parent 693d950dfa
commit b62cfd46c1
25 changed files with 1805 additions and 418 deletions

View File

@@ -21,6 +21,7 @@
<system.Web> <system.Web>
<httpRuntime targetFramework="4.8" /> <httpRuntime targetFramework="4.8" />
</system.Web> </system.Web>
--> -->
<system.web> <system.web>
<sessionState timeout="300000"> <sessionState timeout="300000">
@@ -103,14 +104,8 @@
<errors callbackErrorRedirectUrl="" /> <errors callbackErrorRedirectUrl="" />
</devExpress> </devExpress>
<appSettings> <appSettings>
<add key="UrlCertLogin" value="https://cwe-antifraude.tecnosis.online/LoginCertBridge.aspx" /> <add key="vs:EnableBrowserLink" value="false" />
<add key="CertHeaderName" value="X-ARR-ClientCert" /> <add key="RutaRes" value="https://localhost:44300" />
<add key="AdditionalCertHeaders" value="X-Client-Cert|X-Client-Certificate|X-SSL-CERT|Ssl-Client-Cert" />
<add key="CertLoginEndpoint" value="api/Auth/login-cert-proxy" />
<add key="AllowedParentOrigins" value="https://we-antifraude.tecnosis.online" />
<add key="vs:EnableBrowserLink" value="false" />
<!--<add key="RutaRes" value="https://localhost:44300" />-->
<!--<add key="RutaRes" value="http://192.168.41.122:888" />--> <!--<add key="RutaRes" value="http://192.168.41.122:888" />-->
<add key="SwaggerVB" value="http://localhost:103/" /> <add key="SwaggerVB" value="http://localhost:103/" />
<!--produccion--> <!--produccion-->

View File

@@ -18,25 +18,184 @@ public sealed class AuthController : ControllerBase
{ {
private readonly GlobalLeaksClient _globalLeaksClient; private readonly GlobalLeaksClient _globalLeaksClient;
private readonly GlobalLeaksSessionStore _sessionStore; private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly LoginRateLimiter _rateLimiter; private readonly LoginRateLimiter _rateLimiter;
private readonly JwtOptions _jwtOptions; private readonly JwtOptions _jwtOptions;
private readonly GlobalLeaksOptions _globalLeaksOptions;
private readonly ILogger<AuthController> _logger;
public AuthController( public AuthController(
GlobalLeaksClient globalLeaksClient, GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionStore sessionStore, GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
LoginRateLimiter rateLimiter, LoginRateLimiter rateLimiter,
IOptions<JwtOptions> jwtOptions) IOptions<JwtOptions> jwtOptions,
IOptions<GlobalLeaksOptions> globalLeaksOptions,
ILogger<AuthController> logger)
{ {
_globalLeaksClient = globalLeaksClient; _globalLeaksClient = globalLeaksClient;
_sessionStore = sessionStore; _sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_rateLimiter = rateLimiter; _rateLimiter = rateLimiter;
_jwtOptions = jwtOptions.Value; _jwtOptions = jwtOptions.Value;
_globalLeaksOptions = globalLeaksOptions.Value;
_logger = logger;
}
[HttpPost("login/prepare")]
[AllowAnonymous]
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareLogin(
ApiLoginPrepareRequest request,
CancellationToken cancellationToken)
{
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!_rateLimiter.AllowAttempt(ip))
{
return StatusCode(StatusCodes.Status429TooManyRequests, new ApiError("Demasiados intentos. Espera un minuto."));
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return BadRequest(new ApiError("Debes indicar usuario y contrasena."));
}
try
{
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
_logger.LogInformation(
"Preparando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
loginTimeoutSeconds);
var prepared = await _globalLeaksClient.PrepareLoginAsync(
request.Username.Trim(),
request.Password,
loginCancellation.Token);
var pending = _pendingLoginStore.Create(
prepared.Username,
request.Password,
prepared.FinalPassword);
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"GlobalLeaks rechazo la preparacion del login de {Username}. Status={StatusCode}. Mensaje={Message}",
usernameForLogs,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha preparado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
}
catch (HttpRequestException ex)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado preparando login de {Username}", usernameForLogs);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar el inicio de sesion en la API: {ex.Message}"));
}
}
[HttpPost("login/complete")]
[AllowAnonymous]
public async Task<ActionResult<ApiLoginResponse>> CompleteLogin(
ApiLoginCompleteRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.PendingLoginId))
{
return BadRequest(new ApiError("La preparacion del login ha caducado. Vuelve a introducir usuario y contrasena."));
}
if (string.IsNullOrWhiteSpace(request.Authcode) || !Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
{
return BadRequest(new ApiError("El codigo 2FA debe tener exactamente 6 digitos."));
}
PendingGlobalLeaksLogin pending;
try
{
pending = _pendingLoginStore.Get(request.PendingLoginId);
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
try
{
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var session = await _globalLeaksClient.CompleteLoginAsync(
pending.Username,
pending.FinalPassword,
request.Authcode.Trim(),
loginCancellation.Token);
var response = await CreateLoginResponseAsync(
pending.Username,
pending.Password,
session,
loginCancellation.Token);
_pendingLoginStore.Remove(pending.Id);
return Ok(response);
}
catch (GlobalLeaksValidationException ex)
{
_logger.LogWarning(
"GlobalLeaks rechazo el 2FA de {Username}. Status={StatusCode}. Mensaje={Message}",
pending.Username,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl})."));
}
catch (HttpRequestException ex)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado completando login de {Username}", pending.Username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
}
} }
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult<ApiLoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken) public async Task<ActionResult<ApiLoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
{ {
var usernameForLogs = string.IsNullOrWhiteSpace(request.Username) ? "(sin usuario)" : request.Username.Trim();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (!_rateLimiter.AllowAttempt(ip)) if (!_rateLimiter.AllowAttempt(ip))
{ {
@@ -57,30 +216,65 @@ public sealed class AuthController : ControllerBase
try try
{ {
var loginTimeoutSeconds = Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140);
using var loginCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginCancellation.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
_logger.LogInformation(
"Iniciando login GlobalLeaks para {Username} contra {BaseUrl}. Timeout total API={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
loginTimeoutSeconds);
var session = await _globalLeaksClient.LoginAsync( var session = await _globalLeaksClient.LoginAsync(
request.Username.Trim(), request.Username.Trim(),
request.Password, request.Password,
request.Authcode.Trim(), request.Authcode.Trim(),
cancellationToken); loginCancellation.Token);
var username = string.IsNullOrWhiteSpace(session.Username) return Ok(await CreateLoginResponseAsync(
? request.Username.Trim() request.Username.Trim(),
: session.Username.Trim(); request.Password,
session,
await _sessionStore.SaveAsync(username, request.Password, session.Id, session.Role, cancellationToken); loginCancellation.Token));
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
var token = CreateJwt(username, session.Role, expiresAtUtc);
return Ok(new ApiLoginResponse(username, token, expiresAtUtc, session.Role));
} }
catch (GlobalLeaksValidationException ex) catch (GlobalLeaksValidationException ex)
{ {
_logger.LogWarning(
"GlobalLeaks rechazo el login de {Username}. Status={StatusCode}. Mensaje={Message}",
usernameForLogs,
ex.StatusCode,
ex.Message);
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return StatusCode(ex.StatusCode, new ApiError(ex.Message));
} }
catch catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
return StatusCode(StatusCodes.Status502BadGateway, new ApiError("No se ha podido conectar con GlobalLeaks.")); _logger.LogWarning(
"Timeout de GlobalLeaks al iniciar sesion para {Username}. BaseUrl={BaseUrl}. Timeout={TimeoutSeconds}s",
usernameForLogs,
_globalLeaksOptions.BaseUrl,
Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140));
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError($"GlobalLeaks no ha completado el login en {Math.Clamp(_globalLeaksOptions.TimeoutSeconds + 20, 30, 140)} segundos ({_globalLeaksOptions.BaseUrl}). Revisa el visor de eventos de ApiDenuncias para ver el paso exacto."));
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"No se ha podido conectar con GlobalLeaks para {Username}. BaseUrl={BaseUrl}",
usernameForLogs,
_globalLeaksOptions.BaseUrl);
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError($"No se ha podido conectar con GlobalLeaks ({_globalLeaksOptions.BaseUrl}): {ex.Message}"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado durante el login de {Username}", usernameForLogs);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido completar el inicio de sesion en la API: {ex.Message}"));
} }
} }
@@ -97,6 +291,25 @@ public sealed class AuthController : ControllerBase
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
private async Task<ApiLoginResponse> CreateLoginResponseAsync(
string fallbackUsername,
string password,
GlSession session,
CancellationToken cancellationToken)
{
var username = string.IsNullOrWhiteSpace(session.Username)
? fallbackUsername.Trim()
: session.Username.Trim();
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, cancellationToken);
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
var token = CreateJwt(username, session.Role, expiresAtUtc);
return new ApiLoginResponse(username, token, expiresAtUtc, session.Role);
}
private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc) private string CreateJwt(string username, string? role, DateTimeOffset expiresAtUtc)
{ {
if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey)) if (string.IsNullOrWhiteSpace(_jwtOptions.SigningKey))

View File

@@ -186,6 +186,40 @@ public sealed class InboxController : ControllerBase
} }
} }
[HttpGet("reports/{reportId}/detail")]
public async Task<ActionResult<ReportDetailDto>> GetReportDetail(
string reportId,
CancellationToken cancellationToken)
{
var username = GetUsername();
var session = await RequireActiveSessionAsync(username, cancellationToken);
if (session is null)
{
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
try
{
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken));
}
catch (GlobalLeaksSessionExpiredException)
{
await _sessionStore.ClearSessionAsync(username, cancellationToken);
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido leer el detalle de la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido leer el detalle de la denuncia: {ex.GetType().Name}: {ex.Message}"));
}
}
[HttpPost("local/ensure-storage")] [HttpPost("local/ensure-storage")]
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken) public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
{ {

View File

@@ -29,6 +29,7 @@ builder.Services.AddDataProtection()
builder.Services.AddSingleton<LoginRateLimiter>(); builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>(); builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>(); builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>(); builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>(); builder.Services.AddScoped<MySqlDenunciaStore>();

View File

@@ -30,9 +30,6 @@ public sealed class GestionaDocumentWorkflowService
_configuration["Gestiona:AccessToken"] _configuration["Gestiona:AccessToken"]
?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings."); ?? throw new InvalidOperationException("Falta Gestiona:AccessToken en appsettings.");
private string? PreferredCircuitTemplateName =>
_configuration["Gestiona:PreferredCircuitTemplateName"];
public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName) public async Task<string> UploadDocumentAndReturnUrlAsync(string fileUrl, byte[] contentBytes, string fileName)
{ {
var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase); var fileUrlAbs = EnsureAbsoluteGestionaUrl(fileUrl, GestionaApiBase);
@@ -55,7 +52,8 @@ public sealed class GestionaDocumentWorkflowService
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal"); metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear(); metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); metaReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
metaReq.Content = new StringContent(metaJson, Encoding.UTF8); metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
metaReq.Content.Headers.ContentType = metaReq.Content.Headers.ContentType =
@@ -92,7 +90,7 @@ public sealed class GestionaDocumentWorkflowService
} }
catch catch
{ {
// Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location. // Si Gestiona devuelve cuerpo vacío por Prefer:return=minimal, usamos Location del documento.
} }
} }
@@ -213,7 +211,8 @@ public sealed class GestionaDocumentWorkflowService
{ {
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads"); using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await CreateRawHttp().SendAsync(createReq); using var createResp = await CreateRawHttp().SendAsync(createReq);
var createBody = await createResp.Content.ReadAsStringAsync(); var createBody = await createResp.Content.ReadAsStringAsync();
@@ -230,7 +229,9 @@ public sealed class GestionaDocumentWorkflowService
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes)); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", GetMd5Hex(contentBytes));
putReq.Headers.TryAddWithoutValidation("Slug", fileName); putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
putReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
putReq.Content = new ByteArrayContent(contentBytes); putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
@@ -242,11 +243,14 @@ public sealed class GestionaDocumentWorkflowService
$"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}"); $"CreateUploadAsync (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
} }
using var infoDoc = JsonDocument.Parse(infoJson); if (!string.IsNullOrWhiteSpace(infoJson))
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : null;
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
{ {
throw new InvalidOperationException($"Upload no READY: {status}"); using var infoDoc = JsonDocument.Parse(infoJson);
var status = infoDoc.RootElement.TryGetProperty("status", out var pStatus) ? pStatus.GetString() : "READY";
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Upload no READY: {status}");
}
} }
return uploadUri; return uploadUri;
@@ -260,177 +264,6 @@ public sealed class GestionaDocumentWorkflowService
: $"{normalized}/documents-and-folders"; : $"{normalized}/documents-and-folders";
} }
private async Task<CircuitTemplateCandidate> ObtenerTemplateCircuitoFirmaAsync(string documentUrl)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{documentUrl.TrimEnd('/')}/circuit/templates");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/vnd.gestiona.circuits.templates-filedoc-page");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ObtenerTemplateCircuitoFirmaAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("No se ha podido leer el listado de plantillas de circuito.");
}
var templates = new List<CircuitTemplateCandidate>();
foreach (var item in content.EnumerateArray())
{
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
var href = GetSelfHref(item);
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
var payload = JsonNode.Parse(item.GetRawText()) as JsonObject ?? new JsonObject();
templates.Add(new CircuitTemplateCandidate(
Name: name,
Href: href!,
Payload: payload,
SignersCount: CountArrayItems(payload, "signers"),
BlockEdit: GetBoolean(payload, "block_edit")));
}
if (templates.Count == 0)
{
throw new InvalidOperationException("No hay plantillas de circuito disponibles para el documento.");
}
_logger.LogInformation(
"Plantillas de circuito para {DocumentUrl}: {Templates}",
documentUrl,
string.Join(
" | ",
templates.Select(template =>
$"{template.Name ?? "(sin nombre)"} [firmantes={template.SignersCount}, candado={template.BlockEdit}]")));
if (!string.IsNullOrWhiteSpace(PreferredCircuitTemplateName))
{
var configuredExact = templates.FirstOrDefault(template =>
string.Equals(template.Name, PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredExact is not null)
{
return configuredExact;
}
var configuredContains = templates.FirstOrDefault(template =>
!string.IsNullOrWhiteSpace(template.Name) &&
template.Name.Contains(PreferredCircuitTemplateName, StringComparison.OrdinalIgnoreCase));
if (configuredContains is not null)
{
return configuredContains;
}
}
string[] preferredNames =
[
"CT-Actualizacion de denuncia",
"CT-Actualizaci\u00f3n de denuncia",
"Firma automatizada",
"Firma Sello de \u00D3rgano",
"Firma Sello de Organo"
];
var preferredTemplate = templates.FirstOrDefault(template =>
preferredNames.Any(preferred =>
string.Equals(template.Name, preferred, StringComparison.OrdinalIgnoreCase)));
if (preferredTemplate is not null)
{
return preferredTemplate;
}
var templatesWithSigners = templates
.Where(template => template.SignersCount > 0)
.OrderByDescending(template => template.BlockEdit)
.ThenByDescending(template => template.SignersCount)
.ToList();
if (templatesWithSigners.Count > 0)
{
return templatesWithSigners[0];
}
return templates[0];
}
private static JsonObject BuildCircuitPayloadFromTemplate(
CircuitTemplateCandidate template,
string assignedGroupHref,
int? complaintId)
{
_ = assignedGroupHref;
_ = complaintId;
var payload = (JsonObject)template.Payload.DeepClone();
EnsureTemplateSelfLink(payload, template.Href);
payload.Remove("assigneds_can_use");
return payload;
}
private static void EnsureTemplateSelfLink(JsonObject payload, string templateSelfHref)
{
if (payload["links"] is not JsonArray links)
{
links = new JsonArray();
payload["links"] = links;
}
foreach (var node in links)
{
if (node is not JsonObject link)
{
continue;
}
var rel = link["rel"]?.GetValue<string>();
var href = link["href"]?.GetValue<string>();
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href))
{
return;
}
}
links.Add(JsonSerializer.SerializeToNode(new { rel = "self", href = templateSelfHref }));
}
private static int CountArrayItems(JsonObject payload, string propertyName)
{
return payload[propertyName] is JsonArray array ? array.Count : 0;
}
private static bool GetBoolean(JsonObject payload, string propertyName)
{
return payload[propertyName]?.GetValue<bool?>() ?? false;
}
private static string? GetSelfHref(JsonElement item)
{
if (!item.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(href))
{
return href;
}
}
return null;
}
private static string EnsureAbsoluteGestionaUrl(string url, string apiBase) private static string EnsureAbsoluteGestionaUrl(string url, string apiBase)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
@@ -458,10 +291,4 @@ public sealed class GestionaDocumentWorkflowService
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
private sealed record CircuitTemplateCandidate(
string? Name,
string Href,
JsonObject Payload,
int SignersCount,
bool BlockEdit);
} }

View File

@@ -1,4 +1,5 @@
using GestionaDenuncias.Shared.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@@ -19,11 +20,16 @@ namespace ApiDenuncias.Services
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly GestionaOptions _opts; private readonly GestionaOptions _opts;
private readonly ILogger<GestionaService> _logger;
public GestionaService(HttpClient http, IOptions<GestionaOptions> optsAccessor) public GestionaService(
HttpClient http,
IOptions<GestionaOptions> optsAccessor,
ILogger<GestionaService> logger)
{ {
_http = http; _http = http;
_opts = optsAccessor.Value; _opts = optsAccessor.Value;
_logger = logger;
} }
// ========================================================= // =========================================================
@@ -58,7 +64,7 @@ namespace ApiDenuncias.Services
return null; return null;
} }
// Reemplaza este helper si quieres controlar la versi<EFBFBD>n en Accept: // Reemplaza este helper si quieres controlar la versión en Accept:
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null) private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
{ {
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
@@ -83,6 +89,17 @@ namespace ApiDenuncias.Services
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
} }
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
{
_logger.LogWarning(
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
operation,
string.Join(" | ", deprecated));
}
}
// ========================================================= // =========================================================
@@ -113,65 +130,20 @@ namespace ApiDenuncias.Services
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var fileUrl = GetLinkHref(doc.RootElement, "file") var fileUrl = GetLinkHref(doc.RootElement, "file")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'."); ?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open"); var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file-open'.");
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl); return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
} }
private async Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId) private Task<string> ResolveExternalProcedureCreateFileUrlAsync(Guid procedureId)
{ {
if (Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)) var externalProcedureId = Guid.TryParse(_opts.ExternalProcedureId, out var configuredExternalProcedureId)
{ ? configuredExternalProcedureId
return $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{configuredExternalProcedureId}/create-file"; : procedureId;
}
using var req = new HttpRequestMessage(HttpMethod.Get, $"/rest/catalog-2015/procedures/{procedureId}/external-procedures"); return Task.FromResult(
AddTokenAndAccept(req, "application/vnd.gestiona.external-procedures-page+json"); $"/rest/catalog-2015/procedures/{procedureId}/external-procedures/{externalProcedureId}/create-file");
using var resp = await _http.SendAsync(req);
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no tiene tramites externos configurados en Gestiona.");
}
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"ResolveExternalProcedureCreateFileUrlAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no ha devuelto tramites externos validos en Gestiona.");
}
var createFileCandidates = new List<(string? Id, string Href)>();
foreach (var item in content.EnumerateArray())
{
var createFileHref = GetLinkHref(item, "create-file");
if (!string.IsNullOrWhiteSpace(createFileHref))
{
var externalProcedureId = item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
? idProp.GetString()
: null;
createFileCandidates.Add((externalProcedureId, createFileHref!));
}
}
if (createFileCandidates.Count == 0)
{
throw new InvalidOperationException(
$"El procedimiento {procedureId} no tiene ningun tramite externo con link create-file.");
}
return createFileCandidates
.FirstOrDefault(candidate => string.Equals(candidate.Id, procedureId.ToString(), StringComparison.OrdinalIgnoreCase))
.Href
?? createFileCandidates[0].Href;
} }
public async Task OpenFileAsync( public async Task OpenFileAsync(
@@ -183,9 +155,13 @@ namespace ApiDenuncias.Services
string freeTitle, string freeTitle,
string siaCode) string siaCode)
{ {
var url = string.IsNullOrWhiteSpace(fileOpenUrl) if (string.IsNullOrWhiteSpace(fileOpenUrl))
? $"{fileUrl.TrimEnd('/')}/open" {
: fileOpenUrl; throw new InvalidOperationException(
"OpenFileAsync: falta el link 'file-open' devuelto por Gestiona. No se usa el fallback /open para evitar la ruta deprecated.");
}
var url = fileOpenUrl;
var payload = new var payload = new
{ {
@@ -228,7 +204,8 @@ namespace ApiDenuncias.Services
{ {
Content = content Content = content
}; };
AddTokenAndAccept(req, "application/json"); req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
@@ -248,7 +225,8 @@ namespace ApiDenuncias.Services
{ {
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads"); using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
createReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await _http.SendAsync(createReq); using var createResp = await _http.SendAsync(createReq);
var createBody = await createResp.Content.ReadAsStringAsync(); var createBody = await createResp.Content.ReadAsStringAsync();
@@ -256,7 +234,7 @@ namespace ApiDenuncias.Services
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}"); throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
var uploadUri = createResp.Headers.Location?.ToString() var uploadUri = createResp.Headers.Location?.ToString()
?? throw new InvalidOperationException("No se devolvi<EFBFBD> Location en /rest/uploads"); ?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
string md5Hex; string md5Hex;
using (var md5 = MD5.Create()) using (var md5 = MD5.Create())
@@ -269,8 +247,9 @@ namespace ApiDenuncias.Services
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex); putReq.Headers.TryAddWithoutValidation("X-Gestiona-Upload-MD5", md5Hex);
putReq.Headers.TryAddWithoutValidation("Slug", fileName); putReq.Headers.TryAddWithoutValidation("Slug", fileName);
putReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); putReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
putReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
putReq.Content = new ByteArrayContent(contentBytes); putReq.Content = new ByteArrayContent(contentBytes);
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
@@ -279,11 +258,15 @@ namespace ApiDenuncias.Services
if (!putResp.IsSuccessStatusCode) if (!putResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}"); throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
using var infoDoc = JsonDocument.Parse(infoJson); if (!string.IsNullOrWhiteSpace(infoJson))
var status = infoDoc.RootElement.GetProperty("status").GetString(); {
if (status != "READY") using var infoDoc = JsonDocument.Parse(infoJson);
throw new InvalidOperationException($"Upload no READY: {status}"); var status = infoDoc.RootElement.TryGetProperty("status", out var statusProp)
? statusProp.GetString()
: "READY";
if (!string.Equals(status, "READY", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Upload no READY: {status}");
}
return uploadUri; return uploadUri;
} }
@@ -309,8 +292,10 @@ namespace ApiDenuncias.Services
using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders") using var metaReq = new HttpRequestMessage(HttpMethod.Post, $"{fileUrl}/documents-and-folders")
{ Content = metaContent }; { Content = metaContent };
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear(); metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); metaReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
using var metaResp = await _http.SendAsync(metaReq); using var metaResp = await _http.SendAsync(metaReq);
var body = await metaResp.Content.ReadAsStringAsync(); var body = await metaResp.Content.ReadAsStringAsync();
@@ -386,7 +371,7 @@ namespace ApiDenuncias.Services
if (thirdParty.IsLegalEntity) if (thirdParty.IsLegalEntity)
{ {
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName)) if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
throw new ArgumentException("La raz<EFBFBD>n social es obligatoria para terceros jur<EFBFBD>dicos.", nameof(thirdParty)); throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty));
} }
else else
{ {
@@ -485,7 +470,7 @@ namespace ApiDenuncias.Services
{ {
foreach (var item in content.EnumerateArray()) 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"); var third = links.EnumerateArray().FirstOrDefault(l => l.GetProperty("rel").GetString() == "third");
if (third.ValueKind != JsonValueKind.Undefined) 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) private async Task<string> GetFilesAsync(object? filter = null)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files"); using var req = new HttpRequestMessage(HttpMethod.Get, "/rest/files");
AddBasicHeaders(req); AddBasicHeaders(req);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.files-page+json"));
if (filter is not null) if (filter is not null)
{ {
@@ -575,6 +563,7 @@ namespace ApiDenuncias.Services
} }
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "GET /rest/files");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent) if (resp.StatusCode == System.Net.HttpStatusCode.NoContent)
{ {
return "{\"content\":[]}"; return "{\"content\":[]}";
@@ -612,7 +601,7 @@ namespace ApiDenuncias.Services
} }
/// <summary> /// <summary>
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages p<EFBFBD>ginas. /// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas.
/// </summary> /// </summary>
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1) public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
{ {
@@ -683,9 +672,10 @@ namespace ApiDenuncias.Services
} }
using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl); using var req = new HttpRequestMessage(HttpMethod.Get, fileUrl);
AddTokenAndAccept(req, "application/json"); AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, $"GET {fileUrl}");
if (resp.StatusCode == System.Net.HttpStatusCode.NoContent || if (resp.StatusCode == System.Net.HttpStatusCode.NoContent ||
resp.StatusCode == System.Net.HttpStatusCode.NotFound) resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
@@ -910,7 +900,7 @@ namespace ApiDenuncias.Services
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Error actualizando direcci<EFBFBD>n del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
} }
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref) private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
@@ -1160,7 +1150,7 @@ namespace ApiDenuncias.Services
return value switch return value switch
{ {
"" => "ESP", "" => "ESP",
"es" or "esp" or "espana" or "espa<EFBFBD>a" or "spain" => "ESP", "es" or "esp" or "espana" or "españa" or "spain" => "ESP",
"prt" or "pt" or "portugal" => "PRT", "prt" or "pt" or "portugal" => "PRT",
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3], _ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
_ => "ESP", _ => "ESP",

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@@ -12,6 +13,8 @@ using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services; namespace ApiDenuncias.Services;
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
public sealed class GlobalLeaksClient public sealed class GlobalLeaksClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -50,37 +53,90 @@ public sealed class GlobalLeaksClient
string authcode, string authcode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token"); var prepared = await PrepareLoginAsync(username, password, cancellationToken);
using var tokenResponse = await _httpClient.SendAsync(tokenRequest, cancellationToken); return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken); }
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptográfico.", 502); public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
string username,
string password,
CancellationToken cancellationToken)
{
var loginWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}",
username,
_options.BaseUrl);
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt);
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type"); using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
typeRequest.Content = CreateJsonContent(new { username }); typeRequest.Content = CreateJsonContent(new { username });
using var typeResponse = await _httpClient.SendAsync(typeRequest, cancellationToken); using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken); await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken) var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502); ?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
var passwordWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
username,
authType.Type);
var finalPassword = authType.Type == "key" var finalPassword = authType.Type == "key"
? DerivePassword(password, authType.Salt) ? DerivePassword(password, authType.Salt)
: password; : password;
_logger.LogInformation(
"GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
username,
passwordWatch.ElapsedMilliseconds);
using var authRequest = CreateRequest(HttpMethod.Post, "/api/auth/authentication"); _logger.LogInformation(
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
username,
loginWatch.ElapsedMilliseconds);
return new PreparedGlobalLeaksCredentials(username, finalPassword);
}
public async Task<GlSession> CompleteLoginAsync(
string username,
string finalPassword,
string authcode,
CancellationToken cancellationToken)
{
var loginWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}",
username,
authcode?.Length ?? 0);
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502);
var proofWatch = Stopwatch.StartNew();
_logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username);
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
username,
proofWatch.ElapsedMilliseconds);
using var authRequest = CreateRequest(
HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}");
authRequest.Content = CreateJsonContent(new authRequest.Content = CreateJsonContent(new
{ {
tid = 1, tid = 1,
username, username,
password = finalPassword, password = finalPassword,
authcode, authcode = authcode?.Trim() ?? string.Empty,
}); });
authRequest.Headers.Add("X-Token", tokenAnswer); authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
using var authResponse = await _httpClient.SendAsync(authRequest, cancellationToken); using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
if (!authResponse.IsSuccessStatusCode) if (!authResponse.IsSuccessStatusCode)
{ {
var body = await ReadBodySafeAsync(authResponse, cancellationToken); var body = await ReadBodySafeAsync(authResponse, cancellationToken);
@@ -92,6 +148,10 @@ public sealed class GlobalLeaksClient
(HttpStatusCode)429 => new GlobalLeaksValidationException( (HttpStatusCode)429 => new GlobalLeaksValidationException(
"Demasiados intentos en GlobalLeaks. Espera unos minutos.", "Demasiados intentos en GlobalLeaks. Espera unos minutos.",
StatusCodes.Status429TooManyRequests), StatusCodes.Status429TooManyRequests),
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
new GlobalLeaksValidationException(
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
StatusCodes.Status406NotAcceptable),
_ => new GlobalLeaksValidationException( _ => new GlobalLeaksValidationException(
string.IsNullOrWhiteSpace(body) string.IsNullOrWhiteSpace(body)
? $"Login fallido (código {(int)authResponse.StatusCode})." ? $"Login fallido (código {(int)authResponse.StatusCode})."
@@ -102,7 +162,11 @@ public sealed class GlobalLeaksClient
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken); var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username); var session = ParseAuthSession(authBody, username);
_logger.LogInformation("Login GlobalLeaks correcto para {Username}. Rol: {Role}", session.Username, session.Role ?? "(sin rol)"); _logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username,
session.Role ?? "(sin rol)",
loginWatch.ElapsedMilliseconds);
return session; return session;
} }
@@ -196,6 +260,40 @@ public sealed class GlobalLeaksClient
.ToArray(); .ToArray();
} }
public async Task<ReportDetailDto> GetReportDetailAsync(
string sessionId,
string reportId,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken);
var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken);
var metadata = ParseReports(listBody)
.FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase));
if (metadata is null)
{
throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404);
}
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
{
throw new GlobalLeaksValidationException(
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
422);
}
using var document = JsonDocument.Parse(content);
return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement);
}
public async Task<FileDownloadResult> DownloadReportZipAsync( public async Task<FileDownloadResult> DownloadReportZipAsync(
string sessionId, string sessionId,
string reportId, string reportId,
@@ -253,6 +351,43 @@ public sealed class GlobalLeaksClient
return new FileDownloadResult(content, $"report-{progressive}.json"); return new FileDownloadResult(content, $"report-{progressive}.json");
} }
private async Task<HttpResponseMessage> SendLoginRequestAsync(
HttpRequestMessage request,
string endpoint,
CancellationToken cancellationToken)
{
var stepWatch = Stopwatch.StartNew();
_logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint);
try
{
var response = await _httpClient.SendAsync(request, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms",
endpoint,
(int)response.StatusCode,
stepWatch.ElapsedMilliseconds);
return response;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms",
endpoint,
stepWatch.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms",
endpoint,
stepWatch.ElapsedMilliseconds);
throw;
}
}
private async Task<HttpResponseMessage> SendGlRequestAsync( private async Task<HttpResponseMessage> SendGlRequestAsync(
HttpRequestMessage request, HttpRequestMessage request,
CancellationToken cancellationToken, CancellationToken cancellationToken,
@@ -303,7 +438,7 @@ public sealed class GlobalLeaksClient
return content; return content;
} }
private static string SolveProofOfWork(string tokenId, string tokenSalt) private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
{ {
var idBytes = Encoding.UTF8.GetBytes(tokenId); var idBytes = Encoding.UTF8.GetBytes(tokenId);
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray(); var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
@@ -311,6 +446,11 @@ public sealed class GlobalLeaksClient
while (true) while (true)
{ {
if ((n & 15) == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray(); var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024); var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
if (hash[^1] == 0) if (hash[^1] == 0)
@@ -477,6 +617,74 @@ public sealed class GlobalLeaksClient
return reports; return reports;
} }
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
{
var lastAccessDate = ParseDate(lastAccess);
bool IsNew(string? value)
{
if (lastAccessDate is null)
{
return true;
}
var itemDate = ParseDate(value);
return itemDate is not null && itemDate > lastAccessDate;
}
var comments = EnumerateArray(root, "comments")
.Select(item => new ReportCommentDto(
GetString(item, "id"),
GetString(item, "type"),
GetString(item, "content", "text", "message"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.ToArray();
var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files")
.Select(item => new ReportFileDto(
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.ToArray();
var receiverFiles = EnumerateArray(root, "rfiles")
.Select(item => new ReportFileDto(
GetString(item, "id"),
GetLocalizedString(item, "name", "file_name", "filename"),
GetInt64(item, "size"),
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
GetString(item, "creation_date", "creationDate"),
IsNew(GetString(item, "creation_date", "creationDate"))))
.ToArray();
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
}
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
{
if (root.ValueKind != JsonValueKind.Object)
{
yield break;
}
foreach (var name in names)
{
if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
{
foreach (var item in property.EnumerateArray())
{
yield return item;
}
yield break;
}
}
}
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names) private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
{ {
if (element.ValueKind != JsonValueKind.Object) if (element.ValueKind != JsonValueKind.Object)
@@ -513,6 +721,30 @@ public sealed class GlobalLeaksClient
return null; return null;
} }
private static string? GetLocalizedString(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!element.TryGetProperty(name, out var property))
{
continue;
}
if (property.ValueKind == JsonValueKind.String)
{
return property.GetString();
}
if (property.ValueKind == JsonValueKind.Object)
{
var value = ExtractName(property, string.Empty);
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
private static int? GetInt32(JsonElement element, params string[] names) private static int? GetInt32(JsonElement element, params string[] names)
{ {
foreach (var name in names) foreach (var name in names)
@@ -535,6 +767,28 @@ public sealed class GlobalLeaksClient
return null; return null;
} }
private static long? GetInt64(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
{
return value;
}
if (property.ValueKind == JsonValueKind.String &&
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
return value;
}
}
}
return null;
}
private static bool GetBool(JsonElement element, params string[] names) private static bool GetBool(JsonElement element, params string[] names)
{ {
foreach (var name in names) foreach (var name in names)
@@ -621,6 +875,18 @@ public sealed class GlobalLeaksClient
} }
} }
private static bool IsTwoFactorRequiredError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return false;
}
return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
}
private sealed record TokenResponse(string Id, string Salt); private sealed record TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, string Salt); private sealed record AuthTypeResponse(string Type, string Salt);

View File

@@ -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 _);
}
}
}
}

View File

@@ -1,4 +1,4 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@@ -27,12 +27,12 @@
"Gestiona": { "Gestiona": {
"ApiBase": "https://02.g3stiona.com", "ApiBase": "https://02.g3stiona.com",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c", "AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"ExternalProcedureId": "82722c9b-cecc-4299-8a7b-ce5abeb8170b",
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa", "CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36", "CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
"CircuitSignerStampTitle": "oaaf-complaints-tramit", "CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004", "CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
"CircuitVersion": "2", "CircuitVersion": "2",
"PreferredCircuitTemplateName": "CT-Actualización de denuncia",
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63", "UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", "GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
"Location": "2.02.01" "Location": "2.02.01"

View File

@@ -1,3 +1,12 @@
namespace GestionaDenuncias.Shared.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record LoginRequest(string Username, string Password, string Authcode); public sealed record LoginRequest(string Username, string Password, string Authcode);
public sealed record ApiLoginPrepareRequest(string Username, string Password);
public sealed record ApiLoginPrepareResponse(
string PendingLoginId,
string Username,
DateTimeOffset ExpiresAtUtc);
public sealed record ApiLoginCompleteRequest(string PendingLoginId, string Authcode);

View File

@@ -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);

View 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);
}
}

View File

@@ -4,4 +4,6 @@
<div class=""> <div class="">
@Body @Body
</div> </div>
</div> </div>
<BusyOverlay />

View File

@@ -4,6 +4,7 @@
@inject IHttpContextAccessor HttpContextAccessor @inject IHttpContextAccessor HttpContextAccessor
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject UiBusyService Busy
<div class="app-shell"> <div class="app-shell">
<aside class="app-sidebar"> <aside class="app-sidebar">
@@ -40,6 +41,8 @@
</main> </main>
</div> </div>
<BusyOverlay />
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. An unhandled error has occurred.
<a href="" class="reload">Reload</a> <a href="" class="reload">Reload</a>
@@ -140,9 +143,19 @@
private async Task CerrarSesionAsync() private async Task CerrarSesionAsync()
{ {
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout"); using var busy = Busy.Show(
userState.Token = string.Empty; "Cerrando sesion",
userState.NombreUsu = string.Empty; "Invalidando la sesion interna y limpiando el acceso del usuario.");
Navigation.NavigateTo("/", true);
try
{
await JSRuntime.InvokeAsync<object>("appAuthPost", "/api/auth/logout");
}
finally
{
userState.Token = string.Empty;
userState.NombreUsu = string.Empty;
Navigation.NavigateTo("/", true);
}
} }
} }

View File

@@ -15,6 +15,7 @@
@inject IHostEnvironment HostEnvironment @inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Actualizaciones</PageTitle> <PageTitle>Actualizaciones</PageTitle>
@@ -72,27 +73,6 @@
vertical-align: middle; vertical-align: middle;
} }
/* Overlay de carga */
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.upload-box {
background: #fff;
border-radius: .75rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
min-width: 260px;
text-align: center;
}
/* === Estética de modal igual que en Pendientes === */ /* === Estética de modal igual que en Pendientes === */
.custom-modal { .custom-modal {
@@ -594,17 +574,6 @@ else
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
} }
@if (isUploading)
{
<div class="upload-overlay">
<div class="upload-box">
<div class="spinner-border" role="status" aria-hidden="true"></div>
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
</div>
</div>
}
@code { @code {
private const string reportTxt = "report.txt"; private const string reportTxt = "report.txt";
@@ -810,10 +779,14 @@ else
try try
{ {
isUploading = true; isUploading = true;
using var busy = Busy.Show(
"Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos.");
StateHasChanged(); StateHasChanged();
await Task.Yield(); await Task.Yield();
// 1) Ficheros a subir // 1) Ficheros a subir
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
var existentesF = await CargarFicherosJsonAsync(); var existentesF = await CargarFicherosJsonAsync();
var fDenuncia = GetPendingUpdateFiles( var fDenuncia = GetPendingUpdateFiles(
existentesF existentesF
@@ -843,6 +816,7 @@ else
} }
else else
{ {
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 8");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId, selectedDenuncias.ProcedureId,
nuevoAsunto, nuevoAsunto,
@@ -850,6 +824,7 @@ else
"3109963" "3109963"
); );
fileUrl = createdFile.FileUrl; fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 8");
await ApiDenuncias.OpenGestionaFileAsync( await ApiDenuncias.OpenGestionaFileAsync(
fileUrl, fileUrl,
createdFile.FileOpenUrl, createdFile.FileOpenUrl,
@@ -869,14 +844,17 @@ else
selectedDenuncias.EnGestiona = true; selectedDenuncias.EnGestiona = true;
} }
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8");
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl); await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias); var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 8");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty); await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var ahoraUtc = DateTime.UtcNow; var ahoraUtc = DateTime.UtcNow;
var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}"); var carpetaActualizacion = FixFileName($"Actualizacion {DateTime.Now:yyyy-MM-dd HH-mm-ss}");
Busy.Update(message: "Creando carpeta de actualizacion en Gestiona.", detail: "Paso 5 de 8");
var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion); var carpetaActualizacionGestiona = await ApiDenuncias.CreateGestionaFolderAsync(fileUrl, carpetaActualizacion);
var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl; var documentsTargetUrl = carpetaActualizacionGestiona.DocumentsTargetUrl;
var nombresOriginalesSubidos = new List<string>(); var nombresOriginalesSubidos = new List<string>();
@@ -888,6 +866,7 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 6 de 8");
var reportPdfBytes = PdfHelper.MergeFilesToPdf( var reportPdfBytes = PdfHelper.MergeFilesToPdf(
new (string FileName, byte[] Content)[] { (report.FileName, report.Content) }); new (string FileName, byte[] Content)[] { (report.FileName, report.Content) });
var reportFinalName = FixFileName("Denuncia.pdf"); var reportFinalName = FixFileName("Denuncia.pdf");
@@ -905,6 +884,7 @@ else
if (adjuntos.Count > 0 && uploadMode == "merge") if (adjuntos.Count > 0 && uploadMode == "merge")
{ {
Busy.Update(message: "Uniendo adjuntos nuevos en un unico PDF y subiendolo.", detail: "Paso 7 de 8");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos); var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf"); var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName); var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(documentsTargetUrl, pdfBytes, pdfName);
@@ -927,6 +907,12 @@ else
foreach (var t in adjuntos) foreach (var t in adjuntos)
{ {
Busy.Update(
message: $"Subiendo adjunto nuevo {i} de {adjuntos.Count}.",
detail: "Paso 7 de 8",
current: i,
total: adjuntos.Count);
var origName = t.FileName; var origName = t.FileName;
var content = t.Content; var content = t.Content;
var ext = Path.GetExtension(origName).ToLowerInvariant(); var ext = Path.GetExtension(origName).ToLowerInvariant();
@@ -961,6 +947,7 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar)) if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 8 de 8");
await ApiDenuncias.TramitarGestionaDocumentAsync( await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar, documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(), GetAssignedGroupLinkBySelectedGroup(),
@@ -979,6 +966,8 @@ else
f.FechaSubida = ahoraUtc; f.FechaSubida = ahoraUtc;
} }
} }
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
await DenunciaStore.MarkFicherosAsUploadedAsync( await DenunciaStore.MarkFicherosAsUploadedAsync(
selectedDenuncias.Id_Denuncia, selectedDenuncias.Id_Denuncia,
nombresOriginalesSubidos, nombresOriginalesSubidos,

View File

@@ -1,13 +1,80 @@
@page "/GestionZip" @page "/GestionZip"
@rendermode @(new InteractiveServerRenderMode(prerender: false)) @rendermode @(new InteractiveServerRenderMode(prerender: false))
@attribute [Authorize] @attribute [Authorize]
@implements IAsyncDisposable
@using System.Globalization @using System.Globalization
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject IJSRuntime JSRuntime
@inject UiBusyService Busy
<PageTitle>Entrada de denuncias</PageTitle> <PageTitle>Entrada de denuncias</PageTitle>
<style>
.report-detail-overlay {
position: fixed;
inset: 0;
z-index: 4500;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(1rem, 3vw, 2rem);
background: rgba(6, 22, 41, 0.62);
overflow: hidden;
}
.report-detail-dialog {
width: min(820px, 100%);
max-height: min(88vh, 860px);
display: flex;
}
.report-detail-card {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(215, 228, 241, 0.92);
border-radius: 22px;
background: #ffffff;
box-shadow: 0 28px 90px rgba(6, 22, 41, 0.38);
color: var(--app-ink, #12395f);
}
.report-detail-header,
.report-detail-footer {
flex: 0 0 auto;
background: #ffffff;
}
.report-detail-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.35rem 1rem;
border-bottom: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.35rem;
background: #ffffff;
}
.report-detail-footer {
display: flex;
justify-content: flex-end;
padding: 1rem 1.35rem;
border-top: 1px solid var(--app-border, #d7e4f1);
}
.report-detail-close {
flex: 0 0 auto;
}
</style>
<div class="container py-4"> <div class="container py-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4"> <div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div> <div>
@@ -166,13 +233,14 @@
<th>Ultima actualizacion</th> <th>Ultima actualizacion</th>
<th>Estado</th> <th>Estado</th>
<th>Seguimiento</th> <th>Seguimiento</th>
<th style="width: 7rem;">Detalle</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (!CanUseGlobalLeaks) @if (!CanUseGlobalLeaks)
{ {
<tr> <tr>
<td colspan="7" class="text-muted"> <td colspan="8" class="text-muted">
Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja. Renueva la sesion de GlobalLeaks con un 2FA valido para cargar la bandeja.
</td> </td>
</tr> </tr>
@@ -180,13 +248,13 @@
else if (ReportsBusy) else if (ReportsBusy)
{ {
<tr> <tr>
<td colspan="7" class="text-muted">Cargando denuncias...</td> <td colspan="8" class="text-muted">Cargando denuncias...</td>
</tr> </tr>
} }
else if (!VisibleReports.Any()) else if (!VisibleReports.Any())
{ {
<tr> <tr>
<td colspan="7" class="text-muted">No hay denuncias con los filtros actuales.</td> <td colspan="8" class="text-muted">No hay denuncias con los filtros actuales.</td>
</tr> </tr>
} }
else else
@@ -217,6 +285,15 @@
<div class="small text-muted mt-1">@report.TrackingNote</div> <div class="small text-muted mt-1">@report.TrackingNote</div>
} }
</td> </td>
<td>
<button type="button"
class="btn btn-outline-secondary btn-sm"
title="Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida."
@onclick="@(() => OpenReportDetailAsync(report))"
disabled="@DetailBusy">
Ver detalle
</button>
</td>
</tr> </tr>
} }
} }
@@ -229,6 +306,117 @@
</div> </div>
</div> </div>
@if (DetailModalVisible)
{
<div class="report-detail-overlay" role="dialog" aria-modal="true" aria-labelledby="report-detail-title">
<div class="report-detail-dialog">
<div class="report-detail-card">
<div class="report-detail-header">
<div>
<h5 id="report-detail-title" class="mb-0">Contenido de denuncia #@(DetailReport?.Progressive ?? 0)</h5>
<small class="text-muted">Mensajes y ficheros comparados contra el ultimo acceso registrado.</small>
</div>
<button type="button" class="btn-close report-detail-close" aria-label="Cerrar" @onclick="CloseReportDetail"></button>
</div>
<div class="report-detail-body">
<div class="alert alert-warning small">
Esta consulta abre el detalle en GlobalLeaks y puede marcar la denuncia como leida.
</div>
@if (DetailBusy)
{
<div class="text-muted">Cargando detalle de GlobalLeaks...</div>
}
else if (!string.IsNullOrWhiteSpace(DetailError))
{
<div class="alert alert-danger">@DetailError</div>
}
else if (DetailData is not null)
{
<p class="small text-muted">
Ultimo acceso previo:
@(string.IsNullOrWhiteSpace(DetailData.LastAccess) ? "Sin acceso previo" : FormatDate(DetailData.LastAccess)).
Los elementos posteriores se marcan como nuevos.
</p>
<h6 class="text-uppercase text-muted small fw-bold mt-4">Mensajes (@DetailData.Comments.Count)</h6>
@if (DetailData.Comments.Count == 0)
{
<p class="text-muted small">Sin mensajes.</p>
}
else
{
@foreach (var comment in DetailData.Comments)
{
<div class="border rounded p-3 mb-2 @(comment.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 small text-muted mb-2">
<strong>@GetCommentAuthorLabel(comment.Type)</strong>
<span>@FormatDate(comment.CreationDate)</span>
</div>
@if (comment.IsNew)
{
<span class="badge bg-success mb-2">Nuevo</span>
}
<div style="white-space: pre-wrap;">@(string.IsNullOrWhiteSpace(comment.Content) ? "-" : comment.Content)</div>
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros del denunciante (@DetailData.WhistleblowerFiles.Count)</h6>
@if (DetailData.WhistleblowerFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.WhistleblowerFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
<h6 class="text-uppercase text-muted small fw-bold mt-4">Ficheros internos/receptor (@DetailData.ReceiverFiles.Count)</h6>
@if (DetailData.ReceiverFiles.Count == 0)
{
<p class="text-muted small">Sin ficheros.</p>
}
else
{
@foreach (var file in DetailData.ReceiverFiles)
{
<div class="border rounded p-3 mb-2 @(file.IsNew ? "border-success bg-success-subtle" : "bg-light")">
<div class="d-flex flex-column flex-md-row justify-content-between gap-2">
<strong>@(string.IsNullOrWhiteSpace(file.Name) ? "Fichero sin nombre" : file.Name)</strong>
<span class="small text-muted">@FormatDate(file.CreationDate)</span>
</div>
<div class="small text-muted">@FormatBytes(file.Size) @(string.IsNullOrWhiteSpace(file.ContentType) ? string.Empty : $" - {file.ContentType}")</div>
@if (file.IsNew)
{
<span class="badge bg-success mt-2">Nuevo</span>
}
</div>
}
}
}
</div>
<div class="report-detail-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseReportDetail">Cerrar</button>
</div>
</div>
</div>
</div>
}
@code { @code {
private readonly List<ContextDto> Contexts = []; private readonly List<ContextDto> Contexts = [];
private readonly List<ReportDto> Reports = []; private readonly List<ReportDto> Reports = [];
@@ -252,6 +440,11 @@
private bool ReportsBusy { get; set; } private bool ReportsBusy { get; set; }
private bool RenewBusy { get; set; } private bool RenewBusy { get; set; }
private bool ImportBusy { get; set; } private bool ImportBusy { get; set; }
private bool DetailBusy { get; set; }
private bool DetailModalVisible { get; set; }
private string DetailError { get; set; } = string.Empty;
private ReportDto? DetailReport { get; set; }
private ReportDetailDto? DetailData { get; set; }
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true; private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private int SelectedReportsCount => SelectedIds.Count; private int SelectedReportsCount => SelectedIds.Count;
@@ -311,8 +504,13 @@
RenewBusy = true; RenewBusy = true;
try try
{ {
using var busy = Busy.Show(
"Renovando sesion GlobalLeaks",
"Validando el nuevo codigo 2FA para continuar descargando denuncias.");
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None); SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
RenewAuthcode = string.Empty; RenewAuthcode = string.Empty;
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
await LoadReportsAsync(); await LoadReportsAsync();
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success"); SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
} }
@@ -336,6 +534,10 @@
ReportsBusy = true; ReportsBusy = true;
try try
{ {
using var busy = Busy.Show(
"Actualizando bandeja",
"Consultando GlobalLeaks y cruzando el seguimiento con la base de datos.");
var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None); var inbox = await ApiDenuncias.LoadInboxAsync(CancellationToken.None);
Contexts.Clear(); Contexts.Clear();
@@ -383,8 +585,21 @@
.OrderBy(report => report.Progressive ?? 0) .OrderBy(report => report.Progressive ?? 0)
.ToList(); .ToList();
using var busy = Busy.Show(
"Importando denuncias",
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
selectedReports.Count);
var processed = 0;
foreach (var report in selectedReports) foreach (var report in selectedReports)
{ {
processed++;
Busy.Update(
message: $"Procesando denuncia #{report.Progressive ?? 0}.",
detail: "Descarga, analisis del report y guardado seguro en la base de datos.",
current: processed,
total: selectedReports.Count);
try try
{ {
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None); var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
@@ -442,6 +657,70 @@
} }
} }
private async Task OpenReportDetailAsync(ReportDto report)
{
if (!CanUseGlobalLeaks)
{
SetStatus("Renueva antes la sesion de GlobalLeaks para consultar el detalle.", "alert-warning");
return;
}
DetailModalVisible = true;
DetailReport = report;
DetailData = null;
DetailError = string.Empty;
DetailBusy = true;
await SetBodyScrollLockAsync(true);
try
{
using var busy = Busy.Show(
"Leyendo detalle de denuncia",
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None);
}
catch (UnauthorizedAccessException ex)
{
await LoadSessionStateAsync();
DetailError = ex.Message;
}
catch (Exception ex)
{
DetailError = ex.Message;
}
finally
{
DetailBusy = false;
}
}
private async Task CloseReportDetail()
{
DetailModalVisible = false;
DetailReport = null;
DetailData = null;
DetailError = string.Empty;
await SetBodyScrollLockAsync(false);
}
public async ValueTask DisposeAsync()
{
await SetBodyScrollLockAsync(false);
}
private async Task SetBodyScrollLockAsync(bool locked)
{
try
{
await JSRuntime.InvokeVoidAsync("appSetBodyScrollLock", locked);
}
catch
{
// Si el circuito se esta cerrando, solo aseguramos que no reviente el componente.
}
}
private void ApplyFilters() private void ApplyFilters()
{ {
IEnumerable<ReportDto> filtered = Reports; IEnumerable<ReportDto> filtered = Reports;
@@ -652,6 +931,34 @@
: "-"; : "-";
} }
private static string FormatBytes(long? value)
{
if (value is null or <= 0)
{
return "Tamano no disponible";
}
var bytes = value.Value;
if (bytes < 1024)
{
return $"{bytes} B";
}
if (bytes < 1024 * 1024)
{
return $"{bytes / 1024d:0.#} KB";
}
return $"{bytes / 1024d / 1024d:0.#} MB";
}
private static string GetCommentAuthorLabel(string? type)
=> string.Equals(type, "whistleblower", StringComparison.OrdinalIgnoreCase)
? "Denunciante"
: string.Equals(type, "receiver", StringComparison.OrdinalIgnoreCase)
? "Receptor"
: "Comentario";
private static DateTimeOffset? GetEffectiveMoment(ReportDto report) private static DateTimeOffset? GetEffectiveMoment(ReportDto report)
{ {
return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate); return ParseDate(report.UpdateDate) ?? ParseDate(report.CreationDate);

View File

@@ -2,10 +2,12 @@
@layout EmptyLayout @layout EmptyLayout
@rendermode @(new InteractiveServerRenderMode(prerender: false)) @rendermode @(new InteractiveServerRenderMode(prerender: false))
@using System.Text.Json @using System.Text.Json
@using GestionaDenuncias.Shared.Models
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject UiBusyService Busy
<PageTitle>Portal de denuncias</PageTitle> <PageTitle>Portal de denuncias</PageTitle>
@@ -87,12 +89,12 @@
<div class="row g-5 align-items-center"> <div class="row g-5 align-items-center">
<div class="col-lg-7"> <div class="col-lg-7">
<div class="brand-panel"> <div class="brand-panel">
<span class="brand-kicker">Oficina Antifraude de Andalucía</span> <span class="brand-kicker">Oficina Antifraude de Andalucia</span>
<h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1> <h1 class="brand-title">Una sola puerta para entrar, importar y tramitar denuncias.</h1>
<p class="brand-copy mb-4"> <p class="brand-copy mb-4">
El acceso de la aplicación ya se apoya en GlobalLeaks. La sesión interna de esta app se mantiene El acceso de la aplicacion ya se apoya en GlobalLeaks. La sesion interna de esta app se mantiene
activa de forma persistente, y cuando caduque la sesión de obtención de denuncias solo habrá que activa de forma persistente, y cuando caduque la sesion de obtencion de denuncias solo habra que
renovar el código 2FA desde la bandeja de entrada. renovar el codigo 2FA desde la bandeja de entrada.
</p> </p>
<img src="Content/imagenes/logo-oaaf-negativo-transparente.svg" <img src="Content/imagenes/logo-oaaf-negativo-transparente.svg"
alt="Logo Oficina Antifraude" alt="Logo Oficina Antifraude"
@@ -104,7 +106,7 @@
<div class="login-panel p-4 p-md-5"> <div class="login-panel p-4 p-md-5">
<h2 class="h3 mb-2">Acceso con GlobalLeaks</h2> <h2 class="h3 mb-2">Acceso con GlobalLeaks</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Introduce tu usuario, contraseña y el código 2FA actual para dejar la app iniciada. Primero validamos usuario y contrasena. Despues te pediremos un codigo 2FA fresco para completar el acceso.
</p> </p>
@if (!string.IsNullOrWhiteSpace(StatusMessage)) @if (!string.IsNullOrWhiteSpace(StatusMessage))
@@ -116,33 +118,54 @@
<label class="login-label mb-2">Usuario</label> <label class="login-label mb-2">Usuario</label>
<input class="form-control login-input" <input class="form-control login-input"
@bind="Username" @bind="Username"
@bind:event="oninput"
disabled="@LoginPrepared"
autocomplete="username" autocomplete="username"
placeholder="usuario de GlobalLeaks" /> placeholder="usuario de GlobalLeaks" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="login-label mb-2">Contraseña</label> <label class="login-label mb-2">Contrasena</label>
<input class="form-control login-input" <input class="form-control login-input"
type="password" type="password"
@bind="Password" @bind="Password"
@bind:event="oninput"
disabled="@LoginPrepared"
autocomplete="current-password" /> autocomplete="current-password" />
</div> </div>
<div class="mb-4"> @if (LoginPrepared)
<label class="login-label mb-2">Código 2FA</label> {
<input class="form-control login-input" <div class="alert alert-info mb-4">
@bind="Authcode" Credenciales preparadas. Introduce ahora el codigo 2FA actual para completar el acceso.
@onkeydown="HandleAuthcodeKeyDown" </div>
inputmode="numeric"
maxlength="6" <div class="mb-4">
placeholder="123456" /> <label class="login-label mb-2">Codigo 2FA</label>
</div> <input class="form-control login-input"
@bind="Authcode"
@bind:event="oninput"
@onkeydown="HandleAuthcodeKeyDown"
inputmode="numeric"
maxlength="6"
placeholder="123456" />
</div>
}
<button class="btn btn-primary login-button w-100" <button class="btn btn-primary login-button w-100"
@onclick="LoginAsync" @onclick="LoginAsync"
disabled="@IsBusy"> disabled="@IsBusy">
@(IsBusy ? "Conectando..." : "Entrar") @LoginButtonText
</button> </button>
@if (LoginPrepared)
{
<button class="btn btn-link w-100 mt-3"
type="button"
disabled="@IsBusy"
@onclick="ResetPreparedLogin">
Cambiar usuario o contrasena
</button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -156,9 +179,14 @@
private string Username { get; set; } = string.Empty; private string Username { get; set; } = string.Empty;
private string Password { get; set; } = string.Empty; private string Password { get; set; } = string.Empty;
private string Authcode { get; set; } = string.Empty; private string Authcode { get; set; } = string.Empty;
private string PendingLoginId { get; set; } = string.Empty;
private string StatusMessage { get; set; } = string.Empty; private string StatusMessage { get; set; } = string.Empty;
private string StatusCss { get; set; } = "alert-info"; private string StatusCss { get; set; } = "alert-info";
private bool IsBusy { get; set; } private bool IsBusy { get; set; }
private bool LoginPrepared => !string.IsNullOrWhiteSpace(PendingLoginId);
private string LoginButtonText => IsBusy
? (LoginPrepared ? "Validando 2FA..." : "Preparando acceso...")
: (LoginPrepared ? "Validar 2FA y entrar" : "Preparar acceso");
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -166,18 +194,28 @@
if (authState.User.Identity?.IsAuthenticated == true) if (authState.User.Identity?.IsAuthenticated == true)
{ {
Navigation.NavigateTo(GetTargetUrl(), true); Navigation.NavigateTo(GetTargetUrl(), true);
} }
} }
private async Task LoginAsync() private async Task LoginAsync()
{ {
StatusMessage = string.Empty; StatusMessage = string.Empty;
if (string.IsNullOrWhiteSpace(Username) || if (!LoginPrepared)
string.IsNullOrWhiteSpace(Password) ||
string.IsNullOrWhiteSpace(Authcode))
{ {
SetStatus("Debes rellenar usuario, contraseña y código 2FA.", "alert-warning"); await PrepareLoginAsync();
return;
}
await CompleteLoginAsync();
}
private async Task PrepareLoginAsync()
{
if (string.IsNullOrWhiteSpace(Username) ||
string.IsNullOrWhiteSpace(Password))
{
SetStatus("Debes rellenar usuario y contrasena.", "alert-warning");
return; return;
} }
@@ -185,27 +223,105 @@
try try
{ {
using var busy = Busy.Show(
"Preparando acceso",
"Validando usuario y contrasena con GlobalLeaks. Cuando termine pediremos un codigo 2FA nuevo.");
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
var response = await JSRuntime.InvokeAsync<ApiJsResponse>( var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
"appAuthPostJson", "appAuthPostJson",
"/api/auth/login", loginTimeout.Token,
new LoginRequest(Username.Trim(), Password, Authcode.Trim())); [
"/api/auth/prepare",
new ApiLoginPrepareRequest(Username.Trim(), Password)
]);
if (!response.Ok) if (!response.Ok)
{ {
var error = ReadData<ApiError>(response); var error = ReadData<ApiError>(response);
SetStatus(error?.Error ?? "No se ha podido iniciar sesión.", "alert-danger"); SetStatus(error?.Error ?? "No se ha podido preparar el acceso.", "alert-danger");
return; return;
} }
var prepared = ReadData<ApiLoginPrepareResponse>(response);
if (prepared is null || string.IsNullOrWhiteSpace(prepared.PendingLoginId))
{
SetStatus("La API no ha devuelto una preparacion de login valida.", "alert-danger");
return;
}
PendingLoginId = prepared.PendingLoginId;
Username = prepared.Username;
Password = string.Empty;
Authcode = string.Empty;
SetStatus("Credenciales preparadas. Introduce el codigo 2FA que este activo ahora mismo.", "alert-info");
}
catch (JSException ex)
{
SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
}
catch (OperationCanceledException)
{
SetStatus("La preparacion del acceso ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
}
catch (Exception ex)
{
SetStatus($"No se ha podido preparar el acceso: {ex.Message}", "alert-danger");
}
finally
{
IsBusy = false;
}
}
private async Task CompleteLoginAsync()
{
var authcode = Authcode.Trim();
if (authcode.Length != 6 || authcode.Any(ch => !char.IsDigit(ch)))
{
SetStatus("El codigo 2FA debe tener exactamente 6 digitos.", "alert-warning");
return;
}
IsBusy = true;
try
{
using var busy = Busy.Show(
"Validando 2FA",
"Completando el acceso con el codigo actual de GlobalLeaks.");
using var loginTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(170));
var response = await JSRuntime.InvokeAsync<ApiJsResponse>(
"appAuthPostJson",
loginTimeout.Token,
[
"/api/auth/complete",
new ApiLoginCompleteRequest(PendingLoginId, authcode)
]);
if (!response.Ok)
{
var error = ReadData<ApiError>(response);
SetStatus(error?.Error ?? "No se ha podido completar el inicio de sesion.", "alert-danger");
Authcode = string.Empty;
return;
}
Busy.Update(message: "Creando la sesion interna segura de la aplicacion.");
Navigation.NavigateTo(GetTargetUrl(), true); Navigation.NavigateTo(GetTargetUrl(), true);
} }
catch (JSException ex) catch (JSException ex)
{ {
SetStatus($"Fallo de comunicación con el navegador: {ex.Message}", "alert-danger"); SetStatus($"Fallo de comunicacion con el navegador: {ex.Message}", "alert-danger");
} }
catch catch (OperationCanceledException)
{ {
SetStatus("No se ha podido conectar con el servidor.", "alert-danger"); SetStatus("La validacion del 2FA ha tardado demasiado. Revisa si ApiDenuncias o GlobalLeaks estan respondiendo lento y vuelve a intentarlo.", "alert-danger");
}
catch (Exception ex)
{
SetStatus($"No se ha podido completar el inicio de sesion: {ex.Message}", "alert-danger");
} }
finally finally
{ {
@@ -214,7 +330,15 @@
} }
private Task HandleAuthcodeKeyDown(KeyboardEventArgs args) private Task HandleAuthcodeKeyDown(KeyboardEventArgs args)
=> args.Key == "Enter" ? LoginAsync() : Task.CompletedTask; => LoginPrepared && args.Key == "Enter" ? LoginAsync() : Task.CompletedTask;
private void ResetPreparedLogin()
{
PendingLoginId = string.Empty;
Password = string.Empty;
Authcode = string.Empty;
StatusMessage = string.Empty;
}
private void SetStatus(string message, string cssClass) private void SetStatus(string message, string cssClass)
{ {

View File

@@ -18,30 +18,11 @@
@inject IHostEnvironment HostEnvironment @inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Denuncias Pendientes</PageTitle> <PageTitle>Denuncias Pendientes</PageTitle>
<style> <style>
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 2000; /* por encima de todo, también de los modales */
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.upload-box {
background: #fff;
border-radius: .75rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
min-width: 260px;
text-align: center;
}
.seleccionar-col { .seleccionar-col {
width: 50px; width: 50px;
text-align: center; text-align: center;
@@ -720,7 +701,7 @@ else
<button type="button" class="btn btn-secondary" @onclick="CloseModal"> <button type="button" class="btn btn-secondary" @onclick="CloseModal">
Cancelar Cancelar
</button> </button>
<button type="button" class="btn btn-primary" @onclick="ConfirmarEnvio"> <button type="button" class="btn btn-primary" disabled="@isUploading" @onclick="ConfirmarEnvio">
Confirmar Confirmar
</button> </button>
</div> </div>
@@ -828,17 +809,6 @@ else
</div> </div>
} }
@if (isUploading)
{
<div class="upload-overlay">
<div class="upload-box">
<div class="spinner-border" role="status" aria-hidden="true"></div>
<div class="mt-3 fw-semibold">Subiendo documentos…</div>
<div class="text-muted" style="font-size:.9rem">Por favor, espera</div>
</div>
</div>
}
@code { @code {
private string nombreDocumentos = string.Empty; private string nombreDocumentos = string.Empty;
private bool isUploading = false; private bool isUploading = false;
@@ -970,7 +940,13 @@ else
try try
{ {
isUploading = true; isUploading = true;
using var busy = Busy.Show(
"Enviando a Gestiona",
"Preparando expediente, tercero y documentos de la denuncia.");
StateHasChanged();
await Task.Yield();
Busy.Update(message: "Cargando ficheros de la denuncia.", detail: "Paso 1 de 7");
var existentesF = await CargarFicherosJsonAsync(); var existentesF = await CargarFicherosJsonAsync();
var todos = existentesF var todos = existentesF
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia) .Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
@@ -987,6 +963,7 @@ else
} }
else else
{ {
Busy.Update(message: "Creando expediente nuevo en Gestiona.", detail: "Paso 2 de 7");
var createdFile = await ApiDenuncias.CreateGestionaFileAsync( var createdFile = await ApiDenuncias.CreateGestionaFileAsync(
selectedDenuncias.ProcedureId, selectedDenuncias.ProcedureId,
nuevoAsunto, nuevoAsunto,
@@ -994,6 +971,7 @@ else
"3109963" "3109963"
); );
fileUrl = createdFile.FileUrl; fileUrl = createdFile.FileUrl;
Busy.Update(message: "Abriendo expediente y asignando el grupo elegido.", detail: "Paso 2 de 7");
await ApiDenuncias.OpenGestionaFileAsync( await ApiDenuncias.OpenGestionaFileAsync(
fileUrl, fileUrl,
createdFile.FileOpenUrl, createdFile.FileOpenUrl,
@@ -1013,9 +991,11 @@ else
selectedDenuncias.EnGestiona = true; selectedDenuncias.EnGestiona = true;
} }
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 7");
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl); await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias); var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty); await ApiDenuncias.EnsureGestionaThirdAndLinkAsync(fileUrl, thirdParty);
var nombresOriginalesSubidos = new List<string>(); var nombresOriginalesSubidos = new List<string>();
@@ -1028,6 +1008,7 @@ else
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
Busy.Update(message: "Preparando y subiendo el report para firma.", detail: "Paso 5 de 7");
var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[] var reportPdfBytes = PdfHelper.MergeFilesToPdf(new[]
{ {
(FileName: report.FileName, Content: report.Content) (FileName: report.FileName, Content: report.Content)
@@ -1047,6 +1028,7 @@ else
if (adjuntos.Count > 0 && uploadMode == "merge") if (adjuntos.Count > 0 && uploadMode == "merge")
{ {
Busy.Update(message: "Uniendo adjuntos en un unico PDF y subiendolo.", detail: "Paso 6 de 7");
var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos); var pdfBytes = PdfHelper.MergeFilesToPdf(adjuntos);
var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf"); var pdfName = FixFileName($"Adjuntos {selectedDenuncias.Id_Denuncia}_{ahoraUtc:yyyyMMddHHmmss}.pdf");
var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName); var docUrl = await ApiDenuncias.UploadGestionaDocumentAsync(fileUrl, pdfBytes, pdfName);
@@ -1069,6 +1051,12 @@ else
foreach (var (origName, content) in adjuntos) foreach (var (origName, content) in adjuntos)
{ {
Busy.Update(
message: $"Subiendo adjunto {i} de {adjuntos.Count}.",
detail: "Paso 6 de 7",
current: i,
total: adjuntos.Count);
var ext = Path.GetExtension(origName).ToLowerInvariant(); var ext = Path.GetExtension(origName).ToLowerInvariant();
byte[] bytesParaSubir = content; byte[] bytesParaSubir = content;
string finalName; string finalName;
@@ -1102,6 +1090,7 @@ else
if (!string.IsNullOrWhiteSpace(documentoParaTramitar)) if (!string.IsNullOrWhiteSpace(documentoParaTramitar))
{ {
Busy.Update(message: "Enviando el report al circuito de firma.", detail: "Paso 7 de 7");
await ApiDenuncias.TramitarGestionaDocumentAsync( await ApiDenuncias.TramitarGestionaDocumentAsync(
documentoParaTramitar, documentoParaTramitar,
GetAssignedGroupLinkBySelectedGroup(), GetAssignedGroupLinkBySelectedGroup(),
@@ -1119,6 +1108,8 @@ else
f.FechaSubida = ahoraUtc; f.FechaSubida = ahoraUtc;
} }
} }
Busy.Update(message: "Actualizando trazabilidad en la base de datos.", detail: "Finalizando");
await DenunciaStore.MarkFicherosAsUploadedAsync( await DenunciaStore.MarkFicherosAsUploadedAsync(
selectedDenuncias.Id_Denuncia, selectedDenuncias.Id_Denuncia,
nombresOriginalesSubidos, nombresOriginalesSubidos,

View File

@@ -5,4 +5,5 @@ public sealed class ApiDenunciasOptions
public const string SectionName = "ApiDenuncias"; public const string SectionName = "ApiDenuncias";
public string BaseUrl { get; set; } = "https://localhost:7093"; public string BaseUrl { get; set; } = "https://localhost:7093";
public int LoginTimeoutSeconds { get; set; } = 150;
} }

View File

@@ -19,7 +19,11 @@ CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("es-ES");
builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName)); builder.Services.Configure<ApiDenunciasOptions>(builder.Configuration.GetSection(ApiDenunciasOptions.SectionName));
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents(options =>
{
options.DetailedErrors = true;
options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
});
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services builder.Services
@@ -37,12 +41,17 @@ builder.Services
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();
builder.Services.AddServerSideBlazor().AddCircuitOptions(option => { option.DetailedErrors = true; }); builder.Services.AddServerSideBlazor().AddCircuitOptions(option =>
{
option.DetailedErrors = true;
option.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(180);
});
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddAntiforgery(); builder.Services.AddAntiforgery();
builder.Services.AddScoped<UserState>(); builder.Services.AddScoped<UserState>();
builder.Services.AddSingleton<AppSessionLifetime>(); builder.Services.AddSingleton<AppSessionLifetime>();
builder.Services.AddSingleton<LoginRateLimiter>(); builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddScoped<UiBusyService>();
builder.Services.AddScoped<ApiDenunciasClient>(); builder.Services.AddScoped<ApiDenunciasClient>();
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>(); builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>(); builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
@@ -140,11 +149,60 @@ app.UseAntiforgery();
var api = app.MapGroup("/api"); var api = app.MapGroup("/api");
api.MapPost("/auth/prepare", async (
ApiLoginPrepareRequest request,
ApiDenunciasClient apiClient,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
string.IsNullOrWhiteSpace(request.Password))
{
return Results.Json(
new ApiError("Debes indicar usuario y contrasena."),
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var prepared = await apiClient.PrepareLoginAsync(
request with { Username = request.Username.Trim() },
loginTimeout.Token);
return Results.Ok(prepared);
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery();
api.MapPost("/auth/login", async ( api.MapPost("/auth/login", async (
LoginRequest request, LoginRequest request,
HttpContext httpContext, HttpContext httpContext,
ApiDenunciasClient apiClient, ApiDenunciasClient apiClient,
LoginRateLimiter rateLimiter, LoginRateLimiter rateLimiter,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
@@ -174,13 +232,17 @@ api.MapPost("/auth/login", async (
try try
{ {
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var login = await apiClient.LoginAsync( var login = await apiClient.LoginAsync(
request with request with
{ {
Username = request.Username.Trim(), Username = request.Username.Trim(),
Authcode = request.Authcode.Trim() Authcode = request.Authcode.Trim()
}, },
cancellationToken); loginTimeout.Token);
var claims = new List<Claim> var claims = new List<Claim>
{ {
@@ -220,6 +282,104 @@ api.MapPost("/auth/login", async (
new ApiError(ex.Message), new ApiError(ex.Message),
statusCode: StatusCodes.Status400BadRequest); statusCode: StatusCodes.Status400BadRequest);
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl}). Comprueba los logs de ApiDenuncias: probablemente esta esperando a GlobalLeaks o a una dependencia externa."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery();
api.MapPost("/auth/complete", async (
ApiLoginCompleteRequest request,
HttpContext httpContext,
ApiDenunciasClient apiClient,
IOptions<ApiDenunciasOptions> apiOptions,
CancellationToken cancellationToken) =>
{
var appSessionLifetime = httpContext.RequestServices.GetRequiredService<AppSessionLifetime>();
if (string.IsNullOrWhiteSpace(request.PendingLoginId) ||
string.IsNullOrWhiteSpace(request.Authcode))
{
return Results.Json(
new ApiError("Debes indicar el codigo 2FA."),
statusCode: StatusCodes.Status400BadRequest);
}
if (!Regex.IsMatch(request.Authcode.Trim(), @"^\d{6}$"))
{
return Results.Json(
new ApiError("El codigo 2FA debe tener exactamente 6 digitos."),
statusCode: StatusCodes.Status400BadRequest);
}
try
{
var loginTimeoutSeconds = Math.Clamp(apiOptions.Value.LoginTimeoutSeconds, 15, 300);
using var loginTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loginTimeout.CancelAfter(TimeSpan.FromSeconds(loginTimeoutSeconds));
var login = await apiClient.CompleteLoginAsync(
request with { Authcode = request.Authcode.Trim() },
loginTimeout.Token);
var claims = new List<Claim>
{
new(ClaimTypes.Name, login.Username),
new("app_startup_stamp", appSessionLifetime.StartupStamp),
new(ApiDenunciasClient.AccessTokenClaim, login.AccessToken),
new(ApiDenunciasClient.TokenExpiresAtClaim, login.ExpiresAtUtc.ToString("O", CultureInfo.InvariantCulture)),
};
if (!string.IsNullOrWhiteSpace(login.Role))
{
claims.Add(new Claim("gl_role", login.Role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var authProperties = new AuthenticationProperties
{
IsPersistent = false,
AllowRefresh = true,
};
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authProperties);
return Results.Ok(new LoginResponse(login.Username));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new ApiError(ex.Message), statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(
new ApiError(ex.Message),
statusCode: StatusCodes.Status400BadRequest);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Json(
new ApiError($"La API de denuncias no ha respondido en {apiOptions.Value.LoginTimeoutSeconds} segundos ({apiOptions.Value.BaseUrl})."),
statusCode: StatusCodes.Status504GatewayTimeout);
}
catch (HttpRequestException ex)
{
return Results.Json(
new ApiError($"No se ha podido conectar con la API de denuncias ({apiOptions.Value.BaseUrl}). Detalle: {ex.Message}"),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}).DisableAntiforgery(); }).DisableAntiforgery();
api.MapPost("/auth/logout", async ( api.MapPost("/auth/logout", async (

View File

@@ -35,6 +35,12 @@ public sealed class ApiDenunciasClient
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login", request, authorize: false, cancellationToken); => SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login", request, authorize: false, cancellationToken);
public Task<ApiLoginPrepareResponse> PrepareLoginAsync(ApiLoginPrepareRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(HttpMethod.Post, "api/auth/login/prepare", request, authorize: false, cancellationToken);
public Task<ApiLoginResponse> CompleteLoginAsync(ApiLoginCompleteRequest request, CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginResponse>(HttpMethod.Post, "api/auth/login/complete", request, authorize: false, cancellationToken);
public Task LogoutAsync(CancellationToken cancellationToken = default) public Task LogoutAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken); => SendAsync<object?>(HttpMethod.Post, "api/auth/logout", body: null, authorize: true, cancellationToken);
@@ -63,6 +69,14 @@ public sealed class ApiDenunciasClient
authorize: true, authorize: true,
cancellationToken); cancellationToken);
public Task<ReportDetailDto> GetReportDetailAsync(string reportId, CancellationToken cancellationToken = default)
=> SendAsync<ReportDetailDto>(
HttpMethod.Get,
$"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail",
body: null,
authorize: true,
cancellationToken);
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default) public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken); => SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);

View 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);
}
}
}

View File

@@ -8,6 +8,7 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ForceHttpsRedirection": false, "ForceHttpsRedirection": false,
"ApiDenuncias": { "ApiDenuncias": {
"BaseUrl": "http://localhost:7093" "BaseUrl": "http://localhost:7093",
"LoginTimeoutSeconds": 150
} }
} }

View File

@@ -34,6 +34,11 @@ body {
margin: 0; margin: 0;
} }
html.app-scroll-locked,
body.app-scroll-locked {
overflow: hidden;
}
a, a,
.btn-link { .btn-link {
color: var(--app-accent); color: var(--app-accent);
@@ -197,7 +202,6 @@ pre {
border: 1px solid rgba(90, 155, 213, 0.14); border: 1px solid rgba(90, 155, 213, 0.14);
box-shadow: var(--app-shadow-soft); box-shadow: var(--app-shadow-soft);
padding: 1.5rem; padding: 1.5rem;
backdrop-filter: blur(10px);
} }
.app-content > .container, .app-content > .container,

View File

@@ -1,12 +1,23 @@
window.appAuthPostJson = async function (url, body) { window.appAuthPostJson = async function (url, body) {
const response = await fetch(url, { let response;
method: "POST", try {
credentials: "same-origin", response = await fetch(url, {
headers: { method: "POST",
"Content-Type": "application/json" credentials: "same-origin",
}, headers: {
body: JSON.stringify(body) "Content-Type": "application/json"
}); },
body: JSON.stringify(body)
});
} catch (error) {
return {
ok: false,
status: 0,
data: {
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
}
};
}
let data = null; let data = null;
try { try {
@@ -23,10 +34,21 @@ window.appAuthPostJson = async function (url, body) {
}; };
window.appAuthPost = async function (url) { window.appAuthPost = async function (url) {
const response = await fetch(url, { let response;
method: "POST", try {
credentials: "same-origin" response = await fetch(url, {
}); method: "POST",
credentials: "same-origin"
});
} catch (error) {
return {
ok: false,
status: 0,
data: {
error: "No se ha podido conectar con el servidor de la aplicacion. " + (error?.message ?? "")
}
};
}
let data = null; let data = null;
try { try {
@@ -41,3 +63,8 @@ window.appAuthPost = async function (url) {
data data
}; };
}; };
window.appSetBodyScrollLock = function (locked) {
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
document.body.classList.toggle("app-scroll-locked", Boolean(locked));
};