Compare commits
5 Commits
33c9c03822
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e1f6522f2 | |||
| 2b4678c26d | |||
| 8dedafdc07 | |||
| 6f9392f3d0 | |||
| dd31460cf0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,6 +63,9 @@ project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Local deployment settings containing machine-specific credentials
|
||||
**/appsettings.*.local.json
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncia
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiOPE", "ApiOPE\ApiOPE.csproj", "{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -111,6 +113,18 @@ Global
|
||||
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<ProjectReference Include="..\GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.*.local.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace ApiDenuncias.Configuration
|
||||
public string? CircuitUpdateTemplateName { get; set; }
|
||||
public string? CircuitUpdateSajTemplateName { get; set; }
|
||||
public string? CircuitUpdateSdiTemplateName { get; set; }
|
||||
public string? CircuitCommunicationSajTemplateName { get; set; }
|
||||
public string? CircuitCommunicationSdiTemplateName { get; set; }
|
||||
public string? CircuitSignerStampTitle { get; set; }
|
||||
public string? CircuitVersion { get; set; }
|
||||
public string? DocumentMetadataLanguage { get; set; }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ApiDenuncias.Configuration;
|
||||
|
||||
public sealed class OpeBridgeOptions
|
||||
{
|
||||
public const string SectionName = "OpeBridge";
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key";
|
||||
}
|
||||
@@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase
|
||||
: session.Username.Trim();
|
||||
|
||||
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
|
||||
await _sessionStore.SaveAsync(username, password, session.Id, session.Role, session.DpopPrivateKey, cancellationToken);
|
||||
await _sessionStore.SaveAsync(
|
||||
username,
|
||||
password,
|
||||
session.Id,
|
||||
session.Role,
|
||||
session.DpopPrivateKey,
|
||||
session.ProofOfWorkToken,
|
||||
session.SessionExpiresAtUtc,
|
||||
cancellationToken);
|
||||
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
|
||||
|
||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
||||
|
||||
@@ -12,10 +12,14 @@ namespace ApiDenuncias.Controllers;
|
||||
public sealed class ConfigurationController : ControllerBase
|
||||
{
|
||||
private readonly AppConfigurationService _configurationService;
|
||||
private readonly WorkGroupAdministrationService _workGroupService;
|
||||
|
||||
public ConfigurationController(AppConfigurationService configurationService)
|
||||
public ConfigurationController(
|
||||
AppConfigurationService configurationService,
|
||||
WorkGroupAdministrationService workGroupService)
|
||||
{
|
||||
_configurationService = configurationService;
|
||||
_workGroupService = workGroupService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -48,4 +52,46 @@ public sealed class ConfigurationController : ControllerBase
|
||||
|
||||
return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("work-groups")]
|
||||
[Authorize(Policy = "ConfigurationAdministrators")]
|
||||
public async Task<ActionResult<WorkGroupAdministrationDto>> GetWorkGroups(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await _workGroupService.GetAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("work-groups/current")]
|
||||
public async Task<ActionResult<CurrentUserWorkGroupsDto>> GetCurrentUserWorkGroups(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var username = User.Identity?.Name ??
|
||||
throw new InvalidOperationException("No hay usuario autenticado.");
|
||||
|
||||
return Ok(await _workGroupService.GetUserGroupsAsync(username, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("work-groups/users/{username}")]
|
||||
[Authorize(Policy = "ConfigurationAdministrators")]
|
||||
public async Task<ActionResult<WorkGroupAdministrationDto>> SetUserWorkGroups(
|
||||
string username,
|
||||
UpdateUserWorkGroupsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var changedBy = User.Identity?.Name ??
|
||||
throw new InvalidOperationException("No hay usuario autenticado.");
|
||||
|
||||
try
|
||||
{
|
||||
return Ok(await _workGroupService.UpdateUserGroupsAsync(
|
||||
username,
|
||||
request.GroupCodes ?? [],
|
||||
changedBy,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new ApiError(ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ApiDenuncias.Services;
|
||||
using ApiDenuncias.Security;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -45,13 +46,22 @@ public sealed class DenunciasController : ControllerBase
|
||||
[FromQuery] DenunciaListScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
|
||||
if (allowedIds.Count == 0)
|
||||
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
|
||||
var access = await _accessService.GetComplaintAccessAsync(
|
||||
GetUsername(),
|
||||
null,
|
||||
cancellationToken);
|
||||
if (access.Count == 0)
|
||||
{
|
||||
return Ok(new List<DenunciasGestiona>());
|
||||
}
|
||||
|
||||
return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken));
|
||||
var complaints = await _filteredDenunciaStore.GetDenunciasByIdsAsync(
|
||||
access.Keys.ToArray(),
|
||||
scope,
|
||||
cancellationToken);
|
||||
ApplyAccessMetadata(complaints, access);
|
||||
return Ok(complaints);
|
||||
}
|
||||
|
||||
[HttpGet("{denunciaId:int}")]
|
||||
@@ -62,10 +72,22 @@ public sealed class DenunciasController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken));
|
||||
var complaint = await _denunciaStore.GetDenunciaByIdAsync(denunciaId, cancellationToken);
|
||||
if (complaint is null)
|
||||
{
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
var access = await _accessService.GetComplaintAccessAsync(
|
||||
GetUsername(),
|
||||
[denunciaId],
|
||||
cancellationToken);
|
||||
ApplyAccessMetadata([complaint], access);
|
||||
return Ok(complaint);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
|
||||
[HttpGet("{denunciaId:int}/gestiona-fields")]
|
||||
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
|
||||
int denunciaId,
|
||||
@@ -86,6 +108,7 @@ public sealed class DenunciasController : ControllerBase
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
|
||||
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
|
||||
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFieldsByExpediente(
|
||||
int numeroExpediente,
|
||||
@@ -275,6 +298,24 @@ public sealed class DenunciasController : ControllerBase
|
||||
private string GetUsername()
|
||||
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
|
||||
|
||||
private static void ApplyAccessMetadata(
|
||||
IEnumerable<DenunciasGestiona> complaints,
|
||||
IReadOnlyDictionary<int, ComplaintAccessInfo> access)
|
||||
{
|
||||
foreach (var complaint in complaints)
|
||||
{
|
||||
if (!access.TryGetValue(complaint.Id_Denuncia, out var item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
complaint.OwnerUsername = item.OwnerUsername;
|
||||
complaint.OwnedByCurrentUser = item.OwnedByCurrentUser;
|
||||
complaint.RequiresOwnerConfirmation = item.RequiresOwnerConfirmation;
|
||||
complaint.OwnerWorkGroups = item.OwnerGroupCodes;
|
||||
}
|
||||
}
|
||||
|
||||
private static GestionaComplaintFieldsResponse ToGestionaComplaintFields(DenunciasGestiona denuncia)
|
||||
{
|
||||
var preferenciaNotificacion = ResolveNotificationPreference(denuncia);
|
||||
|
||||
@@ -194,7 +194,8 @@ public sealed class GestionaController : ControllerBase
|
||||
request.DocumentUrl,
|
||||
request.AssignedGroupCode,
|
||||
request.ComplaintId,
|
||||
request.IsUpdate);
|
||||
request.IsUpdate,
|
||||
request.UpdateSource);
|
||||
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class InboxController : ControllerBase
|
||||
private readonly GlobalLeaksSessionStore _sessionStore;
|
||||
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
||||
private readonly GlobalLeaksClient _globalLeaksClient;
|
||||
private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService;
|
||||
private readonly DenunciaInboxService _inboxService;
|
||||
private readonly IInboxTrackingService _trackingService;
|
||||
private readonly ILogger<InboxController> _logger;
|
||||
@@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase
|
||||
GlobalLeaksSessionStore sessionStore,
|
||||
PendingGlobalLeaksLoginStore pendingLoginStore,
|
||||
GlobalLeaksClient globalLeaksClient,
|
||||
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
|
||||
DenunciaInboxService inboxService,
|
||||
IInboxTrackingService trackingService,
|
||||
ILogger<InboxController> logger)
|
||||
@@ -35,6 +37,7 @@ public sealed class InboxController : ControllerBase
|
||||
_sessionStore = sessionStore;
|
||||
_pendingLoginStore = pendingLoginStore;
|
||||
_globalLeaksClient = globalLeaksClient;
|
||||
_sessionKeepAliveService = sessionKeepAliveService;
|
||||
_inboxService = inboxService;
|
||||
_trackingService = trackingService;
|
||||
_logger = logger;
|
||||
@@ -131,7 +134,14 @@ public sealed class InboxController : ControllerBase
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, session.DpopPrivateKey, cancellationToken);
|
||||
await _sessionStore.UpdateSessionAsync(
|
||||
username,
|
||||
session.Id,
|
||||
session.Role,
|
||||
session.DpopPrivateKey,
|
||||
session.ProofOfWorkToken,
|
||||
session.SessionExpiresAtUtc,
|
||||
cancellationToken);
|
||||
var stored = await _sessionStore.GetAsync(username, cancellationToken);
|
||||
return Ok(ToDto(stored));
|
||||
}
|
||||
@@ -152,6 +162,45 @@ public sealed class InboxController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("session/keepalive")]
|
||||
public async Task<ActionResult<ApiGlobalLeaksSessionDto?>> KeepSessionAlive(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var username = GetUsername();
|
||||
|
||||
try
|
||||
{
|
||||
var session = await _sessionKeepAliveService.KeepAliveAsync(username, cancellationToken);
|
||||
return Ok(ToDto(session));
|
||||
}
|
||||
catch (GlobalLeaksSessionExpiredException)
|
||||
{
|
||||
var session = await _sessionStore.GetAsync(username, cancellationToken);
|
||||
return Ok(ToDto(session));
|
||||
}
|
||||
catch (GlobalLeaksValidationException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No se ha podido mantener activa la sesion GlobalLeaks de {Username}. Status={StatusCode}. Mensaje={Message}",
|
||||
username,
|
||||
ex.StatusCode,
|
||||
ex.Message);
|
||||
|
||||
return StatusCode(
|
||||
ex.StatusCode is >= 500 and <= 504
|
||||
? StatusCodes.Status503ServiceUnavailable
|
||||
: StatusCodes.Status502BadGateway,
|
||||
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error renovando la sesion GlobalLeaks de {Username}.", username);
|
||||
return StatusCode(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks."));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("session/clear")]
|
||||
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -222,13 +271,15 @@ public sealed class InboxController : ControllerBase
|
||||
return Unauthorized(new ApiError("La sesion de GlobalLeaks ha caducado. Renueva el 2FA.", SessionExpired: true));
|
||||
}
|
||||
|
||||
var report = string.IsNullOrWhiteSpace(request.Report.Id)
|
||||
? request.Report with { Id = reportId }
|
||||
: request.Report;
|
||||
var report = request.Report with { Id = reportId };
|
||||
|
||||
try
|
||||
{
|
||||
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
|
||||
await _trackingService.EnsureReportCanBeImportedByUserAsync(
|
||||
username,
|
||||
report,
|
||||
request.ConfirmDifferentOwner,
|
||||
cancellationToken);
|
||||
|
||||
ReportDetailDto? reportDetail = null;
|
||||
try
|
||||
@@ -265,7 +316,12 @@ public sealed class InboxController : ControllerBase
|
||||
json = null;
|
||||
}
|
||||
|
||||
var result = await _inboxService.ImportFromGlobalLeaksAsync(reportPackage, json, reportDetail, cancellationToken);
|
||||
var result = await _inboxService.ImportFromGlobalLeaksAsync(
|
||||
reportPackage,
|
||||
json,
|
||||
reportDetail,
|
||||
report,
|
||||
cancellationToken);
|
||||
if (result.ImportedCount > 0)
|
||||
{
|
||||
await _trackingService.MarkReportImportedAsync(
|
||||
@@ -286,6 +342,10 @@ public sealed class InboxController : ControllerBase
|
||||
{
|
||||
return ToGlobalLeaksApiError(ex, "importar la denuncia");
|
||||
}
|
||||
catch (ReportOwnershipException ex)
|
||||
{
|
||||
return Conflict(new ApiError(ex.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
|
||||
|
||||
@@ -52,9 +52,20 @@ public sealed class TrackingController : ControllerBase
|
||||
TrackingImportPermissionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _trackingService.EnsureReportCanBeImportedByUserAsync(GetUsername(), request.Report, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _trackingService.EnsureReportCanBeImportedByUserAsync(
|
||||
GetUsername(),
|
||||
request.Report,
|
||||
request.ConfirmDifferentOwner,
|
||||
cancellationToken);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
catch (ReportOwnershipException ex)
|
||||
{
|
||||
return Conflict(new ApiError(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private string GetUsername()
|
||||
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using ApiDenuncias.Configuration;
|
||||
using ApiDenuncias.Security;
|
||||
using ApiDenuncias.Services;
|
||||
using ApiDenuncias.Configuration;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using ApiDenuncias.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
$"appsettings.{builder.Environment.EnvironmentName}.local.json",
|
||||
optional: true,
|
||||
reloadOnChange: false);
|
||||
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||
builder.Services.Configure<KeyVaultOptions>(builder.Configuration.GetSection(KeyVaultOptions.SectionName));
|
||||
builder.Services.Configure<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
|
||||
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
|
||||
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
|
||||
builder.Services.Configure<ManualPurgeOptions>(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
|
||||
builder.Services.Configure<OpeBridgeOptions>(builder.Configuration.GetSection(OpeBridgeOptions.SectionName));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -32,6 +36,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
|
||||
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
||||
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
|
||||
builder.Services.AddScoped<GlobalLeaksClient>();
|
||||
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
|
||||
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
||||
builder.Services.AddScoped<MySqlDenunciaStore>();
|
||||
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
|
||||
@@ -44,8 +49,10 @@ builder.Services.AddScoped<DenunciaInboxService>();
|
||||
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
|
||||
builder.Services.AddScoped<GestionaExpedienteExceptionStore>();
|
||||
builder.Services.AddScoped<UserComplaintAccessService>();
|
||||
builder.Services.AddScoped<WorkGroupAdministrationService>();
|
||||
builder.Services.AddHttpClient<ManualPurgeService>();
|
||||
builder.Services.AddScoped<AppConfigurationService>();
|
||||
builder.Services.AddScoped<OpeBridgeApiKeyFilter>();
|
||||
|
||||
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS complaints (
|
||||
gestiona_uploaded_at_utc DATETIME(6) NULL,
|
||||
gestiona_last_upload_type TEXT NOT NULL,
|
||||
gestiona_assigned_group TEXT NOT NULL,
|
||||
pending_update_source VARCHAR(256) NOT NULL DEFAULT '',
|
||||
is_in_gestiona TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_rejected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
@@ -87,6 +88,58 @@ CREATE TABLE IF NOT EXISTS app_users (
|
||||
UNIQUE KEY uq_app_users_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_groups (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
code VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_work_groups_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
INSERT INTO work_groups (code, name)
|
||||
VALUES
|
||||
('600', 'Asuntos Juridicos y Proteccion a la Persona Denunciante'),
|
||||
('510', 'SDI - Investigacion Entradas')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
is_active = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_user_groups (
|
||||
app_user_id BIGINT NOT NULL,
|
||||
work_group_id BIGINT NOT NULL,
|
||||
assigned_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
assigned_by_username VARCHAR(256) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (app_user_id, work_group_id),
|
||||
KEY ix_app_user_groups_group (work_group_id),
|
||||
CONSTRAINT fk_app_user_groups_user
|
||||
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_app_user_groups_group
|
||||
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_user_group_history (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
app_user_id BIGINT NOT NULL,
|
||||
work_group_id BIGINT NOT NULL,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
changed_by_username VARCHAR(256) NOT NULL,
|
||||
changed_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
KEY ix_app_user_group_history_user (app_user_id),
|
||||
KEY ix_app_user_group_history_group (work_group_id),
|
||||
CONSTRAINT fk_app_user_group_history_user
|
||||
FOREIGN KEY (app_user_id) REFERENCES app_users(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_app_user_group_history_group
|
||||
FOREIGN KEY (work_group_id) REFERENCES work_groups(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inbox_reports (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
global_report_uuid CHAR(36) NOT NULL,
|
||||
@@ -104,6 +157,7 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
|
||||
last_seen_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
last_downloaded_at_utc DATETIME(6) NULL,
|
||||
last_downloaded_by_user_id BIGINT NULL,
|
||||
owner_user_id BIGINT NULL,
|
||||
imported_complaint_report_id INT NULL,
|
||||
imported_to_store_at_utc DATETIME(6) NULL,
|
||||
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
@@ -112,8 +166,12 @@ CREATE TABLE IF NOT EXISTS inbox_reports (
|
||||
UNIQUE KEY uq_inbox_reports_uuid (global_report_uuid),
|
||||
KEY ix_inbox_reports_progressive (progressive_id),
|
||||
KEY ix_inbox_reports_downloaded (last_downloaded_at_utc),
|
||||
KEY ix_inbox_reports_owner (owner_user_id),
|
||||
CONSTRAINT fk_inbox_reports_last_user
|
||||
FOREIGN KEY (last_downloaded_by_user_id) REFERENCES app_users(id)
|
||||
ON DELETE SET NULL,
|
||||
CONSTRAINT fk_inbox_reports_owner_user
|
||||
FOREIGN KEY (owner_user_id) REFERENCES app_users(id)
|
||||
ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ApiDenuncias.Configuration;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiDenuncias.Security;
|
||||
|
||||
public sealed class OpeBridgeApiKeyFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly OpeBridgeOptions _options;
|
||||
private readonly ILogger<OpeBridgeApiKeyFilter> _logger;
|
||||
|
||||
public OpeBridgeApiKeyFilter(
|
||||
IOptions<OpeBridgeOptions> options,
|
||||
ILogger<OpeBridgeApiKeyFilter> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(
|
||||
ActionExecutingContext context,
|
||||
ActionExecutionDelegate next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey) || _options.ApiKey.Length < 32)
|
||||
{
|
||||
_logger.LogError("La clave interna de ApiOPE no esta configurada.");
|
||||
context.Result = new ObjectResult(new ApiError("El acceso interno OPE no esta configurado."))
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(_options.ApiKeyHeaderName, out var values) ||
|
||||
values.Count != 1 ||
|
||||
!FixedTimeEquals(values[0] ?? string.Empty, _options.ApiKey))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Intento no autorizado de acceso interno OPE en {Path}.",
|
||||
context.HttpContext.Request.Path);
|
||||
context.Result = new UnauthorizedObjectResult(new ApiError("No autorizado."));
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string supplied, string expected)
|
||||
{
|
||||
var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
|
||||
var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected));
|
||||
return CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ public sealed class DenunciaInboxService
|
||||
FileDownloadResult reportDownload,
|
||||
FileDownloadResult? jsonDownload,
|
||||
ReportDetailDto? reportDetail,
|
||||
ReportDto inboxReport,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureStorageReadyAsync(cancellationToken);
|
||||
@@ -102,7 +103,13 @@ public sealed class DenunciaInboxService
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ProcessGlobalLeaksPackageAsync(reportDownload.Content, sourceName, json, reportDetail, cancellationToken);
|
||||
var result = await ProcessGlobalLeaksPackageAsync(
|
||||
reportDownload.Content,
|
||||
sourceName,
|
||||
json,
|
||||
reportDetail,
|
||||
inboxReport,
|
||||
cancellationToken);
|
||||
return new ImportSummary(
|
||||
1,
|
||||
result.ImportedCount,
|
||||
@@ -164,6 +171,7 @@ public sealed class DenunciaInboxService
|
||||
string sourceName,
|
||||
string? globalLeaksJson,
|
||||
ReportDetailDto? reportDetail,
|
||||
ReportDto inboxReport,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var packageStream = new MemoryStream(packageBytes, writable: false);
|
||||
@@ -214,6 +222,12 @@ public sealed class DenunciaInboxService
|
||||
$"No se ha podido determinar el identificador de la denuncia en {sourceName}.");
|
||||
}
|
||||
|
||||
denuncia.PendingUpdateSource = inboxReport.CitizenHasNewActivity
|
||||
? ComplaintUpdateSources.Citizen
|
||||
: inboxReport.ReceiverHasNewActivity
|
||||
? ComplaintUpdateSources.Receiver
|
||||
: string.Empty;
|
||||
|
||||
if (reportIsPdf)
|
||||
{
|
||||
reportText = BuildSyntheticReportText(denuncia);
|
||||
@@ -246,14 +260,17 @@ public sealed class DenunciaInboxService
|
||||
var storedComplaint = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
|
||||
if (IsAlreadyUploadedToGestiona(storedComplaint))
|
||||
{
|
||||
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(denuncia.Id_Denuncia, cancellationToken);
|
||||
var storedFiles = await GetFicherosMetadataByDenunciaForImportAsync(
|
||||
denuncia.Id_Denuncia,
|
||||
cancellationToken);
|
||||
if (!HasPendingFilesForGestiona(storedFiles))
|
||||
{
|
||||
storedComplaint!.EsActualizacion = false;
|
||||
storedComplaint.EnGestiona = true;
|
||||
storedComplaint.PendingUpdateSource = string.Empty;
|
||||
await _denunciaStore.UpsertDenunciaAsync(storedComplaint, cancellationToken);
|
||||
|
||||
warnings.Add($"La denuncia #{denuncia.Id_Denuncia} ya esta en Gestiona y no tiene documentos nuevos pendientes de subir.");
|
||||
warnings.Add(BuildNoCitizenUpdateWarning(inboxReport));
|
||||
return new ProcessPackageResult(denuncia.Id_Denuncia, 0, warnings);
|
||||
}
|
||||
}
|
||||
@@ -379,6 +396,13 @@ public sealed class DenunciaInboxService
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildNoCitizenUpdateWarning(ReportDto inboxReport)
|
||||
{
|
||||
return inboxReport.ReceiverHasNewActivity && !inboxReport.CitizenHasNewActivity
|
||||
? "La actividad detectada procede de la OAAF, pero el report descargado no contiene cambios pendientes de subir a Gestiona."
|
||||
: "La denuncia ya esta en Gestiona y el report no contiene documentos ni comentarios nuevos del ciudadano pendientes de subir.";
|
||||
}
|
||||
|
||||
private static bool HasPendingFilesForGestiona(IReadOnlyList<FicherosDenuncias> files)
|
||||
{
|
||||
var plannedHashes = files
|
||||
@@ -591,6 +615,7 @@ public sealed class DenunciaInboxService
|
||||
target.Pais = source.Pais;
|
||||
target.CamposFormularioJson = source.CamposFormularioJson;
|
||||
target.TextoOriginalReport = source.TextoOriginalReport;
|
||||
target.PendingUpdateSource = source.PendingUpdateSource;
|
||||
}
|
||||
|
||||
private static bool IsSupportedAttachmentEntry(ZipArchiveEntry entry)
|
||||
|
||||
@@ -280,6 +280,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
|
||||
FechaSubidaAGestiona = source.FechaSubidaAGestiona,
|
||||
UltimaSubidaGestionaTipo = source.UltimaSubidaGestionaTipo,
|
||||
UltimoGrupoAsignadoGestiona = source.UltimoGrupoAsignadoGestiona,
|
||||
PendingUpdateSource = source.PendingUpdateSource,
|
||||
EnGestiona = source.EnGestiona,
|
||||
EnRechazada = source.EnRechazada,
|
||||
|
||||
@@ -361,6 +362,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
|
||||
target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona;
|
||||
target.UltimaSubidaGestionaTipo = stored.UltimaSubidaGestionaTipo;
|
||||
target.UltimoGrupoAsignadoGestiona = stored.UltimoGrupoAsignadoGestiona;
|
||||
target.PendingUpdateSource = stored.PendingUpdateSource;
|
||||
target.EnGestiona = stored.EnGestiona;
|
||||
target.EnRechazada = stored.EnRechazada;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ApiDenuncias.Services;
|
||||
@@ -124,11 +125,20 @@ public sealed class GestionaDocumentWorkflowService
|
||||
string documentUrl,
|
||||
string assignedGroupCode,
|
||||
int? complaintId = null,
|
||||
bool isUpdate = false)
|
||||
bool isUpdate = false,
|
||||
string? updateSource = null)
|
||||
{
|
||||
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
|
||||
var operationLabel = isUpdate ? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}" : "nueva denuncia";
|
||||
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(docUrlAbs, isUpdate, assignedGroupCode);
|
||||
var operationLabel = ComplaintUpdateSources.IsReceiver(updateSource)
|
||||
? $"comunicacion OAAF grupo {NormalizeGroupCode(assignedGroupCode)}"
|
||||
: isUpdate
|
||||
? $"actualizacion grupo {NormalizeGroupCode(assignedGroupCode)}"
|
||||
: "nueva denuncia";
|
||||
var (templateHref, payload) = await ResolveCircuitTemplatePayloadAsync(
|
||||
docUrlAbs,
|
||||
isUpdate,
|
||||
assignedGroupCode,
|
||||
updateSource);
|
||||
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
|
||||
if (success)
|
||||
{
|
||||
@@ -155,7 +165,8 @@ public sealed class GestionaDocumentWorkflowService
|
||||
private async Task<(string TemplateHref, string Payload)> ResolveCircuitTemplatePayloadAsync(
|
||||
string documentUrl,
|
||||
bool isUpdate,
|
||||
string? assignedGroupCode)
|
||||
string? assignedGroupCode,
|
||||
string? updateSource)
|
||||
{
|
||||
var templatesUrl = $"{documentUrl.TrimEnd('/')}/circuit/templates";
|
||||
var templates = await GetCircuitTemplatesAsync(templatesUrl);
|
||||
@@ -164,7 +175,7 @@ public sealed class GestionaDocumentWorkflowService
|
||||
throw new InvalidOperationException("Gestiona no ha devuelto plantillas de circuito para el documento.");
|
||||
}
|
||||
|
||||
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode);
|
||||
var selection = GetOperationCircuitTemplateSelection(isUpdate, assignedGroupCode, updateSource);
|
||||
if (!string.IsNullOrWhiteSpace(selection.TemplateName))
|
||||
{
|
||||
var resolved = await TryResolveCircuitTemplatePayloadByNameAsync(templates, selection.TemplateName);
|
||||
@@ -211,7 +222,10 @@ public sealed class GestionaDocumentWorkflowService
|
||||
"No se puede seleccionar de forma inequivoca la plantilla de circuito. Configura Gestiona:CircuitTemplateName o Gestiona:CircuitSignerStampTitle.");
|
||||
}
|
||||
|
||||
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(bool isUpdate, string? assignedGroupCode)
|
||||
private CircuitTemplateSelection GetOperationCircuitTemplateSelection(
|
||||
bool isUpdate,
|
||||
string? assignedGroupCode,
|
||||
string? updateSource)
|
||||
{
|
||||
if (!isUpdate)
|
||||
{
|
||||
@@ -221,6 +235,25 @@ public sealed class GestionaDocumentWorkflowService
|
||||
OperationLabel: "nueva denuncia");
|
||||
}
|
||||
|
||||
if (ComplaintUpdateSources.IsReceiver(updateSource))
|
||||
{
|
||||
return NormalizeGroupCode(assignedGroupCode) switch
|
||||
{
|
||||
"510" => new CircuitTemplateSelection(
|
||||
_configuration["Gestiona:CircuitCommunicationSdiTemplateName"],
|
||||
Required: true,
|
||||
OperationLabel: "comunicacion SDI a denunciante"),
|
||||
|
||||
"600" => new CircuitTemplateSelection(
|
||||
_configuration["Gestiona:CircuitCommunicationSajTemplateName"],
|
||||
Required: true,
|
||||
OperationLabel: "comunicacion SAJ a denunciante"),
|
||||
|
||||
_ => throw new InvalidOperationException(
|
||||
"Las comunicaciones de la OAAF solo pueden tramitarse con los grupos 510 o 600.")
|
||||
};
|
||||
}
|
||||
|
||||
var defaultUpdateTemplateName = FirstConfigured(
|
||||
_configuration["Gestiona:CircuitTemplateName"],
|
||||
_configuration["Gestiona:CircuitUpdateTemplateName"]);
|
||||
|
||||
@@ -16,6 +16,10 @@ namespace ApiDenuncias.Services;
|
||||
|
||||
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
|
||||
|
||||
public sealed record RefreshedGlobalLeaksSession(
|
||||
GlobalLeaksProofOfWorkToken ProofOfWorkToken,
|
||||
DateTimeOffset? SessionExpiresAtUtc);
|
||||
|
||||
public sealed class GlobalLeaksClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -197,6 +201,30 @@ public sealed class GlobalLeaksClient
|
||||
throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
|
||||
}
|
||||
|
||||
public async Task<RefreshedGlobalLeaksSession> RefreshSessionAsync(
|
||||
string sessionId,
|
||||
string dpopPrivateKey,
|
||||
GlobalLeaksProofOfWorkToken proofOfWorkToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string path = "/api/auth/session";
|
||||
var tokenAnswer = SolveProofOfWork(
|
||||
proofOfWorkToken.Id,
|
||||
proofOfWorkToken.Salt,
|
||||
cancellationToken);
|
||||
|
||||
using var request = CreateAuthenticatedRequest(
|
||||
HttpMethod.Post,
|
||||
path,
|
||||
sessionId,
|
||||
dpopPrivateKey);
|
||||
request.Content = CreateJsonContent(new { token = tokenAnswer });
|
||||
|
||||
using var response = await SendGlRequestAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return ParseSessionRefresh(body);
|
||||
}
|
||||
|
||||
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
|
||||
{
|
||||
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
||||
@@ -558,6 +586,14 @@ public sealed class GlobalLeaksClient
|
||||
reference,
|
||||
defaultNewWhenNoReference: false,
|
||||
"rfiles");
|
||||
var receivers = ParseReportReceivers(document.RootElement);
|
||||
var receiverNames = BuildReceiverNameLookup(receivers);
|
||||
comments = comments
|
||||
.Select(comment => WithAuthorName(comment, receiverNames))
|
||||
.ToArray();
|
||||
receiverFiles = receiverFiles
|
||||
.Select(file => WithAuthorName(file, receiverNames))
|
||||
.ToArray();
|
||||
|
||||
var citizenCommentDates = comments
|
||||
.Where(comment => IsWhistleblowerActivityType(comment.Type))
|
||||
@@ -590,7 +626,24 @@ public sealed class GlobalLeaksClient
|
||||
: citizenCommentDates.Concat(citizenFileDates).Append(reportCreationDate.Value);
|
||||
|
||||
var citizenLast = MaxDate(citizenActivityDates);
|
||||
var receiverLast = MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
|
||||
var receiverEvents = comments
|
||||
.Where(comment =>
|
||||
IsReceiverActivityType(comment.Type) ||
|
||||
IsUnclassifiedActivityType(comment.Type))
|
||||
.Select(comment => new ActivityActorEvent(
|
||||
ParseDate(comment.CreationDate),
|
||||
comment.AuthorId,
|
||||
comment.AuthorName))
|
||||
.Concat(receiverFiles.Select(file => new ActivityActorEvent(
|
||||
ParseDate(file.CreationDate),
|
||||
file.AuthorId,
|
||||
file.AuthorName)))
|
||||
.Where(item => item.Date is not null)
|
||||
.OrderByDescending(item => item.Date)
|
||||
.ToArray();
|
||||
var latestReceiverEvent = receiverEvents.FirstOrDefault();
|
||||
var receiverLast = latestReceiverEvent?.Date ??
|
||||
MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));
|
||||
|
||||
return new ReportActivitySnapshot(
|
||||
citizenLast,
|
||||
@@ -605,7 +658,9 @@ public sealed class GlobalLeaksClient
|
||||
comments.Any(comment =>
|
||||
IsUnclassifiedActivityType(comment.Type) &&
|
||||
comment.IsNew) ||
|
||||
receiverFiles.Any(file => file.IsNew));
|
||||
receiverFiles.Any(file => file.IsNew),
|
||||
latestReceiverEvent?.AuthorId,
|
||||
latestReceiverEvent?.AuthorName);
|
||||
}
|
||||
|
||||
private static ReportDto ApplyActivity(ReportDto report, ReportActivitySnapshot activity)
|
||||
@@ -618,7 +673,9 @@ public sealed class GlobalLeaksClient
|
||||
CitizenHasNewComment = activity.HasNewCitizenComment,
|
||||
CitizenHasNewFile = activity.HasNewCitizenFile,
|
||||
ReceiverLastActivity = activity.ReceiverLastActivity?.ToString("O", CultureInfo.InvariantCulture),
|
||||
ReceiverHasNewActivity = activity.HasNewReceiverActivity
|
||||
ReceiverHasNewActivity = activity.HasNewReceiverActivity,
|
||||
ReceiverLastActivityAuthorId = activity.ReceiverLastActivityAuthorId,
|
||||
ReceiverLastActivityAuthorName = activity.ReceiverLastActivityAuthorName
|
||||
};
|
||||
}
|
||||
|
||||
@@ -893,6 +950,15 @@ public sealed class GlobalLeaksClient
|
||||
|
||||
private static DateTimeOffset? GetActivityReference(ReportDto report, DateTimeOffset? fallbackReference)
|
||||
{
|
||||
if (report.AlreadyInGestiona)
|
||||
{
|
||||
var lastGestionaUpload = ParseDate(report.LastGestionaUploadAt);
|
||||
if (lastGestionaUpload is not null)
|
||||
{
|
||||
return lastGestionaUpload;
|
||||
}
|
||||
}
|
||||
|
||||
return ParseDate(report.LastDownloadedAt) ??
|
||||
fallbackReference ??
|
||||
ParseDate(report.LastAccess) ??
|
||||
@@ -1043,10 +1109,32 @@ public sealed class GlobalLeaksClient
|
||||
.Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
|
||||
.ToArray();
|
||||
|
||||
var whistleblowerFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "wbfiles", "files");
|
||||
var receiverFiles = ParseReportFiles(root, lastAccessDate, defaultNewWhenNoReference: true, "rfiles");
|
||||
var receivers = ParseReportReceivers(root);
|
||||
var receiverNames = BuildReceiverNameLookup(receivers);
|
||||
comments = comments
|
||||
.Select(comment => WithAuthorName(comment, receiverNames))
|
||||
.ToArray();
|
||||
var whistleblowerFiles = ParseReportFiles(
|
||||
root,
|
||||
lastAccessDate,
|
||||
defaultNewWhenNoReference: true,
|
||||
"wbfiles",
|
||||
"files");
|
||||
var receiverFiles = ParseReportFiles(
|
||||
root,
|
||||
lastAccessDate,
|
||||
defaultNewWhenNoReference: true,
|
||||
"rfiles")
|
||||
.Select(file => WithAuthorName(file, receiverNames))
|
||||
.ToArray();
|
||||
|
||||
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
|
||||
return new ReportDetailDto(
|
||||
reportId,
|
||||
lastAccess,
|
||||
comments,
|
||||
whistleblowerFiles,
|
||||
receiverFiles,
|
||||
receivers);
|
||||
}
|
||||
|
||||
private static ReportCommentDto CreateReportComment(
|
||||
@@ -1055,12 +1143,25 @@ public sealed class GlobalLeaksClient
|
||||
bool defaultNewWhenNoReference)
|
||||
{
|
||||
var creationDate = GetString(item, "creation_date", "creationDate", "date", "created_at", "createdAt");
|
||||
var authorId = GetString(item, "author_id", "authorId");
|
||||
var activityType = GetCommentActivityType(item);
|
||||
if (!string.IsNullOrWhiteSpace(authorId))
|
||||
{
|
||||
activityType = "receiver";
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(activityType) ||
|
||||
IsUnclassifiedActivityType(activityType))
|
||||
{
|
||||
activityType = "whistleblower";
|
||||
}
|
||||
|
||||
return new ReportCommentDto(
|
||||
GetString(item, "id"),
|
||||
GetCommentActivityType(item),
|
||||
activityType,
|
||||
GetString(item, "content", "text", "message"),
|
||||
creationDate,
|
||||
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
|
||||
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
|
||||
authorId);
|
||||
}
|
||||
|
||||
private static string? GetCommentActivityType(JsonElement item)
|
||||
@@ -1196,13 +1297,62 @@ public sealed class GlobalLeaksClient
|
||||
GetString(item, "id"),
|
||||
GetLocalizedString(item, "name", "file_name", "filename"),
|
||||
GetInt64(item, "size"),
|
||||
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
||||
GetString(item, "content_type", "contentType", "mime_type", "mimetype", "type"),
|
||||
creationDate,
|
||||
IsAfterReference(creationDate, reference, defaultNewWhenNoReference));
|
||||
IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
|
||||
GetString(item, "author_id", "authorId"));
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static ReportReceiverDto[] ParseReportReceivers(JsonElement root)
|
||||
{
|
||||
return EnumerateArray(root, "receivers", "recipients")
|
||||
.Select(item => new ReportReceiverDto(
|
||||
GetString(item, "id") ?? string.Empty,
|
||||
GetLocalizedString(item, "name", "display_name", "displayName", "username") ?? "Gestor",
|
||||
GetBool(item, "active", "is_active", "isActive")))
|
||||
.Where(receiver => !string.IsNullOrWhiteSpace(receiver.Id))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildReceiverNameLookup(
|
||||
IEnumerable<ReportReceiverDto> receivers)
|
||||
{
|
||||
return receivers
|
||||
.GroupBy(receiver => receiver.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.First().Name,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ReportCommentDto WithAuthorName(
|
||||
ReportCommentDto comment,
|
||||
IReadOnlyDictionary<string, string> receiverNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(comment.AuthorId) ||
|
||||
!receiverNames.TryGetValue(comment.AuthorId, out var authorName))
|
||||
{
|
||||
return comment;
|
||||
}
|
||||
|
||||
return comment with { AuthorName = authorName };
|
||||
}
|
||||
|
||||
private static ReportFileDto WithAuthorName(
|
||||
ReportFileDto file,
|
||||
IReadOnlyDictionary<string, string> receiverNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(file.AuthorId) ||
|
||||
!receiverNames.TryGetValue(file.AuthorId, out var authorName))
|
||||
{
|
||||
return file;
|
||||
}
|
||||
|
||||
return file with { AuthorName = authorName };
|
||||
}
|
||||
|
||||
private static bool IsAfterReference(
|
||||
string? value,
|
||||
DateTimeOffset? reference,
|
||||
@@ -1416,8 +1566,84 @@ public sealed class GlobalLeaksClient
|
||||
}
|
||||
|
||||
var role = GetString(root, "role", "user_role", "userRole");
|
||||
var proofOfWorkToken = ParseProofOfWorkToken(root);
|
||||
var sessionExpiresAtUtc = ParseSessionExpiration(root);
|
||||
|
||||
return new GlSession(id, username, role, dpopPrivateKey);
|
||||
return new GlSession(
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
dpopPrivateKey,
|
||||
proofOfWorkToken,
|
||||
sessionExpiresAtUtc);
|
||||
}
|
||||
|
||||
private static RefreshedGlobalLeaksSession ParseSessionRefresh(string body)
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
var proofOfWorkToken = ParseProofOfWorkToken(root)
|
||||
?? throw new GlobalLeaksValidationException(
|
||||
"GlobalLeaks no devolvio el reto necesario para mantener activa la sesion.",
|
||||
StatusCodes.Status502BadGateway);
|
||||
|
||||
return new RefreshedGlobalLeaksSession(
|
||||
proofOfWorkToken,
|
||||
ParseSessionExpiration(root));
|
||||
}
|
||||
|
||||
private static GlobalLeaksProofOfWorkToken? ParseProofOfWorkToken(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("token", out var token) ||
|
||||
token.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var id = GetString(token, "id");
|
||||
var salt = GetString(token, "salt");
|
||||
return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(salt)
|
||||
? null
|
||||
: new GlobalLeaksProofOfWorkToken(id, salt);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseSessionExpiration(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("session_expiration", out var expiration))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double unixSeconds;
|
||||
if (expiration.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (!expiration.TryGetDouble(out unixSeconds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (expiration.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (!double.TryParse(
|
||||
expiration.GetString(),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out unixSeconds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (unixSeconds <= 0 || unixSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds((long)(unixSeconds * 1000));
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessOrThrowAsync(
|
||||
@@ -1482,7 +1708,14 @@ public sealed class GlobalLeaksClient
|
||||
bool HasNewCitizenComment,
|
||||
bool HasNewCitizenFile,
|
||||
DateTimeOffset? ReceiverLastActivity,
|
||||
bool HasNewReceiverActivity);
|
||||
bool HasNewReceiverActivity,
|
||||
string? ReceiverLastActivityAuthorId,
|
||||
string? ReceiverLastActivityAuthorName);
|
||||
|
||||
private sealed record ActivityActorEvent(
|
||||
DateTimeOffset? Date,
|
||||
string? AuthorId,
|
||||
string? AuthorName);
|
||||
|
||||
private sealed record RawReport
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
|
||||
namespace ApiDenuncias.Services;
|
||||
|
||||
public sealed class GlobalLeaksSessionKeepAliveService
|
||||
{
|
||||
private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(20);
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> UserGates =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly GlobalLeaksSessionStore _sessionStore;
|
||||
private readonly GlobalLeaksClient _globalLeaksClient;
|
||||
private readonly ILogger<GlobalLeaksSessionKeepAliveService> _logger;
|
||||
|
||||
public GlobalLeaksSessionKeepAliveService(
|
||||
GlobalLeaksSessionStore sessionStore,
|
||||
GlobalLeaksClient globalLeaksClient,
|
||||
ILogger<GlobalLeaksSessionKeepAliveService> logger)
|
||||
{
|
||||
_sessionStore = sessionStore;
|
||||
_globalLeaksClient = globalLeaksClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GlobalLeaksStoredSession?> KeepAliveAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var gate = UserGates.GetOrAdd(username, static _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var session = await _sessionStore.GetAsync(username, cancellationToken);
|
||||
if (session?.HasActiveSession != true)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
|
||||
if (session.LastKeepAliveAtUtc is { } lastKeepAlive &&
|
||||
DateTimeOffset.UtcNow - lastKeepAlive < MinimumRefreshInterval)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
|
||||
var expectedSessionId = session.SessionId!;
|
||||
try
|
||||
{
|
||||
var refreshed = await _globalLeaksClient.RefreshSessionAsync(
|
||||
expectedSessionId,
|
||||
session.DpopPrivateKey!,
|
||||
session.ProofOfWorkToken!,
|
||||
cancellationToken);
|
||||
|
||||
var updated = await _sessionStore.UpdateKeepAliveAsync(
|
||||
username,
|
||||
expectedSessionId,
|
||||
refreshed.ProofOfWorkToken,
|
||||
refreshed.SessionExpiresAtUtc,
|
||||
cancellationToken);
|
||||
|
||||
if (updated)
|
||||
{
|
||||
_logger.LogDebug("Sesion GlobalLeaks renovada para {Username}.", username);
|
||||
}
|
||||
|
||||
return await _sessionStore.GetAsync(username, cancellationToken);
|
||||
}
|
||||
catch (GlobalLeaksSessionExpiredException)
|
||||
{
|
||||
await _sessionStore.ClearSessionIfMatchesAsync(
|
||||
username,
|
||||
expectedSessionId,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore
|
||||
}
|
||||
|
||||
var path = GetFilePath(username);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
|
||||
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
|
||||
var json = _protector.Unprotect(protectedBase64);
|
||||
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
|
||||
return await ReadUnsafeAsync(path, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -55,8 +47,11 @@ public sealed class GlobalLeaksSessionStore
|
||||
string sessionId,
|
||||
string? role,
|
||||
string? dpopPrivateKey,
|
||||
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
|
||||
DateTimeOffset? sessionExpiresAtUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var data = new GlobalLeaksStoredSession
|
||||
{
|
||||
Username = username,
|
||||
@@ -64,7 +59,10 @@ public sealed class GlobalLeaksSessionStore
|
||||
SessionId = sessionId,
|
||||
Role = role,
|
||||
DpopPrivateKey = dpopPrivateKey,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ProofOfWorkToken = proofOfWorkToken,
|
||||
SessionExpiresAtUtc = sessionExpiresAtUtc,
|
||||
LastKeepAliveAtUtc = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
await WriteAsync(data, cancellationToken);
|
||||
@@ -75,31 +73,115 @@ public sealed class GlobalLeaksSessionStore
|
||||
string sessionId,
|
||||
string? role,
|
||||
string? dpopPrivateKey,
|
||||
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
|
||||
DateTimeOffset? sessionExpiresAtUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await GetAsync(username, cancellationToken)
|
||||
var path = GetFilePath(username);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var current = await ReadUnsafeAsync(path, cancellationToken)
|
||||
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
current.SessionId = sessionId;
|
||||
current.Role = role;
|
||||
current.DpopPrivateKey = dpopPrivateKey;
|
||||
current.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
current.ProofOfWorkToken = proofOfWorkToken;
|
||||
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
|
||||
current.LastKeepAliveAtUtc = now;
|
||||
current.UpdatedAt = now;
|
||||
|
||||
await WriteAsync(current, cancellationToken);
|
||||
await WriteUnsafeAsync(path, current, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateKeepAliveAsync(
|
||||
string username,
|
||||
string expectedSessionId,
|
||||
GlobalLeaksProofOfWorkToken proofOfWorkToken,
|
||||
DateTimeOffset? sessionExpiresAtUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = GetFilePath(username);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var current = await ReadUnsafeAsync(path, cancellationToken);
|
||||
if (current is null ||
|
||||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
current.ProofOfWorkToken = proofOfWorkToken;
|
||||
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
|
||||
current.LastKeepAliveAtUtc = now;
|
||||
current.UpdatedAt = now;
|
||||
await WriteUnsafeAsync(path, current, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await GetAsync(username, cancellationToken);
|
||||
var path = GetFilePath(username);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var current = await ReadUnsafeAsync(path, cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
current.SessionId = null;
|
||||
current.DpopPrivateKey = null;
|
||||
current.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await WriteAsync(current, cancellationToken);
|
||||
ClearSessionValues(current);
|
||||
await WriteUnsafeAsync(path, current, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ClearSessionIfMatchesAsync(
|
||||
string username,
|
||||
string expectedSessionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = GetFilePath(username);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var current = await ReadUnsafeAsync(path, cancellationToken);
|
||||
if (current is null ||
|
||||
!string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ClearSessionValues(current);
|
||||
await WriteUnsafeAsync(path, current, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string username, CancellationToken cancellationToken = default)
|
||||
@@ -127,17 +209,12 @@ public sealed class GlobalLeaksSessionStore
|
||||
|
||||
private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(RootPath);
|
||||
|
||||
var path = GetFilePath(data.Username);
|
||||
var json = JsonSerializer.Serialize(data, JsonOptions);
|
||||
var protectedValue = _protector.Protect(json);
|
||||
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
|
||||
await WriteUnsafeAsync(path, data, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -152,4 +229,41 @@ public sealed class GlobalLeaksSessionStore
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||
return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin");
|
||||
}
|
||||
|
||||
private async Task<GlobalLeaksStoredSession?> ReadUnsafeAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
|
||||
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
|
||||
var json = _protector.Unprotect(protectedBase64);
|
||||
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private async Task WriteUnsafeAsync(
|
||||
string path,
|
||||
GlobalLeaksStoredSession data,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(RootPath);
|
||||
var json = JsonSerializer.Serialize(data, JsonOptions);
|
||||
var protectedValue = _protector.Protect(json);
|
||||
var protectedBytes = Encoding.UTF8.GetBytes(protectedValue);
|
||||
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
|
||||
}
|
||||
|
||||
private static void ClearSessionValues(GlobalLeaksStoredSession session)
|
||||
{
|
||||
session.SessionId = null;
|
||||
session.DpopPrivateKey = null;
|
||||
session.ProofOfWorkToken = null;
|
||||
session.SessionExpiresAtUtc = null;
|
||||
session.LastKeepAliveAtUtc = null;
|
||||
session.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +102,28 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
DownloadedByAnotherUser = meta?.DownloadedByAnotherUser ?? false,
|
||||
LastDownloadedByUsername = meta?.LastDownloadedByUsername,
|
||||
LastDownloadedAt = meta?.LastDownloadedAtUtc?.ToString("O", CultureInfo.InvariantCulture),
|
||||
LastGestionaUploadAt = meta?.LastGestionaUploadAtUtc?.ToString("O", CultureInfo.InvariantCulture),
|
||||
AlreadyImported = meta?.AlreadyImported ?? false,
|
||||
AlreadyInGestiona = meta?.AlreadyInGestiona ?? false,
|
||||
OwnerUsername = meta?.OwnerUsername,
|
||||
OwnedByCurrentUser = meta?.OwnedByCurrentUser ?? false,
|
||||
AccessibleByWorkGroup = meta?.AccessibleByWorkGroup ?? false,
|
||||
RequiresOwnerConfirmation = meta?.RequiresOwnerConfirmation ?? false,
|
||||
OwnerWorkGroups = meta?.OwnerWorkGroups ?? [],
|
||||
TrackingNote = BuildTrackingNote(meta)
|
||||
};
|
||||
})
|
||||
.Where(report => !IsLockedByAnotherUser(report))
|
||||
.Where(report =>
|
||||
string.IsNullOrWhiteSpace(report.OwnerUsername) ||
|
||||
report.OwnedByCurrentUser ||
|
||||
report.AccessibleByWorkGroup)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task EnsureReportCanBeImportedByUserAsync(
|
||||
string username,
|
||||
ReportDto report,
|
||||
bool confirmDifferentOwner = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
|
||||
@@ -140,14 +150,23 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
|
||||
await EnsureConnectionOpenAsync(connection, cancellationToken);
|
||||
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
|
||||
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser)
|
||||
if (!metadata.TryGetValue(report.Id, out var meta) ||
|
||||
string.IsNullOrWhiteSpace(meta.OwnerUsername) ||
|
||||
meta.OwnedByCurrentUser)
|
||||
{
|
||||
var owner = string.IsNullOrWhiteSpace(meta.LastDownloadedByUsername)
|
||||
? "otro usuario"
|
||||
: meta.LastDownloadedByUsername;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"La denuncia ya fue importada por {owner}. Solo ese usuario puede ver e importar sus actualizaciones.");
|
||||
if (!meta.AccessibleByWorkGroup)
|
||||
{
|
||||
throw new ReportOwnershipException(
|
||||
$"La denuncia pertenece a {meta.OwnerUsername} y no compartis ningun grupo de trabajo.");
|
||||
}
|
||||
|
||||
if (!confirmDifferentOwner)
|
||||
{
|
||||
throw new ReportOwnershipException(
|
||||
$"La denuncia pertenece a {meta.OwnerUsername}. Confirma expresamente que deseas importarla como miembro de su grupo.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +199,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
SET
|
||||
last_downloaded_at_utc = @nowUtc,
|
||||
last_downloaded_by_user_id = @userId,
|
||||
owner_user_id = COALESCE(owner_user_id, @userId),
|
||||
imported_complaint_report_id = COALESCE(@complaintId, imported_complaint_report_id),
|
||||
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @nowUtc),
|
||||
updated_at_utc = CURRENT_TIMESTAMP(6)
|
||||
@@ -298,6 +318,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
ELSE last_downloaded_at_utc
|
||||
END,
|
||||
last_downloaded_by_user_id = @userId,
|
||||
owner_user_id = COALESCE(owner_user_id, @userId),
|
||||
imported_complaint_report_id = COALESCE(imported_complaint_report_id, @denunciaId),
|
||||
imported_to_store_at_utc = COALESCE(imported_to_store_at_utc, @handledAtUtc),
|
||||
updated_at_utc = CURRENT_TIMESTAMP(6)
|
||||
@@ -512,11 +533,45 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
ir.global_report_uuid,
|
||||
ir.last_downloaded_at_utc,
|
||||
downloader.username AS last_downloaded_by_username,
|
||||
owner.username AS owner_username,
|
||||
ir.imported_to_store_at_utc,
|
||||
COALESCE(c.is_in_gestiona, 0) AS already_in_gestiona,
|
||||
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user
|
||||
COALESCE(
|
||||
(
|
||||
SELECT MAX(history.uploaded_at_utc)
|
||||
FROM gestiona_upload_history history
|
||||
WHERE history.external_report_id =
|
||||
COALESCE(ir.imported_complaint_report_id, ir.progressive_id)
|
||||
),
|
||||
c.gestiona_uploaded_at_utc
|
||||
) AS last_gestiona_upload_at_utc,
|
||||
CASE WHEN uir.last_downloaded_at_utc IS NULL THEN 0 ELSE 1 END AS downloaded_by_current_user,
|
||||
CASE WHEN ir.owner_user_id = @userId THEN 1 ELSE 0 END AS owned_by_current_user,
|
||||
CASE
|
||||
WHEN ir.owner_user_id IS NULL THEN 0
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM app_user_groups shared_membership
|
||||
INNER JOIN work_groups active_group
|
||||
ON active_group.id = shared_membership.work_group_id
|
||||
AND active_group.is_active = 1
|
||||
WHERE shared_membership.app_user_id IN (@userId, ir.owner_user_id)
|
||||
GROUP BY shared_membership.work_group_id
|
||||
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS accessible_by_group,
|
||||
(
|
||||
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
|
||||
FROM app_user_groups owner_membership
|
||||
INNER JOIN work_groups wg
|
||||
ON wg.id = owner_membership.work_group_id
|
||||
AND wg.is_active = 1
|
||||
WHERE owner_membership.app_user_id = ir.owner_user_id
|
||||
) AS owner_group_codes
|
||||
FROM inbox_reports ir
|
||||
LEFT JOIN app_users downloader ON downloader.id = ir.last_downloaded_by_user_id
|
||||
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
|
||||
LEFT JOIN user_inbox_reports uir
|
||||
ON uir.inbox_report_id = ir.id
|
||||
AND uir.app_user_id = @userId
|
||||
@@ -533,14 +588,20 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
|
||||
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1;
|
||||
var lockedByAnotherUser =
|
||||
!downloadedByCurrentUser &&
|
||||
!reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")) &&
|
||||
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
|
||||
|
||||
var downloadedByAnotherUser =
|
||||
!downloadedByCurrentUser &&
|
||||
!string.IsNullOrWhiteSpace(lastDownloadedByUsername);
|
||||
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("owner_username"));
|
||||
var ownedByCurrentUser =
|
||||
reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
|
||||
var accessibleByGroup =
|
||||
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
|
||||
var ownerWorkGroups = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
|
||||
? []
|
||||
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
metadata[reportId] = new ReportMetadata
|
||||
{
|
||||
@@ -548,9 +609,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
DownloadedByAnotherUser = downloadedByAnotherUser,
|
||||
LastDownloadedByUsername = lastDownloadedByUsername,
|
||||
LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"),
|
||||
LastGestionaUploadAtUtc = GetDateTimeOffset(reader, "last_gestiona_upload_at_utc"),
|
||||
AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")),
|
||||
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
|
||||
LockedByAnotherUser = lockedByAnotherUser,
|
||||
OwnerUsername = ownerUsername,
|
||||
OwnedByCurrentUser = ownedByCurrentUser,
|
||||
AccessibleByWorkGroup = accessibleByGroup,
|
||||
OwnerWorkGroups = ownerWorkGroups,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -654,11 +719,10 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (metadata.LockedByAnotherUser)
|
||||
if (!string.IsNullOrWhiteSpace(metadata.OwnerUsername) &&
|
||||
!metadata.OwnedByCurrentUser)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername)
|
||||
? "Importada por otro usuario"
|
||||
: $"Importada por {metadata.LastDownloadedByUsername}";
|
||||
return $"Propiedad de {metadata.OwnerUsername}";
|
||||
}
|
||||
|
||||
if (metadata.AlreadyInGestiona)
|
||||
@@ -693,15 +757,26 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
||||
{
|
||||
public bool DownloadedByCurrentUser { get; init; }
|
||||
public bool DownloadedByAnotherUser { get; init; }
|
||||
public bool LockedByAnotherUser { get; init; }
|
||||
public string? LastDownloadedByUsername { get; init; }
|
||||
public DateTimeOffset? LastDownloadedAtUtc { get; init; }
|
||||
public DateTimeOffset? LastGestionaUploadAtUtc { get; init; }
|
||||
public bool AlreadyImported { get; init; }
|
||||
public bool AlreadyInGestiona { get; init; }
|
||||
public string? OwnerUsername { get; init; }
|
||||
public bool OwnedByCurrentUser { get; init; }
|
||||
public bool AccessibleByWorkGroup { get; init; }
|
||||
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
|
||||
public bool RequiresOwnerConfirmation =>
|
||||
!OwnedByCurrentUser &&
|
||||
AccessibleByWorkGroup &&
|
||||
!string.IsNullOrWhiteSpace(OwnerUsername);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReportOwnershipException : InvalidOperationException
|
||||
{
|
||||
public ReportOwnershipException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
private static bool IsLockedByAnotherUser(ReportDto report)
|
||||
=> report.AlreadyImported &&
|
||||
report.DownloadedByAnotherUser &&
|
||||
!report.DownloadedByCurrentUser;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
gestiona_uploaded_at_utc,
|
||||
gestiona_last_upload_type,
|
||||
gestiona_assigned_group,
|
||||
pending_update_source,
|
||||
is_in_gestiona,
|
||||
is_rejected,
|
||||
key_date,
|
||||
@@ -184,6 +185,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
|
||||
("complaints", "gestiona_last_upload_type", "`gestiona_last_upload_type` VARCHAR(64) NOT NULL DEFAULT ''"),
|
||||
("complaints", "gestiona_assigned_group", "`gestiona_assigned_group` VARCHAR(255) NOT NULL DEFAULT ''"),
|
||||
("complaints", "pending_update_source", "`pending_update_source` VARCHAR(256) NOT NULL DEFAULT ''"),
|
||||
("inbox_reports", "owner_user_id", "`owner_user_id` BIGINT NULL"),
|
||||
("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"),
|
||||
("complaint_attachments", "key_date", "`key_date` DATE NULL"),
|
||||
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
|
||||
@@ -195,6 +198,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"),
|
||||
("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"),
|
||||
("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"),
|
||||
("inbox_reports", "ix_inbox_reports_owner", "INDEX `ix_inbox_reports_owner` (`owner_user_id`)"),
|
||||
("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"),
|
||||
];
|
||||
|
||||
@@ -709,6 +713,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
gestiona_uploaded_at_utc,
|
||||
gestiona_last_upload_type,
|
||||
gestiona_assigned_group,
|
||||
pending_update_source,
|
||||
is_in_gestiona,
|
||||
is_rejected,
|
||||
key_date,
|
||||
@@ -781,6 +786,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
@gestionaUploadedAtUtc,
|
||||
@gestionaLastUploadType,
|
||||
@gestionaAssignedGroup,
|
||||
@pendingUpdateSource,
|
||||
@isInGestiona,
|
||||
@isRejected,
|
||||
@keyDate,
|
||||
@@ -853,6 +859,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc),
|
||||
gestiona_last_upload_type = VALUES(gestiona_last_upload_type),
|
||||
gestiona_assigned_group = VALUES(gestiona_assigned_group),
|
||||
pending_update_source = VALUES(pending_update_source),
|
||||
is_in_gestiona = VALUES(is_in_gestiona),
|
||||
is_rejected = VALUES(is_rejected),
|
||||
key_date = VALUES(key_date),
|
||||
@@ -930,6 +937,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona));
|
||||
command.Parameters.AddWithValue("@gestionaLastUploadType", denuncia.UltimaSubidaGestionaTipo ?? string.Empty);
|
||||
command.Parameters.AddWithValue("@gestionaAssignedGroup", denuncia.UltimoGrupoAsignadoGestiona ?? string.Empty);
|
||||
command.Parameters.AddWithValue("@pendingUpdateSource", denuncia.PendingUpdateSource ?? string.Empty);
|
||||
command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona);
|
||||
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
|
||||
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
|
||||
@@ -994,9 +1002,6 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
description = @description,
|
||||
attachment_date_utc = @attachmentDateUtc,
|
||||
notes = @notes,
|
||||
content = @content,
|
||||
content_mime_type = @contentMimeType,
|
||||
content_sha256 = @contentSha256,
|
||||
uploaded_to_gestiona = CASE
|
||||
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
|
||||
ELSE @uploadedToGestiona
|
||||
@@ -1005,6 +1010,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
|
||||
ELSE @uploadedAtUtc
|
||||
END,
|
||||
content = @content,
|
||||
content_mime_type = @contentMimeType,
|
||||
content_sha256 = @contentSha256,
|
||||
key_date = @keyDate,
|
||||
encryption_scheme = @encryptionScheme,
|
||||
encrypted_at_utc = @encryptedAtUtc,
|
||||
@@ -1354,6 +1362,54 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
await alterCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await BackfillReportOwnersAsync(connection, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task BackfillReportOwnersAsync(
|
||||
MySqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE inbox_reports ir
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
uir.inbox_report_id,
|
||||
CAST(
|
||||
SUBSTRING_INDEX(
|
||||
GROUP_CONCAT(
|
||||
uir.app_user_id
|
||||
ORDER BY
|
||||
COALESCE(
|
||||
uir.first_downloaded_at_utc,
|
||||
uir.last_downloaded_at_utc,
|
||||
uir.first_seen_at_utc
|
||||
),
|
||||
uir.app_user_id
|
||||
SEPARATOR ','
|
||||
),
|
||||
',',
|
||||
1
|
||||
) AS UNSIGNED
|
||||
) AS first_owner_user_id
|
||||
FROM user_inbox_reports uir
|
||||
WHERE uir.first_downloaded_at_utc IS NOT NULL
|
||||
OR uir.last_downloaded_at_utc IS NOT NULL
|
||||
GROUP BY uir.inbox_report_id
|
||||
) first_owner ON first_owner.inbox_report_id = ir.id
|
||||
SET ir.owner_user_id = COALESCE(
|
||||
first_owner.first_owner_user_id,
|
||||
ir.last_downloaded_by_user_id
|
||||
)
|
||||
WHERE ir.owner_user_id IS NULL
|
||||
AND (
|
||||
ir.imported_to_store_at_utc IS NOT NULL
|
||||
OR ir.imported_complaint_report_id IS NOT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureAttachmentChunksTableAsync(
|
||||
@@ -1671,6 +1727,7 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
|
||||
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
|
||||
UltimaSubidaGestionaTipo = GetString(record, "gestiona_last_upload_type"),
|
||||
UltimoGrupoAsignadoGestiona = GetString(record, "gestiona_assigned_group"),
|
||||
PendingUpdateSource = GetString(record, "pending_update_source"),
|
||||
EnGestiona = GetBoolean(record, "is_in_gestiona"),
|
||||
EnRechazada = GetBoolean(record, "is_rejected"),
|
||||
KeyDate = GetNullableDateOnly(record, "key_date"),
|
||||
|
||||
@@ -12,43 +12,163 @@ public sealed class UserComplaintAccessService
|
||||
_connectionStringProvider = connectionStringProvider;
|
||||
}
|
||||
|
||||
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default)
|
||||
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var access = await GetComplaintAccessAsync(username, null, cancellationToken);
|
||||
return access.Keys.ToHashSet();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, ComplaintAccessInfo>> GetComplaintAccessAsync(
|
||||
string username,
|
||||
IReadOnlyCollection<int>? complaintIds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return [];
|
||||
return new Dictionary<int, ComplaintAccessInfo>();
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
SELECT DISTINCT ir.imported_complaint_report_id
|
||||
FROM inbox_reports ir
|
||||
INNER JOIN user_inbox_reports uir ON uir.inbox_report_id = ir.id
|
||||
INNER JOIN app_users au ON au.id = uir.app_user_id
|
||||
WHERE au.username = @username
|
||||
AND ir.imported_complaint_report_id IS NOT NULL
|
||||
AND uir.download_count > 0;
|
||||
""";
|
||||
|
||||
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
|
||||
await using var connection = new MySqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Parameters.AddWithValue("@username", username.Trim());
|
||||
|
||||
var result = new HashSet<int>();
|
||||
var idFilter = string.Empty;
|
||||
if (complaintIds is { Count: > 0 })
|
||||
{
|
||||
var parameters = new List<string>(complaintIds.Count);
|
||||
var index = 0;
|
||||
foreach (var complaintId in complaintIds.Where(id => id > 0).Distinct())
|
||||
{
|
||||
var parameterName = $"@complaintId{index++}";
|
||||
parameters.Add(parameterName);
|
||||
command.Parameters.AddWithValue(parameterName, complaintId);
|
||||
}
|
||||
|
||||
if (parameters.Count == 0)
|
||||
{
|
||||
return new Dictionary<int, ComplaintAccessInfo>();
|
||||
}
|
||||
|
||||
idFilter = $"AND COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IN ({string.Join(", ", parameters)})";
|
||||
}
|
||||
|
||||
command.CommandText = $"""
|
||||
SELECT
|
||||
COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id,
|
||||
owner.username AS owner_username,
|
||||
CASE WHEN ir.owner_user_id = viewer.id THEN 1 ELSE 0 END AS owned_by_current_user,
|
||||
CASE
|
||||
WHEN ir.owner_user_id IS NULL THEN 0
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM app_user_groups shared_membership
|
||||
INNER JOIN work_groups active_group
|
||||
ON active_group.id = shared_membership.work_group_id
|
||||
AND active_group.is_active = 1
|
||||
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
|
||||
GROUP BY shared_membership.work_group_id
|
||||
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS accessible_by_group,
|
||||
(
|
||||
SELECT GROUP_CONCAT(DISTINCT wg.code ORDER BY wg.code SEPARATOR ',')
|
||||
FROM app_user_groups owner_membership
|
||||
INNER JOIN work_groups wg
|
||||
ON wg.id = owner_membership.work_group_id
|
||||
AND wg.is_active = 1
|
||||
WHERE owner_membership.app_user_id = ir.owner_user_id
|
||||
) AS owner_group_codes
|
||||
FROM app_users viewer
|
||||
INNER JOIN inbox_reports ir
|
||||
ON COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IS NOT NULL
|
||||
LEFT JOIN app_users owner ON owner.id = ir.owner_user_id
|
||||
LEFT JOIN user_inbox_reports current_tracking
|
||||
ON current_tracking.inbox_report_id = ir.id
|
||||
AND current_tracking.app_user_id = viewer.id
|
||||
WHERE viewer.username = @username
|
||||
{idFilter}
|
||||
AND (
|
||||
ir.owner_user_id = viewer.id
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM app_user_groups shared_membership
|
||||
INNER JOIN work_groups active_group
|
||||
ON active_group.id = shared_membership.work_group_id
|
||||
AND active_group.is_active = 1
|
||||
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
|
||||
GROUP BY shared_membership.work_group_id
|
||||
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||
)
|
||||
OR (
|
||||
ir.owner_user_id IS NULL
|
||||
AND current_tracking.download_count > 0
|
||||
)
|
||||
);
|
||||
""";
|
||||
|
||||
var result = new Dictionary<int, ComplaintAccessInfo>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
result.Add(Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture));
|
||||
var complaintId = Convert.ToInt32(
|
||||
reader.GetValue(reader.GetOrdinal("complaint_id")),
|
||||
CultureInfo.InvariantCulture);
|
||||
var ownerUsername = reader.IsDBNull(reader.GetOrdinal("owner_username"))
|
||||
? string.Empty
|
||||
: reader.GetString(reader.GetOrdinal("owner_username"));
|
||||
var ownedByCurrentUser =
|
||||
reader.GetInt32(reader.GetOrdinal("owned_by_current_user")) == 1;
|
||||
var accessibleByGroup =
|
||||
reader.GetInt32(reader.GetOrdinal("accessible_by_group")) == 1;
|
||||
var groupCodes = reader.IsDBNull(reader.GetOrdinal("owner_group_codes"))
|
||||
? []
|
||||
: reader.GetString(reader.GetOrdinal("owner_group_codes"))
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
result[complaintId] = new ComplaintAccessInfo(
|
||||
complaintId,
|
||||
ownerUsername,
|
||||
ownedByCurrentUser,
|
||||
accessibleByGroup,
|
||||
groupCodes);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> CanAccessComplaintAsync(string username, int complaintId, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> CanAccessComplaintAsync(
|
||||
string username,
|
||||
int complaintId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allowedIds = await GetAllowedComplaintIdsAsync(username, cancellationToken);
|
||||
return allowedIds.Contains(complaintId);
|
||||
if (complaintId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var access = await GetComplaintAccessAsync(
|
||||
username,
|
||||
[complaintId],
|
||||
cancellationToken);
|
||||
return access.ContainsKey(complaintId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComplaintAccessInfo(
|
||||
int ComplaintId,
|
||||
string OwnerUsername,
|
||||
bool OwnedByCurrentUser,
|
||||
bool AccessibleByGroup,
|
||||
IReadOnlyList<string> OwnerGroupCodes)
|
||||
{
|
||||
public bool RequiresOwnerConfirmation =>
|
||||
!OwnedByCurrentUser &&
|
||||
AccessibleByGroup &&
|
||||
!string.IsNullOrWhiteSpace(OwnerUsername);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Globalization;
|
||||
using GestionaDenuncias.Shared.Models;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ApiDenuncias.Services;
|
||||
|
||||
public sealed class WorkGroupAdministrationService
|
||||
{
|
||||
private readonly IDenunciaStore _denunciaStore;
|
||||
private readonly MySqlConnectionStringProvider _connectionStringProvider;
|
||||
|
||||
public WorkGroupAdministrationService(
|
||||
IDenunciaStore denunciaStore,
|
||||
MySqlConnectionStringProvider connectionStringProvider)
|
||||
{
|
||||
_denunciaStore = denunciaStore;
|
||||
_connectionStringProvider = connectionStringProvider;
|
||||
}
|
||||
|
||||
public async Task<WorkGroupAdministrationDto> GetAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
|
||||
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var groups = await LoadGroupsAsync(connection, cancellationToken);
|
||||
var users = new Dictionary<long, UserGroupBuilder>();
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
au.id,
|
||||
au.username,
|
||||
wg.code AS group_code
|
||||
FROM app_users au
|
||||
LEFT JOIN app_user_groups aug ON aug.app_user_id = au.id
|
||||
LEFT JOIN work_groups wg
|
||||
ON wg.id = aug.work_group_id
|
||||
AND wg.is_active = 1
|
||||
ORDER BY au.username, wg.code;
|
||||
""";
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var userId = reader.GetInt64(reader.GetOrdinal("id"));
|
||||
if (!users.TryGetValue(userId, out var user))
|
||||
{
|
||||
user = new UserGroupBuilder(
|
||||
userId,
|
||||
reader.GetString(reader.GetOrdinal("username")));
|
||||
users[userId] = user;
|
||||
}
|
||||
|
||||
var groupOrdinal = reader.GetOrdinal("group_code");
|
||||
if (!reader.IsDBNull(groupOrdinal))
|
||||
{
|
||||
user.GroupCodes.Add(reader.GetString(groupOrdinal));
|
||||
}
|
||||
}
|
||||
|
||||
return new WorkGroupAdministrationDto(
|
||||
groups,
|
||||
users.Values
|
||||
.Select(user => new UserWorkGroupDto(
|
||||
user.UserId,
|
||||
user.Username,
|
||||
user.GroupCodes.OrderBy(code => code, StringComparer.Ordinal).ToArray()))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<CurrentUserWorkGroupsDto> GetUserGroupsAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
|
||||
|
||||
var normalizedUsername = username?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedUsername))
|
||||
{
|
||||
throw new InvalidOperationException("No hay usuario autenticado.");
|
||||
}
|
||||
|
||||
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||
const string sql = """
|
||||
SELECT wg.code
|
||||
FROM app_users au
|
||||
INNER JOIN app_user_groups aug ON aug.app_user_id = au.id
|
||||
INNER JOIN work_groups wg
|
||||
ON wg.id = aug.work_group_id
|
||||
AND wg.is_active = 1
|
||||
WHERE LOWER(au.username) = LOWER(@username)
|
||||
ORDER BY wg.code;
|
||||
""";
|
||||
|
||||
var groupCodes = new List<string>();
|
||||
await using var command = new MySqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("@username", normalizedUsername);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
groupCodes.Add(reader.GetString(reader.GetOrdinal("code")));
|
||||
}
|
||||
|
||||
return new CurrentUserWorkGroupsDto(normalizedUsername, groupCodes);
|
||||
}
|
||||
|
||||
public async Task<WorkGroupAdministrationDto> UpdateUserGroupsAsync(
|
||||
string username,
|
||||
IEnumerable<string> groupCodes,
|
||||
string changedByUsername,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
|
||||
|
||||
var normalizedUsername = username?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedUsername))
|
||||
{
|
||||
throw new InvalidOperationException("Debes indicar el usuario que se va a configurar.");
|
||||
}
|
||||
|
||||
var normalizedCodes = groupCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalizedCodes.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Cada usuario debe pertenecer al menos a un grupo de trabajo.");
|
||||
}
|
||||
|
||||
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var validGroups = await LoadGroupIdsAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
normalizedCodes,
|
||||
cancellationToken);
|
||||
|
||||
if (validGroups.Count != normalizedCodes.Length)
|
||||
{
|
||||
throw new InvalidOperationException("Se ha indicado un grupo de trabajo que no existe o no esta activo.");
|
||||
}
|
||||
|
||||
var userId = await EnsureUserAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
normalizedUsername,
|
||||
cancellationToken);
|
||||
var existingGroupIds = await LoadUserGroupIdsAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
userId,
|
||||
cancellationToken);
|
||||
var desiredGroupIds = validGroups.Values.ToHashSet();
|
||||
|
||||
foreach (var removedGroupId in existingGroupIds.Except(desiredGroupIds))
|
||||
{
|
||||
await DeleteMembershipAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
userId,
|
||||
removedGroupId,
|
||||
cancellationToken);
|
||||
await AddHistoryAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
userId,
|
||||
removedGroupId,
|
||||
"removed",
|
||||
changedByUsername,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var addedGroupId in desiredGroupIds.Except(existingGroupIds))
|
||||
{
|
||||
await AddMembershipAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
userId,
|
||||
addedGroupId,
|
||||
changedByUsername,
|
||||
cancellationToken);
|
||||
await AddHistoryAsync(
|
||||
connection,
|
||||
(MySqlTransaction)transaction,
|
||||
userId,
|
||||
addedGroupId,
|
||||
"added",
|
||||
changedByUsername,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return await GetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<List<WorkGroupDto>> LoadGroupsAsync(
|
||||
MySqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT code, name
|
||||
FROM work_groups
|
||||
WHERE is_active = 1
|
||||
ORDER BY code;
|
||||
""";
|
||||
|
||||
var groups = new List<WorkGroupDto>();
|
||||
await using var command = new MySqlCommand(sql, connection);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
groups.Add(new WorkGroupDto(
|
||||
reader.GetString(reader.GetOrdinal("code")),
|
||||
reader.GetString(reader.GetOrdinal("name"))));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, long>> LoadGroupIdsAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
IReadOnlyList<string> codes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var command = new MySqlCommand { Connection = connection, Transaction = transaction };
|
||||
var parameters = new List<string>(codes.Count);
|
||||
for (var index = 0; index < codes.Count; index++)
|
||||
{
|
||||
var parameterName = $"@code{index}";
|
||||
parameters.Add(parameterName);
|
||||
command.Parameters.AddWithValue(parameterName, codes[index]);
|
||||
}
|
||||
|
||||
command.CommandText = $"""
|
||||
SELECT id, code
|
||||
FROM work_groups
|
||||
WHERE is_active = 1
|
||||
AND code IN ({string.Join(", ", parameters)});
|
||||
""";
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
result[reader.GetString(reader.GetOrdinal("code"))] =
|
||||
reader.GetInt64(reader.GetOrdinal("id"));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<long> EnsureUserAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
string username,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string insertSql = """
|
||||
INSERT INTO app_users (username)
|
||||
VALUES (@username)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
updated_at_utc = CURRENT_TIMESTAMP(6);
|
||||
""";
|
||||
|
||||
await using (var insert = new MySqlCommand(insertSql, connection, transaction))
|
||||
{
|
||||
insert.Parameters.AddWithValue("@username", username);
|
||||
await insert.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
const string selectSql = """
|
||||
SELECT id
|
||||
FROM app_users
|
||||
WHERE username = @username
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
await using var select = new MySqlCommand(selectSql, connection, transaction);
|
||||
select.Parameters.AddWithValue("@username", username);
|
||||
var result = await select.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<HashSet<long>> LoadUserGroupIdsAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
long userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT work_group_id
|
||||
FROM app_user_groups
|
||||
WHERE app_user_id = @userId;
|
||||
""";
|
||||
|
||||
var result = new HashSet<long>();
|
||||
await using var command = new MySqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("@userId", userId);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
result.Add(reader.GetInt64(0));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task DeleteMembershipAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
long userId,
|
||||
long groupId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM app_user_groups
|
||||
WHERE app_user_id = @userId
|
||||
AND work_group_id = @groupId;
|
||||
""";
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("@userId", userId);
|
||||
command.Parameters.AddWithValue("@groupId", groupId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task AddMembershipAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
long userId,
|
||||
long groupId,
|
||||
string changedByUsername,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO app_user_groups (
|
||||
app_user_id,
|
||||
work_group_id,
|
||||
assigned_by_username
|
||||
) VALUES (
|
||||
@userId,
|
||||
@groupId,
|
||||
@changedByUsername
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
assigned_by_username = VALUES(assigned_by_username);
|
||||
""";
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("@userId", userId);
|
||||
command.Parameters.AddWithValue("@groupId", groupId);
|
||||
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task AddHistoryAsync(
|
||||
MySqlConnection connection,
|
||||
MySqlTransaction transaction,
|
||||
long userId,
|
||||
long groupId,
|
||||
string action,
|
||||
string changedByUsername,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO app_user_group_history (
|
||||
app_user_id,
|
||||
work_group_id,
|
||||
action,
|
||||
changed_by_username
|
||||
) VALUES (
|
||||
@userId,
|
||||
@groupId,
|
||||
@action,
|
||||
@changedByUsername
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = new MySqlCommand(sql, connection, transaction);
|
||||
command.Parameters.AddWithValue("@userId", userId);
|
||||
command.Parameters.AddWithValue("@groupId", groupId);
|
||||
command.Parameters.AddWithValue("@action", action);
|
||||
command.Parameters.AddWithValue("@changedByUsername", changedByUsername?.Trim() ?? string.Empty);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
|
||||
var connection = new MySqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var command = new MySqlCommand("SET time_zone = '+00:00';", connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private sealed record UserGroupBuilder(long UserId, string Username)
|
||||
{
|
||||
public HashSet<string> GroupCodes { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,9 @@
|
||||
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
|
||||
"UseKeyVault": false,
|
||||
"AutoCreateSchema": true
|
||||
},
|
||||
"OpeBridge": {
|
||||
"ApiKey": "development-only-internal-ope-key-change-me",
|
||||
"ApiKeyHeaderName": "X-ApiOPE-Key"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"CircuitNewComplaintTemplateName": "CT-Nueva denuncia",
|
||||
"CircuitUpdateSajTemplateName": "CT-Actualización denuncia SAJ",
|
||||
"CircuitUpdateSdiTemplateName": "CT-Actualización denuncia SDI",
|
||||
"CircuitCommunicationSajTemplateName": "CT-Comunicación SAJ a denunciante",
|
||||
"CircuitCommunicationSdiTemplateName": "CT-Comunicación SDI a denunciante",
|
||||
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
|
||||
"CircuitVersion": "2",
|
||||
"DocumentMetadataLanguage": "es",
|
||||
@@ -69,5 +71,9 @@
|
||||
"DefaultPort": 3306,
|
||||
"DefaultSslMode": "Required",
|
||||
"AutoCreateSchema": true
|
||||
},
|
||||
"OpeBridge": {
|
||||
"ApiKey": "",
|
||||
"ApiKeyHeaderName": "X-ApiOPE-Key"
|
||||
}
|
||||
}
|
||||
|
||||
24
Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
Normal file
24
Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApiOPE\ApiOPE.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
Normal file
1
Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
92
Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
Normal file
92
Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Contracts;
|
||||
using ApiOPE.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Tests;
|
||||
|
||||
public sealed class OpeFieldMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Map_UsesConfiguredOrderAndFieldNames()
|
||||
{
|
||||
var options = Options.Create(new OpeOptions
|
||||
{
|
||||
OutputFields = ["numeroDenunciaCanal", "fechaDenuncia", "resumenDenuncia"]
|
||||
});
|
||||
var mapper = new OpeFieldMapper(options);
|
||||
var source = new InternalGestionaFieldsResponse(
|
||||
new Dictionary<string, OpeFieldValue>
|
||||
{
|
||||
["fechaDenuncia"] = new("STRING", "2026-07-10"),
|
||||
["numeroDenunciaCanal"] = new("STRING", "116"),
|
||||
["resumenDenuncia"] = new("STRING", "Prueba")
|
||||
});
|
||||
|
||||
var result = mapper.Map(source);
|
||||
|
||||
Assert.Equal("116", result.Data["FIELD_0"].Value);
|
||||
Assert.Equal("2026-07-10", result.Data["FIELD_1"].Value);
|
||||
Assert.Equal("Prueba", result.Data["FIELD_2"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_RejectsMissingInternalField()
|
||||
{
|
||||
var mapper = new OpeFieldMapper(Options.Create(new OpeOptions
|
||||
{
|
||||
OutputFields = ["fechaDenuncia"]
|
||||
}));
|
||||
var source = new InternalGestionaFieldsResponse(
|
||||
new Dictionary<string, OpeFieldValue>());
|
||||
|
||||
Assert.Throws<InvalidDataException>(() => mapper.Map(source));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("53/2026", "api/denuncias/53/2026/gestiona-fields")]
|
||||
[InlineData("116", "api/denuncias/116/gestiona-fields")]
|
||||
public void LookupParser_AcceptsExpedienteAndComplaintId(string value, string expectedPath)
|
||||
{
|
||||
var parsed = DenunciaLookupParser.TryParse(value, out var lookup);
|
||||
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(expectedPath, lookup!.RelativePath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(11)]
|
||||
public void OptionsValidator_RejectsInvalidOutputCount(int count)
|
||||
{
|
||||
var options = new OpeOptions
|
||||
{
|
||||
ClientToken = "client",
|
||||
SecretKey = "test-secret-key-with-more-than-32-characters",
|
||||
PublicBaseUrl = "https://ope.example.test",
|
||||
OrganizationId = "organization",
|
||||
OrganizationDir3 = "dir3",
|
||||
OrganizationCif = "cif",
|
||||
OutputFields = Enumerable.Repeat("fechaDenuncia", count).ToList()
|
||||
};
|
||||
|
||||
Assert.False(OpeOptionsValidator.IsValid(options, isDevelopment: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsValidator_AcceptsTenUniqueConfiguredFields()
|
||||
{
|
||||
var options = new OpeOptions
|
||||
{
|
||||
ClientToken = "client",
|
||||
SecretKey = "test-secret-key-with-more-than-32-characters",
|
||||
PublicBaseUrl = "https://ope.example.test",
|
||||
OrganizationId = "organization",
|
||||
OrganizationDir3 = "dir3",
|
||||
OrganizationCif = "cif",
|
||||
OutputFields = OpeFieldMapper.SupportedInternalFields.Take(10).ToList()
|
||||
};
|
||||
|
||||
Assert.True(OpeOptionsValidator.IsValid(options, isDevelopment: false));
|
||||
}
|
||||
}
|
||||
83
Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
Normal file
83
Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Text;
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Contracts;
|
||||
using ApiOPE.Security;
|
||||
using ApiOPE.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Tests;
|
||||
|
||||
public sealed class OpeProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResponseSigner_MatchesGestionaHexThenBase64Algorithm()
|
||||
{
|
||||
var signer = new OpeResponseSigner(Options.Create(new OpeOptions
|
||||
{
|
||||
SecretKey = "test-secret-key-with-more-than-32-characters"
|
||||
}));
|
||||
|
||||
var signature = signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237");
|
||||
|
||||
Assert.Equal(
|
||||
"MmE0MjEwM2Y1NTM2NjRkM2FmMzRhNDZlNmQxNjE0NzVmNmE0OTQxNDUxN2NlYmU2ODZjOTZiYzg5YmFjY2FmYw==",
|
||||
signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseSigner_PreservesUuidTextCasing()
|
||||
{
|
||||
var signer = new OpeResponseSigner(Options.Create(new OpeOptions
|
||||
{
|
||||
SecretKey = "test-secret-key-with-more-than-32-characters"
|
||||
}));
|
||||
|
||||
Assert.NotEqual(
|
||||
signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237"),
|
||||
signer.Sign("26CD0DA5-C45F-497D-B380-820C03CF3237"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestReader_AcceptsVendorJsonMediaType()
|
||||
{
|
||||
var context = CreateHttpContext(
|
||||
"application/vnd.generic-operation-request+json; charset=utf-8",
|
||||
"""
|
||||
{"data":{"FIELD_0":{"type":"STRING","value":"53/2026"}}}
|
||||
""");
|
||||
|
||||
var result = await GenericOperationRequestReader.ReadAsync(
|
||||
context.Request,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput()
|
||||
{
|
||||
var context = CreateHttpContext(
|
||||
OpeMediaTypes.GenericOperationRequest,
|
||||
"""
|
||||
{"data":{"FIELD_1":{"type":"STRING","value":"unexpected"}}}
|
||||
""");
|
||||
|
||||
var result = await GenericOperationRequestReader.ReadAsync(
|
||||
context.Request,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(OpeFieldErrors.NotExpected, result.Errors["FIELD_1"]);
|
||||
Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_0"]);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(string contentType, string body)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.ContentType = contentType;
|
||||
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
|
||||
return context;
|
||||
}
|
||||
}
|
||||
164
Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
Normal file
164
Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Tests;
|
||||
|
||||
public sealed class OpeTokenValidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1_800_000_000);
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsSignedCurrentToken()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
var token = CreateToken(options, "/genericoperations", Now.ToUnixTimeSeconds(), includeVersion: true);
|
||||
|
||||
var result = validator.Validate(token, "/genericoperations", versionOptional: false);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.NotNull(result.RequestContext);
|
||||
Assert.Equal("/genericoperations", result.RequestContext.Resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsManipulatedSignature()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
var token = CreateToken(
|
||||
options,
|
||||
"/genericoperations",
|
||||
Now.ToUnixTimeSeconds(),
|
||||
includeVersion: true,
|
||||
signingSecret: "another-secret-key-that-is-long-enough");
|
||||
|
||||
var result = validator.Validate(token, "/genericoperations", versionOptional: false);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsExpiredTimestamp()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
var token = CreateToken(
|
||||
options,
|
||||
"/genericoperations",
|
||||
Now.AddMinutes(-7).ToUnixTimeSeconds(),
|
||||
includeVersion: true);
|
||||
|
||||
var result = validator.Validate(token, "/genericoperations", versionOptional: false);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsDifferentResource()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
var token = CreateToken(options, "/", Now.ToUnixTimeSeconds(), includeVersion: true);
|
||||
|
||||
var result = validator.Validate(token, "/genericoperations", versionOptional: false);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsMissingVersionOnlyForVersionEndpoint()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
var token = CreateToken(options, "/versions/current", Now.ToUnixTimeSeconds(), includeVersion: false);
|
||||
|
||||
var versionResult = validator.Validate(token, "/versions/current", versionOptional: true);
|
||||
var operationResult = validator.Validate(token, "/versions/current", versionOptional: false);
|
||||
|
||||
Assert.True(versionResult.IsValid);
|
||||
Assert.False(operationResult.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PreservesOriginalUuidTextForResponseSignature()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var validator = CreateValidator(options);
|
||||
const string uppercaseUuid = "26CD0DA5-C45F-497D-B380-820C03CF3237";
|
||||
var token = CreateToken(
|
||||
options,
|
||||
"/genericoperations",
|
||||
Now.ToUnixTimeSeconds(),
|
||||
includeVersion: true,
|
||||
uuid: uppercaseUuid);
|
||||
|
||||
var result = validator.Validate(token, "/genericoperations", versionOptional: false);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(uppercaseUuid, result.RequestContext!.Uuid);
|
||||
}
|
||||
|
||||
private static OpeTokenValidator CreateValidator(OpeOptions options)
|
||||
=> new(Options.Create(options), new FixedTimeProvider(Now));
|
||||
|
||||
private static OpeOptions CreateOptions()
|
||||
=> new()
|
||||
{
|
||||
ClientToken = "client-token",
|
||||
SecretKey = "test-secret-key-with-more-than-32-characters",
|
||||
Version = "1.0",
|
||||
TokenMaxAgeSeconds = 300,
|
||||
ClockSkewSeconds = 60
|
||||
};
|
||||
|
||||
private static string CreateToken(
|
||||
OpeOptions options,
|
||||
string resource,
|
||||
long timestamp,
|
||||
bool includeVersion,
|
||||
string? signingSecret = null,
|
||||
string uuid = "26cd0da5-c45f-497d-b380-820c03cf3237")
|
||||
{
|
||||
var header = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
|
||||
var payloadValues = new Dictionary<string, object>
|
||||
{
|
||||
["client_token"] = options.ClientToken,
|
||||
["resource"] = resource,
|
||||
["uuid"] = uuid,
|
||||
["transaction_id"] = "37ec011f-4f0b-4a7b-a807-a83e88c73ce9",
|
||||
["timestamp"] = timestamp
|
||||
};
|
||||
if (includeVersion)
|
||||
{
|
||||
payloadValues["version"] = options.Version;
|
||||
}
|
||||
|
||||
var payload = Base64Url(JsonSerializer.SerializeToUtf8Bytes(payloadValues));
|
||||
var signedContent = Encoding.ASCII.GetBytes($"{header}.{payload}");
|
||||
var signature = HMACSHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(signingSecret ?? options.SecretKey),
|
||||
signedContent);
|
||||
|
||||
return $"{header}.{payload}.{Base64Url(signature)}";
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value)
|
||||
=> Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
17
Antifraude.Net/ApiOPE/ApiOPE.csproj
Normal file
17
Antifraude.Net/ApiOPE/ApiOPE.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.*.local.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
39
Antifraude.Net/ApiOPE/ApiOPE.http
Normal file
39
Antifraude.Net/ApiOPE/ApiOPE.http
Normal file
@@ -0,0 +1,39 @@
|
||||
@ApiOPE_HostAddress = http://localhost:5090
|
||||
@OrganizationId = 00000000-0000-0000-0000-000000000001
|
||||
@OrganizationDir3 = DEV000001
|
||||
@OrganizationCif = DEV000001
|
||||
|
||||
# Los JWT deben generarse con el SecretKey del conector y un uuid nuevo.
|
||||
# El resource debe coincidir exactamente con la ruta de cada peticion.
|
||||
|
||||
GET {{ApiOPE_HostAddress}}/versions/current
|
||||
X-Rest-Basic-Token: <JWT resource=/versions/current>
|
||||
X-Organization-ID: {{OrganizationId}}
|
||||
X-Organization-DIR3: {{OrganizationDir3}}
|
||||
X-Organization-CIF: {{OrganizationCif}}
|
||||
|
||||
###
|
||||
|
||||
GET {{ApiOPE_HostAddress}}/
|
||||
X-Rest-Basic-Token: <JWT resource=/ version=1.0>
|
||||
X-Organization-ID: {{OrganizationId}}
|
||||
X-Organization-DIR3: {{OrganizationDir3}}
|
||||
X-Organization-CIF: {{OrganizationCif}}
|
||||
|
||||
###
|
||||
|
||||
POST {{ApiOPE_HostAddress}}/genericoperations
|
||||
Content-Type: application/vnd.generic-operation-request+json
|
||||
X-Rest-Basic-Token: <JWT resource=/genericoperations version=1.0>
|
||||
X-Organization-ID: {{OrganizationId}}
|
||||
X-Organization-DIR3: {{OrganizationDir3}}
|
||||
X-Organization-CIF: {{OrganizationCif}}
|
||||
|
||||
{
|
||||
"data": {
|
||||
"FIELD_0": {
|
||||
"type": "STRING",
|
||||
"value": "53/2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
Normal file
30
Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace ApiOPE.Configuration;
|
||||
|
||||
public sealed class InternalApiOptions
|
||||
{
|
||||
public const string SectionName = "InternalApi";
|
||||
|
||||
public string BaseUrl { get; set; } = "http://localhost:7093";
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key";
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 20;
|
||||
}
|
||||
|
||||
public static class InternalApiOptionsValidator
|
||||
{
|
||||
public static bool IsValid(InternalApiOptions options)
|
||||
{
|
||||
return Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var baseUri) &&
|
||||
(baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps) &&
|
||||
string.IsNullOrEmpty(baseUri.UserInfo) &&
|
||||
string.IsNullOrEmpty(baseUri.Query) &&
|
||||
string.IsNullOrEmpty(baseUri.Fragment) &&
|
||||
!string.IsNullOrWhiteSpace(options.ApiKey) &&
|
||||
options.ApiKey.Length >= 32 &&
|
||||
string.Equals(options.ApiKeyHeaderName, "X-ApiOPE-Key", StringComparison.OrdinalIgnoreCase) &&
|
||||
options.TimeoutSeconds is >= 1 and <= 60;
|
||||
}
|
||||
}
|
||||
62
Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
Normal file
62
Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using ApiOPE.Services;
|
||||
|
||||
namespace ApiOPE.Configuration;
|
||||
|
||||
public sealed class OpeOptions
|
||||
{
|
||||
public const string SectionName = "Ope";
|
||||
|
||||
public string Version { get; set; } = "1.0";
|
||||
|
||||
public string ClientToken { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public string PublicBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
public int TokenMaxAgeSeconds { get; set; } = 300;
|
||||
|
||||
public int ClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public string OrganizationId { get; set; } = string.Empty;
|
||||
|
||||
public string OrganizationDir3 { get; set; } = string.Empty;
|
||||
|
||||
public string OrganizationCif { get; set; } = string.Empty;
|
||||
|
||||
public List<string> OutputFields { get; set; } = [];
|
||||
}
|
||||
|
||||
public static class OpeOptionsValidator
|
||||
{
|
||||
public static bool IsValid(OpeOptions options, bool isDevelopment)
|
||||
{
|
||||
if (!string.Equals(options.Version, "1.0", StringComparison.Ordinal) ||
|
||||
string.IsNullOrWhiteSpace(options.ClientToken) ||
|
||||
string.IsNullOrWhiteSpace(options.SecretKey) ||
|
||||
options.SecretKey.Length < 32 ||
|
||||
string.IsNullOrWhiteSpace(options.OrganizationId) ||
|
||||
string.IsNullOrWhiteSpace(options.OrganizationDir3) ||
|
||||
string.IsNullOrWhiteSpace(options.OrganizationCif) ||
|
||||
options.TokenMaxAgeSeconds is < 1 or > 900 ||
|
||||
options.ClockSkewSeconds is < 0 or > 300 ||
|
||||
options.OutputFields is null ||
|
||||
options.OutputFields.Count is < 1 or > 10 ||
|
||||
options.OutputFields.Distinct(StringComparer.Ordinal).Count() != options.OutputFields.Count ||
|
||||
options.OutputFields.Any(field => !OpeFieldMapper.SupportedInternalFields.Contains(field)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(options.PublicBaseUrl, UriKind.Absolute, out var publicBaseUri) ||
|
||||
(publicBaseUri.Scheme != Uri.UriSchemeHttps && publicBaseUri.Scheme != Uri.UriSchemeHttp) ||
|
||||
!string.IsNullOrEmpty(publicBaseUri.UserInfo) ||
|
||||
!string.IsNullOrEmpty(publicBaseUri.Query) ||
|
||||
!string.IsNullOrEmpty(publicBaseUri.Fragment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDevelopment || publicBaseUri.Scheme == Uri.UriSchemeHttps;
|
||||
}
|
||||
}
|
||||
6
Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
Normal file
6
Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ApiOPE.Contracts;
|
||||
|
||||
public sealed record InternalGestionaFieldsResponse(
|
||||
[property: JsonPropertyName("data")] IReadOnlyDictionary<string, OpeFieldValue> Data);
|
||||
49
Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
Normal file
49
Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ApiOPE.Contracts;
|
||||
|
||||
public sealed record OpeFieldValue(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("value")] string Value);
|
||||
|
||||
public sealed record OpeDataEnvelope(
|
||||
[property: JsonPropertyName("data")] IReadOnlyDictionary<string, OpeFieldValue> Data);
|
||||
|
||||
public sealed record OpeVersionResponse(
|
||||
[property: JsonPropertyName("version")] string Version);
|
||||
|
||||
public sealed record OpeTypedInfo(
|
||||
[property: JsonPropertyName("element")] string? Element,
|
||||
[property: JsonPropertyName("action")] string? Action,
|
||||
[property: JsonPropertyName("type")] string? Type,
|
||||
[property: JsonPropertyName("cause")] string Cause);
|
||||
|
||||
public sealed record OpeErrorResponse(
|
||||
[property: JsonPropertyName("status_code")] int StatusCode,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("typed_info")] OpeTypedInfo TypedInfo,
|
||||
[property: JsonPropertyName("data")] IReadOnlyDictionary<string, string>? Data);
|
||||
|
||||
public static class OpeMediaTypes
|
||||
{
|
||||
public const string Version = "application/vnd.version+json";
|
||||
public const string Bookmarks = "application/vnd.bookmark-list+json";
|
||||
public const string GenericOperationRequest = "application/vnd.generic-operation-request+json";
|
||||
public const string GenericOperationResponse = "application/vnd.generic-operation-response+json";
|
||||
public const string Error = "application/vnd.generic-operation.error+json";
|
||||
}
|
||||
|
||||
public static class OpeErrorCauses
|
||||
{
|
||||
public const string WrongSignature = "WRONG_SIGNATURE";
|
||||
public const string FieldErrors = "FIELD_ERRORS";
|
||||
public const string ElementNotExists = "ELEMENT_NOT_EXISTS";
|
||||
public const string ConnectorError = "CONNECTOR_ERROR";
|
||||
}
|
||||
|
||||
public static class OpeFieldErrors
|
||||
{
|
||||
public const string Expected = "FIELD_EXPECTED";
|
||||
public const string NotExpected = "FIELD_NOT_EXPECTED";
|
||||
public const string UnexpectedFormat = "FIELD_UNEXPECTED_FORMAT";
|
||||
}
|
||||
164
Antifraude.Net/ApiOPE/Controllers/OpeController.cs
Normal file
164
Antifraude.Net/ApiOPE/Controllers/OpeController.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Text.Json;
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Contracts;
|
||||
using ApiOPE.Security;
|
||||
using ApiOPE.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("")]
|
||||
public sealed class OpeController : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly OpeOptions _options;
|
||||
private readonly InternalDenunciasClient _internalApi;
|
||||
private readonly OpeFieldMapper _fieldMapper;
|
||||
private readonly ILogger<OpeController> _logger;
|
||||
|
||||
public OpeController(
|
||||
IOptions<OpeOptions> options,
|
||||
InternalDenunciasClient internalApi,
|
||||
OpeFieldMapper fieldMapper,
|
||||
ILogger<OpeController> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_internalApi = internalApi;
|
||||
_fieldMapper = fieldMapper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("versions/current")]
|
||||
[OpeAuthenticated("/versions/current", versionOptional: true)]
|
||||
[Produces(OpeMediaTypes.Version)]
|
||||
public IActionResult GetCurrentVersion()
|
||||
=> Json(new OpeVersionResponse(_options.Version), OpeMediaTypes.Version);
|
||||
|
||||
[HttpGet]
|
||||
[OpeAuthenticated("/")]
|
||||
[Produces(OpeMediaTypes.Bookmarks)]
|
||||
public IActionResult GetBookmarks()
|
||||
{
|
||||
var genericOperationUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/genericoperations";
|
||||
return Json(
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["generic-operation"] = genericOperationUrl
|
||||
},
|
||||
OpeMediaTypes.Bookmarks);
|
||||
}
|
||||
|
||||
[HttpPost("genericoperations")]
|
||||
[OpeAuthenticated("/genericoperations")]
|
||||
[RequestSizeLimit(64 * 1024)]
|
||||
[Produces(OpeMediaTypes.GenericOperationResponse)]
|
||||
[ProducesResponseType(typeof(OpeErrorResponse), StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<IActionResult> ExecuteGenericOperation(CancellationToken cancellationToken)
|
||||
{
|
||||
var requestContext = OpeRequestContextStore.Get(HttpContext)
|
||||
?? throw new InvalidOperationException("No existe contexto de autenticacion OPE.");
|
||||
|
||||
var parsedRequest = await GenericOperationRequestReader.ReadAsync(Request, cancellationToken);
|
||||
if (!parsedRequest.IsValid)
|
||||
{
|
||||
return Error(
|
||||
"Existen errores en los campos",
|
||||
OpeErrorCauses.FieldErrors,
|
||||
parsedRequest.Errors);
|
||||
}
|
||||
|
||||
var identifier = parsedRequest.Request!.Data["FIELD_0"].Value;
|
||||
if (!DenunciaLookupParser.TryParse(identifier, out var lookup) || lookup is null)
|
||||
{
|
||||
return Error(
|
||||
"Existen errores en los campos",
|
||||
OpeErrorCauses.FieldErrors,
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["FIELD_0"] = OpeFieldErrors.UnexpectedFormat
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _internalApi.GetFieldsAsync(
|
||||
lookup,
|
||||
requestContext.TransactionId,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Status == InternalLookupStatus.NotFound || result.Fields is null)
|
||||
{
|
||||
return Error(
|
||||
"No se ha encontrado la denuncia o expediente solicitado",
|
||||
OpeErrorCauses.ElementNotExists,
|
||||
null);
|
||||
}
|
||||
|
||||
return Json(_fieldMapper.Map(result.Fields), OpeMediaTypes.GenericOperationResponse);
|
||||
}
|
||||
catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Timeout consultando ApiDenuncias para una OPE. TransactionId={TransactionId}",
|
||||
requestContext.TransactionId);
|
||||
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"No se ha podido conectar con ApiDenuncias. TransactionId={TransactionId}",
|
||||
requestContext.TransactionId);
|
||||
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
|
||||
}
|
||||
catch (InternalApiException exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"ApiDenuncias ha devuelto una respuesta no utilizable. TransactionId={TransactionId}",
|
||||
requestContext.TransactionId);
|
||||
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"El contrato entre ApiOPE y ApiDenuncias no coincide. TransactionId={TransactionId}",
|
||||
requestContext.TransactionId);
|
||||
return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static ContentResult Error(
|
||||
string message,
|
||||
string cause,
|
||||
IReadOnlyDictionary<string, string>? data)
|
||||
{
|
||||
var response = new OpeErrorResponse(
|
||||
StatusCodes.Status412PreconditionFailed,
|
||||
message,
|
||||
new OpeTypedInfo(null, null, null, cause),
|
||||
data);
|
||||
|
||||
return Json(
|
||||
response,
|
||||
OpeMediaTypes.Error,
|
||||
StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
private static ContentResult Json<T>(
|
||||
T value,
|
||||
string contentType,
|
||||
int statusCode = StatusCodes.Status200OK)
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
Content = JsonSerializer.Serialize(value, JsonOptions),
|
||||
ContentType = contentType,
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "02213a08-7ce6-49bc-8ca0-0d12965cfdb2",
|
||||
"name": "ApiOPE PRE",
|
||||
"values": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://158.158.42.110:7094",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "client_token",
|
||||
"value": "REEMPLAZAR_CLIENT_TOKEN_GESTIONA",
|
||||
"type": "secret",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "secret_key",
|
||||
"value": "REEMPLAZAR_SECRET_KEY_GESTIONA",
|
||||
"type": "secret",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "organization_id",
|
||||
"value": "REEMPLAZAR_ORGANIZATION_ID",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "organization_dir3",
|
||||
"value": "REEMPLAZAR_ORGANIZATION_DIR3",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "organization_cif",
|
||||
"value": "REEMPLAZAR_ORGANIZATION_CIF",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "expediente_gestiona",
|
||||
"value": "REEMPLAZAR_EXPEDIENTE_NO_PURGADO",
|
||||
"type": "default",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment",
|
||||
"_postman_exported_at": "2026-08-07T00:00:00.000Z",
|
||||
"_postman_exported_using": "Postman/12"
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "6d0ba12e-f700-4d79-88f7-6fe9c70c8acf",
|
||||
"name": "ApiOPE - Simulacion Gestiona",
|
||||
"description": "Simula las tres llamadas consecutivas que realiza Gestiona contra un conector OPE 1.0. Genera automaticamente el JWT HS256 y valida la firma Signature de cada respuesta.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"function utf8Bytes(value) {",
|
||||
" return Array.from(new TextEncoder().encode(value));",
|
||||
"}",
|
||||
"",
|
||||
"function rightRotate(value, amount) {",
|
||||
" return (value >>> amount) | (value << (32 - amount));",
|
||||
"}",
|
||||
"",
|
||||
"function sha256Bytes(input) {",
|
||||
" const constants = [",
|
||||
" 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,",
|
||||
" 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,",
|
||||
" 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,",
|
||||
" 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,",
|
||||
" 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,",
|
||||
" 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,",
|
||||
" 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,",
|
||||
" 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2",
|
||||
" ];",
|
||||
" const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];",
|
||||
" const bytes = Array.from(input);",
|
||||
" const bitLength = bytes.length * 8;",
|
||||
" bytes.push(0x80);",
|
||||
" while (bytes.length % 64 !== 56) bytes.push(0);",
|
||||
" const high = Math.floor(bitLength / 0x100000000);",
|
||||
" const low = bitLength >>> 0;",
|
||||
" for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);",
|
||||
" for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);",
|
||||
"",
|
||||
" for (let offset = 0; offset < bytes.length; offset += 64) {",
|
||||
" const words = new Array(64);",
|
||||
" for (let i = 0; i < 16; i += 1) {",
|
||||
" const index = offset + (i * 4);",
|
||||
" words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;",
|
||||
" }",
|
||||
" for (let i = 16; i < 64; i += 1) {",
|
||||
" const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);",
|
||||
" const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);",
|
||||
" words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" let [a, b, c, d, e, f, g, h] = state;",
|
||||
" for (let i = 0; i < 64; i += 1) {",
|
||||
" const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);",
|
||||
" const choice = (e & f) ^ ((~e) & g);",
|
||||
" const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;",
|
||||
" const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);",
|
||||
" const majority = (a & b) ^ (a & c) ^ (b & c);",
|
||||
" const temp2 = (sum0 + majority) >>> 0;",
|
||||
" h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;",
|
||||
" state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;",
|
||||
" state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;",
|
||||
" state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" const output = [];",
|
||||
" state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));",
|
||||
" return output;",
|
||||
"}",
|
||||
"",
|
||||
"function hmacSha256Bytes(message, secret) {",
|
||||
" let key = utf8Bytes(secret);",
|
||||
" if (key.length > 64) key = sha256Bytes(key);",
|
||||
" while (key.length < 64) key.push(0);",
|
||||
" const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));",
|
||||
" const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));",
|
||||
" return sha256Bytes(outer);",
|
||||
"}",
|
||||
"",
|
||||
"function bytesToBase64(bytes) {",
|
||||
" let binary = '';",
|
||||
" bytes.forEach(value => { binary += String.fromCharCode(value); });",
|
||||
" return btoa(binary);",
|
||||
"}",
|
||||
"",
|
||||
"function toBase64Url(bytes) {",
|
||||
" return bytesToBase64(bytes).replace(/=+$/, '').replace(/\\+/g, '-').replace(/\\//g, '_');",
|
||||
"}",
|
||||
"",
|
||||
"const requests = {",
|
||||
" '01 - Consultar version': { resource: '/versions/current', includeVersion: false, resetTransaction: true },",
|
||||
" '02 - Consultar bookmarks': { resource: '/', includeVersion: true, resetTransaction: false },",
|
||||
" '03 - Consultar datos de denuncia': { resource: '/genericoperations', includeVersion: true, resetTransaction: false }",
|
||||
"};",
|
||||
"",
|
||||
"const current = requests[pm.info.requestName];",
|
||||
"if (!current) {",
|
||||
" throw new Error(`No existe configuracion OPE para la peticion ${pm.info.requestName}`);",
|
||||
"}",
|
||||
"",
|
||||
"const requiredVariables = [",
|
||||
" 'base_url',",
|
||||
" 'client_token',",
|
||||
" 'secret_key',",
|
||||
" 'organization_id',",
|
||||
" 'organization_dir3',",
|
||||
" 'organization_cif'",
|
||||
"];",
|
||||
"",
|
||||
"for (const variableName of requiredVariables) {",
|
||||
" const value = pm.variables.get(variableName);",
|
||||
" if (!value || value.startsWith('REEMPLAZAR_')) {",
|
||||
" throw new Error(`Debes rellenar la variable ${variableName} en el entorno de Postman`);",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"if (current.resource === '/genericoperations') {",
|
||||
" const expediente = pm.variables.get('expediente_gestiona');",
|
||||
" if (!expediente || expediente.startsWith('REEMPLAZAR_')) {",
|
||||
" throw new Error('Debes indicar un expediente no purgado en expediente_gestiona');",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"if (current.resetTransaction || !pm.collectionVariables.get('ope_transaction_id')) {",
|
||||
" pm.collectionVariables.set('ope_transaction_id', pm.variables.replaceIn('{{$guid}}'));",
|
||||
"}",
|
||||
"",
|
||||
"const requestUuid = pm.variables.replaceIn('{{$guid}}');",
|
||||
"const payload = {",
|
||||
" client_token: pm.variables.get('client_token'),",
|
||||
" resource: current.resource,",
|
||||
" uuid: requestUuid,",
|
||||
" timestamp: Math.floor(Date.now() / 1000),",
|
||||
" transaction_id: pm.collectionVariables.get('ope_transaction_id')",
|
||||
"};",
|
||||
"",
|
||||
"if (current.includeVersion) {",
|
||||
" payload.version = pm.variables.get('ope_version') || '1.0';",
|
||||
"}",
|
||||
"",
|
||||
"const headerPart = toBase64Url(utf8Bytes(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));",
|
||||
"const payloadPart = toBase64Url(utf8Bytes(JSON.stringify(payload)));",
|
||||
"const signedContent = `${headerPart}.${payloadPart}`;",
|
||||
"const signaturePart = toBase64Url(hmacSha256Bytes(signedContent, pm.variables.get('secret_key')));",
|
||||
"const token = `${signedContent}.${signaturePart}`;",
|
||||
"",
|
||||
"pm.collectionVariables.set('ope_uuid', requestUuid);",
|
||||
"pm.collectionVariables.set('ope_jwt', token);"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"function utf8Bytes(value) {",
|
||||
" return Array.from(new TextEncoder().encode(value));",
|
||||
"}",
|
||||
"",
|
||||
"function rightRotate(value, amount) {",
|
||||
" return (value >>> amount) | (value << (32 - amount));",
|
||||
"}",
|
||||
"",
|
||||
"function sha256Bytes(input) {",
|
||||
" const constants = [",
|
||||
" 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,",
|
||||
" 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,",
|
||||
" 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,",
|
||||
" 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,",
|
||||
" 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,",
|
||||
" 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,",
|
||||
" 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,",
|
||||
" 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2",
|
||||
" ];",
|
||||
" const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];",
|
||||
" const bytes = Array.from(input);",
|
||||
" const bitLength = bytes.length * 8;",
|
||||
" bytes.push(0x80);",
|
||||
" while (bytes.length % 64 !== 56) bytes.push(0);",
|
||||
" const high = Math.floor(bitLength / 0x100000000);",
|
||||
" const low = bitLength >>> 0;",
|
||||
" for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);",
|
||||
" for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);",
|
||||
"",
|
||||
" for (let offset = 0; offset < bytes.length; offset += 64) {",
|
||||
" const words = new Array(64);",
|
||||
" for (let i = 0; i < 16; i += 1) {",
|
||||
" const index = offset + (i * 4);",
|
||||
" words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;",
|
||||
" }",
|
||||
" for (let i = 16; i < 64; i += 1) {",
|
||||
" const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);",
|
||||
" const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);",
|
||||
" words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" let [a, b, c, d, e, f, g, h] = state;",
|
||||
" for (let i = 0; i < 64; i += 1) {",
|
||||
" const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);",
|
||||
" const choice = (e & f) ^ ((~e) & g);",
|
||||
" const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;",
|
||||
" const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);",
|
||||
" const majority = (a & b) ^ (a & c) ^ (b & c);",
|
||||
" const temp2 = (sum0 + majority) >>> 0;",
|
||||
" h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;",
|
||||
" state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;",
|
||||
" state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;",
|
||||
" state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;",
|
||||
" }",
|
||||
"",
|
||||
" const output = [];",
|
||||
" state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));",
|
||||
" return output;",
|
||||
"}",
|
||||
"",
|
||||
"function hmacSha256Bytes(message, secret) {",
|
||||
" let key = utf8Bytes(secret);",
|
||||
" if (key.length > 64) key = sha256Bytes(key);",
|
||||
" while (key.length < 64) key.push(0);",
|
||||
" const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));",
|
||||
" const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));",
|
||||
" return sha256Bytes(outer);",
|
||||
"}",
|
||||
"",
|
||||
"function bytesToBase64(bytes) {",
|
||||
" let binary = '';",
|
||||
" bytes.forEach(value => { binary += String.fromCharCode(value); });",
|
||||
" return btoa(binary);",
|
||||
"}",
|
||||
"",
|
||||
"function bytesToHex(bytes) {",
|
||||
" return bytes.map(value => value.toString(16).padStart(2, '0')).join('');",
|
||||
"}",
|
||||
"",
|
||||
"const expectedContentTypes = {",
|
||||
" '01 - Consultar version': 'application/vnd.version+json',",
|
||||
" '02 - Consultar bookmarks': 'application/vnd.bookmark-list+json',",
|
||||
" '03 - Consultar datos de denuncia': 'application/vnd.generic-operation-response+json'",
|
||||
"};",
|
||||
"",
|
||||
"pm.test('HTTP 200', () => {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('Content-Type OPE correcto', () => {",
|
||||
" const actual = (pm.response.headers.get('Content-Type') || '').split(';')[0].trim();",
|
||||
" pm.expect(actual).to.eql(expectedContentTypes[pm.info.requestName]);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test('Firma Signature valida', () => {",
|
||||
" const uuid = pm.collectionVariables.get('ope_uuid');",
|
||||
" const secret = pm.variables.get('secret_key');",
|
||||
" const lowercaseHex = bytesToHex(hmacSha256Bytes(uuid, secret));",
|
||||
" const expected = bytesToBase64(utf8Bytes(lowercaseHex));",
|
||||
" pm.expect(pm.response.headers.get('Signature')).to.eql(expected);",
|
||||
"});",
|
||||
"",
|
||||
"const body = pm.response.json();",
|
||||
"",
|
||||
"if (pm.info.requestName === '01 - Consultar version') {",
|
||||
" pm.test('Version 1.0', () => pm.expect(body.version).to.eql('1.0'));",
|
||||
"}",
|
||||
"",
|
||||
"if (pm.info.requestName === '02 - Consultar bookmarks') {",
|
||||
" pm.test('Bookmark de operacion generica correcto', () => {",
|
||||
" const baseUrl = pm.variables.get('base_url').replace(/\\/$/, '');",
|
||||
" pm.expect(body['generic-operation']).to.eql(`${baseUrl}/genericoperations`);",
|
||||
" });",
|
||||
"}",
|
||||
"",
|
||||
"if (pm.info.requestName === '03 - Consultar datos de denuncia') {",
|
||||
" pm.test('Respuesta contiene entre 1 y 10 campos STRING', () => {",
|
||||
" pm.expect(body).to.have.property('data');",
|
||||
" const entries = Object.entries(body.data);",
|
||||
" pm.expect(entries.length).to.be.within(1, 10);",
|
||||
" entries.forEach(([key, value], index) => {",
|
||||
" pm.expect(key).to.eql(`FIELD_${index}`);",
|
||||
" pm.expect(value.type).to.eql('STRING');",
|
||||
" pm.expect(value.value).to.be.a('string');",
|
||||
" });",
|
||||
" });",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "01 - Consultar version",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
|
||||
{ "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
|
||||
{ "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
|
||||
{ "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
|
||||
],
|
||||
"url": "{{base_url}}/versions/current",
|
||||
"description": "Primera llamada de Gestiona. Obtiene la version del estandar soportada por el conector."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "02 - Consultar bookmarks",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
|
||||
{ "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
|
||||
{ "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
|
||||
{ "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
|
||||
],
|
||||
"url": "{{base_url}}/",
|
||||
"description": "Segunda llamada de Gestiona. Recupera la URL navegable de genericoperations."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "03 - Consultar datos de denuncia",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/vnd.generic-operation-request+json", "type": "text" },
|
||||
{ "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
|
||||
{ "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
|
||||
{ "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
|
||||
{ "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"data\": {\n \"FIELD_0\": {\n \"type\": \"STRING\",\n \"value\": \"{{expediente_gestiona}}\"\n }\n }\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": "{{base_url}}/genericoperations",
|
||||
"description": "Tercera llamada de Gestiona. FIELD_0 contiene el numero de expediente, por ejemplo 53/2026."
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{ "key": "ope_version", "value": "1.0", "type": "string" },
|
||||
{ "key": "ope_jwt", "value": "", "type": "string" },
|
||||
{ "key": "ope_uuid", "value": "", "type": "string" },
|
||||
{ "key": "ope_transaction_id", "value": "", "type": "string" }
|
||||
]
|
||||
}
|
||||
43
Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
Normal file
43
Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
PRUEBA API OPE DESDE POSTMAN
|
||||
============================
|
||||
|
||||
Contenido
|
||||
---------
|
||||
- ApiOPE-Simulacion-Gestiona.postman_collection.json
|
||||
- ApiOPE-PRE.postman_environment.json
|
||||
|
||||
Importacion
|
||||
-----------
|
||||
1. Abrir Postman y pulsar Import.
|
||||
2. Importar los dos archivos JSON incluidos en este paquete.
|
||||
3. Seleccionar el entorno "ApiOPE PRE" en la esquina superior derecha.
|
||||
|
||||
Variables que debe rellenar cada probador
|
||||
-----------------------------------------
|
||||
- base_url: direccion publicada de ApiOPE. El paquete propone http://158.158.42.110:7094.
|
||||
- client_token: token de cliente configurado en ApiOPE.
|
||||
- secret_key: secreto compartido usado para firmar el JWT HS256.
|
||||
- organization_id: identificador de la organizacion remitente.
|
||||
- organization_dir3: codigo DIR3 de la organizacion remitente.
|
||||
- organization_cif: CIF de la organizacion remitente.
|
||||
- expediente_gestiona: numero de un expediente de Gestiona que exista y cuyos datos diarios sigan disponibles, por ejemplo 53/2026.
|
||||
|
||||
Las credenciales no se incluyen en este paquete. Deben facilitarse por un canal seguro y guardarse solo como valores locales del entorno de Postman.
|
||||
|
||||
Ejecucion
|
||||
---------
|
||||
Ejecutar las peticiones de la coleccion en este orden:
|
||||
|
||||
1. 01 - Consultar version
|
||||
2. 02 - Consultar bookmarks
|
||||
3. 03 - Consultar datos de denuncia
|
||||
|
||||
Las tres peticiones comparten automaticamente el identificador de transaccion. La coleccion genera el JWT de autenticacion y valida el codigo HTTP, el Content-Type y la cabecera Signature de cada respuesta.
|
||||
|
||||
Resultado esperado
|
||||
------------------
|
||||
- Las tres respuestas devuelven HTTP 200.
|
||||
- Los tests de Postman aparecen en verde.
|
||||
- La tercera respuesta contiene los campos disponibles del expediente solicitado.
|
||||
|
||||
Si la peticion no llega al servidor, comprobar que el equipo tiene conectividad con 158.158.42.110 y acceso al puerto 7094.
|
||||
72
Antifraude.Net/ApiOPE/Program.cs
Normal file
72
Antifraude.Net/ApiOPE/Program.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Net.Http.Headers;
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Security;
|
||||
using ApiOPE.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
$"appsettings.{builder.Environment.EnvironmentName}.local.json",
|
||||
optional: true,
|
||||
reloadOnChange: false);
|
||||
|
||||
var allowHttpPublicBaseUrl = builder.Environment.IsDevelopment() ||
|
||||
builder.Environment.IsEnvironment("PreproductionTest");
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<OpeOptions>()
|
||||
.Bind(builder.Configuration.GetSection(OpeOptions.SectionName))
|
||||
.Validate(
|
||||
options => OpeOptionsValidator.IsValid(options, allowHttpPublicBaseUrl),
|
||||
"La configuracion Ope no es valida. Revisa client token, secret, URL publica, organizacion y campos de salida.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services
|
||||
.AddOptions<InternalApiOptions>()
|
||||
.Bind(builder.Configuration.GetSection(InternalApiOptions.SectionName))
|
||||
.Validate(
|
||||
InternalApiOptionsValidator.IsValid,
|
||||
"La configuracion InternalApi no es valida. Revisa BaseUrl, ApiKey y TimeoutSeconds.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<OpeResponseSigner>();
|
||||
builder.Services.AddSingleton<OpeTokenValidator>();
|
||||
builder.Services.AddSingleton<OpeFieldMapper>();
|
||||
|
||||
builder.Services.AddHttpClient<InternalDenunciasClient>((services, client) =>
|
||||
{
|
||||
var options = services.GetRequiredService<IOptions<InternalApiOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(options.ApiKeyHeaderName, options.ApiKey);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler(exceptionApp => exceptionApp.Run(OpeExceptionHandler.HandleAsync));
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
if (builder.Configuration.GetValue("ForceHttpsRedirection", false))
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
app.UseRouting();
|
||||
app.UseMiddleware<OpeAuthenticationMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
41
Antifraude.Net/ApiOPE/Properties/launchSettings.json
Normal file
41
Antifraude.Net/ApiOPE/Properties/launchSettings.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:32166",
|
||||
"sslPort": 44376
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5090",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7123;http://localhost:5090",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Antifraude.Net/ApiOPE/README.md
Normal file
153
Antifraude.Net/ApiOPE/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# ApiOPE
|
||||
|
||||
`ApiOPE` es el conector externo de operaciones genericas de Gestiona. Expone
|
||||
unicamente el contrato OPE 1.0 y consulta los datos en `ApiDenuncias`, que debe
|
||||
permanecer en la red interna.
|
||||
|
||||
## Flujo
|
||||
|
||||
1. Gestiona consulta `GET /versions/current`.
|
||||
2. Gestiona consulta `GET /` y obtiene el bookmark de la operacion generica.
|
||||
3. Gestiona llama a `POST /genericoperations` con el numero de expediente
|
||||
(`53/2026`) en `FIELD_0`. Para pruebas tambien se admite el identificador de
|
||||
denuncia (`116`).
|
||||
4. `ApiOPE` consulta el endpoint protegido correspondiente de `ApiDenuncias`.
|
||||
5. `ApiOPE` transforma la respuesta interna al formato `FIELD_0`...`FIELD_9` y
|
||||
la devuelve a Gestiona.
|
||||
|
||||
Solo el puerto o FQDN de `ApiOPE` debe publicarse. `ApiDenuncias` debe quedar
|
||||
limitada a la red interna o a localhost y sus endpoints OPE exigen, ademas, la
|
||||
cabecera compartida `X-ApiOPE-Key`.
|
||||
|
||||
## Contrato externo
|
||||
|
||||
| Metodo | Ruta | Content-Type de respuesta |
|
||||
| --- | --- | --- |
|
||||
| GET | `/versions/current` | `application/vnd.version+json` |
|
||||
| GET | `/` | `application/vnd.bookmark-list+json` |
|
||||
| POST | `/genericoperations` | `application/vnd.generic-operation-response+json` |
|
||||
|
||||
Las tres peticiones requieren `X-Rest-Basic-Token`, `X-Organization-ID`,
|
||||
`X-Organization-DIR3` y `X-Organization-CIF`. El JWT se valida con HS256,
|
||||
incluyendo `client_token`, `resource`, `uuid`, `timestamp`, `version` cuando
|
||||
corresponde y `transaction_id`. Las respuestas autenticadas incluyen la
|
||||
cabecera `Signature` calculada con el algoritmo exigido por Gestiona.
|
||||
|
||||
Los errores de datos, elemento inexistente o fallo del conector se devuelven
|
||||
como HTTP 412 con `application/vnd.generic-operation.error+json`. Una firma o
|
||||
identidad de conector invalida devuelve HTTP 401 con causa `WRONG_SIGNATURE`.
|
||||
|
||||
## Configuracion de despliegue
|
||||
|
||||
Los secretos no deben escribirse en los ficheros versionados. Configurar estas
|
||||
claves como variables de entorno del proceso, ajustes de IIS/App Service o
|
||||
secretos equivalentes:
|
||||
|
||||
```text
|
||||
Ope__ClientToken
|
||||
Ope__SecretKey
|
||||
Ope__PublicBaseUrl
|
||||
Ope__OrganizationId
|
||||
Ope__OrganizationDir3
|
||||
Ope__OrganizationCif
|
||||
Ope__TokenMaxAgeSeconds
|
||||
Ope__ClockSkewSeconds
|
||||
Ope__OutputFields__0 ... Ope__OutputFields__9
|
||||
|
||||
InternalApi__BaseUrl
|
||||
InternalApi__ApiKey
|
||||
InternalApi__ApiKeyHeaderName=X-ApiOPE-Key
|
||||
InternalApi__TimeoutSeconds
|
||||
```
|
||||
|
||||
En `ApiDenuncias`, configurar la misma clave interna:
|
||||
|
||||
```text
|
||||
OpeBridge__ApiKey=<mismo valor que InternalApi__ApiKey>
|
||||
OpeBridge__ApiKeyHeaderName=X-ApiOPE-Key
|
||||
```
|
||||
|
||||
`Ope__PublicBaseUrl` debe ser la URL HTTPS publica completa del conector,
|
||||
incluido cualquier path base. El bookmark se construye a partir de ese valor.
|
||||
|
||||
La aplicacion valida la configuracion al arrancar. Si faltan el secreto, los
|
||||
datos de organizacion, la URL publica o el mapa de salida, no inicia para evitar
|
||||
publicar un conector incompleto.
|
||||
|
||||
### Prueba temporal de preproduccion
|
||||
|
||||
Los perfiles IIS de `ApiOPE` y `ApiDenuncias` usan temporalmente el entorno
|
||||
`PreproductionTest` para probar el conector directamente por IP y HTTP. Sus
|
||||
credenciales se guardan en estos archivos locales:
|
||||
|
||||
```text
|
||||
ApiOPE/appsettings.PreproductionTest.local.json
|
||||
ApiDenuncias/appsettings.PreproductionTest.local.json
|
||||
```
|
||||
|
||||
Ambos archivos estan excluidos de Git, pero se copian a los paquetes generados
|
||||
en esta maquina. La clave `InternalApi__ApiKey` de `ApiOPE` debe coincidir con
|
||||
`OpeBridge__ApiKey` de `ApiDenuncias`.
|
||||
|
||||
Este perfil no debe utilizarse como configuracion definitiva. Al crear el
|
||||
conector real en Gestiona se deben configurar por un canal seguro su codigo,
|
||||
su `Secret Key` y los datos reales de organizacion, publicar la URL HTTPS y
|
||||
volver a usar un entorno de produccion.
|
||||
|
||||
## Campos de salida
|
||||
|
||||
La API interna ofrece estos 12 campos:
|
||||
|
||||
```text
|
||||
fechaDenuncia
|
||||
numeroDenunciaCanal
|
||||
aQuienDenuncia
|
||||
resumenDenuncia
|
||||
fechaHechos
|
||||
lugarHechos
|
||||
ambitoCompetencias
|
||||
solicitaProteccion
|
||||
sexoDenunciante
|
||||
autorizaRemisionDenuncia
|
||||
autorizaNotificacionesViaSms
|
||||
preferenciaNotificacionSeguimientoDenuncia
|
||||
```
|
||||
|
||||
OPE 1.0 admite como maximo 10 campos por operacion. Antes de configurar PRE hay
|
||||
que acordar cuales diez se publican o dividir la consulta en dos operaciones.
|
||||
El orden de `Ope__OutputFields__N` determina la correspondencia con `FIELD_N`.
|
||||
|
||||
## Publicacion
|
||||
|
||||
1. Publicar `ApiDenuncias` con `OpeBridge__ApiKey` configurada.
|
||||
2. Publicar `ApiOPE` con los valores anteriores y una clave interna distinta de
|
||||
`Ope__SecretKey`.
|
||||
3. Permitir desde `ApiOPE` la conexion interna a `ApiDenuncias` y bloquear el
|
||||
acceso exterior directo a esta ultima.
|
||||
4. Publicar exclusivamente `ApiOPE` mediante HTTPS y configurar su URL base en
|
||||
el conector de Gestiona.
|
||||
5. Validar consecutivamente version, bookmarks y operacion generica, incluida
|
||||
la cabecera `Signature` de cada respuesta.
|
||||
|
||||
Los logs identifican fallos mediante `transaction_id`, pero nunca registran el
|
||||
JWT, el secreto compartido, la clave interna ni el contenido de la denuncia.
|
||||
|
||||
## Simulacion desde Postman
|
||||
|
||||
En `Postman` se incluyen una coleccion y un entorno importables:
|
||||
|
||||
```text
|
||||
Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json
|
||||
Postman/ApiOPE-PRE.postman_environment.json
|
||||
```
|
||||
|
||||
1. Importar ambos ficheros en Postman y seleccionar el entorno `ApiOPE PRE`.
|
||||
2. Rellenar sus siete variables con la misma URL, identidad y secreto que tenga
|
||||
el conector publicado. El expediente debe corresponder a datos del dia que
|
||||
aun no hayan sido purgados.
|
||||
3. Ejecutar la coleccion completa con Runner para conservar un mismo
|
||||
`transaction_id` durante las tres peticiones.
|
||||
|
||||
La coleccion genera un `uuid` y un JWT HS256 nuevos en cada llamada. Tambien
|
||||
comprueba automaticamente el status, el media type, el cuerpo y la cabecera
|
||||
`Signature` devuelta por `ApiOPE`.
|
||||
15
Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
Normal file
15
Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||
public sealed class OpeAuthenticatedAttribute : Attribute
|
||||
{
|
||||
public OpeAuthenticatedAttribute(string resource, bool versionOptional = false)
|
||||
{
|
||||
Resource = resource;
|
||||
VersionOptional = versionOptional;
|
||||
}
|
||||
|
||||
public string Resource { get; }
|
||||
|
||||
public bool VersionOptional { get; }
|
||||
}
|
||||
131
Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
Normal file
131
Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public sealed class OpeAuthenticationMiddleware
|
||||
{
|
||||
public const string TokenHeaderName = "X-Rest-Basic-Token";
|
||||
public const string OrganizationIdHeaderName = "X-Organization-ID";
|
||||
public const string OrganizationDir3HeaderName = "X-Organization-DIR3";
|
||||
public const string OrganizationCifHeaderName = "X-Organization-CIF";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<OpeAuthenticationMiddleware> _logger;
|
||||
|
||||
public OpeAuthenticationMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<OpeAuthenticationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
OpeTokenValidator tokenValidator,
|
||||
OpeResponseSigner responseSigner,
|
||||
IOptions<OpeOptions> options)
|
||||
{
|
||||
var authentication = context.GetEndpoint()?.Metadata.GetMetadata<OpeAuthenticatedAttribute>();
|
||||
if (authentication is null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetSingleHeader(context.Request.Headers, TokenHeaderName, out var token))
|
||||
{
|
||||
await RejectAsync(context, "Falta la cabecera de autenticacion OPE.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional);
|
||||
if (!validation.IsValid || validation.RequestContext is null)
|
||||
{
|
||||
await RejectAsync(context, validation.FailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ValidateOrganizationHeaders(context.Request.Headers, options.Value, out var organizationFailure))
|
||||
{
|
||||
await RejectAsync(context, organizationFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
OpeRequestContextStore.Set(context, validation.RequestContext);
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim("client_token", validation.RequestContext.ClientToken),
|
||||
new Claim("transaction_id", validation.RequestContext.TransactionId.ToString()),
|
||||
new Claim("uuid", validation.RequestContext.Uuid.ToString())
|
||||
], "OpeJwt"));
|
||||
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
context.Response.Headers["Signature"] = responseSigner.Sign(validation.RequestContext.Uuid);
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers.Pragma = "no-cache";
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private async Task RejectAsync(HttpContext context, string reason)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Peticion OPE rechazada en {Method} {Path}. Motivo={Reason}",
|
||||
context.Request.Method,
|
||||
context.Request.Path,
|
||||
reason);
|
||||
|
||||
await OpeResponseWriter.WriteErrorAsync(
|
||||
context.Response,
|
||||
StatusCodes.Status401Unauthorized,
|
||||
"Unauthorized",
|
||||
OpeErrorCauses.WrongSignature,
|
||||
null,
|
||||
context.RequestAborted);
|
||||
}
|
||||
|
||||
private static bool ValidateOrganizationHeaders(
|
||||
IHeaderDictionary headers,
|
||||
OpeOptions options,
|
||||
out string failureReason)
|
||||
{
|
||||
var valid = MatchesHeader(headers, OrganizationIdHeaderName, options.OrganizationId) &&
|
||||
MatchesHeader(headers, OrganizationDir3HeaderName, options.OrganizationDir3) &&
|
||||
MatchesHeader(headers, OrganizationCifHeaderName, options.OrganizationCif);
|
||||
|
||||
failureReason = valid
|
||||
? string.Empty
|
||||
: "Las cabeceras de organizacion no corresponden al conector configurado.";
|
||||
return valid;
|
||||
}
|
||||
|
||||
private static bool MatchesHeader(IHeaderDictionary headers, string name, string expected)
|
||||
{
|
||||
return TryGetSingleHeader(headers, name, out var value) &&
|
||||
string.Equals(value, expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryGetSingleHeader(
|
||||
IHeaderDictionary headers,
|
||||
string name,
|
||||
out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (!headers.TryGetValue(name, out StringValues values) || values.Count != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = values[0]?.Trim() ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
31
Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
Normal file
31
Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using ApiOPE.Contracts;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public static class OpeExceptionHandler
|
||||
{
|
||||
public static async Task HandleAsync(HttpContext context)
|
||||
{
|
||||
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
|
||||
var requestContext = OpeRequestContextStore.Get(context);
|
||||
var logger = context.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("ApiOPE.UnhandledException");
|
||||
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Error no controlado en la operacion OPE. Path={Path}; TransactionId={TransactionId}",
|
||||
context.Request.Path,
|
||||
requestContext?.TransactionId);
|
||||
|
||||
context.Response.Clear();
|
||||
await OpeResponseWriter.WriteErrorAsync(
|
||||
context.Response,
|
||||
StatusCodes.Status412PreconditionFailed,
|
||||
"Error en el conector",
|
||||
OpeErrorCauses.ConnectorError,
|
||||
null,
|
||||
context.RequestAborted);
|
||||
}
|
||||
}
|
||||
20
Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
Normal file
20
Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public sealed record OpeRequestContext(
|
||||
string ClientToken,
|
||||
string Resource,
|
||||
string Uuid,
|
||||
Guid TransactionId,
|
||||
long Timestamp,
|
||||
string? Version);
|
||||
|
||||
public static class OpeRequestContextStore
|
||||
{
|
||||
private static readonly object Key = new();
|
||||
|
||||
public static void Set(HttpContext context, OpeRequestContext requestContext)
|
||||
=> context.Items[Key] = requestContext;
|
||||
|
||||
public static OpeRequestContext? Get(HttpContext context)
|
||||
=> context.Items.TryGetValue(Key, out var value) ? value as OpeRequestContext : null;
|
||||
}
|
||||
23
Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
Normal file
23
Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ApiOPE.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public sealed class OpeResponseSigner
|
||||
{
|
||||
private readonly byte[] _secretKey;
|
||||
|
||||
public OpeResponseSigner(IOptions<OpeOptions> options)
|
||||
{
|
||||
_secretKey = Encoding.UTF8.GetBytes(options.Value.SecretKey);
|
||||
}
|
||||
|
||||
public string Sign(string uuid)
|
||||
{
|
||||
var hash = HMACSHA256.HashData(_secretKey, Encoding.UTF8.GetBytes(uuid));
|
||||
var lowercaseHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(lowercaseHex));
|
||||
}
|
||||
}
|
||||
33
Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
Normal file
33
Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using ApiOPE.Contracts;
|
||||
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public static class OpeResponseWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static async Task WriteErrorAsync(
|
||||
HttpResponse response,
|
||||
int statusCode,
|
||||
string message,
|
||||
string cause,
|
||||
IReadOnlyDictionary<string, string>? data,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
response.StatusCode = statusCode;
|
||||
response.ContentType = OpeMediaTypes.Error;
|
||||
|
||||
var error = new OpeErrorResponse(
|
||||
statusCode,
|
||||
message,
|
||||
new OpeTypedInfo(null, null, null, cause),
|
||||
data);
|
||||
|
||||
await JsonSerializer.SerializeAsync(
|
||||
response.Body,
|
||||
error,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
238
Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
Normal file
238
Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ApiOPE.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Security;
|
||||
|
||||
public sealed record OpeTokenValidationResult(
|
||||
bool IsValid,
|
||||
OpeRequestContext? RequestContext,
|
||||
string FailureReason)
|
||||
{
|
||||
public static OpeTokenValidationResult Success(OpeRequestContext context)
|
||||
=> new(true, context, string.Empty);
|
||||
|
||||
public static OpeTokenValidationResult Failure(string reason)
|
||||
=> new(false, null, reason);
|
||||
}
|
||||
|
||||
public sealed class OpeTokenValidator
|
||||
{
|
||||
private const int MaxTokenLength = 16 * 1024;
|
||||
|
||||
private readonly OpeOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly byte[] _secretKey;
|
||||
|
||||
public OpeTokenValidator(IOptions<OpeOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider;
|
||||
_secretKey = Encoding.UTF8.GetBytes(_options.SecretKey);
|
||||
}
|
||||
|
||||
public OpeTokenValidationResult Validate(
|
||||
string token,
|
||||
string expectedResource,
|
||||
bool versionOptional)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token) || token.Length > MaxTokenLength)
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("Token ausente o demasiado largo.");
|
||||
}
|
||||
|
||||
var segments = token.Split('.');
|
||||
if (segments.Length != 3 || segments.Any(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El JWT no contiene tres segmentos.");
|
||||
}
|
||||
|
||||
byte[] headerBytes;
|
||||
byte[] payloadBytes;
|
||||
byte[] receivedSignature;
|
||||
try
|
||||
{
|
||||
headerBytes = DecodeBase64Url(segments[0]);
|
||||
payloadBytes = DecodeBase64Url(segments[1]);
|
||||
receivedSignature = DecodeBase64Url(segments[2]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El JWT no esta codificado en Base64Url valido.");
|
||||
}
|
||||
|
||||
if (!HasSupportedHeader(headerBytes))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El JWT no declara exclusivamente HS256.");
|
||||
}
|
||||
|
||||
var signedBytes = Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}");
|
||||
var expectedSignature = HMACSHA256.HashData(_secretKey, signedBytes);
|
||||
if (receivedSignature.Length != expectedSignature.Length ||
|
||||
!CryptographicOperations.FixedTimeEquals(receivedSignature, expectedSignature))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("La firma del JWT no es valida.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var payload = JsonDocument.Parse(payloadBytes, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 8
|
||||
});
|
||||
|
||||
if (payload.RootElement.ValueKind != JsonValueKind.Object ||
|
||||
HasDuplicateProperties(payload.RootElement))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El payload del JWT no es un objeto valido.");
|
||||
}
|
||||
|
||||
var clientToken = GetRequiredString(payload.RootElement, "client_token");
|
||||
var resource = GetRequiredString(payload.RootElement, "resource");
|
||||
var uuidText = GetRequiredString(payload.RootElement, "uuid");
|
||||
var transactionIdText = GetRequiredString(payload.RootElement, "transaction_id");
|
||||
var version = GetOptionalString(payload.RootElement, "version");
|
||||
var timestamp = GetRequiredInt64(payload.RootElement, "timestamp");
|
||||
|
||||
if (!FixedTimeEquals(clientToken, _options.ClientToken))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El client_token no corresponde al conector.");
|
||||
}
|
||||
|
||||
if (!string.Equals(resource, expectedResource, StringComparison.Ordinal))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El recurso firmado no corresponde al recurso solicitado.");
|
||||
}
|
||||
|
||||
if ((!versionOptional && !string.Equals(version, _options.Version, StringComparison.Ordinal)) ||
|
||||
(versionOptional && version is not null && !string.Equals(version, _options.Version, StringComparison.Ordinal)))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("La version firmada no es compatible.");
|
||||
}
|
||||
|
||||
if (!Guid.TryParseExact(uuidText, "D", out _) ||
|
||||
!Guid.TryParseExact(transactionIdText, "D", out var transactionId))
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("uuid o transaction_id no tienen formato UUID.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
|
||||
if (timestamp > now + _options.ClockSkewSeconds ||
|
||||
timestamp < now - _options.TokenMaxAgeSeconds - _options.ClockSkewSeconds)
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El timestamp del JWT esta fuera de la ventana permitida.");
|
||||
}
|
||||
|
||||
return OpeTokenValidationResult.Success(new OpeRequestContext(
|
||||
clientToken,
|
||||
resource,
|
||||
uuidText,
|
||||
transactionId,
|
||||
timestamp,
|
||||
version));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return OpeTokenValidationResult.Failure("El payload del JWT no contiene JSON valido.");
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return OpeTokenValidationResult.Failure(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasSupportedHeader(byte[] headerBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var header = JsonDocument.Parse(headerBytes, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 4
|
||||
});
|
||||
|
||||
return header.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
!HasDuplicateProperties(header.RootElement) &&
|
||||
string.Equals(GetRequiredString(header.RootElement, "alg"), "HS256", StringComparison.Ordinal);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] DecodeBase64Url(string value)
|
||||
{
|
||||
var base64 = value.Replace('-', '+').Replace('_', '/');
|
||||
base64 = (base64.Length % 4) switch
|
||||
{
|
||||
0 => base64,
|
||||
2 => base64 + "==",
|
||||
3 => base64 + "=",
|
||||
_ => throw new FormatException("Longitud Base64Url no valida.")
|
||||
};
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
private static bool HasDuplicateProperties(JsonElement element)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
return element.EnumerateObject().Any(property => !names.Add(property.Name));
|
||||
}
|
||||
|
||||
private static string GetRequiredString(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var property) ||
|
||||
property.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
throw new InvalidOperationException($"Falta la claim obligatoria {name}.");
|
||||
}
|
||||
|
||||
return property.GetString()!;
|
||||
}
|
||||
|
||||
private static string? GetOptionalString(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (property.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
throw new InvalidOperationException($"La claim {name} tiene un formato incorrecto.");
|
||||
}
|
||||
|
||||
return property.GetString();
|
||||
}
|
||||
|
||||
private static long GetRequiredInt64(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Number ||
|
||||
!property.TryGetInt64(out var value))
|
||||
{
|
||||
throw new InvalidOperationException($"Falta la claim numerica obligatoria {name}.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string left, string right)
|
||||
{
|
||||
var leftHash = SHA256.HashData(Encoding.UTF8.GetBytes(left));
|
||||
var rightHash = SHA256.HashData(Encoding.UTF8.GetBytes(right));
|
||||
return CryptographicOperations.FixedTimeEquals(leftHash, rightHash);
|
||||
}
|
||||
}
|
||||
38
Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
Normal file
38
Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ApiOPE.Services;
|
||||
|
||||
public sealed record DenunciaLookup(string RelativePath);
|
||||
|
||||
public static partial class DenunciaLookupParser
|
||||
{
|
||||
public static bool TryParse(string value, out DenunciaLookup? lookup)
|
||||
{
|
||||
lookup = null;
|
||||
var trimmed = value.Trim();
|
||||
|
||||
var expedienteMatch = ExpedientePattern().Match(trimmed);
|
||||
if (expedienteMatch.Success &&
|
||||
int.TryParse(expedienteMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) &&
|
||||
int.TryParse(expedienteMatch.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) &&
|
||||
number > 0 &&
|
||||
year is >= 2000 and <= 9999)
|
||||
{
|
||||
lookup = new DenunciaLookup($"api/denuncias/{number}/{year}/gestiona-fields");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out var complaintId) &&
|
||||
complaintId > 0)
|
||||
{
|
||||
lookup = new DenunciaLookup($"api/denuncias/{complaintId}/gestiona-fields");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(\d+)\s*/\s*(\d{4})$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ExpedientePattern();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ApiOPE.Contracts;
|
||||
|
||||
namespace ApiOPE.Services;
|
||||
|
||||
public sealed record GenericOperationRequestResult(
|
||||
OpeDataEnvelope? Request,
|
||||
IReadOnlyDictionary<string, string> Errors)
|
||||
{
|
||||
public bool IsValid => Request is not null && Errors.Count == 0;
|
||||
}
|
||||
|
||||
public static class GenericOperationRequestReader
|
||||
{
|
||||
private const string ExpectedField = "FIELD_0";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = false,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
MaxDepth = 16
|
||||
};
|
||||
|
||||
public static async Task<GenericOperationRequestResult> ReadAsync(
|
||||
HttpRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) ||
|
||||
!string.Equals(
|
||||
contentType.MediaType,
|
||||
OpeMediaTypes.GenericOperationRequest,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat;
|
||||
return new GenericOperationRequestResult(null, errors);
|
||||
}
|
||||
|
||||
OpeDataEnvelope? envelope;
|
||||
try
|
||||
{
|
||||
envelope = await JsonSerializer.DeserializeAsync<OpeDataEnvelope>(
|
||||
request.Body,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat;
|
||||
return new GenericOperationRequestResult(null, errors);
|
||||
}
|
||||
|
||||
if (envelope?.Data is null)
|
||||
{
|
||||
errors[ExpectedField] = OpeFieldErrors.Expected;
|
||||
return new GenericOperationRequestResult(null, errors);
|
||||
}
|
||||
|
||||
foreach (var field in envelope.Data)
|
||||
{
|
||||
if (!string.Equals(field.Key, ExpectedField, StringComparison.Ordinal))
|
||||
{
|
||||
errors[field.Key] = OpeFieldErrors.NotExpected;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.Value is null ||
|
||||
!string.Equals(field.Value.Type, "STRING", StringComparison.Ordinal) ||
|
||||
string.IsNullOrWhiteSpace(field.Value.Value))
|
||||
{
|
||||
errors[field.Key] = OpeFieldErrors.UnexpectedFormat;
|
||||
}
|
||||
}
|
||||
|
||||
if (!envelope.Data.ContainsKey(ExpectedField))
|
||||
{
|
||||
errors[ExpectedField] = OpeFieldErrors.Expected;
|
||||
}
|
||||
|
||||
if (envelope.Data.Count > 10)
|
||||
{
|
||||
foreach (var field in envelope.Data.Keys.Where(key => key != ExpectedField))
|
||||
{
|
||||
errors[field] = OpeFieldErrors.NotExpected;
|
||||
}
|
||||
}
|
||||
|
||||
return new GenericOperationRequestResult(envelope, errors);
|
||||
}
|
||||
}
|
||||
98
Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
Normal file
98
Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using ApiOPE.Contracts;
|
||||
|
||||
namespace ApiOPE.Services;
|
||||
|
||||
public enum InternalLookupStatus
|
||||
{
|
||||
Success,
|
||||
NotFound
|
||||
}
|
||||
|
||||
public sealed record InternalLookupResult(
|
||||
InternalLookupStatus Status,
|
||||
InternalGestionaFieldsResponse? Fields);
|
||||
|
||||
public sealed class InternalApiException : Exception
|
||||
{
|
||||
public InternalApiException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public InternalApiException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InternalDenunciasClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = false,
|
||||
MaxDepth = 16
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<InternalDenunciasClient> _logger;
|
||||
|
||||
public InternalDenunciasClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<InternalDenunciasClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<InternalLookupResult> GetFieldsAsync(
|
||||
DenunciaLookup lookup,
|
||||
Guid transactionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, lookup.RelativePath);
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
||||
{
|
||||
return new InternalLookupResult(InternalLookupStatus.NotFound, null);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ApiDenuncias ha rechazado una consulta OPE. Status={StatusCode}; TransactionId={TransactionId}",
|
||||
(int)response.StatusCode,
|
||||
transactionId);
|
||||
throw new InternalApiException("ApiDenuncias no ha podido completar la consulta OPE.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var fields = await JsonSerializer.DeserializeAsync<InternalGestionaFieldsResponse>(
|
||||
stream,
|
||||
JsonOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (fields?.Data is null)
|
||||
{
|
||||
throw new InvalidDataException("La respuesta interna no contiene el objeto data.");
|
||||
}
|
||||
|
||||
return new InternalLookupResult(InternalLookupStatus.Success, fields);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InternalApiException("ApiDenuncias ha devuelto una respuesta no valida.", exception);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
throw new InternalApiException("ApiDenuncias ha devuelto una respuesta incompleta.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
Normal file
51
Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using ApiOPE.Configuration;
|
||||
using ApiOPE.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ApiOPE.Services;
|
||||
|
||||
public sealed class OpeFieldMapper
|
||||
{
|
||||
public static readonly IReadOnlySet<string> SupportedInternalFields = new HashSet<string>(
|
||||
[
|
||||
"fechaDenuncia",
|
||||
"numeroDenunciaCanal",
|
||||
"aQuienDenuncia",
|
||||
"resumenDenuncia",
|
||||
"fechaHechos",
|
||||
"lugarHechos",
|
||||
"ambitoCompetencias",
|
||||
"solicitaProteccion",
|
||||
"sexoDenunciante",
|
||||
"autorizaRemisionDenuncia",
|
||||
"autorizaNotificacionesViaSms",
|
||||
"preferenciaNotificacionSeguimientoDenuncia"
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
private readonly string[] _outputFields;
|
||||
|
||||
public OpeFieldMapper(IOptions<OpeOptions> options)
|
||||
{
|
||||
_outputFields = options.Value.OutputFields.ToArray();
|
||||
}
|
||||
|
||||
public OpeDataEnvelope Map(InternalGestionaFieldsResponse source)
|
||||
{
|
||||
var output = new Dictionary<string, OpeFieldValue>(StringComparer.Ordinal);
|
||||
|
||||
for (var index = 0; index < _outputFields.Length; index++)
|
||||
{
|
||||
var internalField = _outputFields[index];
|
||||
if (!source.Data.TryGetValue(internalField, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"La API interna no ha devuelto el campo configurado '{internalField}'.");
|
||||
}
|
||||
|
||||
output[$"FIELD_{index}"] = new OpeFieldValue("STRING", value.Value?.Trim() ?? string.Empty);
|
||||
}
|
||||
|
||||
return new OpeDataEnvelope(output);
|
||||
}
|
||||
}
|
||||
37
Antifraude.Net/ApiOPE/appsettings.Development.json
Normal file
37
Antifraude.Net/ApiOPE/appsettings.Development.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Ope": {
|
||||
"Version": "1.0",
|
||||
"ClientToken": "development-ope-client",
|
||||
"SecretKey": "development-only-ope-secret-key-change-me",
|
||||
"PublicBaseUrl": "http://localhost:5090",
|
||||
"TokenMaxAgeSeconds": 300,
|
||||
"ClockSkewSeconds": 60,
|
||||
"OrganizationId": "00000000-0000-0000-0000-000000000001",
|
||||
"OrganizationDir3": "DEV000001",
|
||||
"OrganizationCif": "DEV000001",
|
||||
"OutputFields": [
|
||||
"fechaDenuncia",
|
||||
"numeroDenunciaCanal",
|
||||
"aQuienDenuncia",
|
||||
"resumenDenuncia",
|
||||
"fechaHechos",
|
||||
"lugarHechos",
|
||||
"ambitoCompetencias",
|
||||
"solicitaProteccion",
|
||||
"sexoDenunciante",
|
||||
"autorizaRemisionDenuncia"
|
||||
]
|
||||
},
|
||||
"InternalApi": {
|
||||
"BaseUrl": "http://localhost:7093",
|
||||
"ApiKey": "development-only-internal-ope-key-change-me",
|
||||
"ApiKeyHeaderName": "X-ApiOPE-Key",
|
||||
"TimeoutSeconds": 20
|
||||
}
|
||||
}
|
||||
28
Antifraude.Net/ApiOPE/appsettings.json
Normal file
28
Antifraude.Net/ApiOPE/appsettings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ForceHttpsRedirection": false,
|
||||
"Ope": {
|
||||
"Version": "1.0",
|
||||
"ClientToken": "",
|
||||
"SecretKey": "",
|
||||
"PublicBaseUrl": "",
|
||||
"TokenMaxAgeSeconds": 300,
|
||||
"ClockSkewSeconds": 60,
|
||||
"OrganizationId": "",
|
||||
"OrganizationDir3": "",
|
||||
"OrganizationCif": "",
|
||||
"OutputFields": []
|
||||
},
|
||||
"InternalApi": {
|
||||
"BaseUrl": "http://localhost:7093",
|
||||
"ApiKey": "",
|
||||
"ApiKeyHeaderName": "X-ApiOPE-Key",
|
||||
"TimeoutSeconds": 20
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ public sealed record InboxSnapshotResponse(
|
||||
IReadOnlyList<ReportDto> Reports,
|
||||
InboxUserState UserState);
|
||||
|
||||
public sealed record ImportReportRequest(ReportDto Report);
|
||||
public sealed record ImportReportRequest(
|
||||
ReportDto Report,
|
||||
bool ConfirmDifferentOwner = false);
|
||||
|
||||
public sealed record MarkFicherosUploadedRequest(
|
||||
IReadOnlyList<string> FileNames,
|
||||
@@ -43,7 +45,8 @@ public sealed record MarkReportHandledInGestionaRequest(
|
||||
|
||||
public sealed record TrackingImportPermissionRequest(
|
||||
string Username,
|
||||
ReportDto Report);
|
||||
ReportDto Report,
|
||||
bool ConfirmDifferentOwner = false);
|
||||
|
||||
public sealed record GestionaCreateFileRequest(
|
||||
string Subject,
|
||||
@@ -92,7 +95,8 @@ public sealed record GestionaTramitarDocumentoRequest(
|
||||
string DocumentUrl,
|
||||
string AssignedGroupCode,
|
||||
int? ComplaintId,
|
||||
bool IsUpdate = false);
|
||||
bool IsUpdate = false,
|
||||
string? UpdateSource = null);
|
||||
|
||||
public sealed record ManualPurgeRequest(string Date);
|
||||
|
||||
@@ -109,6 +113,26 @@ public sealed record AppConfigurationDto(
|
||||
|
||||
public sealed record UpdateExternalUpdateCutoffRequest(string? Date);
|
||||
|
||||
public sealed record WorkGroupDto(
|
||||
string Code,
|
||||
string Name);
|
||||
|
||||
public sealed record UserWorkGroupDto(
|
||||
long UserId,
|
||||
string Username,
|
||||
IReadOnlyList<string> GroupCodes);
|
||||
|
||||
public sealed record WorkGroupAdministrationDto(
|
||||
IReadOnlyList<WorkGroupDto> Groups,
|
||||
IReadOnlyList<UserWorkGroupDto> Users);
|
||||
|
||||
public sealed record CurrentUserWorkGroupsDto(
|
||||
string Username,
|
||||
IReadOnlyList<string> GroupCodes);
|
||||
|
||||
public sealed record UpdateUserWorkGroupsRequest(
|
||||
IReadOnlyList<string> GroupCodes);
|
||||
|
||||
public sealed record GestionaComplaintFieldsResponse(
|
||||
DateTime? FechaDenuncia,
|
||||
int NumeroDenunciaCanal,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace GestionaDenuncias.Shared.Models;
|
||||
|
||||
public static class ComplaintUpdateSources
|
||||
{
|
||||
public const string Citizen = "citizen";
|
||||
public const string Receiver = "receiver";
|
||||
|
||||
public static bool IsCitizen(string? value)
|
||||
=> string.Equals(value, Citizen, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsReceiver(string? value)
|
||||
=> string.Equals(value, Receiver, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -79,10 +79,16 @@ public class DenunciasGestiona
|
||||
public DateTime FechaSubidaAGestiona { get; set; } = DateTime.MinValue;
|
||||
public string UltimaSubidaGestionaTipo { get; set; } = string.Empty;
|
||||
public string UltimoGrupoAsignadoGestiona { get; set; } = string.Empty;
|
||||
public string PendingUpdateSource { get; set; } = string.Empty;
|
||||
|
||||
public bool EnGestiona { get; set; }
|
||||
public bool EnRechazada { get; set; }
|
||||
|
||||
public string OwnerUsername { get; set; } = string.Empty;
|
||||
public bool OwnedByCurrentUser { get; set; }
|
||||
public bool RequiresOwnerConfirmation { get; set; }
|
||||
public IReadOnlyList<string> OwnerWorkGroups { get; set; } = [];
|
||||
|
||||
[JsonIgnore]
|
||||
public DateOnly? KeyDate { get; set; }
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
namespace GestionaDenuncias.Shared.Models;
|
||||
|
||||
public sealed record GlSession(string Id, string Username, string? Role = null, string? DpopPrivateKey = null);
|
||||
public sealed record GlobalLeaksProofOfWorkToken(string Id, string Salt);
|
||||
|
||||
public sealed record GlSession(
|
||||
string Id,
|
||||
string Username,
|
||||
string? Role = null,
|
||||
string? DpopPrivateKey = null,
|
||||
GlobalLeaksProofOfWorkToken? ProofOfWorkToken = null,
|
||||
DateTimeOffset? SessionExpiresAtUtc = null);
|
||||
|
||||
@@ -7,9 +7,14 @@ public sealed class GlobalLeaksStoredSession
|
||||
public string? SessionId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? DpopPrivateKey { get; set; }
|
||||
public GlobalLeaksProofOfWorkToken? ProofOfWorkToken { get; set; }
|
||||
public DateTimeOffset? SessionExpiresAtUtc { get; set; }
|
||||
public DateTimeOffset? LastKeepAliveAtUtc { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool HasActiveSession =>
|
||||
!string.IsNullOrWhiteSpace(SessionId) &&
|
||||
!string.IsNullOrWhiteSpace(DpopPrivateKey);
|
||||
!string.IsNullOrWhiteSpace(DpopPrivateKey) &&
|
||||
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Id) &&
|
||||
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Salt);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ public sealed record ReportDetailDto(
|
||||
string? LastAccess,
|
||||
IReadOnlyList<ReportCommentDto> Comments,
|
||||
IReadOnlyList<ReportFileDto> WhistleblowerFiles,
|
||||
IReadOnlyList<ReportFileDto> ReceiverFiles);
|
||||
IReadOnlyList<ReportFileDto> ReceiverFiles,
|
||||
IReadOnlyList<ReportReceiverDto>? Receivers = null);
|
||||
|
||||
public sealed record ReportCommentDto(
|
||||
string? Id,
|
||||
string? Type,
|
||||
string? Content,
|
||||
string? CreationDate,
|
||||
bool IsNew);
|
||||
bool IsNew,
|
||||
string? AuthorId = null,
|
||||
string? AuthorName = null);
|
||||
|
||||
public sealed record ReportFileDto(
|
||||
string? Id,
|
||||
@@ -20,4 +23,11 @@ public sealed record ReportFileDto(
|
||||
long? Size,
|
||||
string? ContentType,
|
||||
string? CreationDate,
|
||||
bool IsNew);
|
||||
bool IsNew,
|
||||
string? AuthorId = null,
|
||||
string? AuthorName = null);
|
||||
|
||||
public sealed record ReportReceiverDto(
|
||||
string Id,
|
||||
string Name,
|
||||
bool Active);
|
||||
|
||||
@@ -24,11 +24,19 @@ public sealed record ReportDto
|
||||
public bool CitizenHasNewFile { get; init; }
|
||||
public string? ReceiverLastActivity { get; init; }
|
||||
public bool ReceiverHasNewActivity { get; init; }
|
||||
public string? ReceiverLastActivityAuthorId { get; init; }
|
||||
public string? ReceiverLastActivityAuthorName { get; init; }
|
||||
public bool DownloadedByCurrentUser { get; init; }
|
||||
public bool DownloadedByAnotherUser { get; init; }
|
||||
public string? LastDownloadedByUsername { get; init; }
|
||||
public string? LastDownloadedAt { get; init; }
|
||||
public string? LastGestionaUploadAt { get; init; }
|
||||
public bool AlreadyImported { get; init; }
|
||||
public bool AlreadyInGestiona { get; init; }
|
||||
public string? OwnerUsername { get; init; }
|
||||
public bool OwnedByCurrentUser { get; init; }
|
||||
public bool AccessibleByWorkGroup { get; init; }
|
||||
public bool RequiresOwnerConfirmation { get; init; }
|
||||
public IReadOnlyList<string> OwnerWorkGroups { get; init; } = [];
|
||||
public string? TrackingNote { get; init; }
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ public interface IInboxTrackingService
|
||||
Task EnsureReportCanBeImportedByUserAsync(
|
||||
string username,
|
||||
ReportDto report,
|
||||
bool confirmDifferentOwner = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<body>
|
||||
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
|
||||
<script src="Scripts/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/appAuth.js"></script>
|
||||
<script src="js/appAuth.js?v=20260724-session-keepalive"></script>
|
||||
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@implements IDisposable
|
||||
@inject UiDialogService Dialog
|
||||
|
||||
@if (Dialog.IsVisible)
|
||||
{
|
||||
<div class="app-confirmation-backdrop"
|
||||
role="presentation"
|
||||
@onkeydown="HandleKeyDown">
|
||||
<section class="@DialogCss"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="app-confirmation-title"
|
||||
aria-describedby="app-confirmation-message">
|
||||
<div class="app-confirmation__accent" aria-hidden="true"></div>
|
||||
|
||||
<div class="app-confirmation__content">
|
||||
<div class="@IconCss" aria-hidden="true">!</div>
|
||||
|
||||
<div class="app-confirmation__copy">
|
||||
<div class="app-confirmation__eyebrow">@Eyebrow</div>
|
||||
<h2 id="app-confirmation-title" class="app-confirmation__title">
|
||||
@Dialog.Title
|
||||
</h2>
|
||||
<p id="app-confirmation-message" class="app-confirmation__message">
|
||||
@Dialog.Message
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-confirmation__actions">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary app-confirmation__button"
|
||||
@ref="_cancelButton"
|
||||
@onclick="Dialog.Cancel">
|
||||
@Dialog.CancelText
|
||||
</button>
|
||||
<button type="button"
|
||||
class="@ConfirmButtonCss"
|
||||
@onclick="Dialog.Confirm">
|
||||
@Dialog.ConfirmText
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private ElementReference _cancelButton;
|
||||
private bool _focusPending;
|
||||
|
||||
private string DialogCss =>
|
||||
$"app-confirmation app-confirmation--{ToneName}";
|
||||
|
||||
private string IconCss =>
|
||||
$"app-confirmation__icon app-confirmation__icon--{ToneName}";
|
||||
|
||||
private string ConfirmButtonCss =>
|
||||
Dialog.Tone == UiDialogTone.Danger
|
||||
? "btn app-confirmation__button app-confirmation__button--danger"
|
||||
: "btn app-confirmation__button app-confirmation__button--primary";
|
||||
|
||||
private string ToneName => Dialog.Tone switch
|
||||
{
|
||||
UiDialogTone.Danger => "danger",
|
||||
UiDialogTone.Information => "information",
|
||||
_ => "warning"
|
||||
};
|
||||
|
||||
private string Eyebrow => Dialog.Tone switch
|
||||
{
|
||||
UiDialogTone.Danger => "Acción irreversible",
|
||||
UiDialogTone.Information => "Confirmación",
|
||||
_ => "Revisa antes de continuar"
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Dialog.Changed += HandleDialogChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!_focusPending || !Dialog.IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_focusPending = false;
|
||||
await _cancelButton.FocusAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dialog.Changed -= HandleDialogChanged;
|
||||
}
|
||||
|
||||
private void HandleDialogChanged()
|
||||
{
|
||||
_focusPending = Dialog.IsVisible;
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
|
||||
{
|
||||
Dialog.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
.app-confirmation-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
background: rgba(6, 22, 41, 0.64);
|
||||
backdrop-filter: blur(5px);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.app-confirmation {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 28px 80px rgba(5, 27, 54, 0.34);
|
||||
color: #12395f;
|
||||
}
|
||||
|
||||
.app-confirmation__accent {
|
||||
height: 0.35rem;
|
||||
background: #d49620;
|
||||
}
|
||||
|
||||
.app-confirmation--danger .app-confirmation__accent {
|
||||
background: #c93f4f;
|
||||
}
|
||||
|
||||
.app-confirmation--information .app-confirmation__accent {
|
||||
background: #2a5caa;
|
||||
}
|
||||
|
||||
.app-confirmation__content {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 1.5rem 1.1rem;
|
||||
}
|
||||
|
||||
.app-confirmation__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
border: 1px solid #efd8a8;
|
||||
border-radius: 50%;
|
||||
background: #fff6df;
|
||||
color: #8d5e08;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.app-confirmation__icon--danger {
|
||||
border-color: #efc2c8;
|
||||
background: #fff0f2;
|
||||
color: #a82d3b;
|
||||
}
|
||||
|
||||
.app-confirmation__icon--information {
|
||||
border-color: #c4d6ef;
|
||||
background: #eef5ff;
|
||||
color: #214f91;
|
||||
}
|
||||
|
||||
.app-confirmation__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-confirmation__eyebrow {
|
||||
margin-bottom: 0.3rem;
|
||||
color: #6a7786;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-confirmation__title {
|
||||
margin: 0;
|
||||
color: #0a315c;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.app-confirmation__message {
|
||||
margin: 0.65rem 0 0;
|
||||
color: #405f7d;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.app-confirmation__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 1.5rem 1.35rem;
|
||||
border-top: 1px solid #e4ebf2;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.app-confirmation__button {
|
||||
min-width: 8.5rem;
|
||||
min-height: 2.7rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.app-confirmation__button--primary {
|
||||
border-color: #24539a;
|
||||
background: #24539a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-confirmation__button--primary:hover,
|
||||
.app-confirmation__button--primary:focus-visible {
|
||||
border-color: #193f79;
|
||||
background: #193f79;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-confirmation__button--danger {
|
||||
border-color: #b93343;
|
||||
background: #b93343;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-confirmation__button--danger:hover,
|
||||
.app-confirmation__button--danger:focus-visible {
|
||||
border-color: #922936;
|
||||
background: #922936;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.app-confirmation-backdrop {
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.app-confirmation__content {
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
padding: 1.2rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.app-confirmation__icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.app-confirmation__actions {
|
||||
flex-direction: column-reverse;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.app-confirmation__button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@implements IAsyncDisposable
|
||||
@using System.Globalization
|
||||
@inject GestionaDenunciasAN.Models.UserState userState
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject UiBusyService Busy
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject ILogger<MainLayout> Logger
|
||||
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar">
|
||||
@@ -51,6 +52,7 @@
|
||||
</div>
|
||||
|
||||
<BusyOverlay />
|
||||
<AppConfirmationDialog />
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
@@ -59,12 +61,20 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly TimeSpan GlobalLeaksHeartbeatInterval = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan GlobalLeaksIdleTimeout = TimeSpan.FromMinutes(30);
|
||||
|
||||
private string CurrentPageTitle { get; set; } = "Portal de gestion";
|
||||
private string CurrentPageDescription { get; set; } =
|
||||
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
|
||||
private string EncryptionKeyText { get; set; } = "Clave diaria: cargando...";
|
||||
private string EncryptionKeyTooltip { get; set; } = "Consultando la ultima clave diaria de cifrado activa en la API.";
|
||||
private string EncryptionKeyPillCss { get; set; } = "app-session-pill app-key-pill";
|
||||
private readonly CancellationTokenSource _heartbeatCancellation = new();
|
||||
private PeriodicTimer? _heartbeatTimer;
|
||||
private Task? _heartbeatTask;
|
||||
private bool _globalLeaksSessionClearedForIdle;
|
||||
private DateTimeOffset _lastHeartbeatWarningAtUtc = DateTimeOffset.MinValue;
|
||||
|
||||
private string DisplayUsername =>
|
||||
string.IsNullOrWhiteSpace(userState?.NombreUsu)
|
||||
@@ -87,9 +97,58 @@
|
||||
RefreshLayoutState();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.start");
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogError(
|
||||
ex,
|
||||
"No se ha podido iniciar el control de actividad para la sesion GlobalLeaks.");
|
||||
return;
|
||||
}
|
||||
|
||||
_heartbeatTimer = new PeriodicTimer(GlobalLeaksHeartbeatInterval);
|
||||
_heartbeatTask = RunGlobalLeaksHeartbeatAsync(_heartbeatCancellation.Token);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Navigation.LocationChanged -= HandleLocationChanged;
|
||||
_heartbeatCancellation.Cancel();
|
||||
_heartbeatTimer?.Dispose();
|
||||
|
||||
if (_heartbeatTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _heartbeatTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.stop");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
|
||||
_heartbeatCancellation.Dispose();
|
||||
}
|
||||
|
||||
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||
@@ -134,6 +193,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunGlobalLeaksHeartbeatAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (_heartbeatTimer is not null &&
|
||||
await _heartbeatTimer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
await InvokeAsync(() => MaintainGlobalLeaksSessionAsync(cancellationToken));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MaintainGlobalLeaksSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var idleMilliseconds = await JSRuntime.InvokeAsync<double>(
|
||||
"appGlobalLeaksActivity.getIdleMilliseconds",
|
||||
cancellationToken);
|
||||
|
||||
if (idleMilliseconds >= GlobalLeaksIdleTimeout.TotalMilliseconds)
|
||||
{
|
||||
if (!_globalLeaksSessionClearedForIdle)
|
||||
{
|
||||
await ApiDenuncias.ClearGlobalLeaksSessionAsync(cancellationToken);
|
||||
_globalLeaksSessionClearedForIdle = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_globalLeaksSessionClearedForIdle = false;
|
||||
await ApiDenuncias.KeepGlobalLeaksSessionAliveAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_heartbeatCancellation.Cancel();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (now - _lastHeartbeatWarningAtUtc >= TimeSpan.FromMinutes(5))
|
||||
{
|
||||
_lastHeartbeatWarningAtUtc = now;
|
||||
Logger.LogWarning(
|
||||
ex,
|
||||
"No se ha podido ejecutar el mantenimiento de la sesion GlobalLeaks.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToSpanishDateText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@inject IDenunciaStore DenunciaStore
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject UiBusyService Busy
|
||||
@inject UiDialogService Dialogs
|
||||
|
||||
<PageTitle>Actualizaciones</PageTitle>
|
||||
|
||||
@@ -162,7 +163,21 @@ else
|
||||
data-bs-target="#@collapseId"
|
||||
aria-expanded="false"
|
||||
aria-controls="@collapseId">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
|
||||
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
|
||||
{
|
||||
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
|
||||
Responsable: @denuncia.OwnerUsername
|
||||
</span>
|
||||
}
|
||||
@if (denuncia.OwnerWorkGroups.Count > 0)
|
||||
{
|
||||
<span class="badge text-bg-light">
|
||||
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="text-muted small me-3">
|
||||
@@ -703,11 +718,24 @@ else
|
||||
|
||||
private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
|
||||
{
|
||||
if (d.RequiresOwnerConfirmation)
|
||||
{
|
||||
var confirmed = await Dialogs.ConfirmAsync(
|
||||
"Denuncia asignada a otro usuario",
|
||||
$"La denuncia #{d.Id_Denuncia} está asignada a {d.OwnerUsername}. " +
|
||||
"Puedes actualizarla porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
|
||||
"Configurar actualización");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
selectedDenuncias = d;
|
||||
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
|
||||
nombreDocumentos = "";
|
||||
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
|
||||
selectedGroup = NormalizeUpdateGroup(selectedGroup);
|
||||
selectedGroup = NormalizeUpdateGroup(d.UltimoGrupoAsignadoGestiona);
|
||||
operationError = string.Empty;
|
||||
operationNotice = string.Empty;
|
||||
|
||||
@@ -817,12 +845,19 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await ConfirmSelectedGroupAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
isUploading = true;
|
||||
operationError = string.Empty;
|
||||
operationNotice = string.Empty;
|
||||
selectedGroup = NormalizeUpdateGroup(selectedGroup);
|
||||
var pendingUpdateSource = selectedDenuncias.PendingUpdateSource;
|
||||
var uploadType = GetUpdateUploadType(pendingUpdateSource, selectedGroup);
|
||||
using var busy = Busy.Show(
|
||||
"Enviando actualizacion",
|
||||
"Preparando expediente, carpeta de actualizacion y documentos.");
|
||||
@@ -1008,7 +1043,8 @@ else
|
||||
documentoParaTramitar,
|
||||
selectedGroup,
|
||||
selectedDenuncias.Id_Denuncia,
|
||||
isUpdate: true);
|
||||
isUpdate: true,
|
||||
updateSource: pendingUpdateSource);
|
||||
}
|
||||
|
||||
foreach (var orig in nombresOriginalesSubidos)
|
||||
@@ -1030,13 +1066,14 @@ else
|
||||
selectedDenuncias.EsActualizacion = false;
|
||||
selectedDenuncias.NombreDenuncia = nuevoAsunto;
|
||||
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
|
||||
selectedDenuncias.UltimaSubidaGestionaTipo = "Actualización";
|
||||
selectedDenuncias.UltimaSubidaGestionaTipo = uploadType;
|
||||
selectedDenuncias.UltimoGrupoAsignadoGestiona = GetGestionaGroupDisplay(selectedGroup);
|
||||
selectedDenuncias.PendingUpdateSource = string.Empty;
|
||||
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
|
||||
await ActualizarDenunciaAsync(selectedDenuncias);
|
||||
var historialAviso = await RegistrarHistorialGestionaAsync(
|
||||
selectedDenuncias,
|
||||
"Actualización",
|
||||
uploadType,
|
||||
selectedGroup,
|
||||
ahoraUtc,
|
||||
string.Join("; ", nombresFinalesSubidos));
|
||||
@@ -1331,13 +1368,53 @@ else
|
||||
}
|
||||
|
||||
private static string NormalizeUpdateGroup(string? groupCode)
|
||||
=> groupCode == "510" ? "510" : "600";
|
||||
=> groupCode?.Trim().StartsWith("510", StringComparison.Ordinal) == true
|
||||
? "510"
|
||||
: "600";
|
||||
|
||||
private static string GetGestionaGroupDisplay(string? groupCode)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(groupCode) ? string.Empty : groupCode.Trim();
|
||||
}
|
||||
|
||||
private static string GetUpdateUploadType(string? updateSource, string groupCode)
|
||||
{
|
||||
if (!ComplaintUpdateSources.IsReceiver(updateSource))
|
||||
{
|
||||
return "Actualización";
|
||||
}
|
||||
|
||||
return NormalizeUpdateGroup(groupCode) == "510"
|
||||
? "Comunic. SDI a denunciante"
|
||||
: "Comunic. SAJ a denunciante";
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmSelectedGroupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
|
||||
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var configuredGroups = currentGroups.GroupCodes.Count == 0
|
||||
? "ningún grupo"
|
||||
: string.Join(", ", currentGroups.GroupCodes);
|
||||
return await Dialogs.ConfirmAsync(
|
||||
"Cambio de asignación en Gestiona",
|
||||
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
|
||||
"Si continúas, la denuncia cambiará su asignación en Gestiona a un grupo distinto de los tuyos.",
|
||||
"Cambiar asignación");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> RegistrarHistorialGestionaAsync(
|
||||
DenunciasGestiona denuncia,
|
||||
string tipoSubida,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
@using System.Globalization
|
||||
@using GestionaDenunciasAN.Services
|
||||
@using GestionaDenuncias.Shared.Models
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@@ -95,6 +96,104 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">Grupos de trabajo</h5>
|
||||
<p class="text-muted mb-0">
|
||||
Los usuarios pueden consultar y tratar denuncias de otros propietarios cuando comparten al menos un grupo.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
disabled="@isLoadingWorkGroups"
|
||||
@onclick="LoadWorkGroupsAsync">
|
||||
Actualizar usuarios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (isLoadingWorkGroups)
|
||||
{
|
||||
<div class="text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Cargando usuarios y grupos...
|
||||
</div>
|
||||
}
|
||||
else if (workGroupAdministration is null)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">No se ha podido cargar la configuracion de grupos.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Usuario</th>
|
||||
@foreach (var group in workGroupAdministration.Groups)
|
||||
{
|
||||
<th>
|
||||
<span class="d-block">@group.Code</span>
|
||||
<small class="text-muted fw-normal">@group.Name</small>
|
||||
</th>
|
||||
}
|
||||
<th class="text-end">Accion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in workGroupAdministration.Users)
|
||||
{
|
||||
<tr @key="user.UserId">
|
||||
<td>
|
||||
<strong>@user.Username</strong>
|
||||
@if (!GetSelectedGroups(user.UserId).Any())
|
||||
{
|
||||
<span class="badge text-bg-warning ms-2">Sin grupo</span>
|
||||
}
|
||||
</td>
|
||||
@foreach (var group in workGroupAdministration.Groups)
|
||||
{
|
||||
var checkboxId = $"user-group-{user.UserId}-{group.Code}";
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input id="@checkboxId"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
checked="@GetSelectedGroups(user.UserId).Contains(group.Code)"
|
||||
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
|
||||
@onchange="args => ToggleUserGroup(user.UserId, group.Code, args)" />
|
||||
<label class="form-check-label" for="@checkboxId">@group.Code</label>
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
<td class="text-end">
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
disabled="@savingWorkGroupUsers.Contains(user.UserId)"
|
||||
@onclick="() => SaveUserGroupsAsync(user)">
|
||||
@(savingWorkGroupUsers.Contains(user.UserId) ? "Guardando..." : "Guardar")
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(workGroupNotice))
|
||||
{
|
||||
<div class="alert alert-success mt-3 mb-0">@workGroupNotice</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(workGroupError))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@workGroupError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5 class="mb-3">Purga manual con reemplazo</h5>
|
||||
@@ -223,6 +322,12 @@ else
|
||||
private string? configurationNotice;
|
||||
private string? configurationError;
|
||||
private bool isSavingConfiguration;
|
||||
private WorkGroupAdministrationDto? workGroupAdministration;
|
||||
private readonly Dictionary<long, HashSet<string>> selectedWorkGroups = [];
|
||||
private readonly HashSet<long> savingWorkGroupUsers = [];
|
||||
private bool isLoadingWorkGroups;
|
||||
private string? workGroupNotice;
|
||||
private string? workGroupError;
|
||||
|
||||
private string confirmation = string.Empty;
|
||||
private bool acceptedRisk;
|
||||
@@ -246,6 +351,7 @@ else
|
||||
}
|
||||
|
||||
await LoadConfigurationAsync();
|
||||
await LoadWorkGroupsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadConfigurationAsync()
|
||||
@@ -297,6 +403,98 @@ else
|
||||
await SaveExternalUpdateCutoffDateAsync();
|
||||
}
|
||||
|
||||
private async Task LoadWorkGroupsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
isLoadingWorkGroups = true;
|
||||
workGroupError = null;
|
||||
var response = await ApiDenuncias.GetWorkGroupAdministrationAsync();
|
||||
ApplyWorkGroupAdministration(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
workGroupError = $"No se han podido cargar los grupos de trabajo: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoadingWorkGroups = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWorkGroupAdministration(WorkGroupAdministrationDto response)
|
||||
{
|
||||
workGroupAdministration = response;
|
||||
selectedWorkGroups.Clear();
|
||||
foreach (var user in response.Users)
|
||||
{
|
||||
selectedWorkGroups[user.UserId] = user.GroupCodes.ToHashSet(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private HashSet<string> GetSelectedGroups(long userId)
|
||||
{
|
||||
if (!selectedWorkGroups.TryGetValue(userId, out var groups))
|
||||
{
|
||||
groups = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
selectedWorkGroups[userId] = groups;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private void ToggleUserGroup(
|
||||
long userId,
|
||||
string groupCode,
|
||||
ChangeEventArgs args)
|
||||
{
|
||||
var groups = GetSelectedGroups(userId);
|
||||
var enabled = args.Value is bool boolValue
|
||||
? boolValue
|
||||
: bool.TryParse(args.Value?.ToString(), out var parsed) && parsed;
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
groups.Add(groupCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Remove(groupCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveUserGroupsAsync(UserWorkGroupDto user)
|
||||
{
|
||||
var groups = GetSelectedGroups(user.UserId);
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
workGroupNotice = null;
|
||||
workGroupError = $"El usuario {user.Username} debe pertenecer al menos a un grupo.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
savingWorkGroupUsers.Add(user.UserId);
|
||||
workGroupNotice = null;
|
||||
workGroupError = null;
|
||||
var response = await ApiDenuncias.UpdateUserWorkGroupsAsync(
|
||||
user.Username,
|
||||
groups.OrderBy(code => code, StringComparer.Ordinal).ToArray());
|
||||
ApplyWorkGroupAdministration(response);
|
||||
workGroupNotice = $"Grupos de {user.Username} actualizados correctamente.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
workGroupError = $"No se han podido guardar los grupos de {user.Username}: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingWorkGroupUsers.Remove(user.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ToIsoDateText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject UiBusyService Busy
|
||||
@inject UiDialogService Dialogs
|
||||
|
||||
<PageTitle>Entrada de denuncias</PageTitle>
|
||||
|
||||
@@ -122,6 +123,9 @@
|
||||
|
||||
.inbox-activity-cell {
|
||||
width: 9.25rem;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.inbox-action-cell {
|
||||
@@ -226,6 +230,7 @@
|
||||
<option value="all">Todas</option>
|
||||
<option value="new">Nuevas / sin leer</option>
|
||||
<option value="updated">Actualizaciones del ciudadano</option>
|
||||
<option value="receiver">Actividad OAAF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -310,12 +315,12 @@
|
||||
<th>#</th>
|
||||
<th>Canal</th>
|
||||
<th>Presentacion</th>
|
||||
<th>Actividad ciudadano</th>
|
||||
<th>Actividad OAAF</th>
|
||||
<th>Estado</th>
|
||||
<th>Acceso</th>
|
||||
<th>Seguimiento</th>
|
||||
<th class="inbox-action-cell">Detalle</th>
|
||||
<th title="Fecha de la última aportación o comentario realizado por el ciudadano.">Actividad ciudadano</th>
|
||||
<th title="Fecha de la última comunicación o fichero incorporado por un gestor de la OAAF.">Actividad OAAF</th>
|
||||
<th title="Resume si la denuncia es nueva, tiene cambios pendientes o ya está actualizada en Gestiona.">Estado</th>
|
||||
<th title="Indica si tu usuario gestor del buzón puede acceder a esta denuncia.">Acceso</th>
|
||||
<th title="Indica si el cambio está pendiente, descargado en la aplicación o ya incorporado a Gestiona.">Gestiona</th>
|
||||
<th class="inbox-action-cell" title="Abre el detalle disponible en GlobalLeaks.">Detalle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -355,19 +360,19 @@
|
||||
<td class="inbox-channel-cell" title="@(report.ContextName ?? report.ContextId ?? string.Empty)"><span class="d-inline-block text-truncate">@(report.ContextName ?? report.ContextId ?? "-")</span></td>
|
||||
<td>@FormatDate(report.CreationDate)</td>
|
||||
<td class="inbox-activity-cell">@FormatCitizenActivity(report)</td>
|
||||
<td class="inbox-activity-cell">@FormatReceiverActivity(report)</td>
|
||||
<td class="inbox-activity-cell" title="@GetReceiverActivityTitle(report)">@FormatReceiverActivity(report)</td>
|
||||
<td>
|
||||
<span class="badge @GetStatusBadgeCss(report)">
|
||||
<span class="badge @GetStatusBadgeCss(report)" title="@GetStatusHelp(report)">
|
||||
@GetStatusLabel(report)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @GetAccessBadgeCss(report)">
|
||||
<span class="badge @GetAccessBadgeCss(report)" title="@GetAccessHelp(report)">
|
||||
@GetAccessLabel(report)
|
||||
</span>
|
||||
</td>
|
||||
<td class="inbox-tracking-cell" title="@(report.TrackingNote ?? string.Empty)">
|
||||
<span class="badge @GetTrackingBadgeCss(report)">@GetTrackingLabel(report)</span>
|
||||
<td class="inbox-tracking-cell">
|
||||
<span class="badge @GetTrackingBadgeCss(report)" title="@GetTrackingHelp(report)">@GetTrackingLabel(report)</span>
|
||||
</td>
|
||||
<td class="inbox-action-cell">
|
||||
<button type="button"
|
||||
@@ -432,7 +437,7 @@
|
||||
{
|
||||
<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>
|
||||
<strong>@GetCommentAuthorLabel(comment)</strong>
|
||||
<span>@FormatDate(comment.CreationDate)</span>
|
||||
</div>
|
||||
@if (comment.IsNew)
|
||||
@@ -482,6 +487,10 @@
|
||||
<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 (!string.IsNullOrWhiteSpace(file.AuthorName))
|
||||
{
|
||||
<div class="small text-muted">Añadido por @file.AuthorName</div>
|
||||
}
|
||||
@if (file.IsNew)
|
||||
{
|
||||
<span class="badge bg-success mt-2">Nuevo</span>
|
||||
@@ -703,13 +712,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
ImportBusy = true;
|
||||
var importedCount = 0;
|
||||
var errors = new List<string>();
|
||||
var importWarnings = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var selectedReports = Reports
|
||||
.Where(report => SelectedIds.Contains(report.Id))
|
||||
.Where(CanUseReport)
|
||||
@@ -722,6 +724,32 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var reportsFromOtherOwners = selectedReports
|
||||
.Where(report => report.RequiresOwnerConfirmation)
|
||||
.ToArray();
|
||||
if (reportsFromOtherOwners.Length > 0)
|
||||
{
|
||||
var ownerSummary = string.Join(
|
||||
Environment.NewLine,
|
||||
reportsFromOtherOwners.Select(report =>
|
||||
$"Denuncia #{report.Progressive ?? 0}: propiedad de {report.OwnerUsername}."));
|
||||
var confirmed = await Dialogs.ConfirmAsync(
|
||||
"Denuncias asignadas a otros usuarios",
|
||||
$"Vas a importar denuncias asignadas a otros usuarios de tu grupo:{Environment.NewLine}{Environment.NewLine}{ownerSummary}{Environment.NewLine}{Environment.NewLine}La importación quedará registrada con tu usuario.",
|
||||
"Importar denuncias");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ImportBusy = true;
|
||||
var importedCount = 0;
|
||||
var errors = new List<string>();
|
||||
var importWarnings = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using var busy = Busy.Show(
|
||||
"Importando denuncias",
|
||||
$"Descargando y procesando {selectedReports.Count} denuncia(s) desde GlobalLeaks.",
|
||||
@@ -739,7 +767,10 @@
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
|
||||
var result = await ApiDenuncias.ImportReportAsync(
|
||||
report,
|
||||
report.RequiresOwnerConfirmation,
|
||||
CancellationToken.None);
|
||||
importedCount += result.ImportedCount;
|
||||
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
|
||||
if (result.Warnings is not null)
|
||||
@@ -898,7 +929,10 @@
|
||||
filtered = Filter switch
|
||||
{
|
||||
"new" => filtered.Where(report => string.IsNullOrWhiteSpace(report.AccessDate) || string.Equals(report.Status, "new", StringComparison.OrdinalIgnoreCase)),
|
||||
"updated" => filtered.Where(report => report.CitizenHasNewActivity || report.Updated),
|
||||
"updated" => filtered.Where(report =>
|
||||
report.CitizenHasNewActivity ||
|
||||
(!report.ActivityAnalyzed && report.Updated)),
|
||||
"receiver" => filtered.Where(report => report.ReceiverHasNewActivity),
|
||||
_ => filtered
|
||||
};
|
||||
|
||||
@@ -1144,7 +1178,9 @@
|
||||
var receiverDate = FormatOptionalDate(report.ReceiverLastActivity);
|
||||
if (!string.IsNullOrWhiteSpace(receiverDate))
|
||||
{
|
||||
return receiverDate;
|
||||
return string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName)
|
||||
? receiverDate
|
||||
: $"{receiverDate} · {report.ReceiverLastActivityAuthorName}";
|
||||
}
|
||||
|
||||
if (!report.ActivityAnalyzed)
|
||||
@@ -1155,6 +1191,16 @@
|
||||
return "Sin actividad";
|
||||
}
|
||||
|
||||
private static string GetReceiverActivityTitle(ReportDto report)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(report.ReceiverLastActivityAuthorName))
|
||||
{
|
||||
return FormatReceiverActivity(report);
|
||||
}
|
||||
|
||||
return $"Ultima actividad OAAF realizada por {report.ReceiverLastActivityAuthorName}.";
|
||||
}
|
||||
|
||||
private static string FormatBytes(long? value)
|
||||
{
|
||||
if (value is null or <= 0)
|
||||
@@ -1176,11 +1222,13 @@
|
||||
return $"{bytes / 1024d / 1024d:0.#} MB";
|
||||
}
|
||||
|
||||
private static string GetCommentAuthorLabel(string? type)
|
||||
=> IsWhistleblowerActivityType(type)
|
||||
private static string GetCommentAuthorLabel(ReportCommentDto comment)
|
||||
=> IsWhistleblowerActivityType(comment.Type)
|
||||
? "Denunciante"
|
||||
: IsReceiverActivityType(type)
|
||||
? "Receptor"
|
||||
: IsReceiverActivityType(comment.Type)
|
||||
? string.IsNullOrWhiteSpace(comment.AuthorName)
|
||||
? "Gestor OAAF"
|
||||
: $"Gestor OAAF: {comment.AuthorName}"
|
||||
: "Comentario";
|
||||
|
||||
private static bool IsWhistleblowerActivityType(string? value)
|
||||
@@ -1236,7 +1284,7 @@
|
||||
|
||||
if (report.CitizenHasNewActivity)
|
||||
{
|
||||
return "Actualizacion ciudadano";
|
||||
return "Actualización ciudadano";
|
||||
}
|
||||
|
||||
if (IsReceiverOnlyUpdate(report))
|
||||
@@ -1244,14 +1292,19 @@
|
||||
return "Actividad OAAF";
|
||||
}
|
||||
|
||||
if (report.Updated)
|
||||
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
|
||||
{
|
||||
return "Sin comprobar";
|
||||
}
|
||||
|
||||
if (IsUpToDateInGestiona(report))
|
||||
{
|
||||
return "Actualizada";
|
||||
}
|
||||
|
||||
return string.Equals(report.Status, "closed", StringComparison.OrdinalIgnoreCase)
|
||||
? "Cerrada"
|
||||
: "Abierta";
|
||||
: "Nueva denuncia";
|
||||
}
|
||||
|
||||
private static string GetStatusBadgeCss(ReportDto report)
|
||||
@@ -1271,7 +1324,12 @@
|
||||
return "bg-secondary";
|
||||
}
|
||||
|
||||
if (report.Updated)
|
||||
if (report.AlreadyInGestiona && !report.ActivityAnalyzed)
|
||||
{
|
||||
return "bg-warning text-dark";
|
||||
}
|
||||
|
||||
if (IsUpToDateInGestiona(report))
|
||||
{
|
||||
return "bg-light text-dark";
|
||||
}
|
||||
@@ -1281,6 +1339,47 @@
|
||||
: "bg-primary";
|
||||
}
|
||||
|
||||
private static bool IsUpToDateInGestiona(ReportDto report)
|
||||
{
|
||||
if (!report.AlreadyInGestiona)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastUpload = ParseDate(report.LastGestionaUploadAt);
|
||||
var latestActivity = new[]
|
||||
{
|
||||
ParseDate(report.CitizenLastActivity),
|
||||
ParseDate(report.ReceiverLastActivity)
|
||||
}
|
||||
.Where(value => value is not null)
|
||||
.Select(value => value!.Value)
|
||||
.DefaultIfEmpty()
|
||||
.Max();
|
||||
|
||||
if (lastUpload is not null && latestActivity != default)
|
||||
{
|
||||
return lastUpload.Value >= latestActivity;
|
||||
}
|
||||
|
||||
return report.ActivityAnalyzed &&
|
||||
!report.CitizenHasNewActivity &&
|
||||
!report.ReceiverHasNewActivity;
|
||||
}
|
||||
|
||||
private static string GetStatusHelp(ReportDto report)
|
||||
=> GetStatusLabel(report) switch
|
||||
{
|
||||
"Nueva denuncia" => "La denuncia todavía no tiene expediente creado desde la aplicación y está pendiente de actuación.",
|
||||
"Actualización ciudadano" => "El ciudadano ha añadido un comentario, un fichero o una modificación posterior a la última subida a Gestiona.",
|
||||
"Actividad OAAF" => "Un gestor de la OAAF ha realizado una comunicación o añadido un fichero posterior a la última subida a Gestiona.",
|
||||
"Actualizada" => "La última subida a Gestiona es igual o posterior a la actividad del ciudadano y de la OAAF detectada en el buzón.",
|
||||
"Sin comprobar" => "No se ha podido comparar en este momento la actividad del buzón con la última subida a Gestiona.",
|
||||
"Cerrada" => "La denuncia figura cerrada en GlobalLeaks.",
|
||||
"Sin leer" => "La denuncia todavía no se ha abierto con este usuario en GlobalLeaks.",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private static string GetAccessLabel(ReportDto report)
|
||||
=> report.Accessible switch
|
||||
{
|
||||
@@ -1297,8 +1396,16 @@
|
||||
_ => "bg-light text-dark"
|
||||
};
|
||||
|
||||
private static string GetAccessHelp(ReportDto report)
|
||||
=> report.Accessible switch
|
||||
{
|
||||
true => "Tu usuario gestor del buzón tiene acceso a esta denuncia.",
|
||||
false => "GlobalLeaks indica que tu usuario gestor del buzón no tiene acceso a esta denuncia.",
|
||||
_ => "GlobalLeaks no ha informado si tu usuario puede acceder a esta denuncia."
|
||||
};
|
||||
|
||||
private static bool CanUseReport(ReportDto report)
|
||||
=> report.Accessible != false && !IsReceiverOnlyUpdate(report);
|
||||
=> report.Accessible != false;
|
||||
|
||||
private static bool IsReceiverOnlyUpdate(ReportDto report)
|
||||
=> report.AlreadyInGestiona &&
|
||||
@@ -1313,11 +1420,6 @@
|
||||
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
|
||||
}
|
||||
|
||||
if (IsReceiverOnlyUpdate(report))
|
||||
{
|
||||
return "La actividad nueva procede de OAAF/gestor; no se importa como actualizacion del ciudadano.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1328,11 +1430,6 @@
|
||||
return "GlobalLeaks indica que esta denuncia no es accesible para este usuario.";
|
||||
}
|
||||
|
||||
if (IsReceiverOnlyUpdate(report))
|
||||
{
|
||||
return "Actividad interna detectada; puedes consultar el detalle, pero no importarla como actualizacion.";
|
||||
}
|
||||
|
||||
return "Consulta mensajes y ficheros. GlobalLeaks puede marcar la denuncia como leida.";
|
||||
}
|
||||
|
||||
@@ -1386,6 +1483,35 @@
|
||||
return "bg-light text-dark";
|
||||
}
|
||||
|
||||
private static string GetTrackingHelp(ReportDto report)
|
||||
{
|
||||
if (report.AlreadyInGestiona)
|
||||
{
|
||||
return "La denuncia ya tiene un expediente creado en Gestiona. El estado indica si existe actividad posterior pendiente.";
|
||||
}
|
||||
|
||||
if (report.DownloadedByAnotherUser)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(report.TrackingNote)
|
||||
? "Otro usuario ya la descargó en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
|
||||
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
|
||||
}
|
||||
|
||||
if (report.DownloadedByCurrentUser)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(report.TrackingNote)
|
||||
? "Ya la descargaste en la aplicación, pero todavía no se ha materializado la subida a Gestiona."
|
||||
: $"{report.TrackingNote}. Todavía no se ha materializado la subida a Gestiona.";
|
||||
}
|
||||
|
||||
if (report.AlreadyImported)
|
||||
{
|
||||
return "La denuncia está incorporada a la aplicación, pero todavía no se ha subido a Gestiona.";
|
||||
}
|
||||
|
||||
return "La denuncia todavía no se ha descargado ni subido a Gestiona.";
|
||||
}
|
||||
|
||||
private static string? GetReportRowCss(ReportDto report)
|
||||
{
|
||||
if (report.Accessible == false)
|
||||
@@ -1395,7 +1521,7 @@
|
||||
|
||||
if (IsReceiverOnlyUpdate(report))
|
||||
{
|
||||
return "table-secondary report-row-disabled";
|
||||
return "table-secondary";
|
||||
}
|
||||
|
||||
if (report.AlreadyInGestiona)
|
||||
|
||||
@@ -114,28 +114,28 @@ else
|
||||
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#@collapseId" aria-expanded="false" aria-controls="@collapseId">
|
||||
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
|
||||
<div class="header-info">
|
||||
<span><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
|
||||
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @GetHeaderSubject(denuncia, latestHistory)</span>
|
||||
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
|
||||
{
|
||||
<span><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
|
||||
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @denuncia.ExpedienteGestionaMostrable</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(GetUploadType(denuncia, latestHistory)))
|
||||
{
|
||||
<span><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
|
||||
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @GetUploadType(denuncia, latestHistory)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(GetAssignedGroup(denuncia, latestHistory)))
|
||||
{
|
||||
<span><strong>Grupo asignado:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
|
||||
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @FormatGroupCode(GetAssignedGroup(denuncia, latestHistory))</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(GetUploadUser(latestHistory)))
|
||||
{
|
||||
<span><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
|
||||
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(latestHistory)</span>
|
||||
}
|
||||
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
|
||||
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
|
||||
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
|
||||
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
|
||||
@if (!string.IsNullOrWhiteSpace(GetAuditDateText(denuncia)))
|
||||
{
|
||||
<span><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
|
||||
<span title="Último movimiento registrado en la auditoría del expediente de Gestiona."><strong>Última actividad Gestiona:</strong> @GetAuditDateText(denuncia)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,16 +331,16 @@ else
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Denuncia ID: @item.DenunciaId</h5>
|
||||
<div class="header-info">
|
||||
<span><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
|
||||
<span><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
|
||||
<span><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
|
||||
<span><strong>Grupo asignado:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
|
||||
<span title="Asunto con el que se identifica el expediente en Gestiona."><strong>Asunto:</strong> @DisplayOrDash(item.Asunto)</span>
|
||||
<span title="Número asignado al expediente por Gestiona."><strong>Nº expediente:</strong> @DisplayOrDash(item.CodigoExpedienteGestiona)</span>
|
||||
<span title="Tipo de la última operación enviada desde la aplicación a Gestiona."><strong>Última subida:</strong> @DisplayOrDash(item.TipoSubida)</span>
|
||||
<span title="Grupo al que quedó asignado el expediente tras la última subida."><strong>Asignación en Gestiona:</strong> @DisplayOrDash(FormatGroupCode(item.GrupoAsignado))</span>
|
||||
@if (!string.IsNullOrWhiteSpace(GetUploadUser(item)))
|
||||
{
|
||||
<span><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
|
||||
<span title="Usuario de la aplicación que realizó la última subida."><strong>Usuario subida:</strong> @GetUploadUser(item)</span>
|
||||
}
|
||||
<span><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
|
||||
<span><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
|
||||
<span title="Fecha de la última operación enviada a Gestiona."><strong>Fecha de Subida:</strong> @FormatUploadDate(uploadMoment)</span>
|
||||
<span title="Hora local de la última operación enviada a Gestiona."><strong>Hora de Subida:</strong> @FormatUploadTime(uploadMoment)</span>
|
||||
<span class="text-muted">Detalle no disponible por purga criptográfica.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<li>Abre la denuncia para revisar sus datos y adjuntos.</li>
|
||||
<li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li>
|
||||
<li>El report de la denuncia se sube siempre y no se puede desmarcar.</li>
|
||||
<li>Pulsa <strong>Configurar subida</strong> para indicar asunto, grupo destino y modo de subida.</li>
|
||||
<li>Pulsa <strong>Configurar apertura expediente</strong> para indicar asunto, grupo destino y modo de subida.</li>
|
||||
<li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li>
|
||||
<li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
|
||||
</ul>
|
||||
@@ -67,15 +67,15 @@
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h5">Modal de subida a Gestiona</h2>
|
||||
<h2 class="h5">Ventana de configuración de la subida</h2>
|
||||
<p>
|
||||
Antes de confirmar la subida, revisa estos puntos:
|
||||
Al configurar un expediente nuevo o una actualización, revisa estos puntos antes de confirmar:
|
||||
</p>
|
||||
<ul class="mb-0">
|
||||
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li>
|
||||
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</li>
|
||||
<li><strong>Asunto</strong>: texto que identifica el expediente en Gestiona. En las actualizaciones se muestra en modo de solo lectura.</li>
|
||||
<li><strong>Grupo destino</strong>: grupo al que quedará asignado el expediente en Gestiona.</li>
|
||||
<li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li>
|
||||
<li><strong>Tercero</strong>: la app lo rellena desde la denuncia. Si es anonima, se usa el tercero anonimo configurado.</li>
|
||||
<li><strong>Tercero</strong>: la aplicación lo completa con los datos de la denuncia. Si es anónima, se utiliza el tercero anónimo configurado.</li>
|
||||
<li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -87,14 +87,15 @@
|
||||
<div class="card-body">
|
||||
<h2 class="h5">Actualizaciones</h2>
|
||||
<p>
|
||||
Esta pantalla recoge comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
|
||||
Esta pantalla recoge comentarios, comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
|
||||
</p>
|
||||
<ul class="mb-0">
|
||||
<li>Revisa la actualizacion y sus ficheros.</li>
|
||||
<li>La app propone los adjuntos que parecen nuevos.</li>
|
||||
<li>Puedes desmarcar los adjuntos que no quieras subir.</li>
|
||||
<li>El report de la actualizacion se mantiene obligatorio.</li>
|
||||
<li>Confirma la subida para a<EFBFBD>adir los nuevos documentos al expediente existente.</li>
|
||||
<li>Pulsa <strong>Configurar actualización expediente</strong> y confirma para añadir el cambio al expediente existente.</li>
|
||||
<li>Si la actividad procede de la OAAF, la aplicación utiliza el aviso correspondiente al grupo SAJ o SDI.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,12 +106,12 @@
|
||||
<div class="card-body">
|
||||
<h2 class="h5">Gestiona</h2>
|
||||
<p>
|
||||
Aqui se consultan las denuncias que ya se han enviado a Gestiona.
|
||||
Aquí se almacena el histórico permanente de los movimientos enviados desde esta aplicación a Gestiona.
|
||||
</p>
|
||||
<ul class="mb-0">
|
||||
<li>Comprueba el numero de expediente y la fecha de envio.</li>
|
||||
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li>
|
||||
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</li>
|
||||
<li>Comprueba el número de expediente, el tipo de subida, el usuario que la realizó y la asignación en Gestiona.</li>
|
||||
<li>Despliega una operación del día para consultar sus datos y documentos mientras estén disponibles.</li>
|
||||
<li>Por requisitos de seguridad ENS, los detalles sensibles de días anteriores dejan de estar disponibles; la cabecera operativa y la trazabilidad permanecen visibles.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +122,7 @@
|
||||
<div class="card-body">
|
||||
<h2 class="h5">Rechazados</h2>
|
||||
<p>
|
||||
Aqui quedan las denuncias que se han descartado desde Pendientes.
|
||||
Aquí quedan las denuncias o actualizaciones que no se han subido a Gestiona porque se descartaron desde la pantalla de trabajo.
|
||||
</p>
|
||||
<ul class="mb-0">
|
||||
<li>Consulta el motivo indicado al rechazar.</li>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
@inject IDenunciaStore DenunciaStore
|
||||
@inject ApiDenunciasClient ApiDenuncias
|
||||
@inject UiBusyService Busy
|
||||
@inject UiDialogService Dialogs
|
||||
|
||||
<PageTitle>Denuncias Pendientes</PageTitle>
|
||||
|
||||
@@ -199,7 +200,21 @@ else
|
||||
data-bs-target="#@collapseId"
|
||||
aria-expanded="false"
|
||||
aria-controls="@collapseId">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia</h5>
|
||||
@if (!string.IsNullOrWhiteSpace(denuncia.OwnerUsername))
|
||||
{
|
||||
<span class="badge @(denuncia.OwnedByCurrentUser ? "text-bg-secondary" : "text-bg-warning")">
|
||||
Responsable: @denuncia.OwnerUsername
|
||||
</span>
|
||||
}
|
||||
@if (denuncia.OwnerWorkGroups.Count > 0)
|
||||
{
|
||||
<span class="badge text-bg-light">
|
||||
Grupo: @string.Join(" / ", denuncia.OwnerWorkGroups)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button"
|
||||
class="btn btn-success btn-sm me-2"
|
||||
@@ -587,7 +602,7 @@ else
|
||||
600. Asuntos Jurídicos y Protección a la Persona Denunciante
|
||||
</label>
|
||||
</div>
|
||||
@* <div class="form-check">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="radio"
|
||||
name="selectedGroup"
|
||||
@@ -598,17 +613,6 @@ else
|
||||
510. SDI – Investigación Entradas
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input"
|
||||
type="radio"
|
||||
name="selectedGroup"
|
||||
id="grupo700"
|
||||
checked='@(selectedGroup == "700")'
|
||||
@onclick='() => selectedGroup = "700"' />
|
||||
<label class="form-check-label" for="grupo700">
|
||||
700. RESPONSABLE DEL SERVICIO
|
||||
</label>
|
||||
</div> *@
|
||||
|
||||
<!-- DATOS DEL TERCERO -->
|
||||
@{
|
||||
@@ -948,6 +952,11 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await ConfirmSelectedGroupAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
isUploading = true;
|
||||
@@ -1288,8 +1297,13 @@ else
|
||||
await DenunciaStore.UpsertDenunciaAsync(d);
|
||||
}
|
||||
|
||||
private void OpenEnviarAGestionaModal(DenunciasGestiona d)
|
||||
private async Task OpenEnviarAGestionaModal(DenunciasGestiona d)
|
||||
{
|
||||
if (!await ConfirmDifferentOwnerAsync(d, "tramitar"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDenuncias = d;
|
||||
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
|
||||
|
||||
@@ -1301,13 +1315,64 @@ else
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
private void OpenRechazarModal(DenunciasGestiona d)
|
||||
private async Task OpenRechazarModal(DenunciasGestiona d)
|
||||
{
|
||||
if (!await ConfirmDifferentOwnerAsync(d, "rechazar"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDenuncias = d;
|
||||
motivoRechazo = string.Empty;
|
||||
showModalRechazo = true;
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmDifferentOwnerAsync(
|
||||
DenunciasGestiona denuncia,
|
||||
string action)
|
||||
{
|
||||
if (!denuncia.RequiresOwnerConfirmation)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var confirmText = string.Equals(action, "rechazar", StringComparison.OrdinalIgnoreCase)
|
||||
? "Continuar con el rechazo"
|
||||
: "Continuar con la apertura";
|
||||
|
||||
return await Dialogs.ConfirmAsync(
|
||||
"Denuncia asignada a otro usuario",
|
||||
$"La denuncia #{denuncia.Id_Denuncia} está asignada a {denuncia.OwnerUsername}. " +
|
||||
$"Puedes {action}la porque pertenece a un usuario de tu grupo. La acción quedará registrada con tu usuario.",
|
||||
confirmText);
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmSelectedGroupAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentGroups = await ApiDenuncias.GetCurrentUserWorkGroupsAsync();
|
||||
if (currentGroups.GroupCodes.Contains(selectedGroup, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var configuredGroups = currentGroups.GroupCodes.Count == 0
|
||||
? "ningún grupo"
|
||||
: string.Join(", ", currentGroups.GroupCodes);
|
||||
return await Dialogs.ConfirmAsync(
|
||||
"Cambio de asignación en Gestiona",
|
||||
$"El grupo de destino {selectedGroup} no pertenece a tus grupos configurados ({configuredGroups}). " +
|
||||
"Si continúas, la denuncia quedará asignada en Gestiona a un grupo distinto de los tuyos.",
|
||||
"Cambiar asignación");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
operationError = $"No se han podido comprobar tus grupos antes de la subida: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseModal()
|
||||
{
|
||||
showModal = false;
|
||||
|
||||
@@ -52,6 +52,7 @@ builder.Services.AddScoped<UserState>();
|
||||
builder.Services.AddSingleton<AppSessionLifetime>();
|
||||
builder.Services.AddSingleton<LoginRateLimiter>();
|
||||
builder.Services.AddScoped<UiBusyService>();
|
||||
builder.Services.AddScoped<UiDialogService>();
|
||||
builder.Services.AddScoped<ApiDenunciasClient>();
|
||||
builder.Services.AddScoped<IDenunciaStore, ApiDenunciaStore>();
|
||||
builder.Services.AddScoped<IInboxTrackingService, ApiInboxTrackingService>();
|
||||
|
||||
@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
|
||||
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
|
||||
|
||||
public Task<ApiGlobalLeaksSessionDto?> KeepGlobalLeaksSessionAliveAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ApiGlobalLeaksSessionDto?>(
|
||||
HttpMethod.Post,
|
||||
"api/inbox/session/keepalive",
|
||||
body: null,
|
||||
authorize: true,
|
||||
cancellationToken,
|
||||
allowNull: true);
|
||||
|
||||
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ApiLoginPrepareResponse>(
|
||||
HttpMethod.Post,
|
||||
@@ -72,11 +81,14 @@ public sealed class ApiDenunciasClient
|
||||
public Task<InboxSnapshotResponse> LoadInboxAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAsync<InboxSnapshotResponse>(HttpMethod.Get, "api/inbox/reports", body: null, authorize: true, cancellationToken);
|
||||
|
||||
public Task<ImportSummary> ImportReportAsync(ReportDto report, CancellationToken cancellationToken = default)
|
||||
public Task<ImportSummary> ImportReportAsync(
|
||||
ReportDto report,
|
||||
bool confirmDifferentOwner = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> SendAsync<ImportSummary>(
|
||||
HttpMethod.Post,
|
||||
$"api/inbox/reports/{Uri.EscapeDataString(report.Id)}/import",
|
||||
new ImportReportRequest(report),
|
||||
new ImportReportRequest(report, confirmDifferentOwner),
|
||||
authorize: true,
|
||||
cancellationToken);
|
||||
|
||||
@@ -191,10 +203,16 @@ public sealed class ApiDenunciasClient
|
||||
string assignedGroupCode,
|
||||
int? complaintId,
|
||||
bool isUpdate = false,
|
||||
string? updateSource = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> PostAsync(
|
||||
"api/gestiona/documents/tramitar",
|
||||
new GestionaTramitarDocumentoRequest(documentUrl, assignedGroupCode, complaintId, isUpdate),
|
||||
new GestionaTramitarDocumentoRequest(
|
||||
documentUrl,
|
||||
assignedGroupCode,
|
||||
complaintId,
|
||||
isUpdate,
|
||||
updateSource),
|
||||
cancellationToken);
|
||||
|
||||
public Task<List<ExpedienteTerceroDto>> GetGestionaExpedientesPorTerceroAsync(
|
||||
@@ -255,6 +273,29 @@ public sealed class ApiDenunciasClient
|
||||
authorize: true,
|
||||
cancellationToken);
|
||||
|
||||
public Task<WorkGroupAdministrationDto> GetWorkGroupAdministrationAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
=> GetAsync<WorkGroupAdministrationDto>(
|
||||
"api/configuration/work-groups",
|
||||
cancellationToken);
|
||||
|
||||
public Task<CurrentUserWorkGroupsDto> GetCurrentUserWorkGroupsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
=> GetAsync<CurrentUserWorkGroupsDto>(
|
||||
"api/configuration/work-groups/current",
|
||||
cancellationToken);
|
||||
|
||||
public Task<WorkGroupAdministrationDto> UpdateUserWorkGroupsAsync(
|
||||
string username,
|
||||
IReadOnlyList<string> groupCodes,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> SendAsync<WorkGroupAdministrationDto>(
|
||||
HttpMethod.Put,
|
||||
$"api/configuration/work-groups/users/{Uri.EscapeDataString(username)}",
|
||||
new UpdateUserWorkGroupsRequest(groupCodes),
|
||||
authorize: true,
|
||||
cancellationToken);
|
||||
|
||||
internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false)
|
||||
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ public sealed class ApiInboxTrackingService : IInboxTrackingService
|
||||
public Task EnsureReportCanBeImportedByUserAsync(
|
||||
string username,
|
||||
ReportDto report,
|
||||
bool confirmDifferentOwner = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _api.PostAsync(
|
||||
"api/tracking/import-permission",
|
||||
new TrackingImportPermissionRequest(username, report),
|
||||
new TrackingImportPermissionRequest(username, report, confirmDifferentOwner),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace GestionaDenunciasAN.Services;
|
||||
|
||||
public enum UiDialogTone
|
||||
{
|
||||
Information,
|
||||
Warning,
|
||||
Danger
|
||||
}
|
||||
|
||||
public sealed class UiDialogService
|
||||
{
|
||||
private TaskCompletionSource<bool>? _pendingConfirmation;
|
||||
|
||||
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 ConfirmText { get; private set; } = "Continuar";
|
||||
public string CancelText { get; private set; } = "Cancelar";
|
||||
public UiDialogTone Tone { get; private set; } = UiDialogTone.Warning;
|
||||
|
||||
public Task<bool> ConfirmAsync(
|
||||
string title,
|
||||
string message,
|
||||
string confirmText = "Continuar",
|
||||
string cancelText = "Cancelar",
|
||||
UiDialogTone tone = UiDialogTone.Warning)
|
||||
{
|
||||
_pendingConfirmation?.TrySetResult(false);
|
||||
|
||||
Title = title.Trim();
|
||||
Message = message.Trim();
|
||||
ConfirmText = confirmText.Trim();
|
||||
CancelText = cancelText.Trim();
|
||||
Tone = tone;
|
||||
IsVisible = true;
|
||||
|
||||
_pendingConfirmation = new TaskCompletionSource<bool>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Changed?.Invoke();
|
||||
return _pendingConfirmation.Task;
|
||||
}
|
||||
|
||||
public void Confirm()
|
||||
{
|
||||
Complete(true);
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
Complete(false);
|
||||
}
|
||||
|
||||
private void Complete(bool confirmed)
|
||||
{
|
||||
var pendingConfirmation = _pendingConfirmation;
|
||||
_pendingConfirmation = null;
|
||||
IsVisible = false;
|
||||
Changed?.Invoke();
|
||||
pendingConfirmation?.TrySetResult(confirmed);
|
||||
}
|
||||
}
|
||||
@@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) {
|
||||
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
|
||||
document.body.classList.toggle("app-scroll-locked", Boolean(locked));
|
||||
};
|
||||
|
||||
window.appGlobalLeaksActivity = (function () {
|
||||
const storageKey = "gestiona-denuncias:last-user-activity";
|
||||
const events = ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"];
|
||||
let lastActivity = Date.now();
|
||||
let tracking = false;
|
||||
|
||||
function readSharedActivity() {
|
||||
try {
|
||||
const stored = Number(window.localStorage.getItem(storageKey));
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function markActivity() {
|
||||
lastActivity = Date.now();
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(lastActivity));
|
||||
} catch {
|
||||
// The in-memory timestamp still works when local storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (tracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracking = true;
|
||||
markActivity();
|
||||
for (const eventName of events) {
|
||||
window.addEventListener(eventName, markActivity, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!tracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracking = false;
|
||||
for (const eventName of events) {
|
||||
window.removeEventListener(eventName, markActivity);
|
||||
}
|
||||
}
|
||||
|
||||
function getIdleMilliseconds() {
|
||||
const latestActivity = Math.max(lastActivity, readSharedActivity());
|
||||
return Math.max(0, Date.now() - latestActivity);
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
getIdleMilliseconds
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
}
|
||||
|
||||
var separator = certLoginBaseUrl.Contains('?') ? "&" : "?";
|
||||
var url = $"{certLoginBaseUrl}{separator}iframe=true&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
|
||||
var url =
|
||||
$"{certLoginBaseUrl}{separator}iframe=true&origen=Registro&parentOrigin={Uri.EscapeDataString(parentOrigin)}";
|
||||
await JSRuntime.InvokeVoidAsync("iniciarSesionConCertificado", url);
|
||||
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ namespace RegistroPersonalAN.Services
|
||||
var client = _httpClientFactory.CreateClient("DefaultClient");
|
||||
using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest
|
||||
{
|
||||
Dni = dni
|
||||
Dni = dni,
|
||||
Origen = "Registro"
|
||||
});
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
@@ -96,6 +97,7 @@ namespace RegistroPersonalAN.Services
|
||||
public sealed class CertificateProxyLoginRequest
|
||||
{
|
||||
public string Dni { get; set; } = string.Empty;
|
||||
public string Origen { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CertificateProxyLoginResponse
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace SwaggerAntifraude.Controllers
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("login-cert")]
|
||||
public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false)
|
||||
public IActionResult LoginWithCertificateGet([FromQuery] bool iframe = false, [FromQuery] string? origen = null)
|
||||
{
|
||||
var clientCert = HttpContext.Connection.ClientCertificate;
|
||||
if (clientCert == null)
|
||||
@@ -56,7 +56,7 @@ namespace SwaggerAntifraude.Controllers
|
||||
if (string.IsNullOrWhiteSpace(dni))
|
||||
return Unauthorized("No se pudo obtener un DNI válido del certificado.");
|
||||
|
||||
var result = AuthenticateCertificateDni(dni);
|
||||
var result = AuthenticateCertificateDni(dni, origen);
|
||||
if (result.Token == null || result.Persona == null)
|
||||
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
|
||||
|
||||
@@ -82,14 +82,14 @@ namespace SwaggerAntifraude.Controllers
|
||||
return BadRequest("Debe indicarse un DNI.");
|
||||
}
|
||||
|
||||
var result = AuthenticateCertificateDni(request.Dni);
|
||||
var result = AuthenticateCertificateDni(request.Dni, request.Origen);
|
||||
if (result.Token == null || result.Persona == null)
|
||||
return Unauthorized(result.Error ?? "No se pudo autenticar el certificado.");
|
||||
|
||||
return Ok(BuildLoginResponse(result.Persona, result.Token));
|
||||
}
|
||||
|
||||
private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni)
|
||||
private (string? Token, PERSONAS? Persona, string? Error) AuthenticateCertificateDni(string dni, string? origen)
|
||||
{
|
||||
using var context = tsGestionAntifraude.NuevoContexto(SoloLectura: true);
|
||||
|
||||
@@ -100,7 +100,8 @@ namespace SwaggerAntifraude.Controllers
|
||||
{
|
||||
return (null, null, "Usuario no encontrado en la base de datos.");
|
||||
}
|
||||
if (persona.ADMINISTRARPTYREGISTRO != true)
|
||||
if (string.Equals(origen, "Registro", StringComparison.OrdinalIgnoreCase)
|
||||
&& persona.ADMINISTRARPTYREGISTRO != true)
|
||||
{
|
||||
return (null, null, "Usuario no autorizado.");
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@ namespace SwaggerAntifraude.DTOs
|
||||
public class CertificateProxyLoginDto
|
||||
{
|
||||
public string Dni { get; set; } = string.Empty;
|
||||
public string? Origen { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user