Compare commits

..

5 Commits

80 changed files with 1772 additions and 486 deletions

View File

@@ -15,36 +15,102 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenunciasAN", "Gest
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncias\ApiDenuncias.csproj", "{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncias\ApiDenuncias.csproj", "{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|x64.Build.0 = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Debug|x86.Build.0 = Debug|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|Any CPU.Build.0 = Release|Any CPU {B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|Any CPU.Build.0 = Release|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|x64.ActiveCfg = Release|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|x64.Build.0 = Release|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|x86.ActiveCfg = Release|Any CPU
{B8C0C071-81ED-4265-86F0-169CB5A0C82E}.Release|x86.Build.0 = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU {ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|x64.ActiveCfg = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|x64.Build.0 = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|x86.ActiveCfg = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Debug|x86.Build.0 = Debug|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU {ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|Any CPU.Build.0 = Release|Any CPU {ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|Any CPU.Build.0 = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|x64.ActiveCfg = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|x64.Build.0 = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|x86.ActiveCfg = Release|Any CPU
{ED8F423B-D059-4A55-AA8F-0122201E4E1A}.Release|x86.Build.0 = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.Build.0 = Debug|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|Any CPU.Build.0 = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|x64.ActiveCfg = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|x64.Build.0 = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|x86.ActiveCfg = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Debug|x86.Build.0 = Debug|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.ActiveCfg = Release|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.ActiveCfg = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.Build.0 = Release|Any CPU {690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|Any CPU.Build.0 = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|x64.ActiveCfg = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|x64.Build.0 = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|x86.ActiveCfg = Release|Any CPU
{690BFF6A-F3FC-4D94-9E32-C689FBB69455}.Release|x86.Build.0 = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|x64.ActiveCfg = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|x64.Build.0 = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|x86.ActiveCfg = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Debug|x86.Build.0 = Debug|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.Build.0 = Release|Any CPU {063515F3-D202-45DD-91DA-A494FBD005AD}.Release|Any CPU.Build.0 = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|x64.ActiveCfg = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|x64.Build.0 = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|x86.ActiveCfg = Release|Any CPU
{063515F3-D202-45DD-91DA-A494FBD005AD}.Release|x86.Build.0 = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.Build.0 = Debug|Any CPU {77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|x64.ActiveCfg = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|x64.Build.0 = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|x86.ActiveCfg = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Debug|x86.Build.0 = Debug|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.ActiveCfg = Release|Any CPU {77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.Build.0 = Release|Any CPU {77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|Any CPU.Build.0 = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|x64.ActiveCfg = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|x64.Build.0 = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|x86.ActiveCfg = Release|Any CPU
{77BE75E1-E1FD-AAE7-D897-398BED72CEB1}.Release|x86.Build.0 = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|Any CPU.Build.0 = Debug|Any CPU {98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|x64.ActiveCfg = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|x64.Build.0 = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|x86.ActiveCfg = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Debug|x86.Build.0 = Debug|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|Any CPU.ActiveCfg = Release|Any CPU {98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|Any CPU.Build.0 = Release|Any CPU {98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|Any CPU.Build.0 = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|x64.ActiveCfg = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|x64.Build.0 = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|x86.ActiveCfg = Release|Any CPU
{98BF7472-0E7B-4329-A3DC-BB74A942BDAB}.Release|x86.Build.0 = Release|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|x64.ActiveCfg = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|x64.Build.0 = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|x86.ActiveCfg = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Debug|x86.Build.0 = Debug|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|Any CPU.Build.0 = Release|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -7,12 +7,20 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.7.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.25" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.25" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GestionaDenunciasAN\GestionaDenunciasAN.csproj" /> <ProjectReference Include="..\GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,18 @@
namespace ApiDenuncias.Configuration;
public sealed class ComplaintStorageOptions
{
public const string SectionName = "ComplaintStorage";
public string ConnectionString { get; set; } = string.Empty;
public bool AutoCreateSchema { get; set; }
public bool UseKeyVault { get; set; } = true;
public string HostSecretName { get; set; } = "bbdd-host";
public string UserSecretName { get; set; } = "bbdd-user";
public string PasswordSecretName { get; set; } = "bbdd-password";
public string DatabaseSecretName { get; set; } = "bbdd-name";
public string PortSecretName { get; set; } = "bbdd-port";
public string SslModeSecretName { get; set; } = "bbdd-ssl-mode";
public uint DefaultPort { get; set; } = 3306;
public string DefaultSslMode { get; set; } = "Required";
}

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models namespace ApiDenuncias.Configuration
{ {
public class GestionaOptions public class GestionaOptions
{ {
@@ -8,5 +8,10 @@
public string GroupLink { get; set; } = null!; public string GroupLink { get; set; } = null!;
public string Location { get; set; } = null!; public string Location { get; set; } = null!;
public string? ExternalProcedureId { get; set; } public string? ExternalProcedureId { get; set; }
public string? CircuitTemplateId { get; set; }
public string? CircuitSignerStampHref { get; set; }
public string? CircuitSignerStampTitle { get; set; }
public string? CircuitRecipientGroupHref { get; set; }
public string? CircuitVersion { get; set; }
} }
} }

View File

@@ -1,10 +1,12 @@
namespace GestionaDenunciasAN.Configuration; namespace ApiDenuncias.Configuration;
public sealed class GlobalLeaksOptions public sealed class GlobalLeaksOptions
{ {
public const string SectionName = "GlobalLeaks"; public const string SectionName = "GlobalLeaks";
public string BaseUrl { get; set; } = "https://prebuzon.antifraudeandalucia.es"; public string BaseUrl { get; set; } = "https://prebuzon.antifraudeandalucia.es";
public string? HostHeader { get; set; }
public bool AllowInvalidCertificate { get; set; }
public int TimeoutSeconds { get; set; } = 120; public int TimeoutSeconds { get; set; } = 120;
public int MaxDownloadBytes { get; set; } = 500 * 1024 * 1024; public int MaxDownloadBytes { get; set; } = 500 * 1024 * 1024;
} }

View File

@@ -8,4 +8,5 @@ public sealed class JwtOptions
public string Audience { get; set; } = "GestionaDenunciasAN"; public string Audience { get; set; } = "GestionaDenunciasAN";
public string SigningKey { get; set; } = string.Empty; public string SigningKey { get; set; } = string.Empty;
public int ExpirationMinutes { get; set; } = 480; public int ExpirationMinutes { get; set; } = 480;
public bool RequireHttpsMetadata { get; set; } = true;
} }

View File

@@ -0,0 +1,14 @@
namespace ApiDenuncias.Configuration;
public sealed class KeyVaultOptions
{
public const string SectionName = "KeyVault";
public bool Enabled { get; set; } = true;
public string VaultUrl { get; set; } = string.Empty;
public string EncryptionKeySecretName { get; set; } = "denuncias-encryption-key";
public bool AllowLocalEncryptionKeyFallback { get; set; }
}

View File

@@ -3,8 +3,8 @@ using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ApiDenuncias.Configuration; using ApiDenuncias.Configuration;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@@ -1,6 +1,6 @@
using ApiDenuncias.Services; using ApiDenuncias.Services;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@@ -1,5 +1,5 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@@ -1,5 +1,5 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -14,17 +14,20 @@ public sealed class InboxController : ControllerBase
private readonly GlobalLeaksClient _globalLeaksClient; private readonly GlobalLeaksClient _globalLeaksClient;
private readonly DenunciaInboxService _inboxService; private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService; private readonly IInboxTrackingService _trackingService;
private readonly ILogger<InboxController> _logger;
public InboxController( public InboxController(
GlobalLeaksSessionStore sessionStore, GlobalLeaksSessionStore sessionStore,
GlobalLeaksClient globalLeaksClient, GlobalLeaksClient globalLeaksClient,
DenunciaInboxService inboxService, DenunciaInboxService inboxService,
IInboxTrackingService trackingService) IInboxTrackingService trackingService,
ILogger<InboxController> logger)
{ {
_sessionStore = sessionStore; _sessionStore = sessionStore;
_globalLeaksClient = globalLeaksClient; _globalLeaksClient = globalLeaksClient;
_inboxService = inboxService; _inboxService = inboxService;
_trackingService = trackingService; _trackingService = trackingService;
_logger = logger;
} }
[HttpGet("session")] [HttpGet("session")]
@@ -67,6 +70,13 @@ public sealed class InboxController : ControllerBase
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return StatusCode(ex.StatusCode, new ApiError(ex.Message));
} }
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}"));
}
} }
[HttpPost("session/clear")] [HttpPost("session/clear")]
@@ -104,6 +114,13 @@ public sealed class InboxController : ControllerBase
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return StatusCode(ex.StatusCode, new ApiError(ex.Message));
} }
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido cargar la bandeja: {ex.GetType().Name}: {ex.Message}"));
}
} }
[HttpPost("reports/{reportId}/import")] [HttpPost("reports/{reportId}/import")]
@@ -125,6 +142,8 @@ public sealed class InboxController : ControllerBase
try try
{ {
await _trackingService.EnsureReportCanBeImportedByUserAsync(username, report, cancellationToken);
var zip = await _globalLeaksClient.DownloadReportZipAsync(session.SessionId!, report.Id, cancellationToken); var zip = await _globalLeaksClient.DownloadReportZipAsync(session.SessionId!, report.Id, cancellationToken);
FileDownloadResult? json = null; FileDownloadResult? json = null;
@@ -158,6 +177,13 @@ public sealed class InboxController : ControllerBase
{ {
return StatusCode(ex.StatusCode, new ApiError(ex.Message)); return StatusCode(ex.StatusCode, new ApiError(ex.Message));
} }
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido importar la denuncia {ReportId} para {Username}.", reportId, username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido importar la denuncia: {ex.GetType().Name}: {ex.Message}"));
}
} }
[HttpPost("local/ensure-storage")] [HttpPost("local/ensure-storage")]

View File

@@ -1,5 +1,5 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -34,6 +34,15 @@ public sealed class TrackingController : ControllerBase
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
[HttpPost("import-permission")]
public async Task<IActionResult> EnsureImportPermission(
TrackingImportPermissionRequest request,
CancellationToken cancellationToken)
{
await _trackingService.EnsureReportCanBeImportedByUserAsync(GetUsername(), request.Report, cancellationToken);
return Ok(new { ok = true });
}
private string GetUsername() private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado."); => User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
} }

View File

@@ -0,0 +1,4 @@
global using ApiDenuncias.Configuration;
global using ApiDenuncias.Services;
global using GestionaDenuncias.Shared.Models;
global using GestionaDenuncias.Shared.Services;

View File

@@ -1,9 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Helpers; namespace ApiDenuncias.Helpers;
public static class GlobalLeaksJsonEnricher public static class GlobalLeaksJsonEnricher
{ {

View File

@@ -1,9 +1,9 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Helpers; namespace ApiDenuncias.Helpers;
public static class ReportParser public static class ReportParser
{ {

View File

@@ -2,18 +2,20 @@ using System.Net.Http.Headers;
using System.Text; using System.Text;
using ApiDenuncias.Configuration; using ApiDenuncias.Configuration;
using ApiDenuncias.Services; using ApiDenuncias.Services;
using GestionaDenunciasAN.Configuration; using ApiDenuncias.Configuration;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using GestionaDenuncias.Shared.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName)); 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<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName)); builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName)); builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
@@ -28,7 +30,9 @@ builder.Services.AddDataProtection()
builder.Services.AddSingleton<LoginRateLimiter>(); builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>(); builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddScoped<GlobalLeaksClient>(); builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>(); builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
builder.Services.AddScoped<IDenunciaStore, EncryptedDenunciaStore>(); builder.Services.AddScoped<IDenunciaStore, EncryptedDenunciaStore>();
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>(); builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>(); builder.Services.AddScoped<DenunciaInboxService>();
@@ -50,7 +54,7 @@ builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); options.RequireHttpsMetadata = jwt.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
@@ -62,6 +66,30 @@ builder.Services
IssuerSigningKey = new SymmetricSecurityKey(signingKey), IssuerSigningKey = new SymmetricSecurityKey(signingKey),
ClockSkew = TimeSpan.FromMinutes(1) ClockSkew = TimeSpan.FromMinutes(1)
}; };
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ApiDenuncias.Jwt");
logger.LogWarning(context.Exception, "JWT no valido en {Path}", context.HttpContext.Request.Path);
return Task.CompletedTask;
},
OnChallenge = context =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ApiDenuncias.Jwt");
logger.LogWarning(
"JWT rechazado en {Path}. Error={Error}. Description={Description}. AuthorizationHeader={HasAuthorizationHeader}",
context.HttpContext.Request.Path,
context.Error,
context.ErrorDescription,
context.Request.Headers.ContainsKey("Authorization"));
return Task.CompletedTask;
}
};
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
@@ -82,8 +110,15 @@ app.UseExceptionHandler(errorApp =>
logger.LogError(feature.Error, "Error no controlado en {Path}", context.Request.Path); logger.LogError(feature.Error, "Error no controlado en {Path}", context.Request.Path);
} }
var detailedErrors = context.RequestServices
.GetRequiredService<IConfiguration>()
.GetValue("DetailedApiErrors", false);
var message = detailedErrors && feature?.Error is not null
? $"La API de denuncias no ha podido completar la operacion: {feature.Error.GetType().Name}: {feature.Error.Message}"
: "La API de denuncias no ha podido completar la operacion.";
context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ApiError("La API de denuncias no ha podido completar la operacion.")); await context.Response.WriteAsJsonAsync(new ApiError(message));
}); });
}); });
@@ -93,7 +128,10 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); if (builder.Configuration.GetValue("ForceHttpsRedirection", false))
{
app.UseHttpsRedirection();
}
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();

View File

