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

View File

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

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")]
public async Task<IActionResult> EnsureStorage(CancellationToken cancellationToken)
{

View File

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

View File

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

View File

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

View File

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

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

View File

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

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="">
@Body
</div>
</div>
</div>
<BusyOverlay />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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": "*",
"ForceHttpsRedirection": false,
"ApiDenuncias": {
"BaseUrl": "http://localhost:7093"
"BaseUrl": "http://localhost:7093",
"LoginTimeoutSeconds": 150
}
}

View File

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

View File

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