@@ -1,9 +1,9 @@
using System.IO.Compression; using System.IO.Compression;
using System.Text; using System.Text;
using GestionaDenunciasAN.Helpers; using ApiDenuncias.Helpers;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class DenunciaInboxService public sealed class DenunciaInboxService
{ {
@@ -119,21 +119,39 @@ public sealed class DenunciaInboxService
using var zipStream = new MemoryStream(zipBytes, writable: false); using var zipStream = new MemoryStream(zipBytes, writable: false);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false);
var reportEntry = archive.Entries.FirstOrDefault(entry => var reportEntry = FindReportEntry(archive);
string.Equals(NormalizeEntryPath(entry.FullName), "report.txt", StringComparison.OrdinalIgnoreCase));
if (reportEntry is null) if (reportEntry is null)
{ {
throw new InvalidOperationException("El ZIP no contiene el fichero report.txt."); var entries = archive.Entries
.Where(entry => !string.IsNullOrWhiteSpace(entry.Name))
.Select(entry => NormalizeEntryPath(entry.FullName))
.Take(30)
.ToArray();
throw new InvalidOperationException(
entries.Length == 0
? "El ZIP no contiene ficheros."
: $"El ZIP no contiene un report reconocible. Ficheros encontrados: {string.Join(", ", entries)}");
} }
var reportText = await ReadEntryTextAsync(reportEntry, cancellationToken); var reportIsPdf = IsPdfEntry(reportEntry);
var denuncia = ReportParser.ParseReport(reportText); var reportText = reportIsPdf
? string.Empty
: await ReadEntryTextAsync(reportEntry, cancellationToken);
var denuncia = reportIsPdf
? new DenunciasGestiona()
: ReportParser.ParseReport(reportText);
if (!string.IsNullOrWhiteSpace(globalLeaksJson)) if (!string.IsNullOrWhiteSpace(globalLeaksJson))
{ {
GlobalLeaksJsonEnricher.Enrich(denuncia, globalLeaksJson); GlobalLeaksJsonEnricher.Enrich(denuncia, globalLeaksJson);
} }
else if (reportIsPdf)
{
throw new InvalidOperationException(
"El report viene en PDF y no se ha recibido el JSON de GlobalLeaks necesario para extraer los datos de la denuncia.");
}
if (denuncia.Id_Denuncia == 0) if (denuncia.Id_Denuncia == 0)
{ {
@@ -146,6 +164,12 @@ public sealed class DenunciaInboxService
$"No se ha podido determinar el identificador de la denuncia en {sourceName}."); $"No se ha podido determinar el identificador de la denuncia en {sourceName}.");
} }
if (reportIsPdf)
{
reportText = BuildSyntheticReportText(denuncia);
denuncia.TextoOriginalReport = reportText;
}
denuncia.ProcedureId = Guid.Empty; denuncia.ProcedureId = Guid.Empty;
denuncia.GroupId = Guid.Empty; denuncia.GroupId = Guid.Empty;
if (string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona)) if (string.IsNullOrWhiteSpace(denuncia.Expediente_Gestiona))
@@ -170,17 +194,17 @@ public sealed class DenunciaInboxService
new( new(
id_Fichero: 0, id_Fichero: 0,
id_Tipo: 1, id_Tipo: 1,
descripcion: "report.txt original", descripcion: IsPdfEntry(reportEntry) ? "report.pdf original" : "report.txt original",
fecha: reportEntry.LastWriteTime.UtcDateTime == DateTime.MinValue fecha: reportEntry.LastWriteTime.UtcDateTime == DateTime.MinValue
? DateTime.UtcNow ? DateTime.UtcNow
: reportEntry.LastWriteTime.UtcDateTime, : reportEntry.LastWriteTime.UtcDateTime,
observaciones: "", observaciones: "",
id_Denuncia: denunciaId, id_Denuncia: denunciaId,
nombreFichero: "report.txt", nombreFichero: IsPdfEntry(reportEntry) ? "report.pdf" : "report.txt",
fichero: await ReadEntryBytesAsync(reportEntry, cancellationToken)) fichero: await ReadEntryBytesAsync(reportEntry, cancellationToken))
}; };
foreach (var entry in archive.Entries.Where(IsSupportedAttachmentEntry)) foreach (var entry in archive.Entries.Where(entry => IsSupportedAttachmentEntry(entry) && !IsSameEntry(entry, reportEntry)))
{ {
files.Add(new FicherosDenuncias( files.Add(new FicherosDenuncias(
id_Fichero: 0, id_Fichero: 0,
@@ -385,6 +409,44 @@ public sealed class DenunciaInboxService
return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients"); return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients");
} }
private static ZipArchiveEntry? FindReportEntry(ZipArchive archive)
{
return archive.Entries.FirstOrDefault(IsReportEntry);
}
private static bool IsReportEntry(ZipArchiveEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Name))
{
return false;
}
var fileName = Path.GetFileName(NormalizeEntryPath(entry.FullName));
if (string.Equals(fileName, "report.txt", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
return (extension.Equals(".txt", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase)) &&
nameWithoutExtension.StartsWith("report", StringComparison.OrdinalIgnoreCase);
}
private static bool IsPdfEntry(ZipArchiveEntry entry)
{
return Path.GetExtension(entry.Name).Equals(".pdf", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSameEntry(ZipArchiveEntry left, ZipArchiveEntry right)
{
return string.Equals(
NormalizeEntryPath(left.FullName),
NormalizeEntryPath(right.FullName),
StringComparison.OrdinalIgnoreCase);
}
private static bool IsDirectChildOf(string normalizedEntryPath, string rootFolder) private static bool IsDirectChildOf(string normalizedEntryPath, string rootFolder)
{ {
if (!normalizedEntryPath.StartsWith(rootFolder + "/", StringComparison.OrdinalIgnoreCase)) if (!normalizedEntryPath.StartsWith(rootFolder + "/", StringComparison.OrdinalIgnoreCase))
@@ -436,5 +498,94 @@ public sealed class DenunciaInboxService
return 0; return 0;
} }
private static string BuildSyntheticReportText(DenunciasGestiona denuncia)
{
var builder = new StringBuilder();
builder.AppendLine($"ID: {denuncia.Id_Denuncia}");
if (denuncia.Fecha != DateTime.MinValue)
{
builder.AppendLine($"Fecha: {denuncia.Fecha:O}");
}
AppendMetadata(builder, "Etiqueta", denuncia.Etiqueta);
AppendMetadata(builder, "Estado", denuncia.Estado);
builder.AppendLine();
AppendSection(builder, "Datos del denunciante",
("Indique si actúa como persona física o en representación de una persona jurídica.", denuncia.TipoDenunciante),
("Nombre", denuncia.Nombre),
("1º Apellido", denuncia.PrimerApellido),
("2º Apellido", denuncia.SegundoApellido),
("Razón social", denuncia.RazonSocial),
("SEXO", denuncia.Sexo),
("CONTACTO TELEFÓNICO", denuncia.Telefono),
("País de Origen", denuncia.PaisOrigen),
("NIF (DNI, NIE)", denuncia.Dni));
AppendSection(builder, "Descripción",
("Asunto", denuncia.Asunto),
("¿A quién denuncia?", denuncia.A_Quien_Denuncia),
("Describa su denuncia", denuncia.Descripcion_Denuncia),
("¿Ha denunciado estos hechos ante otras instituciones u órganos?", denuncia.Denunciado_Ante_Inst),
("POR FAVOR. INDIQUE EL ORGANISMO O LA INSTITUCION DONDE HA DENUNCIADO LOS HECHOS", denuncia.OrganismoDenunciado),
("¿Solicita medidas concretas de protección?", denuncia.SolicitaProteccion),
("DESCRIBA LAS MEDIDAS DE PROTECCIÓN SOLICITADAS", denuncia.MedidasProteccionSolicitadas),
("Lugar en el que ocurrieron los hechos que denuncia", denuncia.Lugar_Hechos),
("Fecha de los hechos que denuncia", denuncia.Fecha_Hechos == DateTime.MinValue ? string.Empty : denuncia.Fecha_Hechos.ToString("dd/MM/yyyy")),
("Autorización para remitir su denuncia", denuncia.AutorizaRemision),
("En tal caso, ¿desea que su denuncia se remita anonimizada (sin datos personales)?", denuncia.PreferenciaRemision));
AppendSection(builder, "Preferencias de notificación",
("Preferencia de notificación", denuncia.Notificacion_Preferencia),
("Notificaciones Electrónicas", denuncia.Notificacion_Electronica),
("Correo electrónico", denuncia.Correo_Electronico),
("Seguimiento Online", denuncia.SeguimientoOnline),
("Autorizo recibir notificaciones vía Correo Postal", denuncia.NotificacionPostal),
("Provincia", denuncia.Provincia),
("Tipo de vía", denuncia.DireccionTipoVia),
("Nombre de la vía", denuncia.Direccion),
("Código Postal", denuncia.CodigoPostal),
("Localidad", denuncia.Municipio),
("Número/Km", denuncia.DireccionNumero),
("Bloque", denuncia.DireccionBloque),
("Escalera", denuncia.DireccionEscalera),
("Planta", denuncia.DireccionPiso),
("Puerta", denuncia.DireccionPuerta),
("Extra", denuncia.DireccionExtra));
if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{
builder.AppendLine("{Messages}");
builder.AppendLine(denuncia.Comments);
}
return builder.ToString();
}
private static void AppendMetadata(StringBuilder builder, string label, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
builder.AppendLine($"{label}: {value}");
}
}
private static void AppendSection(StringBuilder builder, string section, params (string Label, string? Value)[] fields)
{
builder.AppendLine(section);
foreach (var (label, value) in fields)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.AppendLine($" {label}");
builder.AppendLine($" {value}");
}
builder.AppendLine();
}
} }

View File

@@ -1,27 +1,37 @@
using System.Reflection; using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using GestionaDenunciasAN.Models; using ApiDenuncias.Helpers;
using GestionaDenunciasAN.Services; using GestionaDenuncias.Shared.Models;
using ApiDenuncias.Services;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
namespace ApiDenuncias.Services; namespace ApiDenuncias.Services;
public sealed class EncryptedDenunciaStore : IDenunciaStore public sealed class EncryptedDenunciaStore : IDenunciaStore
{ {
private const string ProtectedStringPrefix = "enc:v1:"; private const string DataProtectionStringPrefix = "enc:v1:";
private static readonly byte[] ProtectedBytesPrefix = Encoding.ASCII.GetBytes("enc:v1:"); private const string KeyVaultStringPrefix = "enc:v2:";
private static readonly byte[] DataProtectionBytesPrefix = Encoding.ASCII.GetBytes("enc:v1:");
private static readonly byte[] KeyVaultBytesPrefix = Encoding.ASCII.GetBytes("enc:v2:");
private const int AesGcmNonceSize = 12;
private const int AesGcmTagSize = 16;
private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona) private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona)
.GetProperties(BindingFlags.Public | BindingFlags.Instance) .GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(property => property.CanRead && property.CanWrite) .Where(property => property.CanRead && property.CanWrite)
.ToArray(); .ToArray();
private readonly MySqlDenunciaStore _inner; private readonly MySqlDenunciaStore _inner;
private readonly IEncryptionKeyProvider _encryptionKeyProvider;
private readonly IDataProtector _protector; private readonly IDataProtector _protector;
public EncryptedDenunciaStore(MySqlDenunciaStore inner, IDataProtectionProvider dataProtectionProvider) public EncryptedDenunciaStore(
MySqlDenunciaStore inner,
IEncryptionKeyProvider encryptionKeyProvider,
IDataProtectionProvider dataProtectionProvider)
{ {
_inner = inner; _inner = inner;
_encryptionKeyProvider = encryptionKeyProvider;
_protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1"); _protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1");
} }
@@ -29,31 +39,47 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
=> _inner.EnsureSchemaAsync(cancellationToken); => _inner.EnsureSchemaAsync(cancellationToken);
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default) public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
=> (await _inner.GetAllDenunciasAsync(cancellationToken)) {
.Select(UnprotectComplaint) var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllDenunciasAsync(cancellationToken))
.Select(denuncia => UnprotectComplaint(denuncia, key))
.ToList(); .ToList();
}
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default) public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
=> (await _inner.GetAllFicherosAsync(cancellationToken)) {
.Select(UnprotectAttachment) var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllFicherosAsync(cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList(); .ToList();
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default) public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default)
=> (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken)) {
.Select(UnprotectAttachment) var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList(); .ToList();
}
public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default) public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default)
{ {
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken); var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken);
return denuncia is null ? null : UnprotectComplaint(denuncia); return denuncia is null ? null : UnprotectComplaint(denuncia, key);
} }
public Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default) public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
=> _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia), cancellationToken); {
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken);
}
public Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default) public async Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default)
=> _inner.UpsertFicherosAsync(ficheros.Select(ProtectAttachment).ToArray(), cancellationToken); {
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken);
}
public Task MarkFicherosAsUploadedAsync( public Task MarkFicherosAsUploadedAsync(
int denunciaId, int denunciaId,
@@ -62,11 +88,106 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken); => _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken);
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source) private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, byte[] key)
=> TransformComplaint(source, ProtectString); => TransformComplaint(ToPersistentComplaint(source), value => ProtectString(value, key));
private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source) private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source, byte[] key)
=> TransformComplaint(source, UnprotectString); {
var decrypted = TransformComplaint(source, value => UnprotectString(value, key));
return RebuildComplaintFromPayload(decrypted);
}
private static DenunciasGestiona ToPersistentComplaint(DenunciasGestiona source)
{
return new DenunciasGestiona
{
// Permanentes tecnicos y de trazabilidad.
Id_RegistroDenuncia = source.Id_RegistroDenuncia,
Id_Denuncia = source.Id_Denuncia,
Fecha = source.Fecha,
Expediente_Gestiona = source.Expediente_Gestiona,
CodigoExpedienteGestiona = source.CodigoExpedienteGestiona,
Id_Persona_Gestiona = source.Id_Persona_Gestiona,
Etiqueta = source.Etiqueta,
Estado = source.Estado,
Confidencial = source.Confidencial,
EsActualizacion = source.EsActualizacion,
ProcedureId = source.ProcedureId,
GroupId = source.GroupId,
NombreDenuncia = source.NombreDenuncia,
EstadoDenuncia = source.EstadoDenuncia,
ArchivoElegido = source.ArchivoElegido,
FechaSubidaAGestiona = source.FechaSubidaAGestiona,
EnGestiona = source.EnGestiona,
EnRechazada = source.EnRechazada,
// Payload temporal cifrado. Los campos funcionales se derivan de aqui al leer.
CamposFormularioJson = source.CamposFormularioJson,
TextoOriginalReport = source.TextoOriginalReport
};
}
private static DenunciasGestiona RebuildComplaintFromPayload(DenunciasGestiona stored)
{
var rebuilt = TryParseStoredReport(stored) ?? new DenunciasGestiona();
if (rebuilt.Id_Denuncia == 0)
{
rebuilt.Id_Denuncia = stored.Id_Denuncia;
}
if (rebuilt.Fecha == DateTime.MinValue)
{
rebuilt.Fecha = stored.Fecha;
}
if (string.IsNullOrWhiteSpace(rebuilt.CamposFormularioJson))
{
rebuilt.CamposFormularioJson = stored.CamposFormularioJson;
}
rebuilt.TextoOriginalReport = stored.TextoOriginalReport;
ApplyPersistentTechnicalFields(rebuilt, stored);
return rebuilt;
}
private static DenunciasGestiona? TryParseStoredReport(DenunciasGestiona stored)
{
if (string.IsNullOrWhiteSpace(stored.TextoOriginalReport))
{
return null;
}
try
{
return ReportParser.ParseReport(stored.TextoOriginalReport);
}
catch
{
return null;
}
}
private static void ApplyPersistentTechnicalFields(DenunciasGestiona target, DenunciasGestiona stored)
{
target.Id_RegistroDenuncia = stored.Id_RegistroDenuncia;
target.Id_Denuncia = stored.Id_Denuncia == 0 ? target.Id_Denuncia : stored.Id_Denuncia;
target.Expediente_Gestiona = stored.Expediente_Gestiona;
target.CodigoExpedienteGestiona = stored.CodigoExpedienteGestiona;
target.Id_Persona_Gestiona = stored.Id_Persona_Gestiona;
target.Etiqueta = stored.Etiqueta;
target.Estado = stored.Estado;
target.Confidencial = stored.Confidencial || target.Confidencial;
target.EsActualizacion = stored.EsActualizacion;
target.ProcedureId = stored.ProcedureId;
target.GroupId = stored.GroupId;
target.NombreDenuncia = stored.NombreDenuncia;
target.EstadoDenuncia = stored.EstadoDenuncia;
target.ArchivoElegido = stored.ArchivoElegido;
target.FechaSubidaAGestiona = stored.FechaSubidaAGestiona;
target.EnGestiona = stored.EnGestiona;
target.EnRechazada = stored.EnRechazada;
}
private static DenunciasGestiona TransformComplaint(DenunciasGestiona source, Func<string, string> transformString) private static DenunciasGestiona TransformComplaint(DenunciasGestiona source, Func<string, string> transformString)
{ {
@@ -88,7 +209,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return target; return target;
} }
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source) private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, byte[] key)
{ {
var content = source.Fichero ?? []; var content = source.Fichero ?? [];
var hash = string.IsNullOrWhiteSpace(source.ContentSha256) var hash = string.IsNullOrWhiteSpace(source.ContentSha256)
@@ -99,56 +220,62 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{ {
Id_Fichero = source.Id_Fichero, Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo, Id_Tipo = source.Id_Tipo,
Descripcion = ProtectString(source.Descripcion ?? string.Empty), Descripcion = ProtectString(source.Descripcion ?? string.Empty, key),
Fecha = source.Fecha, Fecha = source.Fecha,
Observaciones = ProtectString(source.Observaciones ?? string.Empty), Observaciones = ProtectString(source.Observaciones ?? string.Empty, key),
Id_Denuncia = source.Id_Denuncia, Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero, NombreFichero = source.NombreFichero,
Fichero = ProtectBytes(content), Fichero = ProtectBytes(content, key),
Subido = source.Subido, Subido = source.Subido,
FechaSubida = source.FechaSubida, FechaSubida = source.FechaSubida,
ContentSha256 = hash ContentSha256 = hash
}; };
} }
private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source) private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source, byte[] key)
{ {
return new FicherosDenuncias return new FicherosDenuncias
{ {
Id_Fichero = source.Id_Fichero, Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo, Id_Tipo = source.Id_Tipo,
Descripcion = UnprotectString(source.Descripcion ?? string.Empty), Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key),
Fecha = source.Fecha, Fecha = source.Fecha,
Observaciones = UnprotectString(source.Observaciones ?? string.Empty), Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key),
Id_Denuncia = source.Id_Denuncia, Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero, NombreFichero = source.NombreFichero,
Fichero = UnprotectBytes(source.Fichero ?? []), Fichero = UnprotectBytes(source.Fichero ?? [], key),
Subido = source.Subido, Subido = source.Subido,
FechaSubida = source.FechaSubida, FechaSubida = source.FechaSubida,
ContentSha256 = source.ContentSha256 ContentSha256 = source.ContentSha256
}; };
} }
private string ProtectString(string value) private string ProtectString(string value, byte[] key)
{ {
if (string.IsNullOrWhiteSpace(value) || value.StartsWith(ProtectedStringPrefix, StringComparison.Ordinal)) if (string.IsNullOrWhiteSpace(value) ||
value.StartsWith(KeyVaultStringPrefix, StringComparison.Ordinal) ||
value.StartsWith(DataProtectionStringPrefix, StringComparison.Ordinal))
{ {
return value; return value;
} }
return ProtectedStringPrefix + _protector.Protect(value); var encrypted = EncryptBytes(Encoding.UTF8.GetBytes(value), key);
return KeyVaultStringPrefix + Convert.ToBase64String(encrypted);
} }
private string UnprotectString(string value) private string UnprotectString(string value, byte[] key)
{ {
if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(ProtectedStringPrefix, StringComparison.Ordinal)) if (string.IsNullOrWhiteSpace(value))
{ {
return value; return value;
} }
if (value.StartsWith(KeyVaultStringPrefix, StringComparison.Ordinal))
{
try try
{ {
return _protector.Unprotect(value[ProtectedStringPrefix.Length..]); var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]);
return Encoding.UTF8.GetString(DecryptBytes(encrypted, key));
} }
catch catch
{ {
@@ -156,28 +283,63 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
} }
} }
private byte[] ProtectBytes(byte[] value) if (!value.StartsWith(DataProtectionStringPrefix, StringComparison.Ordinal))
{
if (value.Length == 0 || StartsWith(value, ProtectedBytesPrefix))
{
return value;
}
var protectedBytes = _protector.Protect(value);
var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(protectedBytes));
return [.. ProtectedBytesPrefix, .. base64Bytes];
}
private byte[] UnprotectBytes(byte[] value)
{
if (value.Length == 0 || !StartsWith(value, ProtectedBytesPrefix))
{ {
return value; return value;
} }
try try
{ {
var base64 = Encoding.ASCII.GetString(value, ProtectedBytesPrefix.Length, value.Length - ProtectedBytesPrefix.Length); return _protector.Unprotect(value[DataProtectionStringPrefix.Length..]);
}
catch
{
return value;
}
}
private byte[] ProtectBytes(byte[] value, byte[] key)
{
if (value.Length == 0 ||
StartsWith(value, KeyVaultBytesPrefix) ||
StartsWith(value, DataProtectionBytesPrefix))
{
return value;
}
var encrypted = EncryptBytes(value, key);
var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(encrypted));
return [.. KeyVaultBytesPrefix, .. base64Bytes];
}
private byte[] UnprotectBytes(byte[] value, byte[] key)
{
if (value.Length == 0)
{
return value;
}
if (StartsWith(value, KeyVaultBytesPrefix))
{
try
{
var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length);
return DecryptBytes(Convert.FromBase64String(base64), key);
}
catch
{
return value;
}
}
if (!StartsWith(value, DataProtectionBytesPrefix))
{
return value;
}
try
{
var base64 = Encoding.ASCII.GetString(value, DataProtectionBytesPrefix.Length, value.Length - DataProtectionBytesPrefix.Length);
return _protector.Unprotect(Convert.FromBase64String(base64)); return _protector.Unprotect(Convert.FromBase64String(base64));
} }
catch catch
@@ -186,6 +348,36 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
} }
} }
private static byte[] EncryptBytes(byte[] plainBytes, byte[] key)
{
var nonce = RandomNumberGenerator.GetBytes(AesGcmNonceSize);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[AesGcmTagSize];
using var aes = new AesGcm(key, AesGcmTagSize);
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
return [.. nonce, .. tag, .. cipherBytes];
}
private static byte[] DecryptBytes(byte[] encryptedBytes, byte[] key)
{
if (encryptedBytes.Length < AesGcmNonceSize + AesGcmTagSize)
{
throw new CryptographicException("Payload cifrado invalido.");
}
var nonce = encryptedBytes.AsSpan(0, AesGcmNonceSize);
var tag = encryptedBytes.AsSpan(AesGcmNonceSize, AesGcmTagSize);
var cipherBytes = encryptedBytes.AsSpan(AesGcmNonceSize + AesGcmTagSize);
var plainBytes = new byte[cipherBytes.Length];
using var aes = new AesGcm(key, AesGcmTagSize);
aes.Decrypt(nonce, cipherBytes, tag, plainBytes);
return plainBytes;
}
private static bool StartsWith(byte[] value, byte[] prefix) private static bool StartsWith(byte[] value, byte[] prefix)
{ {
if (value.Length < prefix.Length) if (value.Length < prefix.Length)

View File

@@ -5,7 +5,7 @@ using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class GestionaDocumentWorkflowService public sealed class GestionaDocumentWorkflowService
{ {
@@ -53,11 +53,9 @@ public sealed class GestionaDocumentWorkflowService
using var metaReq = new HttpRequestMessage(HttpMethod.Post, documentsTargetUrl); using var metaReq = new HttpRequestMessage(HttpMethod.Post, documentsTargetUrl);
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken); metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear(); metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.file-document+json") metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
{
Parameters = { new NameValueHeaderValue("version", "4") }
});
metaReq.Content = new StringContent(metaJson, Encoding.UTF8); metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
metaReq.Content.Headers.ContentType = metaReq.Content.Headers.ContentType =
@@ -94,7 +92,7 @@ public sealed class GestionaDocumentWorkflowService
} }
catch catch
{ {
// Fallback a busqueda por nombre. // Si Gestiona no devuelve un JSON valido, intentamos con la cabecera Location.
} }
} }
@@ -104,20 +102,22 @@ public sealed class GestionaDocumentWorkflowService
return location!; return location!;
} }
var found = await BuscarDocumentoEnExpedientePorNombreAsync(documentsTargetUrl, fileName);
if (!string.IsNullOrWhiteSpace(found))
{
return found!;
}
throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona."); throw new InvalidOperationException("No se pudo obtener la URL del documento creado en Gestiona.");
} }
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null) public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null)
{ {
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase); var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var template = await ObtenerTemplateCircuitoFirmaAsync(docUrlAbs); var payload = BuildConfiguredCircuitPayload(docUrlAbs, assignedGroupHref, complaintId);
var payload = BuildCircuitPayloadFromTemplate(template, assignedGroupHref, complaintId); string? templateNameForLog = "configurada";
string? templateHrefForLog = GetConfiguredTemplateHref(docUrlAbs);
if (payload is null)
{
throw new InvalidOperationException(
"Faltan Gestiona:CircuitTemplateId o Gestiona:CircuitSignerStampHref. No se listan plantillas para evitar campos deprecated.");
}
var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web)); var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit"); using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit");
@@ -134,8 +134,8 @@ public sealed class GestionaDocumentWorkflowService
_logger.LogError( _logger.LogError(
"Fallo al tramitar documento {DocumentUrl} con plantilla {TemplateName} ({TemplateHref}). Status: {StatusCode}. Body: {Body}", "Fallo al tramitar documento {DocumentUrl} con plantilla {TemplateName} ({TemplateHref}). Status: {StatusCode}. Body: {Body}",
docUrlAbs, docUrlAbs,
template.Name ?? "(sin nombre)", templateNameForLog,
template.Href, templateHrefForLog,
(int)resp.StatusCode, (int)resp.StatusCode,
body); body);
@@ -146,11 +146,67 @@ public sealed class GestionaDocumentWorkflowService
_logger.LogInformation( _logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.", "Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.",
docUrlAbs, docUrlAbs,
template.Name ?? "(sin nombre)", templateNameForLog,
template.Href, templateHrefForLog,
complaintId); complaintId);
} }
private JsonObject? BuildConfiguredCircuitPayload(string documentUrl, string assignedGroupHref, int? complaintId)
{
_ = assignedGroupHref;
_ = complaintId;
var templateHref = GetConfiguredTemplateHref(documentUrl);
var signerHref = _configuration["Gestiona:CircuitSignerStampHref"];
if (string.IsNullOrWhiteSpace(templateHref) || string.IsNullOrWhiteSpace(signerHref))
{
return null;
}
var payload = new JsonObject
{
["block_edit"] = true,
["send_alerts"] = true,
["version"] = _configuration["Gestiona:CircuitVersion"] ?? "2",
["signers"] = new JsonArray
{
JsonSerializer.SerializeToNode(new
{
rel = "signer-stamp",
href = signerHref,
title = _configuration["Gestiona:CircuitSignerStampTitle"] ?? "oaaf-complaints-tramit"
})
},
["links"] = new JsonArray
{
JsonSerializer.SerializeToNode(new { rel = "self", href = templateHref })
}
};
var recipientGroupHref = _configuration["Gestiona:CircuitRecipientGroupHref"];
if (!string.IsNullOrWhiteSpace(recipientGroupHref))
{
payload["recipients"] = new JsonArray
{
JsonSerializer.SerializeToNode(new
{
rel = "group",
href = recipientGroupHref
})
};
}
return payload;
}
private string? GetConfiguredTemplateHref(string documentUrl)
{
var templateId = _configuration["Gestiona:CircuitTemplateId"];
return string.IsNullOrWhiteSpace(templateId)
? null
: $"{documentUrl.TrimEnd('/')}/circuit/templates/{templateId.Trim()}";
}
private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient(); private HttpClient CreateRawHttp() => _httpClientFactory.CreateClient();
private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName) private async Task<string> CreateUploadAsync(byte[] contentBytes, string fileName)
@@ -196,55 +252,6 @@ public sealed class GestionaDocumentWorkflowService
return uploadUri; return uploadUri;
} }
private async Task<string?> BuscarDocumentoEnExpedientePorNombreAsync(string documentsTargetUrl, string fileName)
{
using var req = new HttpRequestMessage(HttpMethod.Get, documentsTargetUrl);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "*/*");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"BuscarDocumentoEnExpedientePorNombreAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
using var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
{
return null;
}
var items = content.EnumerateArray().ToList();
for (var idx = items.Count - 1; idx >= 0; idx--)
{
var item = items[idx];
var name = item.TryGetProperty("name", out var pName) ? pName.GetString() : null;
if (!string.Equals(name, fileName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (item.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
{
foreach (var link in links.EnumerateArray())
{
var rel = link.TryGetProperty("rel", out var pRel) ? pRel.GetString() : null;
var href = link.TryGetProperty("href", out var pHref) ? pHref.GetString() : null;
if (string.Equals(rel, "self", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(href) &&
href!.Contains("/documents/", StringComparison.OrdinalIgnoreCase))
{
return href;
}
}
}
}
return null;
}
private static string ResolveDocumentsContainerUrl(string url) private static string ResolveDocumentsContainerUrl(string url)
{ {
var normalized = url.TrimEnd('/'); var normalized = url.TrimEnd('/');

View File

@@ -1,4 +1,4 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@@ -13,7 +13,7 @@ using System.Text.Json.Serialization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace GestionaDenunciasAN.Services namespace ApiDenuncias.Services
{ {
public class GestionaService : IGestionaService public class GestionaService : IGestionaService
{ {
@@ -58,7 +58,7 @@ namespace GestionaDenunciasAN.Services
return null; return null;
} }
// Reemplaza este helper si quieres controlar la versión en Accept: // Reemplaza este helper si quieres controlar la versión en Accept:
private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null) private void AddTokenAndAccept(HttpRequestMessage req, string mediaType, string? version = null)
{ {
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
@@ -80,7 +80,7 @@ namespace GestionaDenunciasAN.Services
{ {
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear(); req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.files-page+json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
} }
@@ -102,7 +102,7 @@ namespace GestionaDenunciasAN.Services
var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId); var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId);
using var req = new HttpRequestMessage(HttpMethod.Post, url); using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear(); req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.file-opening+json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
@@ -113,7 +113,7 @@ namespace GestionaDenunciasAN.Services
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var fileUrl = GetLinkHref(doc.RootElement, "file") var fileUrl = GetLinkHref(doc.RootElement, "file")
?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'."); ?? throw new InvalidOperationException("CreateFileAsync: Gestiona no ha devuelto link 'file'.");
var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open") ?? resp.Headers.Location?.ToString(); var fileOpenUrl = GetLinkHref(doc.RootElement, "file-open");
return new GestionaCreateFileResponse(fileUrl, fileOpenUrl); return new GestionaCreateFileResponse(fileUrl, fileOpenUrl);
} }
@@ -208,7 +208,7 @@ namespace GestionaDenunciasAN.Services
content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "1")); content.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue("version", "1"));
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
AddTokenAndAccept(req, "application/vnd.gestiona.file+json", "2"); AddTokenAndAccept(req, "application/json");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
@@ -228,7 +228,7 @@ namespace GestionaDenunciasAN.Services
{ {
Content = content Content = content
}; };
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json"); AddTokenAndAccept(req, "application/json");
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
@@ -256,7 +256,7 @@ namespace GestionaDenunciasAN.Services
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}"); throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
var uploadUri = createResp.Headers.Location?.ToString() var uploadUri = createResp.Headers.Location?.ToString()
?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads"); ?? throw new InvalidOperationException("No se devolvió Location en /rest/uploads");
string md5Hex; string md5Hex;
using (var md5 = MD5.Create()) using (var md5 = MD5.Create())
@@ -310,8 +310,7 @@ namespace GestionaDenunciasAN.Services
{ Content = metaContent }; { Content = metaContent };
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken); metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
metaReq.Headers.Accept.Clear(); metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.gestiona.file-document+json") metaReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
{ Parameters = { new NameValueHeaderValue("version", "4") } });
using var metaResp = await _http.SendAsync(metaReq); using var metaResp = await _http.SendAsync(metaReq);
var body = await metaResp.Content.ReadAsStringAsync(); var body = await metaResp.Content.ReadAsStringAsync();
@@ -387,7 +386,7 @@ namespace GestionaDenunciasAN.Services
if (thirdParty.IsLegalEntity) if (thirdParty.IsLegalEntity)
{ {
if (string.IsNullOrWhiteSpace(thirdParty.BusinessName)) if (string.IsNullOrWhiteSpace(thirdParty.BusinessName))
throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty)); throw new ArgumentException("La razón social es obligatoria para terceros jurídicos.", nameof(thirdParty));
} }
else else
{ {
@@ -562,7 +561,7 @@ namespace GestionaDenunciasAN.Services
}; };
} }
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) --- // --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
private async Task<string> GetFilesAsync(object? filter = null) private async Task<string> GetFilesAsync(object? filter = null)
{ {
@@ -613,7 +612,7 @@ namespace GestionaDenunciasAN.Services
} }
/// <summary> /// <summary>
/// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas. /// Devuelve el JSON crudo de /rest/files acumulando hasta maxPages páginas.
/// </summary> /// </summary>
public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1) public async Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1)
{ {
@@ -911,7 +910,7 @@ namespace GestionaDenunciasAN.Services
using var resp = await _http.SendAsync(req); using var resp = await _http.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync(); var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); throw new InvalidOperationException($"Error actualizando dirección del tercero: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}");
} }
private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref) private async Task<bool> ThirdHasAddressesAsync(string thirdSelfHref)
@@ -1161,7 +1160,7 @@ namespace GestionaDenunciasAN.Services
return value switch return value switch
{ {
"" => "ESP", "" => "ESP",
"es" or "esp" or "espana" or "españa" or "spain" => "ESP", "es" or "esp" or "espana" or "españa" or "spain" => "ESP",
"prt" or "pt" or "portugal" => "PRT", "prt" or "pt" or "portugal" => "PRT",
_ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3], _ when country is { Length: >= 3 } => country.Trim().ToUpperInvariant()[..3],
_ => "ESP", _ => "ESP",

View File

@@ -5,12 +5,12 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GestionaDenunciasAN.Configuration; using ApiDenuncias.Configuration;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class GlobalLeaksClient public sealed class GlobalLeaksClient
{ {
@@ -23,11 +23,25 @@ public sealed class GlobalLeaksClient
{ {
_options = options.Value; _options = options.Value;
_logger = logger; _logger = logger;
_httpClient = new HttpClient
var handler = new HttpClientHandler();
if (_options.AllowInvalidCertificate)
{
_logger.LogWarning("GlobalLeaks permite certificados TLS no validos. Usar solo temporalmente en PRE.");
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
_httpClient = new HttpClient(handler)
{ {
BaseAddress = new Uri(_options.BaseUrl.TrimEnd('/')), BaseAddress = new Uri(_options.BaseUrl.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds), Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds),
}; };
if (!string.IsNullOrWhiteSpace(_options.HostHeader))
{
_httpClient.DefaultRequestHeaders.Host = _options.HostHeader.Trim();
}
} }
public async Task<GlSession> LoginAsync( public async Task<GlSession> LoginAsync(

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class GlobalLeaksValidationException(string message, int statusCode = 400) : Exception(message) public sealed class GlobalLeaksValidationException(string message, int statusCode = 400) : Exception(message)
{ {

View File

@@ -1,10 +1,10 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Security.Cryptography; using System.Security.Cryptography;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class GlobalLeaksSessionStore public sealed class GlobalLeaksSessionStore
{ {

View File

@@ -0,0 +1,6 @@
namespace ApiDenuncias.Services;
public interface IEncryptionKeyProvider
{
ValueTask<byte[]> GetKeyAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,9 +1,9 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace GestionaDenunciasAN.Services namespace ApiDenuncias.Services
{ {
public interface IGestionaService public interface IGestionaService
{ {
@@ -22,7 +22,7 @@ namespace GestionaDenunciasAN.Services
); );
/// <summary> /// <summary>
/// Abre el expediente (lo pone en OPEN_EDITABLE), asigna título, clasificación y lo vincula al grupo indicado. /// Abre el expediente (lo pone en OPEN_EDITABLE), asigna título, clasificación y lo vincula al grupo indicado.
/// </summary> /// </summary>
Task OpenFileAsync( Task OpenFileAsync(
string fileUrl, string fileUrl,
@@ -47,7 +47,7 @@ namespace GestionaDenunciasAN.Services
); );
/// <summary> /// <summary>
/// Crea el documento (metadata) y sube el contenido PDF a la raíz o a una carpeta. /// Crea el documento (metadata) y sube el contenido PDF a la raíz o a una carpeta.
/// </summary> /// </summary>
Task UploadDocumentAsync( Task UploadDocumentAsync(
string fileUrl, string fileUrl,
@@ -56,7 +56,7 @@ namespace GestionaDenunciasAN.Services
); );
/// <summary> /// <summary>
/// Crea una carpeta de nombre 'folderName' en la raíz del expediente y devuelve su GUID. /// Crea una carpeta de nombre 'folderName' en la raíz del expediente y devuelve su GUID.
/// </summary> /// </summary>
Task<Guid> CreateFolderAsync( Task<Guid> CreateFolderAsync(
string fileUrl, string fileUrl,
@@ -89,9 +89,9 @@ namespace GestionaDenunciasAN.Services
/// <summary> /// <summary>
/// Usa el NIF tal cual viene. /// Usa el NIF tal cual viene.
/// Si es anónimo o vacío no crea ni enlaza. /// Si es anónimo o vacío ? no crea ni enlaza.
/// Si no existe, lo crea. /// Si no existe, lo crea.
/// Si no está enlazado al expediente, lo enlaza. /// Si no está enlazado al expediente, lo enlaza.
/// </summary> /// </summary>
Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty); Task AsegurarTerceroYEnlazarAsync(string fileUrl, ThirdPartyIdentityData thirdParty);
@@ -102,13 +102,13 @@ namespace GestionaDenunciasAN.Services
// ========================= // =========================
/// <summary> /// <summary>
/// Devuelve el JSON crudo del listado operativo de expedientes, sin recorrer histórico paginado. /// Devuelve el JSON crudo del listado operativo de expedientes, sin recorrer histórico paginado.
/// </summary> /// </summary>
Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1); Task<string> ListarExpedientesJsonAsyncBasico(int maxPages = 1);
/// <summary> /// <summary>
/// Busca directamente un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD". /// Busca directamente un expediente cuyo asunto sea "Denuncia {idDenuncia}-CD".
/// Devuelve URL, número de expediente y título si lo encuentra; null si no. /// Devuelve URL, número de expediente y título si lo encuentra; null si no.
/// </summary> /// </summary>
Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia); Task<GestionaExpedienteInfo?> BuscarExpedientePorIdEnAsuntoAsync(int idDenuncia);

View File

@@ -1,22 +1,20 @@
using System.Globalization; using System.Globalization;
using GestionaDenunciasAN.Configuration; using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Models;
using Microsoft.Extensions.Options;
using MySqlConnector; using MySqlConnector;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class InboxTrackingService : IInboxTrackingService public sealed class InboxTrackingService : IInboxTrackingService
{ {
private readonly ComplaintStorageOptions _options;
private readonly IDenunciaStore _denunciaStore; private readonly IDenunciaStore _denunciaStore;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public InboxTrackingService( public InboxTrackingService(
IOptions<ComplaintStorageOptions> options, IDenunciaStore denunciaStore,
IDenunciaStore denunciaStore) MySqlConnectionStringProvider connectionStringProvider)
{ {
_options = options.Value;
_denunciaStore = denunciaStore; _denunciaStore = denunciaStore;
_connectionStringProvider = connectionStringProvider;
} }
public async Task<InboxUserState> GetUserStateAsync(string username, CancellationToken cancellationToken = default) public async Task<InboxUserState> GetUserStateAsync(string username, CancellationToken cancellationToken = default)
@@ -107,9 +105,49 @@ public sealed class InboxTrackingService : IInboxTrackingService
TrackingNote = BuildTrackingNote(meta) TrackingNote = BuildTrackingNote(meta)
}; };
}) })
.Where(report => !IsLockedByAnotherUser(report))
.ToArray(); .ToArray();
} }
public async Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
CancellationToken cancellationToken = default)
{
await _denunciaStore.EnsureSchemaAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(report.Id))
{
throw new InvalidOperationException("No se ha podido validar la propiedad de la denuncia.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
var userId = await EnsureUserAsync(connection, username, cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
await UpsertInboxReportAsync(connection, (MySqlTransaction)transaction, report, cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser)
{
var owner = string.IsNullOrWhiteSpace(meta.LastDownloadedByUsername)
? "otro usuario"
: meta.LastDownloadedByUsername;
throw new InvalidOperationException(
$"La denuncia ya fue importada por {owner}. Solo ese usuario puede ver e importar sus actualizaciones.");
}
}
public async Task MarkReportImportedAsync( public async Task MarkReportImportedAsync(
string username, string username,
ReportDto report, ReportDto report,
@@ -390,6 +428,11 @@ public sealed class InboxTrackingService : IInboxTrackingService
? null ? null
: reader.GetString(reader.GetOrdinal("last_downloaded_by_username")); : reader.GetString(reader.GetOrdinal("last_downloaded_by_username"));
var downloadedByCurrentUser = reader.GetInt32(reader.GetOrdinal("downloaded_by_current_user")) == 1; 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 = var downloadedByAnotherUser =
!downloadedByCurrentUser && !downloadedByCurrentUser &&
!string.IsNullOrWhiteSpace(lastDownloadedByUsername); !string.IsNullOrWhiteSpace(lastDownloadedByUsername);
@@ -402,6 +445,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"), LastDownloadedAtUtc = GetDateTimeOffset(reader, "last_downloaded_at_utc"),
AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")), AlreadyImported = !reader.IsDBNull(reader.GetOrdinal("imported_to_store_at_utc")),
AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1, AlreadyInGestiona = reader.GetInt32(reader.GetOrdinal("already_in_gestiona")) == 1,
LockedByAnotherUser = lockedByAnotherUser,
}; };
} }
@@ -410,13 +454,8 @@ public sealed class InboxTrackingService : IInboxTrackingService
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken) private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
{ var connection = new MySqlConnection(connectionString);
throw new InvalidOperationException(
"Falta configurar ComplaintStorage:ConnectionString en appsettings.json.");
}
var connection = new MySqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection); await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken); await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
@@ -482,6 +521,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
return null; return null;
} }
if (metadata.LockedByAnotherUser)
{
return string.IsNullOrWhiteSpace(metadata.LastDownloadedByUsername)
? "Importada por otro usuario"
: $"Importada por {metadata.LastDownloadedByUsername}";
}
if (metadata.AlreadyInGestiona) if (metadata.AlreadyInGestiona)
{ {
return "Ya existe expediente en Gestiona"; return "Ya existe expediente en Gestiona";
@@ -514,9 +560,15 @@ public sealed class InboxTrackingService : IInboxTrackingService
{ {
public bool DownloadedByCurrentUser { get; init; } public bool DownloadedByCurrentUser { get; init; }
public bool DownloadedByAnotherUser { get; init; } public bool DownloadedByAnotherUser { get; init; }
public bool LockedByAnotherUser { get; init; }
public string? LastDownloadedByUsername { get; init; } public string? LastDownloadedByUsername { get; init; }
public DateTimeOffset? LastDownloadedAtUtc { get; init; } public DateTimeOffset? LastDownloadedAtUtc { get; init; }
public bool AlreadyImported { get; init; } public bool AlreadyImported { get; init; }
public bool AlreadyInGestiona { get; init; } public bool AlreadyInGestiona { get; init; }
} }
private static bool IsLockedByAnotherUser(ReportDto report)
=> report.AlreadyImported &&
report.DownloadedByAnotherUser &&
!report.DownloadedByCurrentUser;
} }

View File

@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using System.Text;
using ApiDenuncias.Configuration;
using Azure;
using Azure.Core;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services;
public sealed class KeyVaultEncryptionKeyProvider : IEncryptionKeyProvider
{
private readonly KeyVaultOptions _options;
private readonly IConfiguration _configuration;
private readonly ILogger<KeyVaultEncryptionKeyProvider> _logger;
private readonly Lazy<Task<byte[]>> _keyLoader;
public KeyVaultEncryptionKeyProvider(
IOptions<KeyVaultOptions> options,
IConfiguration configuration,
ILogger<KeyVaultEncryptionKeyProvider> logger)
{
_options = options.Value;
_configuration = configuration;
_logger = logger;
_keyLoader = new Lazy<Task<byte[]>>(LoadKeyAsync);
}
public async ValueTask<byte[]> GetKeyAsync(CancellationToken cancellationToken = default)
{
var key = await _keyLoader.Value.WaitAsync(cancellationToken);
return key.ToArray();
}
private async Task<byte[]> LoadKeyAsync()
{
var configuredLocalKey = _configuration["Encryption:LocalDevelopmentKey"];
if (!_options.Enabled)
{
if (string.IsNullOrWhiteSpace(configuredLocalKey))
{
throw new InvalidOperationException(
"Key Vault esta deshabilitado y no se ha configurado Encryption:LocalDevelopmentKey.");
}
_logger.LogWarning("Key Vault deshabilitado. Usando clave local solo para pruebas de desarrollo.");
return NormalizeKey(configuredLocalKey);
}
if (string.IsNullOrWhiteSpace(_options.VaultUrl))
{
throw new InvalidOperationException("KeyVault:VaultUrl no esta configurado.");
}
if (string.IsNullOrWhiteSpace(_options.EncryptionKeySecretName))
{
throw new InvalidOperationException("KeyVault:EncryptionKeySecretName no esta configurado.");
}
var credential = new DefaultAzureCredential();
var client = new SecretClient(new Uri(_options.VaultUrl), credential);
KeyVaultSecret secret;
try
{
var response = await client.GetSecretAsync(_options.EncryptionKeySecretName);
secret = response.Value;
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status404NotFound && _options.AllowLocalEncryptionKeyFallback)
{
if (string.IsNullOrWhiteSpace(configuredLocalKey))
{
throw new InvalidOperationException(
$"El secreto '{_options.EncryptionKeySecretName}' no existe en Key Vault y no se ha configurado Encryption:LocalDevelopmentKey.");
}
_logger.LogWarning(
"El secreto {SecretName} no existe en Key Vault. Usando clave local temporal por AllowLocalEncryptionKeyFallback=true. No usar en produccion real.",
_options.EncryptionKeySecretName);
return NormalizeKey(configuredLocalKey);
}
if (string.IsNullOrWhiteSpace(secret.Value))
{
throw new InvalidOperationException(
$"El secreto '{_options.EncryptionKeySecretName}' de Key Vault esta vacio.");
}
_logger.LogInformation(
"Clave de cifrado cargada desde Key Vault {VaultUrl} usando el secreto {SecretName}.",
_options.VaultUrl,
_options.EncryptionKeySecretName);
return NormalizeKey(secret.Value);
}
private static byte[] NormalizeKey(string secretValue)
{
var trimmed = secretValue.Trim();
try
{
var base64Key = Convert.FromBase64String(trimmed);
if (base64Key.Length is 16 or 24 or 32)
{
return base64Key;
}
}
catch (FormatException)
{
// Si no es base64, derivamos una clave estable desde el valor textual.
}
return SHA256.HashData(Encoding.UTF8.GetBytes(trimmed));
}
}

View File

@@ -0,0 +1,124 @@
using ApiDenuncias.Configuration;
using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class MySqlConnectionStringProvider
{
private readonly ComplaintStorageOptions _storageOptions;
private readonly KeyVaultOptions _keyVaultOptions;
private readonly ILogger<MySqlConnectionStringProvider> _logger;
public MySqlConnectionStringProvider(
IOptions<ComplaintStorageOptions> storageOptions,
IOptions<KeyVaultOptions> keyVaultOptions,
ILogger<MySqlConnectionStringProvider> logger)
{
_storageOptions = storageOptions.Value;
_keyVaultOptions = keyVaultOptions.Value;
_logger = logger;
}
public async ValueTask<string> GetConnectionStringAsync(CancellationToken cancellationToken = default)
{
return await LoadConnectionStringAsync().WaitAsync(cancellationToken);
}
private async Task<string> LoadConnectionStringAsync()
{
if (!_storageOptions.UseKeyVault || !_keyVaultOptions.Enabled)
{
if (string.IsNullOrWhiteSpace(_storageOptions.ConnectionString))
{
throw new InvalidOperationException(
"Falta configurar ComplaintStorage:ConnectionString o activar Key Vault para obtener la conexion MySQL.");
}
_logger.LogWarning("Conexion MySQL cargada desde appsettings. Usar solo para desarrollo/local.");
return _storageOptions.ConnectionString;
}
if (string.IsNullOrWhiteSpace(_keyVaultOptions.VaultUrl))
{
throw new InvalidOperationException("KeyVault:VaultUrl no esta configurado.");
}
var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential());
var host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName);
var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName);
var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName);
var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName);
var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort);
var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode);
var builder = new MySqlConnectionStringBuilder
{
Server = host,
Port = port,
UserID = user,
Password = password,
Database = database,
SslMode = ParseSslMode(sslMode),
};
_logger.LogInformation(
"Conexion MySQL cargada desde Key Vault {VaultUrl}. Host={Host}; Database={Database}; User={User}; Port={Port}.",
_keyVaultOptions.VaultUrl,
host,
database,
user,
port);
return builder.ConnectionString;
}
private static async Task<string> GetRequiredSecretAsync(SecretClient client, string secretName)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"El secreto obligatorio '{secretName}' de Key Vault no existe o esta vacio.");
}
return value.Trim();
}
private static async Task<string> GetOptionalSecretAsync(SecretClient client, string secretName, string? fallback)
{
if (string.IsNullOrWhiteSpace(secretName))
{
return fallback ?? string.Empty;
}
try
{
var secret = await client.GetSecretAsync(secretName.Trim());
return string.IsNullOrWhiteSpace(secret.Value.Value)
? fallback ?? string.Empty
: secret.Value.Value.Trim();
}
catch (RequestFailedException ex) when (ex.Status == StatusCodes.Status404NotFound)
{
return fallback ?? string.Empty;
}
}
private static async Task<uint> GetOptionalUIntSecretAsync(SecretClient client, string secretName, uint fallback)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
return uint.TryParse(value, out var parsed) && parsed > 0
? parsed
: fallback;
}
private static MySqlSslMode ParseSslMode(string? value)
{
return Enum.TryParse<MySqlSslMode>(value, ignoreCase: true, out var parsed)
? parsed
: MySqlSslMode.Required;
}
}

View File

@@ -1,12 +1,12 @@
using System.Data; using System.Data;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using GestionaDenunciasAN.Configuration; using ApiDenuncias.Configuration;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MySqlConnector; using MySqlConnector;
namespace GestionaDenunciasAN.Services; namespace ApiDenuncias.Services;
public sealed class MySqlDenunciaStore : IDenunciaStore public sealed class MySqlDenunciaStore : IDenunciaStore
{ {
@@ -103,15 +103,18 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
private readonly ILogger<MySqlDenunciaStore> _logger; private readonly ILogger<MySqlDenunciaStore> _logger;
private static readonly SemaphoreSlim SchemaGate = new(1, 1); private static readonly SemaphoreSlim SchemaGate = new(1, 1);
private static volatile bool SchemaEnsured; private static volatile bool SchemaEnsured;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public MySqlDenunciaStore( public MySqlDenunciaStore(
IOptions<ComplaintStorageOptions> options, IOptions<ComplaintStorageOptions> options,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<MySqlDenunciaStore> logger) ILogger<MySqlDenunciaStore> logger,
MySqlConnectionStringProvider connectionStringProvider)
{ {
_options = options.Value; _options = options.Value;
_environment = environment; _environment = environment;
_logger = logger; _logger = logger;
_connectionStringProvider = connectionStringProvider;
} }
public Task EnsureSchemaAsync(CancellationToken cancellationToken = default) public Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
@@ -705,12 +708,12 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
content_mime_type = @contentMimeType, content_mime_type = @contentMimeType,
content_sha256 = @contentSha256, content_sha256 = @contentSha256,
uploaded_to_gestiona = CASE uploaded_to_gestiona = CASE
WHEN LOWER(@originalFileName) = 'report.txt' THEN @uploadedToGestiona WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedToGestiona
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_to_gestiona
ELSE @uploadedToGestiona ELSE @uploadedToGestiona
END, END,
uploaded_at_utc = CASE uploaded_at_utc = CASE
WHEN LOWER(@originalFileName) = 'report.txt' THEN @uploadedAtUtc WHEN LOWER(@originalFileName) = 'report.txt' OR LOWER(@originalFileName) = 'report.pdf' THEN @uploadedAtUtc
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc ELSE @uploadedAtUtc
END, END,
@@ -850,13 +853,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken) private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
{ var connection = new MySqlConnection(connectionString);
throw new InvalidOperationException(
"Falta configurar ComplaintStorage:ConnectionString en appsettings.json.");
}
var connection = new MySqlConnection(_options.ConnectionString);
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection); await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);

View File

@@ -1,22 +1,20 @@
using System.Globalization; using System.Globalization;
using GestionaDenunciasAN.Configuration;
using Microsoft.Extensions.Options;
using MySqlConnector; using MySqlConnector;
namespace ApiDenuncias.Services; namespace ApiDenuncias.Services;
public sealed class UserComplaintAccessService public sealed class UserComplaintAccessService
{ {
private readonly ComplaintStorageOptions _options; private readonly MySqlConnectionStringProvider _connectionStringProvider;
public UserComplaintAccessService(IOptions<ComplaintStorageOptions> options) public UserComplaintAccessService(MySqlConnectionStringProvider connectionStringProvider)
{ {
_options = options.Value; _connectionStringProvider = connectionStringProvider;
} }
public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default) public async Task<HashSet<int>> GetAllowedComplaintIdsAsync(string username, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(_options.ConnectionString)) if (string.IsNullOrWhiteSpace(username))
{ {
return []; return [];
} }
@@ -31,7 +29,8 @@ public sealed class UserComplaintAccessService
AND uir.download_count > 0; AND uir.download_count > 0;
"""; """;
await using var connection = new MySqlConnection(_options.ConnectionString); var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
await using var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection); await using var command = new MySqlCommand(sql, connection);

View File

@@ -4,5 +4,16 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"KeyVault": {
"Enabled": false
},
"Encryption": {
"LocalDevelopmentKey": "local-development-only-denuncias-encryption-key"
},
"ComplaintStorage": {
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
"UseKeyVault": false,
"AutoCreateSchema": true
} }
} }

View File

@@ -6,15 +6,32 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"DetailedApiErrors": true,
"Jwt": { "Jwt": {
"Issuer": "ApiDenuncias", "Issuer": "ApiDenuncias",
"Audience": "GestionaDenunciasAN", "Audience": "GestionaDenunciasAN",
"SigningKey": "dev-local-api-denuncias-jwt-signing-key-please-change", "SigningKey": "dev-local-api-denuncias-jwt-signing-key-please-change",
"ExpirationMinutes": 480 "ExpirationMinutes": 480,
"RequireHttpsMetadata": false
},
"ForceHttpsRedirection": false,
"KeyVault": {
"Enabled": true,
"VaultUrl": "https://oaaf-kv-pre.vault.azure.net",
"EncryptionKeySecretName": "denuncias-encryption-key",
"AllowLocalEncryptionKeyFallback": true
},
"Encryption": {
"LocalDevelopmentKey": "presentacion-pre-denuncias-encryption-key-cambiar-antes-de-produccion"
}, },
"Gestiona": { "Gestiona": {
"ApiBase": "https://02.g3stiona.com", "ApiBase": "https://02.g3stiona.com",
"AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c", "AccessToken": "_yr.xVvPOllsyd1TYZRxUxg__c",
"CircuitTemplateId": "bb997758-7436-46ab-9dc3-50dce2e02cfa",
"CircuitSignerStampHref": "https://02.g3stiona.com/rest/organ-stamps/3c6eaab4-7fcd-4b21-8676-bf8719be5d36",
"CircuitSignerStampTitle": "oaaf-complaints-tramit",
"CircuitRecipientGroupHref": "https://02.g3stiona.com/rest/groups/454fa4ec-8b82-4240-9419-113f45d4b004",
"CircuitVersion": "2",
"PreferredCircuitTemplateName": "CT-Actualización de denuncia", "PreferredCircuitTemplateName": "CT-Actualización de denuncia",
"UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63", "UserLink": "https://02.g3stiona.com/rest/users/0c168833-8e27-4695-a301-b79924031f63",
"GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101", "GroupLink": "https://02.g3stiona.com/rest/groups/6dbfc433-1eb6-4b9a-a533-bfebc652c101",
@@ -22,11 +39,22 @@
}, },
"GlobalLeaks": { "GlobalLeaks": {
"BaseUrl": "https://prebuzon.antifraudeandalucia.es", "BaseUrl": "https://prebuzon.antifraudeandalucia.es",
"HostHeader": "",
"AllowInvalidCertificate": true,
"TimeoutSeconds": 120, "TimeoutSeconds": 120,
"MaxDownloadBytes": 524288000 "MaxDownloadBytes": 524288000
}, },
"ComplaintStorage": { "ComplaintStorage": {
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;", "ConnectionString": "",
"UseKeyVault": true,
"HostSecretName": "bbdd-host",
"UserSecretName": "bbdd-user",
"PasswordSecretName": "bbdd-password",
"DatabaseSecretName": "bbdd-name",
"PortSecretName": "bbdd-port",
"SslModeSecretName": "bbdd-ssl-mode",
"DefaultPort": 3306,
"DefaultSslMode": "Required",
"AutoCreateSchema": true "AutoCreateSchema": true
} }
} }

View File

@@ -121,6 +121,16 @@
</div> </div>
</li> </li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" style="font-family:'Satoshi'; color:white" href="#" id="tabFichMaestros" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">AVANZADO</a>
<div class="dropdown-menu" id="dropFicherosMaestros" style="font-family: 'Satoshi';" aria-labelledby="dropFicherosMaestros">
<a class="dropdown-item" href="/GruposEnumeraciones">Enumeraciones</a>
<a class="dropdown-item" href="/Grupos de Usuarios">Grupos de Usuarios</a>
<a class="dropdown-item" href="/Plantillas">Plantillas</a>
<a class="dropdown-item" href="/Usuarios">Usuarios</a>
<a class="dropdown-item" href="/Permisos">Permisos</a>
</div>
</li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,306 @@
@page "/Enumeraciones/"
@page "/Enumeraciones/{cl}"
@using System.Net.Http.Headers
@using System.Linq.Expressions
@using Microsoft.AspNetCore.WebUtilities
@using Newtonsoft.Json
@using System.Text
@using Serialize.Linq.Serializers
@using GestionPersonalWeb.Models
@using BlazorBootstrap
@using bdAntifraude.db
@using Microsoft.AspNetCore.Components
@rendermode InteractiveServer
@inject IJSRuntime JS
@inject NavigationManager NavigationManager
@inject IHttpClientFactory HttpClientFactory
@inject IHttpContextAccessor HttpContextAccessor
@inject UserState UserState
<Toasts class="p-3 font-weight-bold" Style="color:white;" AutoHide="true" Delay="4000" Messages="mensajes" Placement="ToastsPlacement.BottomCenter" />
<div class="pagina">
<div class="d-flex">
<div class="cabecera">
<h6 style="padding-top: 13px;padding-right: 15px;">
<b>Enumeración</b>
</h6>
</div>
<button @onclick="@(() => abrirPopupModificacion(new ENUMERACIONES(), true))" class="btnOAAFAzul">Nuevo </button>
</div>
@if (lEnumeraciones == null)
{
<div id="cargando" class="loadingFrame">
<div class="loadingImg"></div>
</div>
}
else if (!lEnumeraciones.Any())
{
<p>No se encontraron datos para mostrar.</p>
}
else
{
<div class="botonera col-12 gap-1" style="display:flex;" role="group">
</div>
<div style="display:flex; justify-content:start; gap:15px;width:100%"></div>
<div style="overflow-x:auto;" class="">
<Grid TItem="ENUMERACIONES"
Class="table tablaRegPers"
Data="@lEnumeraciones"
AllowFiltering="false"
AllowPaging="false"
AllowSorting="true"
EmptyText="No se han encontrado datos"
Height="80"
PageSizeSelectorVisible="false"
Responsive="true"
PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns>
<GridColumn TItem="ENUMERACIONES" HeaderText="">
<button @onclick="@(() => abrirPopupModificacion(@context, false))" class="btnOAAFAzul">Editar</button>
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Código" PropertyName="CODIGO" FilterButtonCSSClass="hidden" SortKeySelector="item => item.CODIGO">
@context.CODIGO
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="DESCRIPCION" FilterButtonCSSClass="hidden" SortKeySelector="item => item.DESCRIPCION">
@context.DESCRIPCION
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORNUMERICO1" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORNUMERICO1">
@context.VALORNUMERICO1
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORNUMERICO2" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORNUMERICO2">
@context.VALORNUMERICO2
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORNUMERICO3" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORNUMERICO3">
@context.VALORNUMERICO3
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORNUMERICO4" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORNUMERICO4">
@context.VALORNUMERICO4
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORALFABETICO1" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORALFABETICO1">
@context.VALORALFABETICO1
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORALFABETICO2" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORALFABETICO2">
@context.VALORALFABETICO2
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORALFABETICO3" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORALFABETICO3">
@context.VALORALFABETICO3
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORALFABETICO4" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORALFABETICO4">
@context.VALORALFABETICO4
</GridColumn>
<GridColumn TItem="ENUMERACIONES" HeaderText="Descripción" PropertyName="VALORALFABETICOLARGO" FilterButtonCSSClass="hidden" SortKeySelector="item => item.VALORALFABETICOLARGO">
@context.VALORALFABETICOLARGO
</GridColumn>
</GridColumns>
</Grid>
</div>
<!-- Vista móvil -->
}
</div>
<!--Popup de edicion-->
<EditForm EditContext="@editContext" OnValidSubmit="GuardarCambiosPopup" OnInvalidSubmit="@MostrarErroresPopup" FormName="fiestasForm">
<DataAnnotationsValidator></DataAnnotationsValidator>
<Modal @ref="popupGestionDatos" title="@tituloPopup" IsVerticallyCentered="true" UseStaticBackdrop="true" CloseOnEscape="false">
<BodyTemplate>
<div class="row">
<div class="col-md-12">
<label for="txtEDescripcion" class="fw-bold">Código: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.CODIGO" />
</div>
<div class="col-md-12">
<label for="txtEDescripcion" class="fw-bold">Descripcion: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.DESCRIPCION" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Numérico 1: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORNUMERICO1" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Numérico 2: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORNUMERICO2" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Numérico 3: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORNUMERICO3" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Numérico 4: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORNUMERICO4" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Alfabético 1: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORALFABETICO1" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Alfabético 2: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORALFABETICO2" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Alfabético 3: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORALFABETICO3" />
</div>
<div class="col-md-6">
<label for="txtEDescripcion" class="fw-bold">Valor Alfabético 4: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORALFABETICO4" />
</div>
<div class="col-md-12">
<label for="txtEDescripcion" class="fw-bold">Valor Alfabético Largo: </label>
<input class="form-control" id="txtEDescripcion" @bind-value="@ItemEnEdicion.VALORALFABETICOLARGO" />
</div>
</div>
</BodyTemplate>
<FooterTemplate>
<Button Color="ButtonColor.Secondary" @onclick="cerrarPopupModificacion">Cerrar</Button>
<Button Type="ButtonType.Submit" Color="ButtonColor.Primary">@(EsItemNuevo ? "Añadir" : "Modificar")</Button>
</FooterTemplate>
</Modal>
</EditForm>
@code {
[Parameter]
public string? cl { get; set; } = "";
GRUPOSENUMERACIONES grupo = new GRUPOSENUMERACIONES();
List<ENUMERACIONES> lEnumeraciones = new List<ENUMERACIONES>();
private string _filter = "";
private string tituloPopup = "";
private Modal popupGestionDatos = default;
private bool EsItemNuevo = false;
private ENUMERACIONES ItemEnEdicion { get; set; } = new ENUMERACIONES();
private EditContext? editContext;
List<ToastMessage> mensajes = new List<ToastMessage>();
protected override async Task OnInitializedAsync()
{
var url = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
var token = UserState.Token;
var cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
editContext = new EditContext(lEnumeraciones);
if (QueryHelpers.ParseQuery(url.Query).TryGetValue("cl", out var clValue))
{
cl = clValue;
}
if (string.IsNullOrEmpty(cl))
{
//iContrato = new CONTRATOS();
//mostrarBtn = true;
}
else
{
string idDesencriptado = Utilidades.Desencriptar(cl);
int id = int.Parse(idDesencriptado);
var response = await cliente.GetAsync($"/api/GRUPOSENUMERACIONES/{id}");
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Error al obtener los datos. Código: {response.StatusCode}");
}
var resultContent = await response.Content.ReadAsStringAsync();
grupo = JsonConvert.DeserializeObject<GRUPOSENUMERACIONES>(resultContent) ?? throw new Exception("Error al deserializar los datos.");
lEnumeraciones = await Utilidades.ObtenerObjeto<List<ENUMERACIONES>>(cliente, "/api/ENUMERACIONES/EnumeracionesGrupo/"+grupo.GRUPO);
}
}
private async Task abrirPopupModificacion(ENUMERACIONES objeto, bool esNuevo)
{
ItemEnEdicion = Utilidades.ClonarObjeto(objeto);
EsItemNuevo = esNuevo;
if (!EsItemNuevo)
{
tituloPopup = "Modificando Enumeracion";
}
else
{
tituloPopup = "Nueva Enumeracion";
}
await popupGestionDatos.ShowAsync();
}
private async Task cerrarPopupModificacion()
{
await popupGestionDatos.HideAsync();
}
private async Task GuardarCambiosPopup()
{
try
{
ValidarDatos();
if (!editContext!.GetValidationMessages().Any())
{
string accion = EsItemNuevo ? "create" : "update";
await GestionarDatos(accion);
}
else
{
mensajes.Add(new ToastMessage
{
Type = ToastType.Warning,
Message = $"Debe rellenar los campos obligatorios.",
});
}
}
catch (Exception)
{
mensajes.Add(new ToastMessage
{
Type = ToastType.Danger,
Message = $"Error al guardar.",
});
}
}
private async Task GestionarDatos(string accion)
{
var cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
var copia = new List<ENUMERACIONES>(lEnumeraciones);
cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
switch (accion)
{
case "update":
int indice = copia.FindIndex(x => x.IDENUMERACION == ItemEnEdicion.IDENUMERACION);
if (indice > -1)
{
copia[indice] = ItemEnEdicion;
}
var response = await Utilidades.ActualizarObjeto(cliente, "/api/ENUMERACIONES/" + ItemEnEdicion.IDENUMERACION, ItemEnEdicion, mensajes);
break;
case "create":
copia.Add(ItemEnEdicion);
var responsec = await Utilidades.NuevoObjeto(cliente, "/api/ENUMERACIONES/", ItemEnEdicion, mensajes);
break;
case "delete":
break;
}
cerrarPopupModificacion();
lEnumeraciones = copia.ToList();
await InvokeAsync(StateHasChanged);
}
private void ValidarDatos()
{
}
private void MostrarErroresPopup()
{
// messageStore?.Clear();
// foreach (var field in new[] { nameof(descripcionItem) })
// {
// ValidarYActualizar(new ChangeEventArgs { Value = typeof(enumeraciones).GetProperty(field)?.GetValue(itemSeleccionado) }, field);
// }
}
}

View File

@@ -0,0 +1,97 @@
@page "/GruposEnumeraciones"
@using System.Net.Http.Headers
@using System.Linq.Expressions
@using Newtonsoft.Json
@using System.Text
@using Serialize.Linq.Serializers
@using GestionPersonalWeb.Models
@using BlazorBootstrap
@using bdAntifraude.db
@using Microsoft.AspNetCore.Components
@rendermode InteractiveServer
@inject IJSRuntime JS
@inject NavigationManager NavigationManager
@inject IHttpClientFactory HttpClientFactory
@inject IHttpContextAccessor HttpContextAccessor
@inject UserState UserState
<Toasts class="p-3 font-weight-bold" Style="color:white;" AutoHide="true" Delay="4000" Messages="mensajes" Placement="ToastsPlacement.BottomCenter" />
<div class="pagina">
<div class="d-flex">
<div class="cabecera">
<h6 style="padding-top: 13px;padding-right: 15px;">
<b>Grupos Enumeraciones</b>
</h6>
</div>
</div>
@if (lGrupoEnumeraciones == null)
{
<div id="cargando" class="loadingFrame">
<div class="loadingImg"></div>
</div>
}
else if (!lGrupoEnumeraciones.Any())
{
<p>No se encontraron datos para mostrar.</p>
}
else
{
<div class="botonera col-12 gap-1" style="display:flex;" role="group">
</div>
<div style="display:flex; justify-content:start; gap:15px;width:100%"></div>
<div style="overflow-x:auto;" class="">
<Grid TItem="GRUPOSENUMERACIONES"
Class="table tablaRegPers"
Data="@lGrupoEnumeraciones"
AllowFiltering="false"
AllowPaging="false"
AllowSorting="true"
EmptyText="No se han encontrado datos"
Height="80"
PageSizeSelectorVisible="false"
Responsive="true"
PaginationItemsTextFormat="{0} - {1} de {2} elementos">
<GridColumns>
<GridColumn TItem="GRUPOSENUMERACIONES" HeaderText="Grupo" PropertyName="GRUPO" FilterButtonCSSClass="hidden" SortKeySelector="item => item.GRUPO">
<NavLink class="btn btn-link" href="@HashRed(context.IDGRUPOENUMERACION.ToString())">@context.GRUPO</NavLink>
</GridColumn>
<GridColumn TItem="GRUPOSENUMERACIONES" HeaderText="Descripcion" PropertyName="DESCRIPCION" FilterButtonCSSClass="hidden" SortKeySelector="item => item.DESCRIPCION">
@context.DESCRIPCION
</GridColumn>
</GridColumns>
</Grid>
</div>
<!-- Vista móvil -->
}
</div>
@code {
List<GRUPOSENUMERACIONES> lGrupoEnumeraciones = new List<GRUPOSENUMERACIONES>();
// Bandera que indica si se está en modo "Ver Todos"
private bool verTodosActive = false;
List<ToastMessage> mensajes = new List<ToastMessage>();
protected override async Task OnInitializedAsync()
{
verTodosActive = false;
var token = UserState.Token;
var cliente = Utilidades.ObtenerCliente(UserState.Token, HttpClientFactory);
var resultPersonas = await cliente.GetAsync("/api/GRUPOSENUMERACIONES");
var resultContent = await resultPersonas.Content.ReadAsStringAsync();
lGrupoEnumeraciones = JsonConvert.DeserializeObject<List<GRUPOSENUMERACIONES>>(resultContent) ?? new List<GRUPOSENUMERACIONES>(); ;
}
private string HashRed(string id)
{
string link = "/Enumeraciones?cl=" + tsUtilidades.crypt.FEncS(
id,
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890.:/-*",
875421649);
return link;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
@@ -6,14 +6,14 @@ using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf; using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO; using PdfSharpCore.Pdf.IO;
namespace GestionaDenunciasAN.Helpers namespace GestionaDenuncias.Shared.Helpers
{ {
public static class PdfHelper public static class PdfHelper
{ {
/// <summary> /// <summary>
/// Fusiona varios ficheros (PDF, imágenes, TXT) en un único PDF. /// Fusiona varios ficheros (PDF, imágenes, TXT) en un único PDF.
/// Los .txt se renderizan con márgenes iguales, alineación a la izquierda y ajuste de líneas, /// Los .txt se renderizan con márgenes iguales, alineación a la izquierda y ajuste de líneas,
/// preservando líneas en blanco. /// preservando líneas en blanco.
/// </summary> /// </summary>
/// <param name="files">Secuencia de tuplas (FileName, ContentBytes)</param> /// <param name="files">Secuencia de tuplas (FileName, ContentBytes)</param>
/// <returns>Bytes del PDF combinado</returns> /// <returns>Bytes del PDF combinado</returns>
@@ -52,7 +52,7 @@ namespace GestionaDenunciasAN.Helpers
break; break;
case ".txt": case ".txt":
// Renderizado de TXT con margen y ajuste de líneas, preservando líneas en blanco // Renderizado de TXT con margen y ajuste de líneas, preservando líneas en blanco
var text = Encoding.UTF8.GetString(content); var text = Encoding.UTF8.GetString(content);
PdfPage pageTxt = outputDoc.AddPage(); PdfPage pageTxt = outputDoc.AddPage();
XGraphics gfxTxt = XGraphics.FromPdfPage(pageTxt); XGraphics gfxTxt = XGraphics.FromPdfPage(pageTxt);
@@ -72,7 +72,7 @@ namespace GestionaDenunciasAN.Helpers
foreach (var origLine in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) foreach (var origLine in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
{ {
// Línea en blanco: preservarla // Línea en blanco: preservarla
if (string.IsNullOrWhiteSpace(origLine)) if (string.IsNullOrWhiteSpace(origLine))
{ {
y += lineHeight; y += lineHeight;
@@ -99,7 +99,7 @@ namespace GestionaDenunciasAN.Helpers
} }
else else
{ {
// Dibujar la línea acumulada // Dibujar la línea acumulada
gfxTxt.DrawString( gfxTxt.DrawString(
currentLine, currentLine,
font, font,
@@ -109,7 +109,7 @@ namespace GestionaDenunciasAN.Helpers
y += lineHeight; y += lineHeight;
currentLine = word; currentLine = word;
// Paginación si se sale por abajo // Paginación si se sale por abajo
if (y + lineHeight > pageHeight - marginBottom) if (y + lineHeight > pageHeight - marginBottom)
{ {
gfxTxt.Dispose(); gfxTxt.Dispose();
@@ -120,7 +120,7 @@ namespace GestionaDenunciasAN.Helpers
} }
} }
// Dibujar la última línea del párrafo // Dibujar la última línea del párrafo
if (!string.IsNullOrEmpty(currentLine)) if (!string.IsNullOrEmpty(currentLine))
{ {
gfxTxt.DrawString( gfxTxt.DrawString(
@@ -145,7 +145,7 @@ namespace GestionaDenunciasAN.Helpers
break; break;
default: default:
throw new NotSupportedException($"Extensión no soportada: {ext}"); throw new NotSupportedException($"Extensión no soportada: {ext}");
} }
} }

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record ApiLoginResponse( public sealed record ApiLoginResponse(
string Username, string Username,
@@ -36,6 +36,10 @@ public sealed record MarkReportImportedRequest(
ReportDto Report, ReportDto Report,
int? ComplaintId); int? ComplaintId);
public sealed record TrackingImportPermissionRequest(
string Username,
ReportDto Report);
public sealed record GestionaCreateFileRequest( public sealed record GestionaCreateFileRequest(
Guid ProcedureId, Guid ProcedureId,
string Subject, string Subject,

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record ApiError(string Error, bool SessionExpired = false); public sealed record ApiError(string Error, bool SessionExpired = false);

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record ContextDto(string Id, string Name); public sealed record ContextDto(string Id, string Name);

View File

@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public class DenunciasGestiona public class DenunciasGestiona
{ {

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models namespace GestionaDenuncias.Shared.Models
{ {
public class ExpedienteTerceroDto public class ExpedienteTerceroDto
{ {

View File

@@ -1,7 +1,7 @@
// Models/FicherosDenuncias.cs // Models/FicherosDenuncias.cs
using System; using System;
namespace GestionaDenunciasAN.Models namespace GestionaDenuncias.Shared.Models
{ {
public class FicherosDenuncias public class FicherosDenuncias
{ {
@@ -29,17 +29,26 @@ namespace GestionaDenunciasAN.Models
// Fichero completo en formato byte array (BLOB) // Fichero completo en formato byte array (BLOB)
public byte[] Fichero { get; set; } = []; public byte[] Fichero { get; set; } = [];
// Nuevo: marca si ya se subió a Gestión // ? Nuevo: marca si ya se subió a Gestión
public bool Subido { get; set; } public bool Subido { get; set; }
// Nuevo: fecha en que se subió por última vez // ? Nuevo: fecha en que se subió por última vez
public DateTime? FechaSubida { get; set; } public DateTime? FechaSubida { get; set; }
// Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos. // Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos.
public string ContentSha256 { get; set; } = string.Empty; public string ContentSha256 { get; set; } = string.Empty;
public bool EsReport => public bool EsReport
string.Equals(NombreFichero, "report.txt", StringComparison.OrdinalIgnoreCase); {
get
{
var fileName = System.IO.Path.GetFileNameWithoutExtension(NombreFichero);
var extension = System.IO.Path.GetExtension(NombreFichero);
return fileName.StartsWith("report", StringComparison.OrdinalIgnoreCase) &&
(extension.Equals(".txt", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase));
}
}
public FicherosDenuncias() { } public FicherosDenuncias() { }

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record FileDownloadResult(byte[] Content, string FileName); public sealed record FileDownloadResult(byte[] Content, string FileName);

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed class GestionaExpedienteInfo public sealed class GestionaExpedienteInfo
{ {

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record GlSession(string Id, string Username, string? Role = null); public sealed record GlSession(string Id, string Username, string? Role = null);

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed class GlobalLeaksStoredSession public sealed class GlobalLeaksStoredSession
{ {

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record ImportSummary( public sealed record ImportSummary(
int TotalCandidates, int TotalCandidates,

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record InboxUserState public sealed record InboxUserState
{ {

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record LoginRequest(string Username, string Password, string Authcode); public sealed record LoginRequest(string Username, string Password, string Authcode);

View File

@@ -1,3 +1,3 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record LoginResponse(string Username); public sealed record LoginResponse(string Username);

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed record ReportDto public sealed record ReportDto
{ {

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed class ReportFieldEntry public sealed class ReportFieldEntry
{ {

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed class ThirdPartyAddressData public sealed class ThirdPartyAddressData
{ {

View File

@@ -1,6 +1,6 @@
using System; using System;
namespace GestionaDenunciasAN.Models; namespace GestionaDenuncias.Shared.Models;
public sealed class ThirdPartyIdentityData public sealed class ThirdPartyIdentityData
{ {

View File

@@ -1,6 +1,6 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Services; namespace GestionaDenuncias.Shared.Services;
public interface IDenunciaStore public interface IDenunciaStore
{ {

View File

@@ -1,6 +1,6 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Services; namespace GestionaDenuncias.Shared.Services;
public interface IInboxTrackingService public interface IInboxTrackingService
{ {
@@ -14,4 +14,9 @@ public interface IInboxTrackingService
ReportDto report, ReportDto report,
int? complaintId, int? complaintId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
CancellationToken cancellationToken = default);
} }

View File

@@ -1,6 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace GestionaDenunciasAN.Services; namespace GestionaDenuncias.Shared.Services;
public sealed class LoginRateLimiter public sealed class LoginRateLimiter
{ {

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@@ -1,4 +1,4 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="main"> <div class="main">
<div class=""> <div class="">

View File

@@ -1,13 +1,13 @@
@page "/Actualizaciones" @page "/Actualizaciones"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenuncias.Shared.Models
@using System.Globalization @using System.Globalization
@using System.IO @using System.IO
@using System.Linq @using System.Linq
@using System.Text @using System.Text
@using GestionaDenunciasAN.Helpers @using GestionaDenuncias.Shared.Helpers
@using GestionaDenunciasAN.Services @using GestionaDenunciasAN.Services
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@@ -731,6 +731,19 @@ else
useAutoFoundExpediente = true; useAutoFoundExpediente = true;
} }
private static bool IsReportFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var name = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
return name.StartsWith("report", StringComparison.OrdinalIgnoreCase) &&
(extension.Equals(".txt", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase));
}
private string FixFileName(string input) private string FixFileName(string input)
{ {
var n = input.Normalize(NormalizationForm.FormD); var n = input.Normalize(NormalizationForm.FormD);
@@ -871,7 +884,7 @@ else
string? documentoParaTramitar = null; string? documentoParaTramitar = null;
var report = todos.FirstOrDefault(t => var report = todos.FirstOrDefault(t =>
string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase)); IsReportFileName(t.FileName));
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
@@ -887,7 +900,7 @@ else
} }
var adjuntos = todos var adjuntos = todos
.Where(t => !string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase)) .Where(t => !IsReportFileName(t.FileName))
.ToList(); .ToList();
if (adjuntos.Count > 0 && uploadMode == "merge") if (adjuntos.Count > 0 && uploadMode == "merge")

View File

@@ -1,4 +1,4 @@
@page "/Buscador" @page "/Buscador"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@@ -62,7 +62,7 @@
checked="@IsModo(ModoUltimos)" checked="@IsModo(ModoUltimos)"
@onchange="@(() => SetModo(ModoUltimos))" /> @onchange="@(() => SetModo(ModoUltimos))" />
<label class="form-check-label" for="modoUltimos"> <label class="form-check-label" for="modoUltimos">
Últimos X meses <EFBFBD>ltimos X meses
</label> </label>
</div> </div>
</div> </div>
@@ -90,7 +90,7 @@
{ {
<div class="row g-2 mt-2"> <div class="row g-2 mt-2">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Últimos meses</label> <label class="form-label"><EFBFBD>ltimos meses</label>
<select class="form-select" @bind="mesesUltimos"> <select class="form-select" @bind="mesesUltimos">
<option value="3">3 meses</option> <option value="3">3 meses</option>
<option value="6">6 meses</option> <option value="6">6 meses</option>
@@ -100,7 +100,7 @@
</div> </div>
} }
<!-- Botón buscar --> <!-- Bot<EFBFBD>n buscar -->
<div class="row g-2 mt-3"> <div class="row g-2 mt-3">
<div class="col-md-3"> <div class="col-md-3">
<button class="btn btn-primary" <button class="btn btn-primary"
@@ -109,7 +109,7 @@
@if (isSearching) @if (isSearching)
{ {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Buscando</span> <span class="ms-2">Buscando<EFBFBD></span>
} }
else else
{ {
@@ -145,7 +145,7 @@
<tr> <tr>
<th>Expediente</th> <th>Expediente</th>
<th>Asunto</th> <th>Asunto</th>
<th>Fecha creación</th> <th>Fecha creaci<EFBFBD>n</th>
<th>Estado</th> <th>Estado</th>
<th></th> <th></th>
</tr> </tr>
@@ -177,7 +177,7 @@
<dt class="col-sm-2">Asunto</dt> <dt class="col-sm-2">Asunto</dt>
<dd class="col-sm-10">@exp.Asunto</dd> <dd class="col-sm-10">@exp.Asunto</dd>
<dt class="col-sm-2">Fecha creación</dt> <dt class="col-sm-2">Fecha creaci<EFBFBD>n</dt>
<dd class="col-sm-10"> <dd class="col-sm-10">
@exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm") @exp.FechaCreacion?.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
</dd> </dd>
@@ -228,7 +228,7 @@
private List<ExpedienteTerceroDto> expedientes = new(); private List<ExpedienteTerceroDto> expedientes = new();
// expediente cuyo detalle está abierto // expediente cuyo detalle est<EFBFBD> abierto
private ExpedienteTerceroDto? expedienteSeleccionado; private ExpedienteTerceroDto? expedienteSeleccionado;
private bool IsModo(string valor) => string.Equals(modoFecha, valor, StringComparison.Ordinal); private bool IsModo(string valor) => string.Equals(modoFecha, valor, StringComparison.Ordinal);
@@ -243,7 +243,7 @@
} }
// ============================================================ // ============================================================
// BÚSQUEDA PRINCIPAL // B<EFBFBD>SQUEDA PRINCIPAL
// ============================================================ // ============================================================
private async Task BuscarAsync() private async Task BuscarAsync()
@@ -263,7 +263,7 @@
DateTimeOffset? desde = null; DateTimeOffset? desde = null;
DateTimeOffset? hasta = null; DateTimeOffset? hasta = null;
// Calcular rango según el modo // Calcular rango seg<EFBFBD>n el modo
if (modoFecha == ModoRango) if (modoFecha == ModoRango)
{ {
if (!fechaDesde.HasValue || !fechaHasta.HasValue) if (!fechaDesde.HasValue || !fechaHasta.HasValue)
@@ -285,7 +285,7 @@
{ {
if (mesesUltimos <= 0) if (mesesUltimos <= 0)
{ {
errorMessage = "El número de meses debe ser mayor que 0."; errorMessage = "El n<EFBFBD>mero de meses debe ser mayor que 0.";
return; return;
} }

View File

@@ -1,4 +1,4 @@
@page "/Error" @page "/Error"
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Error</PageTitle> <PageTitle>Error</PageTitle>

View File

@@ -17,9 +17,6 @@
para seguir trayendo denuncias. para seguir trayendo denuncias.
</p> </p>
</div> </div>
<button type="button" class="btn btn-outline-secondary" @onclick="ProcessLocalZipsAsync" disabled="@LocalBusy">
@(LocalBusy ? "Procesando..." : "Procesar carpeta local")
</button>
</div> </div>
@if (!string.IsNullOrWhiteSpace(StatusMessage)) @if (!string.IsNullOrWhiteSpace(StatusMessage))
@@ -230,53 +227,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card shadow-sm mt-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div>
<h5 class="card-title mb-1">Carpeta local</h5>
<p class="text-muted mb-0">
ZIPs pendientes detectados en <code>C:\ZipsDenuncias</code>.
</p>
</div>
<button type="button" class="btn btn-outline-secondary" @onclick="ProcessLocalZipsAsync" disabled="@LocalBusy">
@(LocalBusy ? "Procesando..." : "Procesar ZIPs")
</button>
</div>
@if (!ExistingZips.Any())
{
<p class="mb-0 text-muted">No hay ZIPs pendientes en la carpeta.</p>
}
else
{
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>ZIP</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody>
@foreach (var zip in ExistingZips)
{
<tr>
<td>@zip</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteZipAsync(zip)">
Eliminar
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div> </div>
@code { @code {
@@ -285,7 +235,6 @@
private List<ReportDto> VisibleReports = []; private List<ReportDto> VisibleReports = [];
private HashSet<string> SelectedIds = []; private HashSet<string> SelectedIds = [];
private IReadOnlyList<string> ExistingZips = Array.Empty<string>();
private ApiGlobalLeaksSessionDto? SessionInfo; private ApiGlobalLeaksSessionDto? SessionInfo;
private InboxUserState UserInboxState = new(); private InboxUserState UserInboxState = new();
private string CurrentUsername { get; set; } = string.Empty; private string CurrentUsername { get; set; } = string.Empty;
@@ -303,7 +252,6 @@
private bool ReportsBusy { get; set; } private bool ReportsBusy { get; set; }
private bool RenewBusy { get; set; } private bool RenewBusy { get; set; }
private bool ImportBusy { get; set; } private bool ImportBusy { get; set; }
private bool LocalBusy { get; set; }
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true; private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private int SelectedReportsCount => SelectedIds.Count; private int SelectedReportsCount => SelectedIds.Count;
@@ -337,8 +285,6 @@
"La aplicacion sigue iniciada, pero la sesion de GlobalLeaks no esta activa. Introduce un nuevo 2FA para renovarla.", "La aplicacion sigue iniciada, pero la sesion de GlobalLeaks no esta activa. Introduce un nuevo 2FA para renovarla.",
"alert-warning"); "alert-warning");
} }
await TryRefreshLocalZipListAsync();
} }
private async Task LoadSessionStateAsync() private async Task LoadSessionStateAsync()
@@ -457,7 +403,6 @@
} }
} }
await RefreshLocalZipListAsync();
SelectedIds.Clear(); SelectedIds.Clear();
await LoadReportsAsync(); await LoadReportsAsync();
@@ -497,55 +442,6 @@
} }
} }
private async Task ProcessLocalZipsAsync()
{
LocalBusy = true;
try
{
var result = await ApiDenuncias.ProcessLocalZipsAsync();
await RefreshLocalZipListAsync();
if (result.TotalCandidates == 0)
{
SetStatus("No hay ZIPs pendientes en la carpeta local.", "alert-info");
return;
}
SetStatus(
result.Errors.Count == 0
? $"Se han procesado {result.ImportedCount} ZIP(s) de la carpeta local."
: $"Se han procesado {result.ImportedCount} ZIP(s) con incidencias: {string.Join(" | ", result.Errors)}",
result.Errors.Count == 0 ? "alert-success" : "alert-warning");
}
finally
{
LocalBusy = false;
}
}
private async Task DeleteZipAsync(string zipName)
{
await ApiDenuncias.DeleteZipAsync(zipName);
await RefreshLocalZipListAsync();
}
private async Task TryRefreshLocalZipListAsync()
{
try
{
await RefreshLocalZipListAsync();
}
catch
{
ExistingZips = Array.Empty<string>();
}
}
private async Task RefreshLocalZipListAsync()
{
ExistingZips = await ApiDenuncias.GetExistingZipNamesAsync();
}
private void ApplyFilters() private void ApplyFilters()
{ {
IEnumerable<ReportDto> filtered = Reports; IEnumerable<ReportDto> filtered = Reports;

View File

@@ -1,4 +1,4 @@
@page "/Gestiona" @page "/Gestiona"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@@ -11,7 +11,7 @@
@inject IDenunciaStore DenunciaStore @inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias @inject ApiDenunciasClient ApiDenuncias
<PageTitle>Denuncias Gestión</PageTitle> <PageTitle>Denuncias Gesti<EFBFBD>n</PageTitle>
<style> <style>
/* Contenedor para la lista de denuncias */ /* Contenedor para la lista de denuncias */
@@ -65,7 +65,7 @@
.card-body { .card-body {
padding: 1.25rem; padding: 1.25rem;
} }
/* Estilos para los títulos de sección dentro de la card */ /* Estilos para los t<EFBFBD>tulos de secci<EFBFBD>n dentro de la card */
.section-heading { .section-heading {
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
@@ -76,9 +76,9 @@
} }
</style> </style>
<h1>Denuncias en Gestión</h1> <h1>Denuncias en Gesti<EFBFBD>n</h1>
<!-- Campo de búsqueda --> <!-- Campo de b<EFBFBD>squeda -->
<input type="text" <input type="text"
class="form-control" class="form-control"
placeholder="Buscar denuncias..." placeholder="Buscar denuncias..."
@@ -92,7 +92,7 @@
} }
else if (denunciasGestiona == null || !denunciasGestiona.Any()) else if (denunciasGestiona == null || !denunciasGestiona.Any())
{ {
<p>No hay denuncias en gestión.</p> <p>No hay denuncias en gesti<EFBFBD>n.</p>
} }
else else
{ {
@@ -138,12 +138,12 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable)) @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{ {
<dt class="col-sm-3">Nº expediente Gestiona</dt> <dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
{ {
<dt class="col-sm-3">ID Persona Gestión</dt> <dt class="col-sm-3">ID Persona Gesti<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd> <dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta)) @if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -192,13 +192,13 @@ else
<dd class="col-sm-9">@denuncia.Asunto</dd> <dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Quien Denuncia</dt> <dt class="col-sm-3">A Quien Denuncia</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd> <dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
<dt class="col-sm-3">Descripción Denuncia</dt> <dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd> <dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt> <dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd> <dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion)) @if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{ {
<dt class="col-sm-3">Modalidad Información</dt> <dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd> <dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
} }
<dt class="col-sm-3">Lugar Hechos</dt> <dt class="col-sm-3">Lugar Hechos</dt>
@@ -210,27 +210,27 @@ else
} }
</dl> </dl>
<!-- Datos de Notificación --> <!-- Datos de Notificaci<EFBFBD>n -->
<h5 class="section-heading">Datos de Notificación</h5> <h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5>
<dl class="row"> <dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{ {
<dt class="col-sm-3">Notificación Preferencia</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd> <dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{ {
<dt class="col-sm-3">Notificación Electrónica</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd> <dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico)) @if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{ {
<dt class="col-sm-3">Correo Electrónico</dt> <dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd> <dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{ {
<dt class="col-sm-3">Notificación SMS</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd> <dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
} }
</dl> </dl>
@@ -241,7 +241,7 @@ else
@if (denuncia.Condiciones) @if (denuncia.Condiciones)
{ {
<dt class="col-sm-3">Condiciones</dt> <dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">Sí</dd> <dd class="col-sm-9">S<EFBFBD></dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Comments)) @if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{ {
@@ -258,7 +258,7 @@ else
<thead> <thead>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
<th>Tamaño (bytes)</th> <th>Tama<EFBFBD>o (bytes)</th>
<th>Ver</th> <th>Ver</th>
</tr> </tr>
</thead> </thead>
@@ -309,7 +309,7 @@ else
private List<DenunciasGestiona> denunciasGestiona = new(); private List<DenunciasGestiona> denunciasGestiona = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new(); private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
// Variable para la búsqueda // Variable para la b<EFBFBD>squeda
private string busqueda = ""; private string busqueda = "";
private bool hasLoaded = false; private bool hasLoaded = false;

View File

@@ -1,4 +1,4 @@
@page "/Instrucciones" @page "/Instrucciones"
@attribute [Authorize] @attribute [Authorize]
@attribute [StreamRendering] @attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState @inject GestionaDenunciasAN.Models.UserState userState
@@ -7,22 +7,22 @@
<PageTitle>Instrucciones</PageTitle> <PageTitle>Instrucciones</PageTitle>
<div class="container mt-4"> <div class="container mt-4">
<h1 class="mb-4">Guía de Uso Gestión de Denuncias</h1> <h1 class="mb-4">Gu<EFBFBD>a de Uso <EFBFBD> Gesti<EFBFBD>n de Denuncias</h1>
<p> <p>
Esta aplicación permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas: Esta aplicaci<EFBFBD>n permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas:
<strong>Pendientes</strong>, <strong>Gestión</strong> (aceptadas) y <strong>Rechazadas</strong>. <strong>Pendientes</strong>, <strong>Gesti<EFBFBD>n</strong> (aceptadas) y <strong>Rechazadas</strong>.
</p> </p>
<h2>1. Carga de ZIPs</h2> <h2>1. Carga de ZIPs</h2>
<ul> <ul>
<li> <li>
Sitúate en la pestaña <strong>Gestión de ZIP</strong>. Haz clic en <em>Subir nuevo ZIP</em>, Sit<EFBFBD>ate en la pesta<EFBFBD>a <strong>Gesti<EFBFBD>n de ZIP</strong>. Haz clic en <em>Subir nuevo ZIP</em>,
selecciona uno o varios archivos <code>.zip</code> y espera a que se extraigan. selecciona uno o varios archivos <code>.zip</code> y espera a que se extraigan.
</li> </li>
<li> <li>
Cada ZIP debe incluir un <code>report.txt</code> con los campos de la denuncia, y opcionalmente Cada ZIP debe incluir un <code>report.txt</code> con los campos de la denuncia, y opcionalmente
subcarpetas <code>files</code> o <code>files_attached_from_recipients</code> con PDF e imágenes. subcarpetas <code>files</code> o <code>files_attached_from_recipients</code> con PDF e im<EFBFBD>genes.
</li> </li>
<li> <li>
Tras el procesado, la app lee los <code>report.txt</code> y actualiza la base de datos: Tras el procesado, la app lee los <code>report.txt</code> y actualiza la base de datos:
@@ -32,10 +32,10 @@
</li> </li>
</ul> </ul>
<h2>2. Pestaña <strong>Pendientes</strong></h2> <h2>2. Pesta<EFBFBD>a <strong>Pendientes</strong></h2>
<ul> <ul>
<li> <li>
Verás cada denuncia en una tarjeta colapsable con sus datos y el listado de ficheros adjuntos. Ver<EFBFBD>s cada denuncia en una tarjeta colapsable con sus datos y el listado de ficheros adjuntos.
</li> </li>
<li> <li>
Hay dos acciones: Hay dos acciones:
@@ -47,30 +47,30 @@
<li> <li>
Elegir el modo de subida: Elegir el modo de subida:
<ul> <ul>
<li><em>Unir</em> todos los ficheros en un único PDF.</li> <li><em>Unir</em> todos los ficheros en un <EFBFBD>nico PDF.</li>
<li><em>Subir</em> cada fichero de forma independiente.</li> <li><em>Subir</em> cada fichero de forma independiente.</li>
</ul> </ul>
</li> </li>
<li>Seleccionar el grupo de destino (600, 510 o 700).</li> <li>Seleccionar el grupo de destino (600, 510 o 700).</li>
<li> <li>
Confirmar. La denuncia se crea y abre en Gestióna, sube los documentos Confirmar. La denuncia se crea y abre en Gesti<EFBFBD>na, sube los documentos
y pasa a la pestaña <strong>Gestión</strong>. y pasa a la pesta<EFBFBD>a <strong>Gesti<EFBFBD>n</strong>.
</li> </li>
</ol> </ol>
</li> </li>
<li> <li>
<strong>Rechazar denuncia</strong> (rojo): abre un modal para poner el motivo. <strong>Rechazar denuncia</strong> (rojo): abre un modal para poner el motivo.
Al confirmar, la denuncia se marca como rechazada y va a la pestaña Al confirmar, la denuncia se marca como rechazada y va a la pesta<EFBFBD>a
<strong>Rechazados</strong>. <strong>Rechazados</strong>.
</li> </li>
</ul> </ul>
</li> </li>
</ul> </ul>
<h2>3. Pestaña <strong>Gestión</strong></h2> <h2>3. Pesta<EFBFBD>a <strong>Gesti<EFBFBD>n</strong></h2>
<ul> <ul>
<li> <li>
Aquí se listan las denuncias que ya han sido <em>enviadas a Gestión</em>. Aqu<EFBFBD> se listan las denuncias que ya han sido <em>enviadas a Gesti<EFBFBD>n</em>.
Aparecen con fondo verde. Aparecen con fondo verde.
</li> </li>
<li> <li>
@@ -78,34 +78,34 @@
<ul> <ul>
<li>ID, nombre, archivo subido</li> <li>ID, nombre, archivo subido</li>
<li>Fecha y hora de subida</li> <li>Fecha y hora de subida</li>
<li>Detalles completos y enlaces Ver a los PDFs/imágenes</li> <li>Detalles completos y enlaces <EFBFBD>Ver<EFBFBD> a los PDFs/im<EFBFBD>genes</li>
</ul> </ul>
</li> </li>
</ul> </ul>
<h2>4. Pestaña <strong>Rechazadas</strong></h2> <h2>4. Pesta<EFBFBD>a <strong>Rechazadas</strong></h2>
<ul> <ul>
<li> <li>
Aquí verás todas las denuncias que han sido rechazadas. Fondo rojo. Aqu<EFBFBD> ver<EFBFBD>s todas las denuncias que han sido rechazadas. Fondo rojo.
</li> </li>
<li> <li>
Cada tarjeta muestra el motivo de rechazo y la fecha/hora en que se marcó. Cada tarjeta muestra el motivo de rechazo y la fecha/hora en que se marc<EFBFBD>.
</li> </li>
</ul> </ul>
<h2>5. Flujo completo</h2> <h2>5. Flujo completo</h2>
<ol> <ol>
<li>Subes uno o varios ZIP en la pestaña <strong>Gestión de ZIP</strong>.</li> <li>Subes uno o varios ZIP en la pesta<EFBFBD>a <strong>Gesti<EFBFBD>n de ZIP</strong>.</li>
<li>La aplicación extrae y parsea informes, los añade a <strong>Pendientes</strong>.</li> <li>La aplicaci<EFBFBD>n extrae y parsea informes, los a<EFBFBD>ade a <strong>Pendientes</strong>.</li>
<li> <li>
En <strong>Pendientes</strong> eliges qué hacer con cada denuncia: En <strong>Pendientes</strong> eliges qu<EFBFBD> hacer con cada denuncia:
<ul> <ul>
<li><strong>Configurar subida</strong> pasa a <strong>Gestión</strong>.</li> <li><strong>Configurar subida</strong> ? pasa a <strong>Gesti<EFBFBD>n</strong>.</li>
<li><strong>Rechazar denuncia</strong> pasa a <strong>Rechazadas</strong>.</li> <li><strong>Rechazar denuncia</strong> ? pasa a <strong>Rechazadas</strong>.</li>
</ul> </ul>
</li> </li>
<li> <li>
En <strong>Gestión</strong> puedes revisar lo ya subido; en En <strong>Gesti<EFBFBD>n</strong> puedes revisar lo ya subido; en
<strong>Rechazadas</strong> ves los motivos. <strong>Rechazadas</strong> ves los motivos.
</li> </li>
</ol> </ol>

View File

@@ -2,13 +2,13 @@
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenuncias.Shared.Models
@using System.Globalization @using System.Globalization
@using System.IO @using System.IO
@using System.Linq @using System.Linq
@using System.Text @using System.Text
@using GestionaDenunciasAN.Helpers @using GestionaDenuncias.Shared.Helpers
@using GestionaDenunciasAN.Services @using GestionaDenunciasAN.Services
@attribute [StreamRendering] @attribute [StreamRendering]
@@ -175,6 +175,11 @@
<h1>Denuncias Pendientes</h1> <h1>Denuncias Pendientes</h1>
@if (!string.IsNullOrWhiteSpace(loadError))
{
<div class="alert alert-danger">@loadError</div>
}
<input type="text" <input type="text"
class="form-control" class="form-control"
placeholder="Buscar denuncias..." placeholder="Buscar denuncias..."
@@ -849,6 +854,7 @@ else
private Dictionary<int, string> preselectedFicheros = new(); private Dictionary<int, string> preselectedFicheros = new();
private bool hasLoaded = false; private bool hasLoaded = false;
private string loadError = string.Empty;
private bool showModal = false; private bool showModal = false;
private bool showModalRechazo = false; private bool showModalRechazo = false;
@@ -876,6 +882,9 @@ else
private async Task CargarDatosAsync() private async Task CargarDatosAsync()
{ {
try
{
loadError = string.Empty;
var todas = await CargarDenunciasJsonAsync(); var todas = await CargarDenunciasJsonAsync();
// Asegura ProcedureId/GroupId por si faltan // Asegura ProcedureId/GroupId por si faltan
@@ -897,6 +906,13 @@ else
.Where(f => idsPend.Contains(f.Id_Denuncia)) .Where(f => idsPend.Contains(f.Id_Denuncia))
.GroupBy(f => f.Id_Denuncia) .GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
}
catch (Exception ex)
{
pendientes.Clear();
ficherosAdjuntos.Clear();
loadError = $"No se han podido cargar las denuncias pendientes: {ex.Message}";
}
hasLoaded = true; hasLoaded = true;
StateHasChanged(); StateHasChanged();
@@ -912,6 +928,19 @@ else
return await DenunciaStore.GetAllFicherosAsync(); return await DenunciaStore.GetAllFicherosAsync();
} }
private static bool IsReportFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var name = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
return name.StartsWith("report", StringComparison.OrdinalIgnoreCase) &&
(extension.Equals(".txt", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase));
}
private string FixFileName(string input) private string FixFileName(string input)
{ {
var normalized = input.Normalize(NormalizationForm.FormD); var normalized = input.Normalize(NormalizationForm.FormD);
@@ -995,7 +1024,7 @@ else
string? documentoParaTramitar = null; string? documentoParaTramitar = null;
var report = todos.FirstOrDefault(t => var report = todos.FirstOrDefault(t =>
string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase)); IsReportFileName(t.FileName));
if (!string.IsNullOrWhiteSpace(report.FileName)) if (!string.IsNullOrWhiteSpace(report.FileName))
{ {
@@ -1013,7 +1042,7 @@ else
} }
var adjuntos = todos var adjuntos = todos
.Where(t => !string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase)) .Where(t => !IsReportFileName(t.FileName))
.ToList(); .ToList();
if (adjuntos.Count > 0 && uploadMode == "merge") if (adjuntos.Count > 0 && uploadMode == "merge")

View File

@@ -1,4 +1,4 @@
@page "/Rechazados" @page "/Rechazados"
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize] @attribute [Authorize]
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@@ -62,7 +62,7 @@
.card-body { .card-body {
padding: 1.25rem; padding: 1.25rem;
} }
/* Estilos para los títulos de sección dentro de la card */ /* Estilos para los t<EFBFBD>tulos de secci<EFBFBD>n dentro de la card */
.section-heading { .section-heading {
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
@@ -75,7 +75,7 @@
<h1>Denuncias Rechazadas</h1> <h1>Denuncias Rechazadas</h1>
<!-- Campo de búsqueda --> <!-- Campo de b<EFBFBD>squeda -->
<input type="text" <input type="text"
class="form-control" class="form-control"
placeholder="Buscar denuncias..." placeholder="Buscar denuncias..."
@@ -134,12 +134,12 @@ else
} }
@if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable)) @if (!string.IsNullOrWhiteSpace(denuncia.ExpedienteGestionaMostrable))
{ {
<dt class="col-sm-3">Nº expediente Gestiona</dt> <dt class="col-sm-3">N<EFBFBD> expediente Gestiona</dt>
<dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd> <dd class="col-sm-9">@denuncia.ExpedienteGestionaMostrable</dd>
} }
@if (denuncia.Id_Persona_Gestiona != 0) @if (denuncia.Id_Persona_Gestiona != 0)
{ {
<dt class="col-sm-3">ID Persona Gestión</dt> <dt class="col-sm-3">ID Persona Gesti<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd> <dd class="col-sm-9">@denuncia.Id_Persona_Gestiona</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta)) @if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -187,13 +187,13 @@ else
<dd class="col-sm-9">@denuncia.Asunto</dd> <dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Quien Denuncia</dt> <dt class="col-sm-3">A Quien Denuncia</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd> <dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
<dt class="col-sm-3">Descripción Denuncia</dt> <dt class="col-sm-3">Descripci<EFBFBD>n Denuncia</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd> <dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
<dt class="col-sm-3">Denunciado Ante Inst</dt> <dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd> <dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion)) @if (!string.IsNullOrWhiteSpace(denuncia.Modalidad_Informacion))
{ {
<dt class="col-sm-3">Modalidad Información</dt> <dt class="col-sm-3">Modalidad Informaci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd> <dd class="col-sm-9">@denuncia.Modalidad_Informacion</dd>
} }
<dt class="col-sm-3">Lugar Hechos</dt> <dt class="col-sm-3">Lugar Hechos</dt>
@@ -205,27 +205,27 @@ else
} }
</dl> </dl>
<!-- Datos de Notificación --> <!-- Datos de Notificaci<EFBFBD>n -->
<h5 class="section-heading">Datos de Notificación</h5> <h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5>
<dl class="row"> <dl class="row">
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Preferencia))
{ {
<dt class="col-sm-3">Notificación Preferencia</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n Preferencia</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd> <dd class="col-sm-9">@denuncia.Notificacion_Preferencia</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Electronica))
{ {
<dt class="col-sm-3">Notificación Electrónica</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n Electr<EFBFBD>nica</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd> <dd class="col-sm-9">@denuncia.Notificacion_Electronica</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico)) @if (!string.IsNullOrWhiteSpace(denuncia.Correo_Electronico))
{ {
<dt class="col-sm-3">Correo Electrónico</dt> <dt class="col-sm-3">Correo Electr<EFBFBD>nico</dt>
<dd class="col-sm-9">@denuncia.Correo_Electronico</dd> <dd class="col-sm-9">@denuncia.Correo_Electronico</dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms)) @if (!string.IsNullOrWhiteSpace(denuncia.Notificacion_Sms))
{ {
<dt class="col-sm-3">Notificación SMS</dt> <dt class="col-sm-3">Notificaci<EFBFBD>n SMS</dt>
<dd class="col-sm-9">@denuncia.Notificacion_Sms</dd> <dd class="col-sm-9">@denuncia.Notificacion_Sms</dd>
} }
</dl> </dl>
@@ -236,7 +236,7 @@ else
@if (denuncia.Condiciones) @if (denuncia.Condiciones)
{ {
<dt class="col-sm-3">Condiciones</dt> <dt class="col-sm-3">Condiciones</dt>
<dd class="col-sm-9">Sí</dd> <dd class="col-sm-9">S<EFBFBD></dd>
} }
@if (!string.IsNullOrWhiteSpace(denuncia.Comments)) @if (!string.IsNullOrWhiteSpace(denuncia.Comments))
{ {
@@ -253,7 +253,7 @@ else
<thead> <thead>
<tr> <tr>
<th>Nombre</th> <th>Nombre</th>
<th>Tamaño (bytes)</th> <th>Tama<EFBFBD>o (bytes)</th>
<th>Ver</th> <th>Ver</th>
</tr> </tr>
</thead> </thead>
@@ -304,7 +304,7 @@ else
private List<DenunciasGestiona> denunciasRechazadas = new(); private List<DenunciasGestiona> denunciasRechazadas = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new(); private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
// Variable para la búsqueda // Variable para la b<EFBFBD>squeda
private string busqueda = ""; private string busqueda = "";
private bool hasLoaded = false; private bool hasLoaded = false;

View File

@@ -11,5 +11,7 @@
@using GestionaDenunciasAN @using GestionaDenunciasAN
@using GestionaDenunciasAN.Components @using GestionaDenunciasAN.Components
@using GestionaDenunciasAN.Components.Layout @using GestionaDenunciasAN.Components.Layout
@using GestionaDenuncias.Shared.Models
@using GestionaDenuncias.Shared.Services
@using GestionaDenunciasAN.Models @using GestionaDenunciasAN.Models
@using GestionaDenunciasAN.Services @using GestionaDenunciasAN.Services

View File

@@ -1,9 +0,0 @@
namespace GestionaDenunciasAN.Configuration;
public sealed class ComplaintStorageOptions
{
public const string SectionName = "ComplaintStorage";
public string ConnectionString { get; set; } = string.Empty;
public bool AutoCreateSchema { get; set; }
}

View File

@@ -7,13 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <ProjectReference Include="..\GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj" />
<PackageReference Include="MySqlConnector" Version="2.4.0" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
</ItemGroup>
<ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,2 @@
global using GestionaDenuncias.Shared.Models;
global using GestionaDenuncias.Shared.Services;

View File

@@ -1,4 +1,4 @@
namespace GestionaDenunciasAN.Models namespace GestionaDenunciasAN.Models
{ {
public class UserState public class UserState
{ {

View File

@@ -4,6 +4,7 @@ using System.Text.RegularExpressions;
using GestionaDenunciasAN.Components; using GestionaDenunciasAN.Components;
using GestionaDenunciasAN.Configuration; using GestionaDenunciasAN.Configuration;
using GestionaDenunciasAN.Models; using GestionaDenunciasAN.Models;
using GestionaDenuncias.Shared.Models;
using GestionaDenunciasAN.Services; using GestionaDenunciasAN.Services;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -71,7 +72,10 @@ app.Use(async (context, next) =>
await next(); await next();
}); });
app.UseHttpsRedirection(); if (builder.Configuration.GetValue("ForceHttpsRedirection", false))
{
app.UseHttpsRedirection();
}
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();

View File

@@ -1,4 +1,4 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Services; namespace GestionaDenunciasAN.Services;
@@ -16,7 +16,6 @@ public sealed class ApiDenunciaStore : IDenunciaStore
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default) public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<DenunciasGestiona>>("api/denuncias", cancellationToken)) ?? []; => (await _api.GetAsync<List<DenunciasGestiona>>("api/denuncias", cancellationToken)) ?? [];
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default) public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<FicherosDenuncias>>("api/denuncias/ficheros", cancellationToken)) ?? []; => (await _api.GetAsync<List<FicherosDenuncias>>("api/denuncias/ficheros", cancellationToken)) ?? [];

View File

@@ -2,7 +2,7 @@ using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
namespace GestionaDenunciasAN.Services; namespace GestionaDenunciasAN.Services;
@@ -18,15 +18,18 @@ public sealed class ApiDenunciasClient
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly AuthenticationStateProvider _authenticationStateProvider; private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly ILogger<ApiDenunciasClient> _logger;
public ApiDenunciasClient( public ApiDenunciasClient(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
AuthenticationStateProvider authenticationStateProvider) AuthenticationStateProvider authenticationStateProvider,
ILogger<ApiDenunciasClient> logger)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_authenticationStateProvider = authenticationStateProvider; _authenticationStateProvider = authenticationStateProvider;
_logger = logger;
} }
public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) public Task<ApiLoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
@@ -212,16 +215,26 @@ public sealed class ApiDenunciasClient
var token = await GetAccessTokenAsync(); var token = await GetAccessTokenAsync();
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
{ {
_logger.LogWarning("No hay token de API disponible para llamar a {Path}. Usuario autenticado={IsAuthenticated}",
path,
_httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated);
throw new UnauthorizedAccessException("No hay token de API activo. Vuelve a iniciar sesion."); throw new UnauthorizedAccessException("No hay token de API activo. Vuelve a iniciar sesion.");
} }
_logger.LogInformation("Llamando a API protegida {Path}. Token presente={TokenPresent}", path, true);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
} }
using var response = await client.SendAsync(request, cancellationToken); using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
throw new UnauthorizedAccessException("La sesion de API ha caducado. Vuelve a iniciar sesion."); if (authorize)
{
throw new UnauthorizedAccessException($"La sesion de API ha caducado al llamar a {path}. Vuelve a iniciar sesion.");
}
var message = await ReadErrorMessageAsync(response, cancellationToken);
throw new UnauthorizedAccessException(message);
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)

View File

@@ -1,4 +1,4 @@
using GestionaDenunciasAN.Models; using GestionaDenuncias.Shared.Models;
namespace GestionaDenunciasAN.Services; namespace GestionaDenunciasAN.Services;
@@ -32,4 +32,13 @@ public sealed class ApiInboxTrackingService : IInboxTrackingService
"api/tracking/imported", "api/tracking/imported",
new MarkReportImportedRequest(username, report, complaintId), new MarkReportImportedRequest(username, report, complaintId),
cancellationToken); cancellationToken);
public Task EnsureReportCanBeImportedByUserAsync(
string username,
ReportDto report,
CancellationToken cancellationToken = default)
=> _api.PostAsync(
"api/tracking/import-permission",
new TrackingImportPermissionRequest(username, report),
cancellationToken);
} }

View File

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

View File

@@ -1,5 +1,5 @@
{ {
"AllowedHosts": "rp-antifraude.tecnosis.online;crp-antifraude.tecnosis.online;localhost;127.0.0.1", "AllowedHosts": "registrodepersonal.antifraudeandalucia.es;rp-antifraude.tecnosis.online;crp-antifraude.tecnosis.online;localhost;127.0.0.1",
"SwaggerCC": "http://localhost:103/api/", "SwaggerCC": "http://localhost:103/api/",
"SwaggerVB": "http://localhost:888/", "SwaggerVB": "http://localhost:888/",
"CertificateLogin": { "CertificateLogin": {
@@ -12,7 +12,9 @@
"Ssl-Client-Cert" "Ssl-Client-Cert"
], ],
"AllowedParentOrigins": [ "AllowedParentOrigins": [
"https://rp-antifraude.tecnosis.online" "https://rp-antifraude.tecnosis.online",
"https://registrodepersonal.antifraudeandalucia.es",
"http://192.168.41.122:5000"
] ]
}, },
"ReverseProxy": { "ReverseProxy": {