Compare commits

...

2 Commits

Author SHA1 Message Date
d84d41b0e0 Merge branch 'main' of https://gitea.tecnosis.net/Antifraude/Antifraude.Net 2026-06-08 12:58:45 +02:00
ff2867d916 denuncias 2026-06-08 12:58:30 +02:00
51 changed files with 4012 additions and 766 deletions

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.7.0" />
<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" />
@@ -21,6 +22,7 @@
<ItemGroup>
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -11,4 +11,6 @@ public sealed class KeyVaultOptions
public string EncryptionKeySecretName { get; set; } = "denuncias-encryption-key";
public bool AllowLocalEncryptionKeyFallback { get; set; }
public int EncryptionKeyTimeoutSeconds { get; set; } = 25;
}

View File

@@ -0,0 +1,20 @@
namespace ApiDenuncias.Configuration;
public sealed class ManualPurgeOptions
{
public const string SectionName = "ManualPurge";
public string BaseUrl { get; set; } = "";
public string FunctionUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge";
public string ForceRotateUrl { get; set; } = "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate";
public string FunctionKeySecretName { get; set; } = "purge-function-key";
public bool ReplaceOnManualPurge { get; set; } = true;
public bool RecoverPartialReplaceFailure { get; set; } = true;
public int TimeoutSeconds { get; set; } = 30;
}

View File

@@ -80,7 +80,8 @@ public sealed class AuthController : ControllerBase
var pending = _pendingLoginStore.Create(
prepared.Username,
request.Password,
prepared.FinalPassword);
prepared.FinalPassword,
prepared.TokenAnswer);
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
}
@@ -149,6 +150,7 @@ public sealed class AuthController : ControllerBase
var session = await _globalLeaksClient.CompleteLoginAsync(
pending.Username,
pending.FinalPassword,
pending.TokenAnswer,
request.Authcode.Trim(),
loginCancellation.Token);

View File

@@ -0,0 +1,50 @@
using System.Globalization;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ApiDenuncias.Controllers;
[ApiController]
[Authorize]
[Route("api/configuration")]
public sealed class ConfigurationController : ControllerBase
{
private readonly AppConfigurationService _configurationService;
public ConfigurationController(AppConfigurationService configurationService)
{
_configurationService = configurationService;
}
[HttpGet]
public async Task<ActionResult<AppConfigurationDto>> Get(CancellationToken cancellationToken)
{
return Ok(await _configurationService.GetAsync(cancellationToken));
}
[HttpPut("external-update-cutoff")]
public async Task<ActionResult<AppConfigurationDto>> SetExternalUpdateCutoff(
UpdateExternalUpdateCutoffRequest request,
CancellationToken cancellationToken)
{
DateOnly? date = null;
if (!string.IsNullOrWhiteSpace(request.Date))
{
if (!DateOnly.TryParseExact(
request.Date.Trim(),
["yyyy-MM-dd", "dd/MM/yyyy"],
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed))
{
return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD o DD/MM/AAAA."));
}
date = parsed;
}
return Ok(await _configurationService.SetExternalUpdateCutoffDateAsync(date, cancellationToken));
}
}

View File

@@ -1,6 +1,5 @@
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,11 +11,16 @@ namespace ApiDenuncias.Controllers;
public sealed class DenunciasController : ControllerBase
{
private readonly IDenunciaStore _denunciaStore;
private readonly IFilteredDenunciaStore _filteredDenunciaStore;
private readonly UserComplaintAccessService _accessService;
public DenunciasController(IDenunciaStore denunciaStore, UserComplaintAccessService accessService)
public DenunciasController(
IDenunciaStore denunciaStore,
IFilteredDenunciaStore filteredDenunciaStore,
UserComplaintAccessService accessService)
{
_denunciaStore = denunciaStore;
_filteredDenunciaStore = filteredDenunciaStore;
_accessService = accessService;
}
@@ -28,11 +32,17 @@ public sealed class DenunciasController : ControllerBase
}
[HttpGet]
public async Task<ActionResult<List<DenunciasGestiona>>> GetAll(CancellationToken cancellationToken)
public async Task<ActionResult<List<DenunciasGestiona>>> GetAll(
[FromQuery] DenunciaListScope scope,
CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
var denuncias = await _denunciaStore.GetAllDenunciasAsync(cancellationToken);
return Ok(denuncias.Where(d => allowedIds.Contains(d.Id_Denuncia)).ToList());
if (allowedIds.Count == 0)
{
return Ok(new List<DenunciasGestiona>());
}
return Ok(await _filteredDenunciaStore.GetDenunciasByIdsAsync(allowedIds, scope, cancellationToken));
}
[HttpGet("{denunciaId:int}")]
@@ -62,8 +72,12 @@ public sealed class DenunciasController : ControllerBase
public async Task<ActionResult<List<FicherosDenuncias>>> GetAllFicheros(CancellationToken cancellationToken)
{
var allowedIds = await GetAllowedIdsAsync(cancellationToken);
var ficheros = await _denunciaStore.GetAllFicherosAsync(cancellationToken);
return Ok(ficheros.Where(f => allowedIds.Contains(f.Id_Denuncia)).ToList());
if (allowedIds.Count == 0)
{
return Ok(new List<FicherosDenuncias>());
}
return Ok(await _filteredDenunciaStore.GetFicherosByDenunciaIdsAsync(allowedIds, cancellationToken));
}
[HttpGet("{denunciaId:int}/ficheros")]
@@ -77,6 +91,37 @@ public sealed class DenunciasController : ControllerBase
return Ok(await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken));
}
[HttpGet("{denunciaId:int}/ficheros/content")]
public async Task<IActionResult> GetFicheroContent(
int denunciaId,
[FromQuery] string fileName,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return BadRequest(new ApiError("Nombre de fichero obligatorio."));
}
if (!await CanAccessAsync(denunciaId, cancellationToken))
{
return NotFound();
}
var ficheros = await _denunciaStore.GetFicherosByDenunciaAsync(denunciaId, cancellationToken);
var fichero = ficheros.FirstOrDefault(file =>
string.Equals(file.NombreFichero, fileName, StringComparison.Ordinal));
if (fichero?.Fichero is not { Length: > 0 } bytes)
{
return NotFound();
}
return File(
bytes,
GetAttachmentContentType(fichero.NombreFichero),
enableRangeProcessing: true);
}
[HttpPost("ficheros")]
public async Task<IActionResult> UpsertFicheros(UpsertFicherosRequest request, CancellationToken cancellationToken)
{
@@ -132,4 +177,18 @@ public sealed class DenunciasController : ControllerBase
private string GetUsername()
=> User.Identity?.Name ?? throw new InvalidOperationException("No hay usuario autenticado.");
private static string GetAttachmentContentType(string? fileName)
{
return Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".txt" => "text/plain",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".zip" => "application/zip",
_ => "application/octet-stream"
};
}
}

View File

@@ -101,6 +101,10 @@ public sealed class GestionaController : ControllerBase
await _gestiona.AsegurarTerceroYEnlazarAsync(request.FileUrl, request.ThirdParty);
return Ok(new { ok = true });
}
catch (ArgumentException ex)
{
return BadRequest(new ApiError(ex.Message));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));

View File

@@ -11,6 +11,7 @@ namespace ApiDenuncias.Controllers;
public sealed class InboxController : ControllerBase
{
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly DenunciaInboxService _inboxService;
private readonly IInboxTrackingService _trackingService;
@@ -18,12 +19,14 @@ public sealed class InboxController : ControllerBase
public InboxController(
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient,
DenunciaInboxService inboxService,
IInboxTrackingService trackingService,
ILogger<InboxController> logger)
{
_sessionStore = sessionStore;
_pendingLoginStore = pendingLoginStore;
_globalLeaksClient = globalLeaksClient;
_inboxService = inboxService;
_trackingService = trackingService;
@@ -37,6 +40,44 @@ public sealed class InboxController : ControllerBase
return Ok(ToDto(session));
}
[HttpPost("session/renew/prepare")]
public async Task<ActionResult<ApiLoginPrepareResponse>> PrepareRenewSession(CancellationToken cancellationToken)
{
var username = GetUsername();
var current = await _sessionStore.GetAsync(username, cancellationToken);
if (current is null || string.IsNullOrWhiteSpace(current.Password))
{
return BadRequest(new ApiError("No hay credenciales guardadas para este usuario. Cierra sesion y vuelve a entrar."));
}
try
{
var prepared = await _globalLeaksClient.PrepareLoginAsync(
current.Username,
current.Password,
cancellationToken);
var pending = _pendingLoginStore.Create(
prepared.Username,
current.Password,
prepared.FinalPassword,
prepared.TokenAnswer);
return Ok(new ApiLoginPrepareResponse(pending.Id, pending.Username, pending.ExpiresAtUtc));
}
catch (GlobalLeaksValidationException ex)
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido preparar la renovacion GlobalLeaks para {Username}.", username);
return StatusCode(
StatusCodes.Status500InternalServerError,
new ApiError($"No se ha podido preparar la renovacion: {ex.GetType().Name}: {ex.Message}"));
}
}
[HttpPost("session/renew")]
public async Task<ActionResult<ApiGlobalLeaksSessionDto>> RenewSession(
RenewGlobalLeaksSessionRequest request,
@@ -56,11 +97,32 @@ public sealed class InboxController : ControllerBase
try
{
var session = await _globalLeaksClient.LoginAsync(
GlSession session;
if (!string.IsNullOrWhiteSpace(request.PendingLoginId))
{
var pending = _pendingLoginStore.Get(request.PendingLoginId);
if (!string.Equals(pending.Username, current.Username, StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new ApiError("La preparacion del login no corresponde al usuario actual."));
}
session = await _globalLeaksClient.CompleteLoginAsync(
pending.Username,
pending.FinalPassword,
pending.TokenAnswer,
request.Authcode.Trim(),
cancellationToken);
_pendingLoginStore.Remove(pending.Id);
}
else
{
session = await _globalLeaksClient.LoginAsync(
current.Username,
current.Password,
request.Authcode.Trim(),
cancellationToken);
}
await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, cancellationToken);
var stored = await _sessionStore.GetAsync(username, cancellationToken);
@@ -70,6 +132,10 @@ public sealed class InboxController : ControllerBase
{
return StatusCode(ex.StatusCode, new ApiError(ex.Message));
}
catch (InvalidOperationException ex)
{
return BadRequest(new ApiError(ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "No se ha podido cargar la bandeja GlobalLeaks para {Username}.", username);
@@ -99,7 +165,7 @@ public sealed class InboxController : ControllerBase
try
{
var contexts = await _globalLeaksClient.GetContextsAsync(session.SessionId!, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken);
var reports = await _globalLeaksClient.GetReportsAsync(session.SessionId!, "all", null, null, cancellationToken, contexts);
var enrichedReports = await _trackingService.RegisterSnapshotAsync(username, reports, cancellationToken);
var state = await _trackingService.GetUserStateAsync(username, cancellationToken);
@@ -189,6 +255,7 @@ public sealed class InboxController : ControllerBase
[HttpGet("reports/{reportId}/detail")]
public async Task<ActionResult<ReportDetailDto>> GetReportDetail(
string reportId,
[FromQuery] string? lastAccess,
CancellationToken cancellationToken)
{
var username = GetUsername();
@@ -200,7 +267,7 @@ public sealed class InboxController : ControllerBase
try
{
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, cancellationToken));
return Ok(await _globalLeaksClient.GetReportDetailAsync(session.SessionId!, reportId, lastAccess, cancellationToken));
}
catch (GlobalLeaksSessionExpiredException)
{

View File

@@ -0,0 +1,71 @@
using System.Globalization;
using ApiDenuncias.Services;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ApiDenuncias.Controllers;
[ApiController]
[Authorize]
[Route("api/purge")]
public sealed class PurgeController : ControllerBase
{
private readonly ManualPurgeService _manualPurgeService;
private readonly ILogger<PurgeController> _logger;
public PurgeController(
ManualPurgeService manualPurgeService,
ILogger<PurgeController> logger)
{
_manualPurgeService = manualPurgeService;
_logger = logger;
}
[HttpPost("manual/current")]
public Task<ActionResult<ManualPurgeResponse>> ExecuteCurrentManualPurge(CancellationToken cancellationToken)
=> ExecuteManualPurgeCore(DateOnly.FromDateTime(DateTime.UtcNow), cancellationToken);
[HttpPost("manual")]
public async Task<ActionResult<ManualPurgeResponse>> ExecuteManualPurge(
ManualPurgeRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Date) ||
!DateOnly.TryParseExact(
request.Date.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var purgeDate))
{
return BadRequest(new ApiError("Debes indicar una fecha valida con formato YYYY-MM-DD."));
}
return await ExecuteManualPurgeCore(purgeDate, cancellationToken);
}
private async Task<ActionResult<ManualPurgeResponse>> ExecuteManualPurgeCore(
DateOnly purgeDate,
CancellationToken cancellationToken)
{
try
{
var result = await _manualPurgeService.ExecuteAsync(purgeDate, cancellationToken);
return Ok(result);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return StatusCode(
StatusCodes.Status504GatewayTimeout,
new ApiError("La Function App de purga no ha respondido dentro del tiempo configurado."));
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "No se ha podido ejecutar la purga manual para {Date}.", purgeDate);
return StatusCode(
StatusCodes.Status502BadGateway,
new ApiError(ex.Message));
}
}
}

View File

@@ -245,7 +245,13 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.RazonSocial, value => denuncia.RazonSocial = value, answers, "razon social");
SetIfMissing(() => denuncia.Sexo, value => denuncia.Sexo = value, answers, "sexo");
SetIfMissing(() => denuncia.PaisOrigen, value => denuncia.PaisOrigen = value, answers, "pais de origen");
SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "dni", "nif", "nie", "cif", "otro documento identificativo");
SetIfMissing(() => denuncia.Dni, value => denuncia.Dni = value, answers, "nif dni nie", "dni", "nif", "nie", "cif", "otro documento identificativo");
if (!string.IsNullOrWhiteSpace(denuncia.Dni) &&
string.IsNullOrWhiteSpace(denuncia.TipoDocumentoIdentificativo))
{
denuncia.TipoDocumentoIdentificativo = "NIF (DNI, NIE)";
}
SetIfMissing(() => denuncia.A_Quien_Denuncia, value => denuncia.A_Quien_Denuncia = value, answers, "a quien denuncia");
SetIfMissing(() => denuncia.DenunciadoDetalle, value => denuncia.DenunciadoDetalle = value, answers, "especifique a quien denuncia");
SetIfMissing(() => denuncia.Descripcion_Denuncia, value => denuncia.Descripcion_Denuncia = value, answers, "describa su denuncia", "descripcion de la denuncia");
@@ -261,7 +267,7 @@ public static class GlobalLeaksJsonEnricher
SetIfMissing(() => denuncia.SeguimientoOnline, value => denuncia.SeguimientoOnline = value, answers, "seguimiento online");
SetIfMissing(() => denuncia.NotificacionPostal, value => denuncia.NotificacionPostal = value, answers, "autorizo recibir notificaciones via correo postal");
SetIfMissing(() => denuncia.Correo_Electronico, value => denuncia.Correo_Electronico = value, answers, "correo electronico", "email");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "telefono", "telefono movil");
SetIfMissing(() => denuncia.Telefono, value => denuncia.Telefono = value, answers, "contacto telefonico", "telefono", "telefono movil");
SetIfMissing(() => denuncia.Direccion, value => denuncia.Direccion = value, answers, "nombre de la via", "direccion", "domicilio");
SetIfMissing(() => denuncia.DireccionTipoVia, value => denuncia.DireccionTipoVia = value, answers, "tipo de via");
SetIfMissing(() => denuncia.DireccionNumero, value => denuncia.DireccionNumero = value, answers, "numero", "numero km");
@@ -383,6 +389,11 @@ public static class GlobalLeaksJsonEnricher
continue;
}
if (character is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}

View File

@@ -7,6 +7,60 @@ namespace ApiDenuncias.Helpers;
public static class ReportParser
{
private static readonly HashSet<string> FlatReportSections = NormalizeAll(
"Datos del denunciante",
"Descripción",
"Preferencias de notificación",
"Condiciones y reglas de uso",
"Comments");
private static readonly HashSet<string> FlatReportLabels = NormalizeAll(
"Indique si actúa como persona física o en representación de una persona jurídica.",
"Nombre",
"1º Apellido",
"2º Apellido",
"SEXO",
"CONTACTO TELEFÓNICO",
"País de Origen",
"NIF (DNI, NIE)",
"Razón social",
"CIF",
"Otro documento identificativo",
"Asunto",
"¿A quién denuncia?",
"Especifique a quién denuncia",
"Describa su denuncia",
"¿Ha denunciado estos hechos ante otras instituciones u órganos?",
"POR FAVOR. INDIQUE EL ORGANISMO O LA INSTITUCION DONDE HA DENUNCIADO LOS HECHOS",
"¿Solicita medidas concretas de protección?",
"DESCRIBA LAS MEDIDAS DE PROTECCIÓN SOLICITADAS",
"Lugar en el que ocurrieron los hechos que denuncia",
"Fecha de los hechos que denuncia",
"Autorización para remitir su denuncia",
"En tal caso, ¿desea que su denuncia se remita anonimizada (sin sus datos personales)?",
"En tal caso, ¿desea que su denuncia se remita anonimizada (sin datos personales)?",
"Preferencia de notificación",
"Notificaciones Electrónicas",
"Correo electrónico",
"Seguimiento Online",
"Autorizo recibir notificaciones vía Correo Postal",
"Provincia",
"Tipo de vía",
"Nombre de la vía",
"Código Postal",
"Localidad",
"Municipio",
"Número/Km",
"Número",
"Bloque",
"Escalera",
"Planta",
"Piso",
"Puerta",
"Extra",
"Condiciones y reglas de uso del buzón de denuncias",
"TRATAMIENTO DE DATOS PERSONALES");
public static DenunciasGestiona ParseReport(string reportText)
{
var lines = NormalizeLines(reportText);
@@ -187,6 +241,96 @@ public static class ReportParser
i = j - 1;
}
comments = commentBuilder.ToString().Trim();
if (fields.Count == 0)
{
fields = ParseFlatFormFields(lines, out comments);
}
return fields;
}
private static List<ReportFieldEntry> ParseFlatFormFields(string[] lines, out string comments)
{
var fields = new List<ReportFieldEntry>();
var commentBuilder = new StringBuilder();
var currentSection = string.Empty;
var order = 0;
for (var i = 0; i < lines.Length; i++)
{
var trimmed = lines[i].Trim();
if (ShouldSkipFlatLine(trimmed) || IsMetadataLine(trimmed))
{
continue;
}
if (IsMessagesStart(trimmed))
{
for (var j = i + 1; j < lines.Length; j++)
{
var commentLine = lines[j].Trim();
if (ShouldSkipFlatLine(commentLine))
{
continue;
}
if (commentBuilder.Length > 0)
{
commentBuilder.AppendLine();
}
commentBuilder.Append(commentLine);
}
break;
}
if (IsFlatSection(trimmed))
{
currentSection = trimmed;
continue;
}
if (!IsFlatLabel(trimmed))
{
continue;
}
var valueLines = new List<string>();
var nextIndex = i + 1;
while (nextIndex < lines.Length)
{
var nextTrimmed = lines[nextIndex].Trim();
if (ShouldSkipFlatLine(nextTrimmed))
{
nextIndex++;
continue;
}
if (IsMetadataLine(nextTrimmed) ||
IsMessagesStart(nextTrimmed) ||
IsFlatSection(nextTrimmed) ||
IsFlatLabel(nextTrimmed))
{
break;
}
valueLines.Add(nextTrimmed);
nextIndex++;
}
fields.Add(new ReportFieldEntry
{
Order = ++order,
Section = currentSection,
Label = trimmed,
Value = string.Join(Environment.NewLine, valueLines)
});
i = nextIndex - 1;
}
comments = commentBuilder.ToString().Trim();
return fields;
}
@@ -415,6 +559,37 @@ public static class ReportParser
line.StartsWith("Estado:", StringComparison.OrdinalIgnoreCase);
}
private static HashSet<string> NormalizeAll(params string[] values)
=> values
.Select(Normalize)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToHashSet(StringComparer.Ordinal);
private static bool IsFlatSection(string value)
=> FlatReportSections.Contains(Normalize(value));
private static bool IsFlatLabel(string value)
=> FlatReportLabels.Contains(Normalize(value));
private static bool IsMessagesStart(string value)
{
var normalized = Normalize(value);
return normalized == "messages" ||
normalized == "comments" ||
normalized.StartsWith("comments ", StringComparison.Ordinal);
}
private static bool ShouldSkipFlatLine(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
return Regex.IsMatch(value, @"^\d+/\d+$", RegexOptions.CultureInvariant) ||
Regex.IsMatch(value, @"^REPORT\s+\d+\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
private static int CountIndentation(string value)
{
var indent = 0;
@@ -456,6 +631,11 @@ public static class ReportParser
continue;
}
if (character is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : ' ');
}

View File

@@ -19,6 +19,7 @@ builder.Services.Configure<KeyVaultOptions>(builder.Configuration.GetSection(Key
builder.Services.Configure<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
builder.Services.Configure<ManualPurgeOptions>(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -34,11 +35,16 @@ builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
builder.Services.AddScoped<IDenunciaStore, EncryptedDenunciaStore>();
builder.Services.AddSingleton<IEnvelopeEncryptionKeyProvider, EnvelopeEncryptionKeyProvider>();
builder.Services.AddScoped<EncryptedDenunciaStore>();
builder.Services.AddScoped<IDenunciaStore>(sp => sp.GetRequiredService<EncryptedDenunciaStore>());
builder.Services.AddScoped<IFilteredDenunciaStore>(sp => sp.GetRequiredService<EncryptedDenunciaStore>());
builder.Services.AddScoped<IInboxTrackingService, InboxTrackingService>();
builder.Services.AddScoped<DenunciaInboxService>();
builder.Services.AddScoped<GestionaDocumentWorkflowService>();
builder.Services.AddScoped<UserComplaintAccessService>();
builder.Services.AddHttpClient<ManualPurgeService>();
builder.Services.AddScoped<AppConfigurationService>();
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
{
@@ -111,6 +117,20 @@ app.UseExceptionHandler(errorApp =>
logger.LogError(feature.Error, "Error no controlado en {Path}", context.Request.Path);
}
if (feature?.Error is EncryptedDataPurgedException purgedException)
{
context.Response.StatusCode = StatusCodes.Status410Gone;
await context.Response.WriteAsJsonAsync(new ApiError(purgedException.Message));
return;
}
if (feature?.Error is EncryptionKeyUnavailableException keyException)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ApiError(keyException.Message));
return;
}
var detailedErrors = context.RequestServices
.GetRequiredService<IConfiguration>()
.GetValue("DetailedApiErrors", false);

View File

@@ -0,0 +1,281 @@
-- ============================================================
-- Migracion de base de datos - ApiDenuncias
-- Envelope Encryption adaptado al esquema real de la aplicacion
-- Entorno previsto: PRE / MySQL Flexible Server
-- BD prevista: gestiona
-- ============================================================
--
-- OBJETIVO
-- Incorporar el modelo de claves diarias propuesto por infraestructura
--
-- MODELO REAL DE LA APP
-- complaints
-- Guarda la denuncia, metadatos tecnicos, estado, expediente Gestiona
-- y payload sensible cifrado en columnas existentes como:
-- - raw_report_text
-- - form_fields_json
-- - campos textuales derivados del report
--
-- complaint_attachments
-- Guarda adjuntos y report, con hash en claro para deduplicacion:
-- - content
-- - description
-- - notes
-- - content_sha256
--
-- inbox_reports / user_inbox_reports / app_users
-- Trazabilidad de bandeja, usuario propietario y control de descargas.
-- No almacenan el contenido sensible de la denuncia.
--
-- ESTRATEGIA
-- 1. Crear encryption_keys, gestionada por Function App / infraestructura.
-- 2. Anadir key_date a complaints y complaint_attachments.
-- 3. Mantener key_date nullable para no romper datos legacy o entornos
-- con registros previos. La API debera rellenarlo en nuevas escrituras.
-- 4. No se crean columnas descripcion_cifrada/iv/auth_tag separadas porque
-- la API cifra el payload/contenido en el propio campo con AES-256-GCM,
-- empaquetando nonce + auth_tag + ciphertext en el valor persistido.
--
-- IMPORTANTE
-- Ejecutar dentro de la base de datos correcta:
-- USE gestiona;
-- ============================================================
USE gestiona;
-- ------------------------------------------------------------
-- TABLA: encryption_keys
-- Gestionada por la Function App de claves.
-- La API la lee para obtener la eDEK diaria y hacer unwrapKey en Key Vault.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS encryption_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
key_date DATE NOT NULL COMMENT 'Fecha de validez de la KEK/DEK diaria. Una fila por dia.',
key_name VARCHAR(100) NOT NULL COMMENT 'Nombre de la clave en Azure Key Vault. Ej: key-2026-05-19.',
key_version VARCHAR(500) NOT NULL COMMENT 'URI completa con version de la KEK en Key Vault. Se usa para unwrapKey.',
edek TEXT NOT NULL COMMENT 'DEK cifrada en Base64. La API hace unwrapKey y mantiene la DEK solo en memoria.',
status ENUM('active','purged') NOT NULL DEFAULT 'active' COMMENT 'active: operativa | purged: KEK deshabilitada y datos ilegibles.',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Timestamp de creacion de la clave.',
purged_at DATETIME NULL COMMENT 'Timestamp de purga criptografica. NULL mientras status = active.',
CONSTRAINT uq_encryption_keys_date UNIQUE (key_date),
INDEX ix_encryption_keys_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ------------------------------------------------------------
-- Helpers idempotentes para MySQL.
-- Evitamos ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS porque
-- no son seguros en todas las versiones/configuraciones de MySQL usadas.
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS add_column_if_missing;
DROP PROCEDURE IF EXISTS add_index_if_missing;
DROP PROCEDURE IF EXISTS add_fk_if_missing;
DELIMITER //
CREATE PROCEDURE add_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND COLUMN_NAME = p_column_name
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN ', p_column_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
CREATE PROCEDURE add_index_if_missing(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND INDEX_NAME = p_index_name
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_index_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
CREATE PROCEDURE add_fk_if_missing(
IN p_table_name VARCHAR(64),
IN p_constraint_name VARCHAR(64),
IN p_constraint_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = p_table_name
AND CONSTRAINT_NAME = p_constraint_name
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', p_constraint_name, '` ', p_constraint_definition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END//
DELIMITER ;
-- ------------------------------------------------------------
-- MODIFICACION: complaints
-- key_date indica con que DEK diaria se cifro la denuncia/payload sensible.
-- Queda nullable para permitir registros anteriores a la migracion.
-- ------------------------------------------------------------
CALL add_column_if_missing(
'complaints',
'key_date',
'`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar los datos sensibles de la denuncia.'' AFTER `is_rejected`'
);
CALL add_column_if_missing(
'complaints',
'encryption_scheme',
'`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`'
);
CALL add_column_if_missing(
'complaints',
'encrypted_at_utc',
'`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el payload sensible.'' AFTER `encryption_scheme`'
);
CALL add_index_if_missing(
'complaints',
'ix_complaints_key_date',
'INDEX `ix_complaints_key_date` (`key_date`)'
);
CALL add_fk_if_missing(
'complaints',
'fk_complaints_key_date',
'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)'
);
-- ------------------------------------------------------------
-- MODIFICACION: complaint_attachments
-- key_date indica con que DEK diaria se cifro cada adjunto/report.
-- El hash content_sha256 permanece en claro para deduplicar actualizaciones.
-- ------------------------------------------------------------
CALL add_column_if_missing(
'complaint_attachments',
'key_date',
'`key_date` DATE NULL COMMENT ''FK a encryption_keys.key_date. Indica la clave diaria usada para cifrar el contenido del adjunto.'' AFTER `content_sha256`'
);
CALL add_column_if_missing(
'complaint_attachments',
'encryption_scheme',
'`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT ''none'' COMMENT ''Formato de cifrado aplicado. Ej: aes-256-gcm-envelope-v1.'' AFTER `key_date`'
);
CALL add_column_if_missing(
'complaint_attachments',
'encrypted_at_utc',
'`encrypted_at_utc` DATETIME(6) NULL COMMENT ''Fecha UTC en la que la API cifro o recifro el adjunto.'' AFTER `encryption_scheme`'
);
CALL add_index_if_missing(
'complaint_attachments',
'ix_attachments_key_date',
'INDEX `ix_attachments_key_date` (`key_date`)'
);
CALL add_fk_if_missing(
'complaint_attachments',
'fk_attachments_key_date',
'FOREIGN KEY (`key_date`) REFERENCES `encryption_keys` (`key_date`)'
);
-- ------------------------------------------------------------
-- Limpieza de helpers.
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS add_column_if_missing;
DROP PROCEDURE IF EXISTS add_index_if_missing;
DROP PROCEDURE IF EXISTS add_fk_if_missing;
-- ============================================================
-- CONSULTAS DE VERIFICACION
-- ============================================================
SELECT
TABLE_NAME,
ENGINE,
TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments')
ORDER BY TABLE_NAME;
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'encryption_keys'
ORDER BY ORDINAL_POSITION;
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('complaints', 'complaint_attachments')
AND COLUMN_NAME IN ('key_date', 'encryption_scheme', 'encrypted_at_utc')
ORDER BY TABLE_NAME, ORDINAL_POSITION;
SELECT
CONSTRAINT_NAME,
TABLE_NAME,
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME = 'encryption_keys'
ORDER BY TABLE_NAME, CONSTRAINT_NAME;
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('encryption_keys', 'complaints', 'complaint_attachments')
AND INDEX_NAME IN ('ix_encryption_keys_status', 'ix_complaints_key_date', 'ix_attachments_key_date')
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- ============================================================
-- FIN
-- ============================================================

View File

@@ -156,3 +156,14 @@ CREATE TABLE IF NOT EXISTS complaint_attachments (
FOREIGN KEY (complaint_id) REFERENCES complaints(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS complaint_attachment_chunks (
attachment_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content LONGBLOB NOT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (attachment_id, chunk_index),
CONSTRAINT fk_attachment_chunks_attachment
FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -0,0 +1,87 @@
using System.Globalization;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class AppConfigurationService
{
private const string ExternalUpdateCutoffDateKey = "external_update_cutoff_date";
private readonly MySqlConnectionStringProvider _connectionStringProvider;
public AppConfigurationService(MySqlConnectionStringProvider connectionStringProvider)
{
_connectionStringProvider = connectionStringProvider;
}
public async Task<AppConfigurationDto> GetAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
await using var command = new MySqlCommand(
"""
SELECT setting_value
FROM app_settings
WHERE setting_key = @settingKey
LIMIT 1;
""",
connection);
command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey);
var value = await command.ExecuteScalarAsync(cancellationToken);
var dateText = value is null or DBNull
? null
: Convert.ToString(value, CultureInfo.InvariantCulture);
return new AppConfigurationDto(string.IsNullOrWhiteSpace(dateText) ? null : dateText);
}
public async Task<AppConfigurationDto> SetExternalUpdateCutoffDateAsync(
DateOnly? date,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await EnsureTableAsync(connection, cancellationToken);
var dateText = date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
await using var command = new MySqlCommand(
"""
INSERT INTO app_settings (setting_key, setting_value, updated_at_utc)
VALUES (@settingKey, @settingValue, UTC_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
updated_at_utc = UTC_TIMESTAMP(6);
""",
connection);
command.Parameters.AddWithValue("@settingKey", ExternalUpdateCutoffDateKey);
command.Parameters.AddWithValue("@settingValue", string.IsNullOrWhiteSpace(dateText) ? DBNull.Value : dateText);
await command.ExecuteNonQueryAsync(cancellationToken);
return new AppConfigurationDto(dateText);
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
return connection;
}
private static async Task EnsureTableAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new MySqlCommand(
"""
CREATE TABLE IF NOT EXISTS app_settings (
setting_key VARCHAR(128) NOT NULL,
setting_value TEXT NULL,
updated_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -9,6 +9,51 @@ public sealed class DenunciaInboxService
{
private const string RootPath = @"C:\ZipsDenuncias";
private static readonly HashSet<string> BlockedAttachmentExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".ade",
".adp",
".apk",
".app",
".appx",
".bat",
".cmd",
".com",
".cpl",
".dll",
".exe",
".gadget",
".hta",
".ins",
".iso",
".jar",
".js",
".jse",
".lnk",
".msc",
".msi",
".msp",
".mst",
".pif",
".ps1",
".ps1xml",
".ps2",
".ps2xml",
".psc1",
".psc2",
".reg",
".scr",
".sh",
".sys",
".vb",
".vbe",
".vbs",
".ws",
".wsc",
".wsf",
".wsh"
};
private readonly IGestionaService _gestionaService;
private readonly IDenunciaStore _denunciaStore;
private readonly ILogger<DenunciaInboxService> _logger;
@@ -60,6 +105,7 @@ public sealed class DenunciaInboxService
.ToList();
var errors = new List<string>();
var warnings = new List<string>();
var importedCount = 0;
var complaintIds = new List<int>();
@@ -68,10 +114,11 @@ public sealed class DenunciaInboxService
try
{
var zipBytes = await File.ReadAllBytesAsync(zipPath, cancellationToken);
var complaintId = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
var result = await ProcessZipAsync(zipBytes, Path.GetFileName(zipPath), null, cancellationToken);
File.Delete(zipPath);
importedCount++;
complaintIds.Add(complaintId);
complaintIds.Add(result.ComplaintId);
warnings.AddRange(result.Warnings.Select(warning => $"{Path.GetFileName(zipPath)}: {warning}"));
}
catch (Exception ex)
{
@@ -80,7 +127,7 @@ public sealed class DenunciaInboxService
}
}
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds);
return new ImportSummary(zipPaths.Count, importedCount, errors, complaintIds, warnings);
}
public async Task<ImportSummary> ImportFromGlobalLeaksAsync(
@@ -100,8 +147,8 @@ public sealed class DenunciaInboxService
try
{
var complaintId = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken);
return new ImportSummary(1, 1, [], [complaintId]);
var result = await ProcessZipAsync(zipDownload.Content, fileName, json, cancellationToken);
return new ImportSummary(1, 1, [], [result.ComplaintId], result.Warnings);
}
catch (Exception ex)
{
@@ -110,7 +157,7 @@ public sealed class DenunciaInboxService
}
}
private async Task<int> ProcessZipAsync(
private async Task<ProcessZipResult> ProcessZipAsync(
byte[] zipBytes,
string sourceName,
string? globalLeaksJson,
@@ -177,18 +224,19 @@ public sealed class DenunciaInboxService
denuncia.Expediente_Gestiona = "Pendiente";
}
var nuevosFicheros = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken);
var readFilesResult = await ReadFilesFromArchiveAsync(archive, reportEntry, denuncia.Id_Denuncia, cancellationToken);
await MergeComplaintAsync(denuncia, cancellationToken);
await MergeFilesAsync(nuevosFicheros, cancellationToken);
return denuncia.Id_Denuncia;
await MergeFilesAsync(readFilesResult.Files, cancellationToken);
return new ProcessZipResult(denuncia.Id_Denuncia, readFilesResult.Warnings);
}
private async Task<List<FicherosDenuncias>> ReadFilesFromArchiveAsync(
private async Task<ReadFilesResult> ReadFilesFromArchiveAsync(
ZipArchive archive,
ZipArchiveEntry reportEntry,
int denunciaId,
CancellationToken cancellationToken)
{
var warnings = new List<string>();
var files = new List<FicherosDenuncias>
{
new(
@@ -206,6 +254,12 @@ public sealed class DenunciaInboxService
foreach (var entry in archive.Entries.Where(entry => IsSupportedAttachmentEntry(entry) && !IsSameEntry(entry, reportEntry)))
{
if (IsBlockedAttachmentEntry(entry))
{
warnings.Add(BuildBlockedAttachmentWarning(entry));
continue;
}
files.Add(new FicherosDenuncias(
id_Fichero: 0,
id_Tipo: 1,
@@ -219,12 +273,23 @@ public sealed class DenunciaInboxService
fichero: await ReadEntryBytesAsync(entry, cancellationToken)));
}
return files;
return new ReadFilesResult(files, warnings);
}
private async Task MergeComplaintAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken)
{
var existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
DenunciasGestiona? existing = null;
try
{
existing = await _denunciaStore.GetDenunciaByIdAsync(denuncia.Id_Denuncia, cancellationToken);
}
catch (EncryptedDataPurgedException ex)
{
_logger.LogInformation(
ex,
"La denuncia {DenunciaId} existe con datos purgados. Se reimportara como nueva copia cifrada con la clave activa.",
denuncia.Id_Denuncia);
}
if (existing is not null)
{
@@ -409,6 +474,20 @@ public sealed class DenunciaInboxService
return IsDirectChildOf(normalized, "files") || IsDirectChildOf(normalized, "files_attached_from_recipients");
}
private static bool IsBlockedAttachmentEntry(ZipArchiveEntry entry)
{
var extension = Path.GetExtension(entry.Name);
return !string.IsNullOrWhiteSpace(extension) &&
BlockedAttachmentExtensions.Contains(extension);
}
private static string BuildBlockedAttachmentWarning(ZipArchiveEntry entry)
{
var fileName = Path.GetFileName(entry.FullName);
var extension = Path.GetExtension(entry.Name);
return $"Se ha detectado y eliminado el archivo '{fileName}' porque la extension '{extension}' no esta permitida.";
}
private static ZipArchiveEntry? FindReportEntry(ZipArchive archive)
{
return archive.Entries.FirstOrDefault(IsReportEntry);
@@ -587,5 +666,9 @@ public sealed class DenunciaInboxService
builder.AppendLine();
}
private sealed record ProcessZipResult(int ComplaintId, IReadOnlyList<string> Warnings);
private sealed record ReadFilesResult(List<FicherosDenuncias> Files, IReadOnlyList<string> Warnings);
}

View File

@@ -8,12 +8,13 @@ using Microsoft.AspNetCore.DataProtection;
namespace ApiDenuncias.Services;
public sealed class EncryptedDenunciaStore : IDenunciaStore
public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaStore
{
private const string DataProtectionStringPrefix = "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 static readonly byte[] KeyVaultRawBytesPrefix = Encoding.ASCII.GetBytes("enc:v3:");
private const int AesGcmNonceSize = 12;
private const int AesGcmTagSize = 16;
private static readonly PropertyInfo[] ComplaintProperties = typeof(DenunciasGestiona)
@@ -22,17 +23,23 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
.ToArray();
private readonly MySqlDenunciaStore _inner;
private readonly IEncryptionKeyProvider _encryptionKeyProvider;
private readonly IEnvelopeEncryptionKeyProvider _envelopeKeyProvider;
private readonly IEncryptionKeyProvider _legacyKeyProvider;
private readonly IDataProtector _protector;
private readonly ILogger<EncryptedDenunciaStore> _logger;
public EncryptedDenunciaStore(
MySqlDenunciaStore inner,
IEncryptionKeyProvider encryptionKeyProvider,
IDataProtectionProvider dataProtectionProvider)
IEnvelopeEncryptionKeyProvider envelopeKeyProvider,
IEncryptionKeyProvider legacyKeyProvider,
IDataProtectionProvider dataProtectionProvider,
ILogger<EncryptedDenunciaStore> logger)
{
_inner = inner;
_encryptionKeyProvider = encryptionKeyProvider;
_envelopeKeyProvider = envelopeKeyProvider;
_legacyKeyProvider = legacyKeyProvider;
_protector = dataProtectionProvider.CreateProtector("ApiDenuncias.DatabaseSensitiveData.v1");
_logger = logger;
}
public Task EnsureSchemaAsync(CancellationToken cancellationToken = default)
@@ -40,44 +47,121 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllDenunciasAsync(cancellationToken))
.Select(denuncia => UnprotectComplaint(denuncia, key))
.ToList();
var denuncias = await _inner.GetAllDenunciasAsync(cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByScopeAsync(scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var denuncias = await _inner.GetDenunciasByIdsAsync(denunciaIds, scope, cancellationToken);
return await UnprotectComplaintsAsync(denuncias, skipPurgedRows: true, cancellationToken);
}
private async Task<List<DenunciasGestiona>> UnprotectComplaintsAsync(
List<DenunciasGestiona> denuncias,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<DenunciasGestiona>(denuncias.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
foreach (var denuncia in denuncias)
{
try
{
result.Add(await UnprotectComplaintAsync(denuncia, requestKeyCache, cancellationToken));
}
catch (EncryptedDataPurgedException ex) when (skipPurgedRows)
{
_logger.LogWarning(
"Se omite la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.",
denuncia.Id_Denuncia,
ex.KeyDate);
}
}
return result;
}
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetAllFicherosAsync(cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetAllFicherosAsync(cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var ficheros = await _inner.GetFicherosByDenunciaIdsAsync(denunciaIds, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: true, cancellationToken);
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
return (await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken))
.Select(fichero => UnprotectAttachment(fichero, key))
.ToList();
var ficheros = await _inner.GetFicherosByDenunciaAsync(denunciaId, cancellationToken);
return await UnprotectAttachmentsAsync(ficheros, skipPurgedRows: false, cancellationToken);
}
private async Task<List<FicherosDenuncias>> UnprotectAttachmentsAsync(
List<FicherosDenuncias> ficheros,
bool skipPurgedRows,
CancellationToken cancellationToken)
{
var result = new List<FicherosDenuncias>(ficheros.Count);
var requestKeyCache = new Dictionary<DateOnly, byte[]>();
foreach (var fichero in ficheros)
{
try
{
result.Add(await UnprotectAttachmentAsync(fichero, requestKeyCache, cancellationToken));
}
catch (EncryptedDataPurgedException ex) when (skipPurgedRows)
{
_logger.LogWarning(
"Se omite el adjunto {AttachmentId} de la denuncia {DenunciaId} en el listado porque sus datos estan purgados para la clave diaria {KeyDate}.",
fichero.Id_Fichero,
fichero.Id_Denuncia,
ex.KeyDate);
}
}
return result;
}
public async Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var denuncia = await _inner.GetDenunciaByIdAsync(denunciaId, cancellationToken);
return denuncia is null ? null : UnprotectComplaint(denuncia, key);
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertDenunciaAsync(ProtectComplaint(denuncia, key), cancellationToken);
}
public async Task UpsertFicherosAsync(IEnumerable<FicherosDenuncias> ficheros, CancellationToken cancellationToken = default)
{
var key = await _encryptionKeyProvider.GetKeyAsync(cancellationToken);
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);
await _inner.UpsertFicherosAsync(ficheros.Select(fichero => ProtectAttachment(fichero, key)).ToArray(), cancellationToken);
}
@@ -88,13 +172,30 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
CancellationToken cancellationToken = default)
=> _inner.MarkFicherosAsUploadedAsync(denunciaId, fileNames, uploadedAtUtc, cancellationToken);
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, byte[] key)
=> TransformComplaint(ToPersistentComplaint(source), value => ProtectString(value, key));
private DenunciasGestiona UnprotectComplaint(DenunciasGestiona source, byte[] key)
private DenunciasGestiona ProtectComplaint(DenunciasGestiona source, EncryptionKeyMaterial key)
{
var decrypted = TransformComplaint(source, value => UnprotectString(value, key));
return RebuildComplaintFromPayload(decrypted);
var protectedComplaint = TransformComplaint(
ToPersistentComplaint(source),
value => ProtectString(value, key.Key));
protectedComplaint.KeyDate = key.KeyDate;
protectedComplaint.EncryptionScheme = key.Scheme;
protectedComplaint.EncryptedAtUtc = DateTime.UtcNow;
return protectedComplaint;
}
private async Task<DenunciasGestiona> UnprotectComplaintAsync(
DenunciasGestiona source,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken);
var decrypted = TransformComplaint(source, value => UnprotectString(value, key, source.KeyDate));
var rebuilt = RebuildComplaintFromPayload(decrypted);
rebuilt.KeyDate = source.KeyDate;
rebuilt.EncryptionScheme = source.EncryptionScheme;
rebuilt.EncryptedAtUtc = source.EncryptedAtUtc;
return rebuilt;
}
private static DenunciasGestiona ToPersistentComplaint(DenunciasGestiona source)
@@ -209,7 +310,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return target;
}
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, byte[] key)
private FicherosDenuncias ProtectAttachment(FicherosDenuncias source, EncryptionKeyMaterial key)
{
var content = source.Fichero ?? [];
var hash = string.IsNullOrWhiteSpace(source.ContentSha256)
@@ -220,36 +321,67 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo,
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key),
Descripcion = ProtectString(source.Descripcion ?? string.Empty, key.Key),
Fecha = source.Fecha,
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key),
Observaciones = ProtectString(source.Observaciones ?? string.Empty, key.Key),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = ProtectBytes(content, key),
Fichero = ProtectBytes(content, key.Key),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = hash
ContentSha256 = hash,
KeyDate = key.KeyDate,
EncryptionScheme = key.Scheme,
EncryptedAtUtc = DateTime.UtcNow
};
}
private FicherosDenuncias UnprotectAttachment(FicherosDenuncias source, byte[] key)
private async Task<FicherosDenuncias> UnprotectAttachmentAsync(
FicherosDenuncias source,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
var key = await ResolveReadKeyAsync(source.KeyDate, requestKeyCache, cancellationToken);
return new FicherosDenuncias
{
Id_Fichero = source.Id_Fichero,
Id_Tipo = source.Id_Tipo,
Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key),
Descripcion = UnprotectString(source.Descripcion ?? string.Empty, key, source.KeyDate),
Fecha = source.Fecha,
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key),
Observaciones = UnprotectString(source.Observaciones ?? string.Empty, key, source.KeyDate),
Id_Denuncia = source.Id_Denuncia,
NombreFichero = source.NombreFichero,
Fichero = UnprotectBytes(source.Fichero ?? [], key),
Fichero = UnprotectBytes(source.Fichero ?? [], key, source.KeyDate),
Subido = source.Subido,
FechaSubida = source.FechaSubida,
ContentSha256 = source.ContentSha256
ContentSha256 = source.ContentSha256,
KeyDate = source.KeyDate,
EncryptionScheme = source.EncryptionScheme,
EncryptedAtUtc = source.EncryptedAtUtc
};
}
private async Task<byte[]> ResolveReadKeyAsync(
DateOnly? keyDate,
Dictionary<DateOnly, byte[]> requestKeyCache,
CancellationToken cancellationToken)
{
if (keyDate.HasValue)
{
if (requestKeyCache.TryGetValue(keyDate.Value, out var cached))
{
return cached;
}
var key = await _envelopeKeyProvider.GetKeyForDateAsync(keyDate.Value, cancellationToken);
var copy = key.CopyKey();
requestKeyCache[keyDate.Value] = copy;
return copy;
}
return await _legacyKeyProvider.GetKeyAsync(cancellationToken);
}
private string ProtectString(string value, byte[] key)
{
if (string.IsNullOrWhiteSpace(value) ||
@@ -263,7 +395,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
return KeyVaultStringPrefix + Convert.ToBase64String(encrypted);
}
private string UnprotectString(string value, byte[] key)
private string UnprotectString(string value, byte[] key, DateOnly? keyDate = null)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -277,8 +409,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var encrypted = Convert.FromBase64String(value[KeyVaultStringPrefix.Length..]);
return Encoding.UTF8.GetString(DecryptBytes(encrypted, key));
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -302,23 +439,40 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
{
if (value.Length == 0 ||
StartsWith(value, KeyVaultBytesPrefix) ||
StartsWith(value, KeyVaultRawBytesPrefix) ||
StartsWith(value, DataProtectionBytesPrefix))
{
return value;
}
var encrypted = EncryptBytes(value, key);
var base64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(encrypted));
return [.. KeyVaultBytesPrefix, .. base64Bytes];
return [.. KeyVaultRawBytesPrefix, .. encrypted];
}
private byte[] UnprotectBytes(byte[] value, byte[] key)
private byte[] UnprotectBytes(byte[] value, byte[] key, DateOnly? keyDate = null)
{
if (value.Length == 0)
{
return value;
}
if (StartsWith(value, KeyVaultRawBytesPrefix))
{
try
{
return DecryptBytes(value[KeyVaultRawBytesPrefix.Length..], key);
}
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
if (StartsWith(value, KeyVaultBytesPrefix))
{
try
@@ -326,8 +480,13 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
var base64 = Encoding.ASCII.GetString(value, KeyVaultBytesPrefix.Length, value.Length - KeyVaultBytesPrefix.Length);
return DecryptBytes(Convert.FromBase64String(base64), key);
}
catch
catch (Exception ex)
{
if (keyDate.HasValue)
{
throw CreatePurgedDataException(keyDate.Value, ex);
}
return value;
}
}
@@ -398,4 +557,7 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore
private static string ComputeSha256Hex(byte[] content)
=> Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
private static EncryptedDataPurgedException CreatePurgedDataException(DateOnly keyDate, Exception innerException)
=> new(keyDate, innerException);
}

View File

@@ -0,0 +1,31 @@
namespace ApiDenuncias.Services;
public sealed class EncryptionKeyUnavailableException : Exception
{
public EncryptionKeyUnavailableException(string message)
: base(message)
{
}
public EncryptionKeyUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}
public sealed class EncryptedDataPurgedException : Exception
{
public EncryptedDataPurgedException(DateOnly keyDate)
: base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.")
{
KeyDate = keyDate;
}
public EncryptedDataPurgedException(DateOnly keyDate, Exception innerException)
: base($"Denuncia no disponible: datos purgados para la clave diaria {keyDate:yyyy-MM-dd}.", innerException)
{
KeyDate = keyDate;
}
public DateOnly KeyDate { get; }
}

View File

@@ -0,0 +1,12 @@
namespace ApiDenuncias.Services;
public sealed record EncryptionKeyMaterial(
DateOnly KeyDate,
byte[] Key,
string KeyVersion,
string Scheme)
{
public const string EnvelopeScheme = "aes-256-gcm-envelope-v1";
public byte[] CopyKey() => Key.ToArray();
}

View File

@@ -0,0 +1,321 @@
using System.Security.Cryptography;
using System.Text;
using System.Collections.Concurrent;
using ApiDenuncias.Configuration;
using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Keys.Cryptography;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ApiDenuncias.Services;
public sealed class EnvelopeEncryptionKeyProvider : IEnvelopeEncryptionKeyProvider
{
private readonly KeyVaultOptions _keyVaultOptions;
private readonly IConfiguration _configuration;
private readonly MySqlConnectionStringProvider _connectionStringProvider;
private readonly ILogger<EnvelopeEncryptionKeyProvider> _logger;
private readonly SemaphoreSlim _unwrapGate = new(1, 1);
private readonly ConcurrentDictionary<DateOnly, CachedEncryptionKey> _cache = [];
public EnvelopeEncryptionKeyProvider(
IOptions<KeyVaultOptions> keyVaultOptions,
IConfiguration configuration,
MySqlConnectionStringProvider connectionStringProvider,
ILogger<EnvelopeEncryptionKeyProvider> logger)
{
_keyVaultOptions = keyVaultOptions.Value;
_configuration = configuration;
_connectionStringProvider = connectionStringProvider;
_logger = logger;
}
public async ValueTask<EncryptionKeyMaterial> GetCurrentKeyAsync(CancellationToken cancellationToken = default)
{
if (!_keyVaultOptions.Enabled)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
return CreateLocalDevelopmentKey(today);
}
using var timeout = CreateKeyOperationTimeout(cancellationToken);
try
{
var row = await LoadCurrentKeyRowAsync(timeout.Token);
if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase))
{
throw new EncryptionKeyUnavailableException(
$"No hay clave activa para hoy ({row.KeyDate:yyyy-MM-dd}). Estado actual: {row.Status}.");
}
return await GetOrUnwrapAsync(row, timeout.Token);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout cargando la clave diaria activa desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
}
public async ValueTask<EncryptionKeyMaterial> GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default)
{
if (!_keyVaultOptions.Enabled)
{
return CreateLocalDevelopmentKey(keyDate);
}
using var timeout = CreateKeyOperationTimeout(cancellationToken);
try
{
var row = await LoadKeyRowAsync(keyDate, timeout.Token);
if (string.Equals(row.Status, "purged", StringComparison.OrdinalIgnoreCase))
{
_cache.TryRemove(keyDate, out _);
throw new EncryptedDataPurgedException(keyDate);
}
if (!string.Equals(row.Status, "active", StringComparison.OrdinalIgnoreCase))
{
throw new EncryptionKeyUnavailableException(
$"La clave diaria {keyDate:yyyy-MM-dd} no esta activa. Estado actual: {row.Status}.");
}
return await GetOrUnwrapAsync(row, timeout.Token);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout cargando la clave diaria {keyDate:yyyy-MM-dd} desde encryption_keys/Key Vault tras {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
}
private CancellationTokenSource CreateKeyOperationTimeout(CancellationToken cancellationToken)
{
var timeoutSeconds = Math.Clamp(_keyVaultOptions.EncryptionKeyTimeoutSeconds, 5, 120);
var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
return timeout;
}
private async Task<EncryptionKeyRow> LoadCurrentKeyRowAsync(CancellationToken cancellationToken)
{
const string sql = """
SELECT key_date, key_version, edek, status
FROM encryption_keys
WHERE key_date = CURDATE()
LIMIT 1;
""";
return await LoadKeyRowCoreAsync(sql, keyDate: null, cancellationToken);
}
private async Task<EncryptionKeyRow> LoadKeyRowAsync(DateOnly keyDate, CancellationToken cancellationToken)
{
const string sql = """
SELECT key_date, key_version, edek, status
FROM encryption_keys
WHERE key_date = @keyDate
LIMIT 1;
""";
return await LoadKeyRowCoreAsync(sql, keyDate, cancellationToken);
}
private async Task<EncryptionKeyRow> LoadKeyRowCoreAsync(
string sql,
DateOnly? keyDate,
CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
if (keyDate.HasValue)
{
command.Parameters.AddWithValue("@keyDate", keyDate.Value.ToDateTime(TimeOnly.MinValue));
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
var dateMessage = keyDate.HasValue
? $"para {keyDate.Value:yyyy-MM-dd}"
: "para hoy";
throw new EncryptionKeyUnavailableException(
$"No existe fila en encryption_keys {dateMessage}. Ejecutar la Function App de claves antes de arrancar la API.");
}
return new EncryptionKeyRow(
DateOnly.FromDateTime(reader.GetDateTime("key_date")),
reader.GetString("key_version"),
reader.GetString("edek"),
reader.GetString("status"));
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connectionString = await _connectionStringProvider.GetConnectionStringAsync(cancellationToken);
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
return connection;
}
private async Task<EncryptionKeyMaterial> GetOrUnwrapAsync(
EncryptionKeyRow row,
CancellationToken cancellationToken)
{
var edekHash = ComputeSha256Hex(row.Edek);
try
{
await _unwrapGate.WaitAsync(cancellationToken);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"No se ha podido obtener el bloqueo de carga de clave en {_keyVaultOptions.EncryptionKeyTimeoutSeconds} segundos.",
ex);
}
try
{
if (_cache.TryGetValue(row.KeyDate, out var cached) &&
string.Equals(cached.KeyVersion, row.KeyVersion, StringComparison.Ordinal) &&
string.Equals(cached.EdekHash, edekHash, StringComparison.Ordinal))
{
return cached.ToMaterial();
}
_logger.LogInformation(
"Cargando DEK diaria desde Key Vault. KeyDate={KeyDate}; KeyVersion={KeyVersion}",
row.KeyDate,
row.KeyVersion);
var dek = await UnwrapDekAsync(row, cancellationToken);
var cachedKey = new CachedEncryptionKey(
row.KeyDate,
row.KeyVersion,
edekHash,
dek);
_cache[row.KeyDate] = cachedKey;
_logger.LogInformation(
"DEK diaria cargada en memoria desde encryption_keys. KeyDate={KeyDate}; KeyVersion={KeyVersion}",
row.KeyDate,
row.KeyVersion);
return cachedKey.ToMaterial();
}
finally
{
_unwrapGate.Release();
}
}
private static async Task<byte[]> UnwrapDekAsync(
EncryptionKeyRow row,
CancellationToken cancellationToken)
{
byte[] encryptedDek;
try
{
encryptedDek = Convert.FromBase64String(row.Edek.Trim());
}
catch (FormatException ex)
{
throw new EncryptionKeyUnavailableException(
$"La eDEK de encryption_keys para {row.KeyDate:yyyy-MM-dd} no es Base64 valida.",
ex);
}
try
{
var cryptographyClient = new CryptographyClient(new Uri(row.KeyVersion), new DefaultAzureCredential());
var response = await cryptographyClient.UnwrapKeyAsync(
KeyWrapAlgorithm.RsaOaep,
encryptedDek,
cancellationToken);
var dek = response.Key;
if (dek.Length != 32)
{
throw new EncryptionKeyUnavailableException(
$"La DEK de {row.KeyDate:yyyy-MM-dd} no tiene 32 bytes tras unwrapKey.");
}
return dek.ToArray();
}
catch (RequestFailedException ex)
{
throw new EncryptionKeyUnavailableException(
$"Key Vault no ha podido hacer unwrapKey para {row.KeyDate:yyyy-MM-dd}. Status={ex.Status}; Error={ex.ErrorCode}.",
ex);
}
catch (OperationCanceledException ex)
{
throw new EncryptionKeyUnavailableException(
$"Timeout obteniendo la DEK diaria {row.KeyDate:yyyy-MM-dd} desde encryption_keys/Key Vault. Revisa acceso a Key Vault, permisos unwrapKey y conectividad.",
ex);
}
}
private EncryptionKeyMaterial CreateLocalDevelopmentKey(DateOnly keyDate)
{
var configuredLocalKey = _configuration["Encryption:LocalDevelopmentKey"];
if (string.IsNullOrWhiteSpace(configuredLocalKey))
{
throw new EncryptionKeyUnavailableException(
"Key Vault esta deshabilitado y no se ha configurado Encryption:LocalDevelopmentKey.");
}
return new EncryptionKeyMaterial(
keyDate,
NormalizeKey(configuredLocalKey),
"local-development",
EncryptionKeyMaterial.EnvelopeScheme);
}
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.Length == 32 ? base64Key : SHA256.HashData(base64Key);
}
}
catch (FormatException)
{
// Si no es base64, derivamos una clave estable desde el valor textual.
}
return SHA256.HashData(Encoding.UTF8.GetBytes(trimmed));
}
private static string ComputeSha256Hex(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private sealed record EncryptionKeyRow(
DateOnly KeyDate,
string KeyVersion,
string Edek,
string Status);
private sealed record CachedEncryptionKey(
DateOnly KeyDate,
string KeyVersion,
string EdekHash,
byte[] Key)
{
public EncryptionKeyMaterial ToMaterial()
=> new(KeyDate, Key.ToArray(), KeyVersion, EncryptionKeyMaterial.EnvelopeScheme);
}
}

View File

@@ -2,13 +2,18 @@ using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
namespace ApiDenuncias.Services;
public sealed class GestionaDocumentWorkflowService
{
private const string CircuitTemplateFileDocContentType =
"application/vnd.gestiona.circuits.template-filedoc+json; version=7";
private const string FileDocumentContentType =
"application/vnd.gestiona.file-document+json; version=4";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<GestionaDocumentWorkflowService> _logger;
@@ -52,14 +57,12 @@ public sealed class GestionaDocumentWorkflowService
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
metaReq.Content = new StringContent(metaJson, Encoding.UTF8);
metaReq.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4");
metaReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(FileDocumentContentType);
using var metaResp = await CreateRawHttp().SendAsync(metaReq);
LogDeprecatedHeaders(metaResp, "POST documento Gestiona");
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
@@ -105,96 +108,78 @@ public sealed class GestionaDocumentWorkflowService
public async Task TramitarDocumentoAsync(string documentUrl, string assignedGroupHref, int? complaintId = null)
{
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var payload = BuildConfiguredCircuitPayload(docUrlAbs, assignedGroupHref, complaintId);
string? templateNameForLog = "configurada";
string? templateHrefForLog = GetConfiguredTemplateHref(docUrlAbs);
_ = assignedGroupHref;
if (payload is null)
var docUrlAbs = EnsureAbsoluteGestionaUrl(documentUrl, GestionaApiBase);
var templateHref = GetConfiguredTemplateHref(docUrlAbs);
if (string.IsNullOrWhiteSpace(templateHref))
{
throw new InvalidOperationException(
"Faltan Gestiona:CircuitTemplateId o Gestiona:CircuitSignerStampHref. No se listan plantillas para evitar campos deprecated.");
"Falta Gestiona:CircuitTemplateId. No se listan plantillas para evitar campos deprecated.");
}
var json = payload.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var req = new HttpRequestMessage(HttpMethod.Post, $"{docUrlAbs.TrimEnd('/')}/circuit");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
req.Content = new StringContent(json, Encoding.UTF8);
req.Content.Headers.ContentType =
MediaTypeHeaderValue.Parse("application/vnd.gestiona.circuits.template-filedoc+json; version=7");
using var resp = await CreateRawHttp().SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
var payload = await GetCircuitTemplatePayloadAsync(templateHref);
var (success, statusCode, body) = await TryPostCircuitAsync(docUrlAbs, payload);
if (success)
{
_logger.LogError(
"Fallo al tramitar documento {DocumentUrl} con plantilla {TemplateName} ({TemplateHref}). Status: {StatusCode}. Body: {Body}",
_logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito con plantilla {TemplateHref}. Denuncia={ComplaintId}.",
docUrlAbs,
templateNameForLog,
templateHrefForLog,
(int)resp.StatusCode,
templateHref,
complaintId);
return;
}
_logger.LogError(
"Fallo al tramitar documento {DocumentUrl} con plantilla configurada ({TemplateHref}). Status: {StatusCode}. Body: {Body}",
docUrlAbs,
templateHref,
(int)statusCode,
body);
throw new InvalidOperationException(
$"TramitarDocumentoAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
$"TramitarDocumentoAsync: {(int)statusCode} {statusCode}\n{body}");
}
_logger.LogInformation(
"Documento {DocumentUrl} enviado a circuito {TemplateName} ({TemplateHref}) para denuncia {ComplaintId}.",
docUrlAbs,
templateNameForLog,
templateHrefForLog,
complaintId);
private async Task<string> GetCircuitTemplatePayloadAsync(string templateHref)
{
using var req = new HttpRequestMessage(HttpMethod.Get, templateHref);
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse(CircuitTemplateFileDocContentType));
using var resp = await CreateRawHttp().SendAsync(req);
LogDeprecatedHeaders(resp, "GET plantilla circuito Gestiona");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"GetCircuitTemplatePayloadAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
}
private JsonObject? BuildConfiguredCircuitPayload(string documentUrl, string assignedGroupHref, int? complaintId)
if (string.IsNullOrWhiteSpace(body))
{
_ = assignedGroupHref;
_ = complaintId;
var templateHref = GetConfiguredTemplateHref(documentUrl);
var signerHref = _configuration["Gestiona:CircuitSignerStampHref"];
if (string.IsNullOrWhiteSpace(templateHref) || string.IsNullOrWhiteSpace(signerHref))
{
return null;
throw new InvalidOperationException("Gestiona ha devuelto vacia la plantilla de circuito configurada.");
}
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 body;
}
return payload;
private async Task<(bool Success, System.Net.HttpStatusCode StatusCode, string Body)> TryPostCircuitAsync(
string documentUrl,
string payload)
{
using var req = new HttpRequestMessage(HttpMethod.Post, $"{documentUrl.TrimEnd('/')}/circuit");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
req.Content = new StringContent(payload, Encoding.UTF8);
req.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(CircuitTemplateFileDocContentType);
using var resp = await CreateRawHttp().SendAsync(req);
LogDeprecatedHeaders(resp, "POST circuito Gestiona");
var body = await resp.Content.ReadAsStringAsync();
return (resp.IsSuccessStatusCode, resp.StatusCode, body);
}
private string? GetConfiguredTemplateHref(string documentUrl)
@@ -211,10 +196,12 @@ public sealed class GestionaDocumentWorkflowService
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{GestionaApiBase}/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", GestionaAccessToken);
createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await CreateRawHttp().SendAsync(createReq);
LogDeprecatedHeaders(createResp, "POST upload Gestiona");
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
{
@@ -236,6 +223,7 @@ public sealed class GestionaDocumentWorkflowService
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await CreateRawHttp().SendAsync(putReq);
LogDeprecatedHeaders(putResp, "PUT upload Gestiona");
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
{
@@ -291,4 +279,15 @@ public sealed class GestionaDocumentWorkflowService
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private void LogDeprecatedHeaders(HttpResponseMessage response, string operation)
{
if (response.Headers.TryGetValues("X-Gestiona-Deprecated", out var deprecated))
{
_logger.LogWarning(
"Gestiona devolvio X-Gestiona-Deprecated en {Operation}: {Deprecated}",
operation,
string.Join(" | ", deprecated));
}
}
}

View File

@@ -119,10 +119,12 @@ namespace ApiDenuncias.Services
var url = await ResolveExternalProcedureCreateFileUrlAsync(effectiveProcedureId);
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-opening+json; version=1"));
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST create-file");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -187,6 +189,7 @@ namespace ApiDenuncias.Services
AddTokenAndAccept(req, "application/json");
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-open");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenFileAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -205,9 +208,11 @@ namespace ApiDenuncias.Services
Content = content
};
req.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
AddTokenAndAccept(req, "application/vnd.gestiona.file-folder+json", "1");
req.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
req.Headers.Accept.Clear();
using var resp = await _http.SendAsync(req);
LogDeprecatedHeaders(resp, "POST file-folder");
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateFolderAsync: {(int)resp.StatusCode} {resp.StatusCode}\n{body}");
@@ -225,10 +230,12 @@ namespace ApiDenuncias.Services
{
using var createReq = new HttpRequestMessage(HttpMethod.Post, "/rest/uploads");
createReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
createReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
createReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.upload+json"));
using var createResp = await _http.SendAsync(createReq);
LogDeprecatedHeaders(createResp, "POST /rest/uploads");
var createBody = await createResp.Content.ReadAsStringAsync();
if (!createResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (POST): {(int)createResp.StatusCode} {createResp.StatusCode}\n{createBody}");
@@ -254,6 +261,7 @@ namespace ApiDenuncias.Services
putReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var putResp = await _http.SendAsync(putReq);
LogDeprecatedHeaders(putResp, "PUT upload content");
var infoJson = await putResp.Content.ReadAsStringAsync();
if (!putResp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateUpload (PUT): {(int)putResp.StatusCode} {putResp.StatusCode}\n{infoJson}");
@@ -294,10 +302,9 @@ namespace ApiDenuncias.Services
metaReq.Headers.TryAddWithoutValidation("X-Gestiona-Access-Token", _opts.AccessToken);
metaReq.Headers.TryAddWithoutValidation("Prefer", "return=minimal");
metaReq.Headers.Accept.Clear();
metaReq.Headers.Accept.Add(
MediaTypeWithQualityHeaderValue.Parse("application/vnd.gestiona.file-document+json; version=4"));
using var metaResp = await _http.SendAsync(metaReq);
LogDeprecatedHeaders(metaResp, "POST file document");
var body = await metaResp.Content.ReadAsStringAsync();
if (!metaResp.IsSuccessStatusCode)
throw new InvalidOperationException($"UploadDocumentAsync: {(int)metaResp.StatusCode} {metaResp.StatusCode}\n{body}");
@@ -513,6 +520,14 @@ namespace ApiDenuncias.Services
if (string.IsNullOrEmpty(encontrado.SelfHref))
{
if (!CanCreateThirdParty(thirdParty))
{
_logger.LogWarning(
"Se omite la creacion/enlace del tercero en Gestiona para el expediente {FileUrl}: datos identificativos incompletos.",
fileUrl);
return;
}
encontrado = await CrearTerceroAsync(thirdParty);
}
else if (thirdParty.Address?.HasAnyValue == true)
@@ -529,7 +544,27 @@ namespace ApiDenuncias.Services
{
if (!thirdParty.IsAnonymous)
{
return thirdParty;
var documentId = (thirdParty.DocumentId ?? string.Empty).Trim().ToUpperInvariant();
var firstName = (thirdParty.FirstName ?? string.Empty).Trim();
var lastName = (thirdParty.LastName ?? string.Empty).Trim();
var businessName = (thirdParty.BusinessName ?? string.Empty).Trim();
var isLegalEntity = thirdParty.IsLegalEntity ||
LooksLikeLegalEntityDocument(documentId) ||
(!string.IsNullOrWhiteSpace(businessName) &&
string.IsNullOrWhiteSpace(firstName));
return new ThirdPartyIdentityData
{
IsAnonymous = false,
IsLegalEntity = isLegalEntity,
DocumentId = documentId,
FirstName = isLegalEntity ? string.Empty : firstName,
LastName = isLegalEntity ? string.Empty : lastName,
BusinessName = businessName,
Email = (thirdParty.Email ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(thirdParty.CountryCode) ? "ESP" : thirdParty.CountryCode.Trim(),
Address = thirdParty.Address
};
}
return new ThirdPartyIdentityData
@@ -546,6 +581,25 @@ namespace ApiDenuncias.Services
};
}
private static bool CanCreateThirdParty(ThirdPartyIdentityData thirdParty)
{
if (string.IsNullOrWhiteSpace(thirdParty.DocumentId))
{
return false;
}
return thirdParty.IsLegalEntity
? !string.IsNullOrWhiteSpace(thirdParty.BusinessName)
: !string.IsNullOrWhiteSpace(thirdParty.FirstName) &&
!string.IsNullOrWhiteSpace(thirdParty.LastName);
}
private static bool LooksLikeLegalEntityDocument(string documentId)
{
var value = (documentId ?? string.Empty).Trim().ToUpperInvariant();
return Regex.IsMatch(value, @"^[ABCDEFGHJKLMNPQRSUVW]\d{7}[A-Z0-9]$");
}
// --- CONSULTAS DE EXPEDIENTES (sin recorrer histórico paginado) ---
private async Task<string> GetFilesAsync(object? filter = null)
@@ -559,7 +613,7 @@ namespace ApiDenuncias.Services
if (filter is not null)
{
var json = JsonSerializer.Serialize(filter);
req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files");
req.Content = new StringContent(json, Encoding.UTF8, "application/vnd.gestiona.filter.files+json");
}
using var resp = await _http.SendAsync(req);

View File

@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
@@ -13,7 +13,7 @@ using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services;
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
public sealed class GlobalLeaksClient
{
@@ -54,7 +54,12 @@ public sealed class GlobalLeaksClient
CancellationToken cancellationToken)
{
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
return await CompleteLoginAsync(
prepared.Username,
prepared.FinalPassword,
prepared.TokenAnswer,
authcode,
cancellationToken);
}
public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
@@ -90,12 +95,14 @@ public sealed class GlobalLeaksClient
username,
passwordWatch.ElapsedMilliseconds);
var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
_logger.LogInformation(
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
"GlobalLeaks login: credenciales y proof-of-work preparados para {Username}. Tiempo total={ElapsedMs} ms",
username,
loginWatch.ElapsedMilliseconds);
return new PreparedGlobalLeaksCredentials(username, finalPassword);
return new PreparedGlobalLeaksCredentials(username, finalPassword, tokenAnswer);
}
public async Task<GlSession> CompleteLoginAsync(
@@ -103,13 +110,87 @@ public sealed class GlobalLeaksClient
string finalPassword,
string authcode,
CancellationToken cancellationToken)
{
var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
return await CompleteLoginAsync(username, finalPassword, tokenAnswer, authcode, cancellationToken);
}
public async Task<GlSession> CompleteLoginAsync(
string username,
string finalPassword,
string tokenAnswer,
string authcode,
CancellationToken cancellationToken)
{
var loginWatch = Stopwatch.StartNew();
_logger.LogInformation(
"GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}",
"GlobalLeaks login: enviando autenticacion final para {Username}. AuthcodeLength={AuthcodeLength}",
username,
authcode?.Length ?? 0);
var currentTokenAnswer = tokenAnswer;
for (var attempt = 0; attempt < 2; attempt++)
{
using var authRequest = CreateRequest(
HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(currentTokenAnswer)}");
authRequest.Content = CreateJsonContent(new
{
tid = 1,
username,
password = finalPassword,
authcode = authcode?.Trim() ?? string.Empty,
});
authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer);
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
if (authResponse.IsSuccessStatusCode)
{
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username);
_logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username,
session.Role ?? "(sin rol)",
loginWatch.ElapsedMilliseconds);
return session;
}
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
if (attempt == 0 && IsMissingTokenOrSessionError(body))
{
_logger.LogWarning(
"GlobalLeaks rechazo el token preparado para {Username}; se generara un token nuevo y se reintentara la autenticacion.",
username);
currentTokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
continue;
}
throw authResponse.StatusCode switch
{
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
"Credenciales incorrectas o codigo 2FA invalido.",
StatusCodes.Status401Unauthorized),
(HttpStatusCode)429 => new GlobalLeaksValidationException(
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
StatusCodes.Status429TooManyRequests),
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
new GlobalLeaksValidationException(
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
StatusCodes.Status406NotAcceptable),
_ => new GlobalLeaksValidationException(
string.IsNullOrWhiteSpace(body)
? $"Login fallido (codigo {(int)authResponse.StatusCode})."
: $"Login fallido (codigo {(int)authResponse.StatusCode}): {body}",
(int)authResponse.StatusCode),
};
}
throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
}
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
{
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
@@ -123,51 +204,7 @@ public sealed class GlobalLeaksClient
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
username,
proofWatch.ElapsedMilliseconds);
using var authRequest = CreateRequest(
HttpMethod.Post,
$"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}");
authRequest.Content = CreateJsonContent(new
{
tid = 1,
username,
password = finalPassword,
authcode = authcode?.Trim() ?? string.Empty,
});
authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
if (!authResponse.IsSuccessStatusCode)
{
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
throw authResponse.StatusCode switch
{
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
"Credenciales incorrectas o código 2FA inválido.",
StatusCodes.Status401Unauthorized),
(HttpStatusCode)429 => new GlobalLeaksValidationException(
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
StatusCodes.Status429TooManyRequests),
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
new GlobalLeaksValidationException(
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
StatusCodes.Status406NotAcceptable),
_ => new GlobalLeaksValidationException(
string.IsNullOrWhiteSpace(body)
? $"Login fallido (código {(int)authResponse.StatusCode})."
: $"Login fallido (código {(int)authResponse.StatusCode}): {body}",
(int)authResponse.StatusCode),
};
}
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
var session = ParseAuthSession(authBody, username);
_logger.LogInformation(
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
session.Username,
session.Role ?? "(sin rol)",
loginWatch.ElapsedMilliseconds);
return session;
return tokenAnswer;
}
public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
@@ -187,7 +224,8 @@ public sealed class GlobalLeaksClient
string? filter,
string? dateFrom,
string? dateTo,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IReadOnlyList<ContextDto>? contexts = null)
{
filter ??= "all";
@@ -197,8 +235,8 @@ public sealed class GlobalLeaksClient
var tips = ParseReports(body);
_logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);
var contexts = await GetContextsAsync(sessionId, cancellationToken);
var contextNames = contexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);
var availableContexts = contexts ?? await GetContextsAsync(sessionId, cancellationToken);
var contextNames = availableContexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);
IEnumerable<RawReport> filtered = tips;
@@ -263,21 +301,11 @@ public sealed class GlobalLeaksClient
public async Task<ReportDetailDto> GetReportDetailAsync(
string sessionId,
string reportId,
string? lastAccess,
CancellationToken cancellationToken)
{
ValidateUuid(reportId);
using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken);
var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken);
var metadata = ParseReports(listBody)
.FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase));
if (metadata is null)
{
throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404);
}
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
@@ -291,7 +319,7 @@ public sealed class GlobalLeaksClient
}
using var document = JsonDocument.Parse(content);
return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement);
return ParseReportDetail(reportId, lastAccess, document.RootElement);
}
public async Task<FileDownloadResult> DownloadReportZipAsync(
@@ -887,6 +915,13 @@ public sealed class GlobalLeaksClient
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMissingTokenOrSessionError(string body)
{
return !string.IsNullOrWhiteSpace(body) &&
(body.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) ||
body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase));
}
private sealed record TokenResponse(string Id, string Salt);
private sealed record AuthTypeResponse(string Type, string Salt);

View File

@@ -0,0 +1,8 @@
namespace ApiDenuncias.Services;
public interface IEnvelopeEncryptionKeyProvider
{
ValueTask<EncryptionKeyMaterial> GetCurrentKeyAsync(CancellationToken cancellationToken = default);
ValueTask<EncryptionKeyMaterial> GetKeyForDateAsync(DateOnly keyDate, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,19 @@
using GestionaDenuncias.Shared.Models;
namespace ApiDenuncias.Services;
public interface IFilteredDenunciaStore
{
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);
}

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using System.Data;
using GestionaDenuncias.Shared.Models;
using MySqlConnector;
@@ -84,10 +85,11 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, reportList.Select(r => r.Id).ToList(), cancellationToken);
return reportList
@@ -132,10 +134,11 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = await LoadMetadataAsync(connection, userId, [report.Id], cancellationToken);
if (metadata.TryGetValue(report.Id, out var meta) && meta.LockedByAnotherUser)
{
@@ -254,7 +257,7 @@ public sealed class InboxTrackingService : IInboxTrackingService
}
catch
{
await transaction.RollbackAsync(cancellationToken);
await SafeRollbackAsync(transaction, cancellationToken);
throw;
}
}
@@ -386,6 +389,8 @@ public sealed class InboxTrackingService : IInboxTrackingService
List<string> reportIds,
CancellationToken cancellationToken)
{
await EnsureConnectionOpenAsync(connection, cancellationToken);
var metadata = new Dictionary<string, ReportMetadata>(StringComparer.OrdinalIgnoreCase);
if (reportIds.Count == 0)
{
@@ -462,6 +467,34 @@ public sealed class InboxTrackingService : IInboxTrackingService
return connection;
}
private static async Task EnsureConnectionOpenAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
if (connection.State == ConnectionState.Open)
{
return;
}
await connection.OpenAsync(cancellationToken);
await using var timeZoneCommand = new MySqlCommand("SET time_zone = '+00:00';", connection);
await timeZoneCommand.ExecuteNonQueryAsync(cancellationToken);
}
private async Task SafeRollbackAsync(
MySqlTransaction transaction,
CancellationToken cancellationToken)
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch
{
// No dejamos que un rollback sobre una conexion ya cerrada oculte el error real.
}
}
private static object ToDbString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? DBNull.Value : value;

View File

@@ -0,0 +1,252 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ApiDenuncias.Configuration;
using Azure;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Options;
namespace ApiDenuncias.Services;
public sealed class ManualPurgeService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly KeyVaultOptions _keyVaultOptions;
private readonly ManualPurgeOptions _manualPurgeOptions;
private readonly ILogger<ManualPurgeService> _logger;
public ManualPurgeService(
HttpClient httpClient,
IOptions<KeyVaultOptions> keyVaultOptions,
IOptions<ManualPurgeOptions> manualPurgeOptions,
ILogger<ManualPurgeService> logger)
{
_httpClient = httpClient;
_keyVaultOptions = keyVaultOptions.Value;
_manualPurgeOptions = manualPurgeOptions.Value;
_logger = logger;
}
public async Task<ManualPurgeResponse> ExecuteAsync(DateOnly purgeDate, CancellationToken cancellationToken)
{
var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY");
var hasDirectFunctionKey = !string.IsNullOrWhiteSpace(configuredFunctionKey);
if (!_keyVaultOptions.Enabled && !hasDirectFunctionKey)
{
throw new InvalidOperationException("Key Vault debe estar habilitado o KEYMGMT_FUNCTION_KEY debe estar configurada para ejecutar una purga manual.");
}
if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_keyVaultOptions.VaultUrl))
{
throw new InvalidOperationException("KeyVault:VaultUrl no esta configurado.");
}
var functionUri = ResolveEndpointUri(
_manualPurgeOptions.FunctionUrl,
"manual_purge",
"ManualPurge:FunctionUrl");
Uri? forceRotateUri = null;
if (_manualPurgeOptions.ReplaceOnManualPurge && _manualPurgeOptions.RecoverPartialReplaceFailure)
{
forceRotateUri = ResolveEndpointUri(
_manualPurgeOptions.ForceRotateUrl,
"force_rotate",
"ManualPurge:ForceRotateUrl");
}
if (!hasDirectFunctionKey && string.IsNullOrWhiteSpace(_manualPurgeOptions.FunctionKeySecretName))
{
throw new InvalidOperationException("ManualPurge:FunctionKeySecretName no esta configurado.");
}
var timeoutSeconds = Math.Clamp(_manualPurgeOptions.TimeoutSeconds, 5, 120);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
var dateText = purgeDate.ToString("yyyy-MM-dd");
var functionKey = await ReadFunctionKeyAsync(timeout.Token);
using var request = new HttpRequestMessage(HttpMethod.Post, functionUri);
request.Headers.TryAddWithoutValidation("x-functions-key", functionKey);
request.Content = JsonContent.Create(
new { date = dateText, replace = _manualPurgeOptions.ReplaceOnManualPurge },
options: JsonOptions);
_logger.LogWarning(
"Ejecutando purga manual mediante Function App. Date={Date}; Replace={Replace}; FunctionUrl={FunctionUrl}; SecretName={SecretName}",
dateText,
_manualPurgeOptions.ReplaceOnManualPurge,
functionUri,
_manualPurgeOptions.FunctionKeySecretName);
using var response = await _httpClient.SendAsync(request, timeout.Token);
var body = await response.Content.ReadAsStringAsync(timeout.Token);
if (!response.IsSuccessStatusCode)
{
if (forceRotateUri is not null && IsPartialPurgeFailure(body))
{
return await RecoverWithForceRotateAsync(
forceRotateUri,
dateText,
functionKey,
body,
timeout.Token);
}
throw new InvalidOperationException(
$"La Function App de purga ha respondido con {(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}");
}
_logger.LogWarning(
"Purga manual completada por Function App. Date={Date}; StatusCode={StatusCode}",
dateText,
(int)response.StatusCode);
return new ManualPurgeResponse(
dateText,
Success: true,
StatusCode: (int)response.StatusCode,
ResponseBody: body);
}
private async Task<ManualPurgeResponse> RecoverWithForceRotateAsync(
Uri forceRotateUri,
string dateText,
string functionKey,
string purgeBody,
CancellationToken cancellationToken)
{
_logger.LogWarning(
"La purga manual se completo, pero fallo la creacion de la nueva clave. Reintentando force_rotate. Date={Date}; ForceRotateUrl={ForceRotateUrl}",
dateText,
forceRotateUri);
using var request = new HttpRequestMessage(HttpMethod.Post, forceRotateUri);
request.Headers.TryAddWithoutValidation("x-functions-key", functionKey);
request.Content = JsonContent.Create(new { date = dateText }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Conflict)
{
throw new InvalidOperationException(
"La purga se completo, pero no se ha podido crear la nueva clave con force_rotate. " +
$"Status={(int)response.StatusCode} ({response.ReasonPhrase}). Body: {TrimBody(body)}. " +
$"Respuesta original manual_purge: {TrimBody(purgeBody)}");
}
_logger.LogWarning(
"Recuperacion force_rotate completada tras purga manual parcial. Date={Date}; StatusCode={StatusCode}",
dateText,
(int)response.StatusCode);
var recoveryBody = JsonSerializer.Serialize(
new
{
message = "manual_purge purgo la clave, pero fallo al crear la nueva. La API ha reintentado force_rotate para la misma fecha.",
manual_purge_body = TrimBody(purgeBody),
force_rotate_status = (int)response.StatusCode,
force_rotate_body = TrimBody(body)
},
JsonOptions);
return new ManualPurgeResponse(
dateText,
Success: true,
StatusCode: (int)response.StatusCode,
ResponseBody: recoveryBody);
}
private async Task<string> ReadFunctionKeyAsync(CancellationToken cancellationToken)
{
var configuredFunctionKey = Environment.GetEnvironmentVariable("KEYMGMT_FUNCTION_KEY");
if (!string.IsNullOrWhiteSpace(configuredFunctionKey))
{
return configuredFunctionKey.Trim();
}
try
{
var client = new SecretClient(new Uri(_keyVaultOptions.VaultUrl), new DefaultAzureCredential());
var secret = await client.GetSecretAsync(
_manualPurgeOptions.FunctionKeySecretName.Trim(),
cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(secret.Value.Value))
{
throw new InvalidOperationException(
$"El secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault esta vacio.");
}
return secret.Value.Value.Trim();
}
catch (RequestFailedException ex)
{
throw new InvalidOperationException(
$"No se ha podido leer el secreto '{_manualPurgeOptions.FunctionKeySecretName}' de Key Vault. Status={ex.Status}; Error={ex.ErrorCode}.",
ex);
}
}
private static string TrimBody(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return "(sin cuerpo)";
}
return body.Length <= 1200
? body
: body[..1200] + "...";
}
private static bool IsPartialPurgeFailure(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return false;
}
try
{
using var document = JsonDocument.Parse(body);
return document.RootElement.TryGetProperty("purged", out var purged) &&
purged.ValueKind == JsonValueKind.True;
}
catch (JsonException)
{
return false;
}
}
private Uri ResolveEndpointUri(string configuredUrl, string endpointName, string optionName)
{
var baseUrl = FirstNonWhiteSpace(
Environment.GetEnvironmentVariable("KEYMGMT_BASE_URL"),
_manualPurgeOptions.BaseUrl);
var candidateUrl = string.IsNullOrWhiteSpace(baseUrl)
? configuredUrl
: $"{baseUrl.Trim().TrimEnd('/')}/{endpointName}";
if (!Uri.TryCreate(candidateUrl, UriKind.Absolute, out var uri))
{
var sourceName = string.IsNullOrWhiteSpace(baseUrl)
? optionName
: "KEYMGMT_BASE_URL/ManualPurge:BaseUrl";
throw new InvalidOperationException($"{sourceName} no contiene una URL valida.");
}
return uri;
}
private static string? FirstNonWhiteSpace(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
}

View File

@@ -12,6 +12,8 @@ public sealed class MySqlConnectionStringProvider
private readonly ComplaintStorageOptions _storageOptions;
private readonly KeyVaultOptions _keyVaultOptions;
private readonly ILogger<MySqlConnectionStringProvider> _logger;
private readonly SemaphoreSlim _loadGate = new(1, 1);
private string? _cachedConnectionString;
public MySqlConnectionStringProvider(
IOptions<ComplaintStorageOptions> storageOptions,
@@ -25,10 +27,28 @@ public sealed class MySqlConnectionStringProvider
public async ValueTask<string> GetConnectionStringAsync(CancellationToken cancellationToken = default)
{
return await LoadConnectionStringAsync().WaitAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(_cachedConnectionString))
{
return _cachedConnectionString;
}
private async Task<string> LoadConnectionStringAsync()
await _loadGate.WaitAsync(cancellationToken);
try
{
if (string.IsNullOrWhiteSpace(_cachedConnectionString))
{
_cachedConnectionString = await LoadConnectionStringAsync(cancellationToken);
}
return _cachedConnectionString;
}
finally
{
_loadGate.Release();
}
}
private async Task<string> LoadConnectionStringAsync(CancellationToken cancellationToken)
{
if (!_storageOptions.UseKeyVault || !_keyVaultOptions.Enabled)
{
@@ -48,12 +68,12 @@ public sealed class MySqlConnectionStringProvider
}
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 host = await GetRequiredSecretAsync(client, _storageOptions.HostSecretName, cancellationToken);
var user = await GetRequiredSecretAsync(client, _storageOptions.UserSecretName, cancellationToken);
var password = await GetRequiredSecretAsync(client, _storageOptions.PasswordSecretName, cancellationToken);
var database = await GetRequiredSecretAsync(client, _storageOptions.DatabaseSecretName, cancellationToken);
var port = await GetOptionalUIntSecretAsync(client, _storageOptions.PortSecretName, _storageOptions.DefaultPort, cancellationToken);
var sslMode = await GetOptionalSecretAsync(client, _storageOptions.SslModeSecretName, _storageOptions.DefaultSslMode, cancellationToken);
var builder = new MySqlConnectionStringBuilder
{
@@ -76,9 +96,12 @@ public sealed class MySqlConnectionStringProvider
return builder.ConnectionString;
}
private static async Task<string> GetRequiredSecretAsync(SecretClient client, string secretName)
private static async Task<string> GetRequiredSecretAsync(
SecretClient client,
string secretName,
CancellationToken cancellationToken)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"El secreto obligatorio '{secretName}' de Key Vault no existe o esta vacio.");
@@ -87,7 +110,11 @@ public sealed class MySqlConnectionStringProvider
return value.Trim();
}
private static async Task<string> GetOptionalSecretAsync(SecretClient client, string secretName, string? fallback)
private static async Task<string> GetOptionalSecretAsync(
SecretClient client,
string secretName,
string? fallback,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(secretName))
{
@@ -96,7 +123,7 @@ public sealed class MySqlConnectionStringProvider
try
{
var secret = await client.GetSecretAsync(secretName.Trim());
var secret = await client.GetSecretAsync(secretName.Trim(), cancellationToken: cancellationToken);
return string.IsNullOrWhiteSpace(secret.Value.Value)
? fallback ?? string.Empty
: secret.Value.Value.Trim();
@@ -107,9 +134,13 @@ public sealed class MySqlConnectionStringProvider
}
}
private static async Task<uint> GetOptionalUIntSecretAsync(SecretClient client, string secretName, uint fallback)
private static async Task<uint> GetOptionalUIntSecretAsync(
SecretClient client,
string secretName,
uint fallback,
CancellationToken cancellationToken)
{
var value = await GetOptionalSecretAsync(client, secretName, null);
var value = await GetOptionalSecretAsync(client, secretName, null, cancellationToken);
return uint.TryParse(value, out var parsed) && parsed > 0
? parsed
: fallback;

View File

@@ -1,6 +1,7 @@
using System.Data;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using ApiDenuncias.Configuration;
using GestionaDenuncias.Shared.Models;
using Microsoft.Extensions.Options;
@@ -10,6 +11,113 @@ namespace ApiDenuncias.Services;
public sealed class MySqlDenunciaStore : IDenunciaStore
{
private const int AttachmentChunkSizeBytes = 8 * 1024 * 1024;
private const int AttachmentChunkThresholdBytes = 32 * 1024 * 1024;
private static readonly byte[] ChunkedAttachmentPrefix = Encoding.ASCII.GetBytes("chunked:v1:");
private const string AttachmentChunksTableSql = """
CREATE TABLE IF NOT EXISTS complaint_attachment_chunks (
attachment_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content LONGBLOB NOT NULL,
created_at_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (attachment_id, chunk_index),
CONSTRAINT fk_attachment_chunks_attachment
FOREIGN KEY (attachment_id) REFERENCES complaint_attachments(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""";
private const string ComplaintSelectColumns = """
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected,
key_date,
encryption_scheme,
encrypted_at_utc
""";
private const string AttachmentSelectColumns = """
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc,
a.key_date,
a.encryption_scheme,
a.encrypted_at_utc
""";
private static readonly (string Table, string Column, string Definition)[] SchemaColumnsToEnsure =
[
("complaints", "gestiona_file_code", "`gestiona_file_code` VARCHAR(128) NOT NULL DEFAULT ''"),
@@ -32,12 +140,21 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
("complaints", "address_extra", "`address_extra` VARCHAR(256) NOT NULL DEFAULT ''"),
("complaints", "form_fields_json", "`form_fields_json` LONGTEXT NULL"),
("complaints", "raw_report_text", "`raw_report_text` LONGTEXT NULL"),
("complaints", "key_date", "`key_date` DATE NULL"),
("complaints", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("complaints", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
("complaint_attachments", "content_sha256", "`content_sha256` CHAR(64) NOT NULL DEFAULT ''"),
("complaint_attachments", "key_date", "`key_date` DATE NULL"),
("complaint_attachments", "encryption_scheme", "`encryption_scheme` VARCHAR(64) NOT NULL DEFAULT 'none'"),
("complaint_attachments", "encrypted_at_utc", "`encrypted_at_utc` DATETIME(6) NULL"),
];
private static readonly (string Table, string IndexName, string Definition)[] SchemaIndexesToEnsure =
[
("complaint_attachments", "ix_attachments_sha256", "INDEX `ix_attachments_sha256` (`content_sha256`)"),
("complaints", "ix_complaints_key_date", "INDEX `ix_complaints_key_date` (`key_date`)"),
("complaints", "ix_complaints_flags", "INDEX `ix_complaints_flags` (`is_update`, `is_in_gestiona`, `is_rejected`)"),
("complaint_attachments", "ix_attachments_key_date", "INDEX `ix_attachments_key_date` (`key_date`)"),
];
private static readonly (string Table, string Definition)[] SchemaEncryptedColumnsToEnsure =
@@ -123,85 +240,79 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
}
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
{
return await GetDenunciasAsync(null, DenunciaListScope.All, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
return await GetDenunciasAsync(null, scope, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
return await GetDenunciasAsync(ids, DenunciaListScope.All, cancellationToken);
}
public async Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
return await GetDenunciasAsync(ids, scope, cancellationToken);
}
private async Task<List<DenunciasGestiona>> GetDenunciasAsync(
IReadOnlyList<int>? denunciaIds,
DenunciaListScope scope,
CancellationToken cancellationToken)
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
var whereClauses = new List<string>();
if (denunciaIds is { Count: > 0 })
{
var parameterNames = AddIntParameters(command, "id", denunciaIds);
whereClauses.Add($"external_report_id IN ({string.Join(", ", parameterNames)})");
}
var scopeWhereClause = GetScopeWhereClause(scope);
if (!string.IsNullOrWhiteSpace(scopeWhereClause))
{
whereClauses.Add(scopeWhereClause);
}
var whereSql = whereClauses.Count == 0
? string.Empty
: $"WHERE {string.Join(" AND ", whereClauses)}";
command.CommandText = $"""
SELECT
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
{ComplaintSelectColumns}
FROM complaints
{whereSql}
ORDER BY COALESCE(gestiona_uploaded_at_utc, report_date_utc) DESC, external_report_id DESC;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<DenunciasGestiona>();
while (await reader.ReadAsync(cancellationToken))
{
@@ -215,19 +326,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
ORDER BY c.external_report_id DESC, a.original_file_name ASC;
@@ -235,13 +336,55 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<FicherosDenuncias>();
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
public async Task<List<FicherosDenuncias>> GetFicherosByDenunciaIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var ids = NormalizeIds(denunciaIds);
if (ids.Count == 0)
{
return [];
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
var parameterNames = AddIntParameters(command, "id", ids);
command.CommandText = $"""
SELECT
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id IN ({string.Join(", ", parameterNames)})
ORDER BY c.external_report_id DESC, a.original_file_name ASC;
""";
var result = new List<FicherosDenuncias>();
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
@@ -252,19 +395,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
a.id,
a.attachment_type_id,
a.description,
a.attachment_date_utc,
a.notes,
c.external_report_id,
a.original_file_name,
a.content,
a.content_sha256,
a.uploaded_to_gestiona,
a.uploaded_at_utc
{AttachmentSelectColumns}
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @denunciaId
@@ -275,12 +408,16 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@denunciaId", denunciaId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var result = new List<FicherosDenuncias>();
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
result.Add(MapAttachment(reader));
}
}
await HydrateChunkedAttachmentsAsync(connection, result, cancellationToken);
return result;
}
@@ -291,74 +428,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await EnsureSchemaReadyAsync(cancellationToken);
const string sql = """
var sql = $"""
SELECT
external_registry_id,
external_report_id,
report_date_utc,
gestiona_file_url,
gestiona_file_code,
gestiona_person_id,
tag,
status_name,
complaint_type,
reporter_kind,
is_legal_entity,
reporter_first_name,
reporter_first_surname,
reporter_second_surname,
reporter_last_name,
reporter_business_name,
reporter_gender,
reporter_document_id,
reporter_document_type,
reporter_origin_country,
subject,
accused_party,
accused_party_details,
complaint_description,
reported_to_institution,
reported_institution_details,
requested_protection,
requested_protection_details,
information_mode,
facts_location,
facts_date_utc,
forwarding_authorization,
forwarding_personal_data_preference,
notification_preference,
electronic_notification,
online_tracking_preference,
postal_notification_preference,
email,
sms_notification,
accepted_terms,
comments,
phone,
address_line,
address_road_type,
address_number,
address_floor,
address_door,
address_block,
address_stair,
address_extra,
municipality,
province,
postal_code,
country_code,
form_fields_json,
raw_report_text,
is_confidential,
is_update,
gestiona_procedure_id,
gestiona_group_id,
display_name,
workflow_status,
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
{ComplaintSelectColumns}
FROM complaints
WHERE external_report_id = @denunciaId
LIMIT 1;
@@ -445,7 +517,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
selected_document_name,
gestiona_uploaded_at_utc,
is_in_gestiona,
is_rejected
is_rejected,
key_date,
encryption_scheme,
encrypted_at_utc
) VALUES (
@externalRegistryId,
@externalReportId,
@@ -512,7 +587,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@selectedDocumentName,
@gestionaUploadedAtUtc,
@isInGestiona,
@isRejected
@isRejected,
@keyDate,
@encryptionScheme,
@encryptedAtUtc
)
ON DUPLICATE KEY UPDATE
external_registry_id = VALUES(external_registry_id),
@@ -580,6 +658,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
gestiona_uploaded_at_utc = VALUES(gestiona_uploaded_at_utc),
is_in_gestiona = VALUES(is_in_gestiona),
is_rejected = VALUES(is_rejected),
key_date = VALUES(key_date),
encryption_scheme = VALUES(encryption_scheme),
encrypted_at_utc = VALUES(encrypted_at_utc),
updated_at_utc = UTC_TIMESTAMP(6);
""";
@@ -652,6 +733,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@gestionaUploadedAtUtc", ToDbDate(denuncia.FechaSubidaAGestiona));
command.Parameters.AddWithValue("@isInGestiona", denuncia.EnGestiona);
command.Parameters.AddWithValue("@isRejected", denuncia.EnRechazada);
command.Parameters.AddWithValue("@keyDate", ToDbDate(denuncia.KeyDate));
command.Parameters.AddWithValue("@encryptionScheme", denuncia.EncryptionScheme ?? string.Empty);
command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(denuncia.EncryptedAtUtc));
await command.ExecuteNonQueryAsync(cancellationToken);
}
@@ -680,7 +764,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
content_mime_type,
content_sha256,
uploaded_to_gestiona,
uploaded_at_utc
uploaded_at_utc,
key_date,
encryption_scheme,
encrypted_at_utc
) VALUES (
(
SELECT c.id
@@ -697,9 +784,13 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
@contentMimeType,
@contentSha256,
@uploadedToGestiona,
@uploadedAtUtc
@uploadedAtUtc,
@keyDate,
@encryptionScheme,
@encryptedAtUtc
)
ON DUPLICATE KEY UPDATE
id = LAST_INSERT_ID(id),
attachment_type_id = @attachmentTypeId,
description = @description,
attachment_date_utc = @attachmentDateUtc,
@@ -717,6 +808,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
WHEN complaint_attachments.content_sha256 = @contentSha256 THEN complaint_attachments.uploaded_at_utc
ELSE @uploadedAtUtc
END,
key_date = @keyDate,
encryption_scheme = @encryptionScheme,
encrypted_at_utc = @encryptedAtUtc,
updated_at_utc = CURRENT_TIMESTAMP(6);
""";
@@ -729,6 +823,10 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
{
await using var command = new MySqlCommand(sql, connection, (MySqlTransaction)transaction);
var content = fichero.Fichero ?? [];
var useChunks = content.Length > AttachmentChunkThresholdBytes;
var contentToStore = useChunks
? CreateChunkedAttachmentMarker(content.LongLength, GetChunkCount(content.Length))
: content;
var sha256 = string.IsNullOrWhiteSpace(fichero.ContentSha256)
? ComputeSha256Hex(content)
: fichero.ContentSha256.Trim().ToLowerInvariant();
@@ -738,25 +836,151 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
command.Parameters.AddWithValue("@attachmentDateUtc", ToDbDate(fichero.Fecha));
command.Parameters.AddWithValue("@notes", fichero.Observaciones ?? string.Empty);
command.Parameters.AddWithValue("@originalFileName", fichero.NombreFichero ?? string.Empty);
command.Parameters.AddWithValue("@content", content);
command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = contentToStore;
command.Parameters.AddWithValue("@contentMimeType", DetectMimeType(fichero.NombreFichero));
command.Parameters.AddWithValue("@contentSha256", sha256);
command.Parameters.AddWithValue("@uploadedToGestiona", fichero.Subido);
command.Parameters.AddWithValue("@uploadedAtUtc", ToDbDate(fichero.FechaSubida));
command.Parameters.AddWithValue("@keyDate", ToDbDate(fichero.KeyDate));
command.Parameters.AddWithValue("@encryptionScheme", fichero.EncryptionScheme ?? string.Empty);
command.Parameters.AddWithValue("@encryptedAtUtc", ToDbDate(fichero.EncryptedAtUtc));
command.Parameters.AddWithValue("@externalReportId", fichero.Id_Denuncia);
await command.ExecuteNonQueryAsync(cancellationToken);
var attachmentId = command.LastInsertedId;
if (attachmentId <= 0)
{
attachmentId = await GetAttachmentIdAsync(
connection,
(MySqlTransaction)transaction,
fichero.Id_Denuncia,
fichero.NombreFichero ?? string.Empty,
cancellationToken);
}
if (useChunks)
{
await ReplaceAttachmentChunksAsync(
connection,
(MySqlTransaction)transaction,
attachmentId,
content,
cancellationToken);
}
else
{
await DeleteAttachmentChunksAsync(
connection,
(MySqlTransaction)transaction,
attachmentId,
cancellationToken);
}
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
try
{
await transaction.RollbackAsync(cancellationToken);
}
catch (Exception rollbackException)
{
_logger.LogWarning(
rollbackException,
"No se ha podido deshacer la transaccion de adjuntos porque la conexion ya no estaba disponible.");
}
throw;
}
}
private static async Task<long> GetAttachmentIdAsync(
MySqlConnection connection,
MySqlTransaction transaction,
int externalReportId,
string originalFileName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT a.id
FROM complaint_attachments a
INNER JOIN complaints c ON c.id = a.complaint_id
WHERE c.external_report_id = @externalReportId
AND a.original_file_name = @originalFileName
LIMIT 1;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@externalReportId", externalReportId);
command.Parameters.AddWithValue("@originalFileName", originalFileName);
var result = await command.ExecuteScalarAsync(cancellationToken);
if (result is null || result is DBNull)
{
throw new InvalidOperationException(
$"No se ha encontrado el adjunto '{originalFileName}' de la denuncia #{externalReportId} tras guardarlo.");
}
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
private static async Task ReplaceAttachmentChunksAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long attachmentId,
byte[] content,
CancellationToken cancellationToken)
{
await DeleteAttachmentChunksAsync(connection, transaction, attachmentId, cancellationToken);
const string sql = """
INSERT INTO complaint_attachment_chunks (
attachment_id,
chunk_index,
content
) VALUES (
@attachmentId,
@chunkIndex,
@content
);
""";
var chunkIndex = 0;
for (var offset = 0; offset < content.Length; offset += AttachmentChunkSizeBytes)
{
var length = Math.Min(AttachmentChunkSizeBytes, content.Length - offset);
var chunk = new byte[length];
Buffer.BlockCopy(content, offset, chunk, 0, length);
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
command.Parameters.AddWithValue("@chunkIndex", chunkIndex);
command.Parameters.Add("@content", MySqlDbType.LongBlob).Value = chunk;
await command.ExecuteNonQueryAsync(cancellationToken);
chunkIndex++;
}
}
private static async Task DeleteAttachmentChunksAsync(
MySqlConnection connection,
MySqlTransaction transaction,
long attachmentId,
CancellationToken cancellationToken)
{
const string sql = """
DELETE FROM complaint_attachment_chunks
WHERE attachment_id = @attachmentId;
""";
await using var command = new MySqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task MarkFicherosAsUploadedAsync(
int denunciaId,
IEnumerable<string> fileNames,
@@ -802,6 +1026,39 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static List<int> NormalizeIds(IEnumerable<int> denunciaIds)
{
return denunciaIds
.Where(id => id > 0)
.Distinct()
.ToList();
}
private static string GetScopeWhereClause(DenunciaListScope scope)
{
return scope switch
{
DenunciaListScope.Pending => "is_in_gestiona = 0 AND is_rejected = 0 AND is_update = 0",
DenunciaListScope.Updates => "is_update = 1",
DenunciaListScope.InGestiona => "is_in_gestiona = 1",
DenunciaListScope.Rejected => "is_rejected = 1",
_ => string.Empty
};
}
private static List<string> AddIntParameters(MySqlCommand command, string prefix, IReadOnlyList<int> ids)
{
var parameterNames = new List<string>(ids.Count);
for (var i = 0; i < ids.Count; i++)
{
var parameterName = $"@{prefix}{i}";
command.Parameters.AddWithValue(parameterName, ids[i]);
parameterNames.Add(parameterName);
}
return parameterNames;
}
private async Task EnsureSchemaReadyAsync(CancellationToken cancellationToken)
{
if (SchemaEnsured)
@@ -867,6 +1124,8 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
MySqlConnection connection,
CancellationToken cancellationToken)
{
await EnsureAttachmentChunksTableAsync(connection, cancellationToken);
foreach (var (table, column, definition) in SchemaColumnsToEnsure)
{
if (!await ColumnExistsAsync(connection, table, column, cancellationToken))
@@ -899,6 +1158,15 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
}
}
private static async Task EnsureAttachmentChunksTableAsync(
MySqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = AttachmentChunksTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static string ExtractColumnName(string definition)
{
var first = definition.IndexOf('`');
@@ -1008,6 +1276,124 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return Path.Combine(AppContext.BaseDirectory, "Scripts", "gestiondenuncias_schema.sql");
}
private static async Task HydrateChunkedAttachmentsAsync(
MySqlConnection connection,
IEnumerable<FicherosDenuncias> attachments,
CancellationToken cancellationToken)
{
foreach (var attachment in attachments)
{
var content = attachment.Fichero;
if (content is null ||
!TryParseChunkedAttachmentMarker(content, out var totalLength, out var chunkCount))
{
continue;
}
attachment.Fichero = await LoadAttachmentChunksAsync(
connection,
attachment.Id_Fichero,
totalLength,
chunkCount,
cancellationToken);
}
}
private static async Task<byte[]> LoadAttachmentChunksAsync(
MySqlConnection connection,
long attachmentId,
long totalLength,
int expectedChunkCount,
CancellationToken cancellationToken)
{
if (totalLength < 0 || totalLength > int.MaxValue)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} no puede reconstruirse en memoria. Tamano declarado: {totalLength} bytes.");
}
if (expectedChunkCount <= 0)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene un marcador de chunks invalido.");
}
const string sql = """
SELECT chunk_index, content
FROM complaint_attachment_chunks
WHERE attachment_id = @attachmentId
ORDER BY chunk_index ASC;
""";
var result = new byte[(int)totalLength];
var offset = 0;
var chunksRead = 0;
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@attachmentId", attachmentId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var chunkIndex = GetInt32(reader, "chunk_index");
if (chunkIndex != chunksRead)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene chunks incompletos o desordenados.");
}
var chunk = GetBytes(reader, "content");
if (offset + chunk.Length > result.Length)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} tiene mas datos de chunks que los declarados.");
}
Buffer.BlockCopy(chunk, 0, result, offset, chunk.Length);
offset += chunk.Length;
chunksRead++;
}
if (chunksRead != expectedChunkCount || offset != result.Length)
{
throw new InvalidOperationException(
$"El adjunto {attachmentId} no tiene todos los chunks esperados.");
}
return result;
}
private static byte[] CreateChunkedAttachmentMarker(long totalLength, int chunkCount)
{
return Encoding.ASCII.GetBytes(
$"chunked:v1:{totalLength.ToString(CultureInfo.InvariantCulture)}:{chunkCount.ToString(CultureInfo.InvariantCulture)}");
}
private static bool TryParseChunkedAttachmentMarker(
byte[] content,
out long totalLength,
out int chunkCount)
{
totalLength = 0;
chunkCount = 0;
if (!content.AsSpan().StartsWith(ChunkedAttachmentPrefix))
{
return false;
}
var marker = Encoding.ASCII.GetString(content);
var parts = marker.Split(':');
return parts.Length == 4 &&
long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out totalLength) &&
int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out chunkCount);
}
private static int GetChunkCount(int length)
{
return (int)(((long)length + AttachmentChunkSizeBytes - 1) / AttachmentChunkSizeBytes);
}
private static DenunciasGestiona MapComplaint(IDataRecord record)
{
return new DenunciasGestiona
@@ -1078,6 +1464,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
FechaSubidaAGestiona = GetDateTime(record, "gestiona_uploaded_at_utc"),
EnGestiona = GetBoolean(record, "is_in_gestiona"),
EnRechazada = GetBoolean(record, "is_rejected"),
KeyDate = GetNullableDateOnly(record, "key_date"),
EncryptionScheme = GetString(record, "encryption_scheme"),
EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"),
};
}
@@ -1096,6 +1485,9 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
ContentSha256 = GetString(record, "content_sha256"),
Subido = GetBoolean(record, "uploaded_to_gestiona"),
FechaSubida = GetNullableDateTime(record, "uploaded_at_utc"),
KeyDate = GetNullableDateOnly(record, "key_date"),
EncryptionScheme = GetString(record, "encryption_scheme"),
EncryptedAtUtc = GetNullableDateTime(record, "encrypted_at_utc"),
};
}
@@ -1109,6 +1501,11 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return value is null || value == DateTime.MinValue ? DBNull.Value : value.Value;
}
private static object ToDbDate(DateOnly? value)
{
return value is null ? DBNull.Value : value.Value.ToDateTime(TimeOnly.MinValue);
}
private static object ToDbGuid(Guid value)
{
return value == Guid.Empty ? DBNull.Value : value.ToString();
@@ -1160,6 +1557,12 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
return record.IsDBNull(ordinal) ? null : record.GetDateTime(ordinal);
}
private static DateOnly? GetNullableDateOnly(IDataRecord record, string columnName)
{
var ordinal = record.GetOrdinal(columnName);
return record.IsDBNull(ordinal) ? null : DateOnly.FromDateTime(record.GetDateTime(ordinal));
}
private static Guid GetGuid(IDataRecord record, string columnName)
{
var ordinal = record.GetOrdinal(columnName);

View File

@@ -8,6 +8,7 @@ public sealed record PendingGlobalLeaksLogin(
string Username,
string Password,
string FinalPassword,
string TokenAnswer,
DateTimeOffset ExpiresAtUtc);
public sealed class PendingGlobalLeaksLoginStore
@@ -15,7 +16,7 @@ public sealed class PendingGlobalLeaksLoginStore
private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
private readonly ConcurrentDictionary<string, PendingGlobalLeaksLogin> _items = new(StringComparer.Ordinal);
public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword)
public PendingGlobalLeaksLogin Create(string username, string password, string finalPassword, string tokenAnswer)
{
CleanupExpired();
@@ -25,6 +26,7 @@ public sealed class PendingGlobalLeaksLoginStore
username,
password,
finalPassword,
tokenAnswer,
DateTimeOffset.UtcNow.Add(Lifetime));
_items[id] = pending;

View File

@@ -21,6 +21,15 @@
"EncryptionKeySecretName": "denuncias-encryption-key",
"AllowLocalEncryptionKeyFallback": true
},
"ManualPurge": {
"BaseUrl": "https://func-keymgmt-pre.azurewebsites.net/api",
"FunctionUrl": "https://func-keymgmt-pre.azurewebsites.net/api/manual_purge",
"ForceRotateUrl": "https://func-keymgmt-pre.azurewebsites.net/api/force_rotate",
"FunctionKeySecretName": "purge-function-key",
"ReplaceOnManualPurge": true,
"RecoverPartialReplaceFailure": true,
"TimeoutSeconds": 30
},
"Encryption": {
"LocalDevelopmentKey": "presentacion-pre-denuncias-encryption-key-cambiar-antes-de-produccion"
},

View File

@@ -12,7 +12,7 @@ public sealed record ApiGlobalLeaksSessionDto(
bool HasActiveSession,
DateTimeOffset? UpdatedAt);
public sealed record RenewGlobalLeaksSessionRequest(string Authcode);
public sealed record RenewGlobalLeaksSessionRequest(string Authcode, string? PendingLoginId = null);
public sealed record InboxSnapshotResponse(
IReadOnlyList<ContextDto> Contexts,
@@ -82,3 +82,15 @@ public sealed record GestionaTramitarDocumentoRequest(
string DocumentUrl,
string AssignedGroupHref,
int? ComplaintId);
public sealed record ManualPurgeRequest(string Date);
public sealed record ManualPurgeResponse(
string Date,
bool Success,
int StatusCode,
string ResponseBody);
public sealed record AppConfigurationDto(string? ExternalUpdateCutoffDate);
public sealed record UpdateExternalUpdateCutoffRequest(string? Date);

View File

@@ -0,0 +1,10 @@
namespace GestionaDenuncias.Shared.Models;
public enum DenunciaListScope
{
All = 0,
Pending = 1,
Updates = 2,
InGestiona = 3,
Rejected = 4
}

View File

@@ -81,6 +81,15 @@ public class DenunciasGestiona
public bool EnGestiona { get; set; }
public bool EnRechazada { get; set; }
[JsonIgnore]
public DateOnly? KeyDate { get; set; }
[JsonIgnore]
public string EncryptionScheme { get; set; } = string.Empty;
[JsonIgnore]
public DateTime? EncryptedAtUtc { get; set; }
[JsonIgnore]
public string NombreResuelto => ResolveValue(Nombre, "nombre");
@@ -273,10 +282,20 @@ public class DenunciasGestiona
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
{
builder.Append(char.ToLowerInvariant(ch));
if (ch is 'º' or 'ª')
{
continue;
}
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : ' ');
}
}
return builder.ToString().Normalize(NormalizationForm.FormC).Trim();
return string.Join(
' ',
builder
.ToString()
.Normalize(NormalizationForm.FormC)
.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}

View File

@@ -1,5 +1,6 @@
// Models/FicherosDenuncias.cs
using System;
using System.Text.Json.Serialization;
namespace GestionaDenuncias.Shared.Models
{
@@ -38,6 +39,15 @@ namespace GestionaDenuncias.Shared.Models
// Hash SHA-256 del contenido, para evitar re-subir adjuntos repetidos.
public string ContentSha256 { get; set; } = string.Empty;
[JsonIgnore]
public DateOnly? KeyDate { get; set; }
[JsonIgnore]
public string EncryptionScheme { get; set; } = string.Empty;
[JsonIgnore]
public DateTime? EncryptedAtUtc { get; set; }
public bool EsReport
{
get

View File

@@ -4,4 +4,5 @@ public sealed record ImportSummary(
int TotalCandidates,
int ImportedCount,
IReadOnlyList<string> Errors,
IReadOnlyList<int>? ImportedComplaintIds = null);
IReadOnlyList<int>? ImportedComplaintIds = null,
IReadOnlyList<string>? Warnings = null);

View File

@@ -26,22 +26,31 @@ public sealed class ThirdPartyIdentityData
{
ArgumentNullException.ThrowIfNull(denuncia);
var documentId = (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant();
var firstName = denuncia.NombreResuelto.Trim();
var lastName = BuildLastName(denuncia);
var businessName = denuncia.RazonSocialResuelta.Trim();
var isAnonymous = denuncia.EsAnonima ||
string.Equals(
denuncia.DocumentoResuelto?.Trim(),
documentId,
"00000000T",
StringComparison.OrdinalIgnoreCase);
var isLegalEntity = !isAnonymous &&
(denuncia.EsPersonaJuridica ||
LooksLikeLegalEntityDocument(documentId) ||
(!string.IsNullOrWhiteSpace(businessName) &&
string.IsNullOrWhiteSpace(firstName)));
return new ThirdPartyIdentityData
{
IsAnonymous = isAnonymous,
IsLegalEntity = !isAnonymous && denuncia.EsPersonaJuridica,
IsLegalEntity = isLegalEntity,
DocumentId = isAnonymous
? "00000000T"
: (denuncia.DocumentoResuelto ?? string.Empty).Trim().ToUpperInvariant(),
FirstName = isAnonymous ? "Anonimo" : denuncia.NombreResuelto.Trim(),
LastName = isAnonymous ? "-" : BuildLastName(denuncia),
BusinessName = isAnonymous ? string.Empty : denuncia.RazonSocialResuelta.Trim(),
: documentId,
FirstName = isAnonymous ? "Anonimo" : isLegalEntity ? string.Empty : firstName,
LastName = isAnonymous ? "-" : isLegalEntity ? string.Empty : lastName,
BusinessName = isAnonymous ? string.Empty : businessName,
Email = (denuncia.Correo_Electronico ?? string.Empty).Trim(),
CountryCode = string.IsNullOrWhiteSpace(denuncia.Pais) ? denuncia.PaisOrigen : denuncia.Pais,
Address = isAnonymous ? null : ThirdPartyAddressData.FromComplaint(denuncia)
@@ -60,4 +69,17 @@ public sealed class ThirdPartyIdentityData
? denuncia.ApellidosResueltos.Trim()
: separated;
}
private static bool LooksLikeLegalEntityDocument(string documentId)
{
var value = (documentId ?? string.Empty).Trim().ToUpperInvariant();
if (value.Length != 9)
{
return false;
}
const string legalEntityPrefixes = "ABCDEFGHJKLMNPQRSUVW";
return legalEntityPrefixes.Contains(value[0]) &&
value.Skip(1).Take(7).All(char.IsDigit);
}
}

View File

@@ -6,6 +6,7 @@ public interface IDenunciaStore
{
Task EnsureSchemaAsync(CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(DenunciaListScope scope, CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default);
Task<List<FicherosDenuncias>> GetFicherosByDenunciaAsync(int denunciaId, CancellationToken cancellationToken = default);
Task<DenunciasGestiona?> GetDenunciaByIdAsync(int denunciaId, CancellationToken cancellationToken = default);

View File

@@ -116,6 +116,9 @@
"buscador" => (
"Buscador de terceros",
"Localiza terceros y expedientes relacionados para validar identidades antes de tramitar."),
"configuracion" => (
"Configuracion",
"Ajusta criterios de actualizaciones externas y ejecuta operaciones tecnicas controladas."),
"instrucciones" => (
"Instrucciones",
"Referencia rapida de uso para el equipo gestor y para las operaciones mas frecuentes."),

View File

@@ -65,6 +65,14 @@
<span class="menu-link__meta">Consulta identidades y expedientes vinculados</span>
</span>
</NavLink>
<NavLink class="menu-link" href="/Configuracion" Match="NavLinkMatch.All">
<span class="menu-link__icon bi bi-gear" aria-hidden="true"></span>
<span class="menu-link__content">
<span class="menu-link__title">Configuracion</span>
<span class="menu-link__meta">Fecha de corte y purga manual</span>
</span>
</NavLink>
</div>
<div class="nav-section nav-section--footer">

View File

@@ -1,4 +1,4 @@
@page "/Actualizaciones"
@page "/Actualizaciones"
@rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenuncias.Shared.Models
@@ -58,7 +58,7 @@
margin: 1rem 0 0.5rem;
}
/* Tarjetas de actualización (azules) */
/* Tarjetas de actualizaci<EFBFBD>n (azules) */
.collapse-card.update-card {
background-color: #e3f2fd;
}
@@ -73,7 +73,7 @@
vertical-align: middle;
}
/* === Estética de modal igual que en Pendientes === */
/* === Est<EFBFBD>tica de modal igual que en Pendientes === */
.custom-modal {
background: rgba(0, 0, 0, 0.5);
@@ -119,6 +119,15 @@
<h3>Actualizaciones</h3>
@if (!string.IsNullOrWhiteSpace(loadError))
{
<div class="alert alert-danger">@loadError</div>
}
@if (!string.IsNullOrWhiteSpace(operationNotice))
{
<div class="alert alert-warning">@operationNotice</div>
}
<input type="text"
class="form-control"
placeholder="Buscar actualizaciones..."
@@ -153,7 +162,7 @@ else
data-bs-target="#@collapseId"
aria-expanded="false"
aria-controls="@collapseId">
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualización)</h5>
<h5 class="mb-0">Denuncia ID: @denuncia.Id_Denuncia (Actualizaci<EFBFBD>n)</h5>
<div class="d-flex align-items-center">
<div class="text-muted small me-3">
@@ -169,7 +178,7 @@ else
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
title="Marcar como pendiente (no actualización)"
title="Marcar como pendiente (no actualizaci<EFBFBD>n)"
@onclick:stopPropagation="true"
@onclick="() => MoverAPendientes(denuncia)">
Mover a Pendientes
@@ -195,7 +204,7 @@ else
}
@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>
}
@if (!string.IsNullOrWhiteSpace(denuncia.Etiqueta))
@@ -215,6 +224,13 @@ else
}
</dl>
@if (IsExternalGestionaUpdate(denuncia))
{
<div class="alert alert-warning" role="alert">
@GetExternalUpdateWarningText()
</div>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto) ||
!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos) ||
!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta) ||
@@ -229,7 +245,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{
<dt class="col-sm-3">Razón social</dt>
<dt class="col-sm-3">Raz<EFBFBD>n social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -239,12 +255,12 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{
<dt class="col-sm-3">1º apellido</dt>
<dt class="col-sm-3">1<EFBFBD> apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{
<dt class="col-sm-3">2º apellido</dt>
<dt class="col-sm-3">2<EFBFBD> apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -264,14 +280,14 @@ else
<dl class="row">
<dt class="col-sm-3">Asunto</dt>
<dd class="col-sm-9">@denuncia.Asunto</dd>
<dt class="col-sm-3">A Quién</dt>
<dt class="col-sm-3">A Qui<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.A_Quien_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.DenunciadoDetalle))
{
<dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</dd>
}
<dt class="col-sm-3">Descripción</dt>
<dt class="col-sm-3">Descripci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.Descripcion_Denuncia</dd>
@if (!string.IsNullOrWhiteSpace(denuncia.OrganismoDenunciado))
{
@@ -280,7 +296,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{
<dt class="col-sm-3">Solicita protección</dt>
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -297,7 +313,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{
<dt class="col-sm-3">Autoriza remisión</dt>
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
}
</dl>
@@ -308,14 +324,14 @@ else
@if (camposFormulario.Count > 0)
{
<h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section))
{
<h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row">
@foreach (var campo in grupoCampos)
{
<dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd>
}
</dl>
}
@@ -327,22 +343,44 @@ else
<table class="table table-striped">
<thead>
<tr>
<th>Subir</th>
<th>Nombre</th>
<th>Fecha</th>
<th>Motivo</th>
<th>Tamaño (bytes)</th>
<th>Tama<EFBFBD>o (bytes)</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
@foreach (var f in fAdj)
{
var isReport = IsReportFileName(f.NombreFichero);
<tr>
<td>@f.NombreFichero</td>
<td>
<input class="form-check-input"
type="checkbox"
checked="@IsFileSelectedForUpload(denuncia.Id_Denuncia, f.NombreFichero)"
disabled="@isReport"
title='@(isReport ? "El report se sube siempre" : "Incluir este fichero en la subida")'
@onchange="args => ToggleFileSelection(denuncia.Id_Denuncia, f.NombreFichero, args.Value is bool selected && selected)" />
</td>
<td>
@f.NombreFichero
@if (isReport)
{
<span class="badge bg-primary ms-2">Obligatorio</span>
}
</td>
<td>@FormatFileDate(f.Fecha)</td>
<td>
@if (f.EsReport)
{
<span class="badge bg-primary">Siempre se sube para notificar</span>
}
else if (IsExternalGestionaUpdate(denuncia))
{
<span class="badge bg-warning text-dark">@GetExternalUpdateFileReason()</span>
}
else
{
<span class="badge bg-warning text-dark">Hash nuevo pendiente</span>
@@ -352,14 +390,13 @@ else
<td>
@if (f.Fichero != null && f.Fichero.Length > 0)
{
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(f.Fichero)', '@GetContentType(f.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, f.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
}
else
{
<span class="text-muted"></span>
<span class="text-muted"><EFBFBD></span>
}
</td>
</tr>
@@ -410,19 +447,19 @@ else
{
<div class="alert alert-info d-flex align-items-center" role="alert">
<div class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></div>
Buscando expediente existente en Gestiona
Buscando expediente existente en Gestiona<EFBFBD>
</div>
}
else if (autoSearchTried && !string.IsNullOrWhiteSpace(autoFoundFileUrl))
{
<div class="alert alert-success" role="alert">
<div class="fw-semibold">Expediente detectado en Gestiona.</div>
<div>Asunto: <strong>@(autoFoundTitle ?? "(sin título)")</strong></div>
<div>Asunto: <strong>@(autoFoundTitle ?? "(sin t<EFBFBD>tulo)")</strong></div>
<div class="text-muted small">@autoFoundFileUrl</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="chkUsarDetectado" @bind="useAutoFoundExpediente" />
<label class="form-check-label" for="chkUsarDetectado">
Añadir documentos a este expediente.
A<EFBFBD>adir documentos a este expediente.
</label>
</div>
</div>
@@ -431,7 +468,7 @@ else
{
<div class="alert alert-warning" role="alert">
<div class="fw-semibold">No se ha detectado expediente en Gestiona por asunto.</div>
<div class="small">Puede que esto no sea una actualización del mismo caso.</div>
<div class="small">Puede que esto no sea una actualizaci<EFBFBD>n del mismo caso.</div>
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="MoverADenunciasPendientes">
Mover a Pendientes
@@ -440,7 +477,21 @@ else
</div>
}
<h6 class="modal-section-heading">Descripción</h6>
@if (!string.IsNullOrWhiteSpace(operationError))
{
<div class="alert alert-danger" role="alert">
@operationError
</div>
}
@if (IsExternalGestionaUpdate(selectedDenuncias))
{
<div class="alert alert-warning" role="alert">
@GetExternalUpdateWarningText()
</div>
}
<h6 class="modal-section-heading">Descripci<63>n</h6>
<div class="mb-3">
<input type="text" class="form-control" @bind="nuevoAsunto" placeholder="Ingrese el nombre de la denuncia" />
</div>
@@ -448,9 +499,9 @@ else
<h6 class="modal-section-heading">Nombre de los documentos</h6>
<div class="mb-3">
<input type="text" class="form-control" @bind="nombreDocumentos"
placeholder="Ej.: Gestión AN (Documento Adjunto 1 Gestión AN...)" />
placeholder="Ej.: Gesti<EFBFBD>n AN (Documento Adjunto 1 Gesti<EFBFBD>n AN...)" />
<small class="text-muted">
Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualización.
Se aplica al modo individual. <em>report.txt</em> se sube como <strong>Denuncia</strong> si entra en esta actualizaci<EFBFBD>n.
</small>
</div>
@@ -458,7 +509,7 @@ else
<div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoMerge"
checked='@(uploadMode == "merge")' @onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">Unir todos los ficheros en un único PDF</label>
<label class="form-check-label" for="modoMerge">Unir todos los ficheros en un <EFBFBD>nico PDF</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="uploadMode" id="modoIndividual"
@@ -471,13 +522,13 @@ else
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo600"
checked='@(selectedGroup == "600")' @onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600">
600. Asuntos Jurídicos y Protección a la Persona Denunciante
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo510"
checked='@(selectedGroup == "510")' @onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">510. SDI Investigación Entradas</label>
<label class="form-check-label" for="grupo510">510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="selectedGroup" id="grupo700"
@@ -491,13 +542,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3">
Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>.
</div>
@if (modalThirdParty.IsAnonymous)
{
<div class="alert alert-warning mb-3">
Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>.
</div>
}
@@ -526,7 +577,7 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Razón social</label>
<label class="form-label">Raz<EFBFBD>n social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div>
</div>
@@ -539,11 +590,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">1º apellido</label>
<label class="form-label">1<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">2º apellido</label>
<label class="form-label">2<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div>
</div>
@@ -553,14 +604,14 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Dirección postal</label>
<label class="form-label">Direcci<EFBFBD>n postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div>
</div>
}
<small class="text-muted">
Antes de subir la actualización se comprobará el tercero extraído del formulario y, si no está enlazado al expediente, se enlazará.
Antes de subir la actualizaci<EFBFBD>n se comprobar<EFBFBD> el tercero extra<EFBFBD>do del formulario y, si no est<EFBFBD> enlazado al expediente, se enlazar<EFBFBD>.
</small>
</div>
@@ -575,12 +626,15 @@ else
}
@code {
private const string reportTxt = "report.txt";
private bool hasLoaded = false;
private string busqueda = "";
private List<DenunciasGestiona> actualizaciones = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
private Dictionary<int, HashSet<string>> excludedUploadFiles = new();
private string loadError = string.Empty;
private string operationError = string.Empty;
private string operationNotice = string.Empty;
private DateOnly? externalUpdateCutoffDate;
// --- Modal / estado ---
private bool showModal = false;
@@ -592,7 +646,7 @@ else
private DenunciasGestiona? selectedDenuncias;
private ThirdPartyIdentityData? selectedThirdParty;
// --- Detección automática en Gestiona por asunto ---
// --- Detecci<EFBFBD>n autom<EFBFBD>tica en Gestiona por asunto ---
private bool autoSearchLoading = false;
private bool autoSearchTried = false;
private string? autoFoundFileUrl = null;
@@ -608,6 +662,11 @@ else
private async Task CargarDatosAsync()
{
try
{
loadError = string.Empty;
var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ParseConfiguredCutoffDate(config.ExternalUpdateCutoffDate);
var todas = await CargarDenunciasJsonAsync();
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
@@ -621,12 +680,14 @@ else
.OrderByDescending(d => d.FechaSubidaAGestiona != DateTime.MinValue ? d.FechaSubidaAGestiona : d.Fecha)
.ToList();
var listaF = await CargarFicherosJsonAsync();
var idsUpd = actualizaciones.Select(a => a.Id_Denuncia).ToHashSet();
ficherosAdjuntos = listaF
.Where(f => idsUpd.Contains(f.Id_Denuncia))
.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => GetPendingUpdateFiles(g.ToList()));
ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(actualizaciones);
}
catch (Exception ex)
{
actualizaciones.Clear();
ficherosAdjuntos.Clear();
loadError = $"No se han podido cargar las actualizaciones: {ex.Message}";
}
hasLoaded = true;
StateHasChanged();
@@ -634,12 +695,24 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Updates);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task<Dictionary<int, List<FicherosDenuncias>>> CargarFicherosPorDenunciaAsync(IEnumerable<DenunciasGestiona> denuncias)
{
return await DenunciaStore.GetAllFicherosAsync();
var result = new Dictionary<int, List<FicherosDenuncias>>();
foreach (var denuncia in denuncias.Where(d => d.Id_Denuncia > 0).DistinctBy(d => d.Id_Denuncia))
{
var ficheros = GetPendingUpdateFilesForComplaint(
denuncia,
await DenunciaStore.GetFicherosByDenunciaAsync(denuncia.Id_Denuncia));
if (ficheros.Count > 0)
{
result[denuncia.Id_Denuncia] = ficheros;
}
}
return result;
}
private void OpenEnviarAGestionaModal(DenunciasGestiona d)
@@ -648,6 +721,8 @@ else
nuevoAsunto = string.IsNullOrWhiteSpace(d.NombreDenuncia) ? $"Denuncia-{d.Id_Denuncia}-CD" : d.NombreDenuncia;
nombreDocumentos = "";
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
operationError = string.Empty;
operationNotice = string.Empty;
autoFoundFileUrl = null;
autoFoundTitle = null;
@@ -662,7 +737,7 @@ else
try
{
// Búsqueda automática desactivada temporalmente
// B<EFBFBD>squeda autom<EFBFBD>tica desactivada temporalmente
}
catch
{
@@ -693,6 +768,7 @@ else
selectedThirdParty = null;
nuevoAsunto = "";
nombreDocumentos = "";
operationError = string.Empty;
autoFoundFileUrl = null;
autoFoundTitle = null;
autoSearchLoading = false;
@@ -779,6 +855,8 @@ else
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
using var busy = Busy.Show(
"Enviando actualizacion",
"Preparando expediente, carpeta de actualizacion y documentos.");
@@ -787,20 +865,34 @@ else
// 1) Ficheros a subir
Busy.Update(message: "Cargando ficheros pendientes de esta actualizacion.", detail: "Paso 1 de 8");
var existentesF = await CargarFicherosJsonAsync();
var fDenuncia = GetPendingUpdateFiles(
existentesF
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
.ToList());
var existentesF = await DenunciaStore.GetFicherosByDenunciaAsync(selectedDenuncias.Id_Denuncia);
var fDenuncia = GetPendingUpdateFilesForComplaint(selectedDenuncias, existentesF);
var ficherosNoSeleccionados = GetExcludedUploadFileNames(selectedDenuncias.Id_Denuncia, fDenuncia);
var ficherosSeleccionados = GetSelectedUploadFiles(selectedDenuncias.Id_Denuncia, fDenuncia);
var todos = new List<(string FileName, byte[] Content)>();
foreach (var f in fDenuncia)
foreach (var f in ficherosSeleccionados)
{
if (f.Fichero == null) continue;
todos.Add((f.NombreFichero ?? string.Empty, f.Fichero));
}
if (!todos.Any()) return;
var ficherosVacios = todos
.Where(t => t.Content.Length == 0)
.Select(t => string.IsNullOrWhiteSpace(t.FileName) ? "(sin nombre)" : t.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
todos = todos
.Where(t => t.Content.Length > 0)
.ToList();
if (!todos.Any())
{
operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros pendientes para subir como actualizaci<63>n."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<61>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return;
}
// 2) Determinar expediente destino
string fileUrl;
@@ -844,8 +936,9 @@ else
selectedDenuncias.EnGestiona = true;
}
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 8");
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 8");
selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true;
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
@@ -954,12 +1047,9 @@ else
selectedDenuncias.Id_Denuncia);
}
var ficherosAll = await CargarFicherosJsonAsync();
foreach (var orig in nombresOriginalesSubidos)
{
var f = ficherosAll.FirstOrDefault(x =>
x.Id_Denuncia == selectedDenuncias.Id_Denuncia &&
x.NombreFichero == orig);
var f = existentesF.FirstOrDefault(x => x.NombreFichero == orig);
if (f != null)
{
f.Subido = true;
@@ -978,13 +1068,30 @@ else
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
await ActualizarDenunciaAsync(selectedDenuncias);
actualizaciones.RemoveAll(x => x.Id_Denuncia == selectedDenuncias.Id_Denuncia);
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
actualizaciones.RemoveAll(x => x.Id_Denuncia == denunciaProcesadaId);
excludedUploadFiles.Remove(denunciaProcesadaId);
CloseModal();
var avisos = new List<string>();
if (ficherosVacios.Count > 0)
{
avisos.Add($"Se omitieron ficheros vac<61>os: {string.Join(", ", ficherosVacios)}.");
}
if (ficherosNoSeleccionados.Count > 0)
{
avisos.Add($"No se subieron por selecci<63>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
}
if (avisos.Count > 0)
{
operationNotice = $"Actualizaci<63>n #{denunciaProcesadaId} completada. {string.Join(" ", avisos)}";
}
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error al confirmar envío (Actualizaciones): {ex}");
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o (Actualizaciones): {ex}");
operationError = $"No se ha podido completar la actualizaci<63>n #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
}
finally
{
@@ -993,6 +1100,71 @@ else
}
}
private bool IsFileSelectedForUpload(int denunciaId, string? fileName)
{
if (IsReportFileName(fileName))
{
return true;
}
return string.IsNullOrWhiteSpace(fileName) ||
!excludedUploadFiles.TryGetValue(denunciaId, out var excluded) ||
!excluded.Contains(fileName);
}
private void ToggleFileSelection(int denunciaId, string? fileName, bool selected)
{
if (string.IsNullOrWhiteSpace(fileName) || IsReportFileName(fileName))
{
return;
}
if (!excludedUploadFiles.TryGetValue(denunciaId, out var excluded))
{
excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
excludedUploadFiles[denunciaId] = excluded;
}
if (selected)
{
excluded.Remove(fileName);
if (excluded.Count == 0)
{
excludedUploadFiles.Remove(denunciaId);
}
}
else
{
excluded.Add(fileName);
}
}
private List<FicherosDenuncias> GetSelectedUploadFiles(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => IsReportFileName(file.NombreFichero) || IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.ToList();
}
private List<string> GetExcludedUploadFileNames(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => !IsReportFileName(file.NombreFichero) && !IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.Select(file => string.IsNullOrWhiteSpace(file.NombreFichero) ? "(sin nombre)" : file.NombreFichero)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private static string FormatFileDate(DateTime date)
=> date == DateTime.MinValue
? "-"
: date.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
private string GetContentType(string fileName)
{
return Path.GetExtension(fileName).ToLowerInvariant() switch
@@ -1014,7 +1186,14 @@ else
}
private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "" : value;
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value;
private static bool IsExternalGestionaUpdate(DenunciasGestiona denuncia)
{
return denuncia.EsActualizacion &&
denuncia.EnGestiona &&
denuncia.FechaSubidaAGestiona == DateTime.MinValue;
}
private static bool HasPostalAddress(DenunciasGestiona denuncia)
{
@@ -1073,7 +1252,16 @@ else
return string.Join(" | ", parts);
}
private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files)
private List<FicherosDenuncias> GetPendingUpdateFilesForComplaint(DenunciasGestiona denuncia, List<FicherosDenuncias> files)
{
var cutoffDate = IsExternalGestionaUpdate(denuncia)
? externalUpdateCutoffDate
: null;
return GetPendingUpdateFiles(files, cutoffDate);
}
private static List<FicherosDenuncias> GetPendingUpdateFiles(List<FicherosDenuncias> files, DateOnly? externalUpdateCutoffDate = null)
{
var uploadedHashes = files
.Where(file => file.Subido && !file.EsReport && !string.IsNullOrWhiteSpace(file.ContentSha256))
@@ -1096,6 +1284,11 @@ else
.Where(file => !file.EsReport && !file.Subido)
.OrderBy(file => file.NombreFichero, StringComparer.OrdinalIgnoreCase))
{
if (externalUpdateCutoffDate.HasValue && !IsFileAfterCutoff(file, externalUpdateCutoffDate.Value))
{
continue;
}
if (string.IsNullOrWhiteSpace(file.ContentSha256))
{
pending.Add(file);
@@ -1111,6 +1304,43 @@ else
return pending;
}
private static bool IsFileAfterCutoff(FicherosDenuncias file, DateOnly cutoffDate)
{
if (file.Fecha == DateTime.MinValue)
{
return true;
}
return DateOnly.FromDateTime(file.Fecha) > cutoffDate;
}
private static DateOnly? ParseConfiguredCutoffDate(string? value)
{
return DateOnly.TryParseExact(
value,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var parsed)
? parsed
: null;
}
private string GetExternalUpdateWarningText()
{
const string prefix = "Esta actualizacion corresponde a una denuncia que ya existia en Gestiona, pero no consta como subida desde esta app.";
return externalUpdateCutoffDate.HasValue
? $"{prefix} Como no tenemos hashes previos, solo se propondran como nuevos los adjuntos con fecha posterior a {externalUpdateCutoffDate.Value:dd/MM/yyyy}."
: $"{prefix} Configura una fecha de corte para que los adjuntos antiguos no aparezcan como nuevos.";
}
private string GetExternalUpdateFileReason()
{
return externalUpdateCutoffDate.HasValue
? $"Posterior al corte {externalUpdateCutoffDate.Value:dd/MM/yyyy}"
: "Sin historico local";
}
private string GetAssignedGroupLinkBySelectedGroup()
{
return selectedGroup switch

View File

@@ -0,0 +1,320 @@
@page "/Configuracion"
@rendermode InteractiveServer
@attribute [Authorize]
@using System.Globalization
@using GestionaDenunciasAN.Services
@inject ApiDenunciasClient ApiDenuncias
@inject UiBusyService Busy
<PageTitle>Configuracion</PageTitle>
<h3>Configuracion</h3>
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Fecha de actualizaciones externas</h5>
<div class="row g-3 align-items-start">
<div class="col-12 col-lg-4">
<label class="form-label" for="external-update-cutoff">Fecha de corte</label>
<input id="external-update-cutoff"
class="form-control"
type="text"
inputmode="numeric"
maxlength="10"
placeholder="dd/mm/aaaa"
@bind="externalUpdateCutoffDate"
@bind:event="oninput"
disabled="@isSavingConfiguration" />
<div class="form-text">Formato: dd/mm/aaaa. Ejemplo: 05/06/2026.</div>
</div>
<div class="col-12 col-lg-auto pt-lg-4">
<div class="d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-primary"
disabled="@isSavingConfiguration"
@onclick="SaveExternalUpdateCutoffDateAsync">
@if (isSavingConfiguration)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Guardando</span>
}
else
{
<span class="bi bi-floppy" aria-hidden="true"></span>
<span class="ms-1">Guardar fecha</span>
}
</button>
<button type="button"
class="btn btn-outline-secondary"
disabled="@isSavingConfiguration || string.IsNullOrWhiteSpace(externalUpdateCutoffDate)"
@onclick="ClearExternalUpdateCutoffDateAsync">
Limpiar
</button>
</div>
</div>
</div>
<div class="alert alert-info mt-3 mb-0">
La comparacion por hash se mantiene igual. Esta fecha solo se usa cuando la actualizacion pertenece a un expediente que ya estaba en Gestiona y no tenemos hashes previos en esta BBDD: en ese caso se propondran como nuevos solo los adjuntos posteriores a la fecha de corte.
</div>
@if (!string.IsNullOrWhiteSpace(configurationNotice))
{
<div class="alert alert-success mt-3 mb-0">@configurationNotice</div>
}
@if (!string.IsNullOrWhiteSpace(configurationError))
{
<div class="alert alert-danger mt-3 mb-0">@configurationError</div>
}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="mb-3">Purga manual con reemplazo</h5>
<div class="alert alert-danger">
<div class="d-flex gap-3">
<span class="bi bi-exclamation-triangle-fill fs-3" aria-hidden="true"></span>
<div>
<strong>Operacion critica e irreversible.</strong>
<div>
Esta accion llama a la Function App de claves para purgar criptograficamente la clave diaria actual y crear una clave nueva inmediatamente.
Las denuncias y adjuntos cifrados antes de la purga dejaran de poder abrirse.
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<label class="form-label">Alcance</label>
<div class="border rounded px-3 py-2 bg-light">
<span class="bi bi-key me-2" aria-hidden="true"></span>
<strong>Clave diaria actual</strong>
</div>
</div>
<div class="col-12 col-lg-7">
<label class="form-label" for="purge-confirmation">Confirmacion escrita</label>
<input id="purge-confirmation"
class="form-control"
placeholder="@RequiredConfirmation"
@bind="confirmation"
@bind:event="oninput"
disabled="@isExecutingPurge" />
<div class="form-text">
Escribe exactamente: <code>@RequiredConfirmation</code>
</div>
</div>
</div>
<div class="form-check mt-3">
<input id="purge-risk"
class="form-check-input"
type="checkbox"
@bind="acceptedRisk"
disabled="@isExecutingPurge" />
<label class="form-check-label" for="purge-risk">
Entiendo que esta purga dejara ilegibles las denuncias cifradas antes del reemplazo de clave.
</label>
</div>
<div class="d-flex justify-content-start mt-3">
<button type="button"
class="btn btn-danger"
disabled="@(!CanExecutePurge)"
@onclick="ExecutePurgeAsync">
@if (isExecutingPurge)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="ms-2">Ejecutando purga y reemplazo</span>
}
else
{
<span class="bi bi-trash" aria-hidden="true"></span>
<span class="ms-1">Ejecutar purga y reemplazo</span>
}
</button>
</div>
<div class="alert alert-warning mt-3 mb-0">
La API calculara la fecha actual de la clave diaria, obtendra la Function Key desde <code>KEYMGMT_FUNCTION_KEY</code> o desde el secreto <code>purge-function-key</code> de Key Vault y enviara la peticion a <code>manual_purge</code> con <code>replace: true</code>. Si la purga se completa pero falla la nueva clave, reintentara automaticamente con <code>force_rotate</code>.
</div>
</div>
</div>
@if (purgeResult is not null)
{
<div class="alert alert-success mt-3">
<strong>Purga con reemplazo solicitada correctamente para @purgeResult.Date.</strong>
<div>Function App respondio con codigo @purgeResult.StatusCode.</div>
@if (!string.IsNullOrWhiteSpace(purgeResult.ResponseBody))
{
<pre class="mt-2 mb-0 small">@purgeResult.ResponseBody</pre>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(purgeErrorMessage))
{
<div class="alert alert-danger mt-3">
<strong>No se ha podido ejecutar la purga.</strong>
<div>@purgeErrorMessage</div>
</div>
}
@code {
private const string RequiredConfirmation = "PURGAR CLAVE ACTUAL";
private string externalUpdateCutoffDate = string.Empty;
private string? configurationNotice;
private string? configurationError;
private bool isSavingConfiguration;
private string confirmation = string.Empty;
private bool acceptedRisk;
private bool isExecutingPurge;
private ManualPurgeResponse? purgeResult;
private string? purgeErrorMessage;
private bool CanExecutePurge =>
acceptedRisk &&
!isExecutingPurge &&
string.Equals(confirmation.Trim(), RequiredConfirmation, StringComparison.Ordinal);
protected override async Task OnInitializedAsync()
{
await LoadConfigurationAsync();
}
private async Task LoadConfigurationAsync()
{
try
{
configurationError = null;
var config = await ApiDenuncias.GetAppConfigurationAsync();
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
}
catch (Exception ex)
{
configurationError = $"No se ha podido cargar la configuracion: {ex.Message}";
}
}
private async Task SaveExternalUpdateCutoffDateAsync()
{
try
{
isSavingConfiguration = true;
configurationNotice = null;
configurationError = null;
var date = ToIsoDateText(externalUpdateCutoffDate);
var config = await ApiDenuncias.UpdateExternalUpdateCutoffDateAsync(date);
externalUpdateCutoffDate = ToSpanishDateText(config.ExternalUpdateCutoffDate);
configurationNotice = string.IsNullOrWhiteSpace(externalUpdateCutoffDate)
? "Fecha de corte eliminada."
: $"Fecha de corte guardada: {externalUpdateCutoffDate}.";
}
catch (Exception ex)
{
configurationError = $"No se ha podido guardar la fecha de corte: {ex.Message}";
}
finally
{
isSavingConfiguration = false;
}
}
private async Task ClearExternalUpdateCutoffDateAsync()
{
externalUpdateCutoffDate = string.Empty;
await SaveExternalUpdateCutoffDateAsync();
}
private static string? ToIsoDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (DateOnly.TryParseExact(
trimmed,
"dd/MM/yyyy",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var spanishDate))
{
return spanishDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
if (DateOnly.TryParseExact(
trimmed,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var isoDate))
{
return isoDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
throw new InvalidOperationException("La fecha de corte debe tener formato dd/mm/aaaa. Ejemplo: 05/06/2026.");
}
private static string ToSpanishDateText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return DateOnly.TryParseExact(
value.Trim(),
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date)
? date.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)
: value.Trim();
}
private async Task ExecutePurgeAsync()
{
if (!CanExecutePurge)
{
return;
}
isExecutingPurge = true;
purgeResult = null;
purgeErrorMessage = null;
using var busy = Busy.Show(
"Ejecutando purga manual",
"Solicitando la purga criptografica y la creacion inmediata de una nueva clave diaria.");
try
{
purgeResult = await ApiDenuncias.ExecuteCurrentManualPurgeAsync();
acceptedRisk = false;
confirmation = string.Empty;
}
catch (Exception ex)
{
purgeErrorMessage = ex.Message;
}
finally
{
isExecutingPurge = false;
}
}
}

View File

@@ -120,11 +120,12 @@
@bind="RenewAuthcode"
maxlength="6"
inputmode="numeric"
disabled="@(!RenewPrepared || RenewBusy)"
placeholder="123456" />
</div>
<button type="button" class="btn btn-primary w-100" @onclick="RenewSessionAsync" disabled="@RenewBusy">
@(RenewBusy ? "Renovando..." : SessionRenewButtonText)
@(RenewBusy ? SessionRenewBusyText : SessionRenewButtonText)
</button>
</div>
</div>
@@ -427,6 +428,7 @@
private InboxUserState UserInboxState = new();
private string CurrentUsername { get; set; } = string.Empty;
private string RenewAuthcode { get; set; } = string.Empty;
private string RenewPendingLoginId { get; set; } = string.Empty;
private string Filter { get; set; } = "all";
private string DateScope { get; set; } = "all";
private string DateFrom { get; set; } = string.Empty;
@@ -445,8 +447,10 @@
private string DetailError { get; set; } = string.Empty;
private ReportDto? DetailReport { get; set; }
private ReportDetailDto? DetailData { get; set; }
private readonly Dictionary<string, ReportDetailDto> DetailCache = new(StringComparer.OrdinalIgnoreCase);
private bool CanUseGlobalLeaks => SessionInfo?.HasActiveSession == true;
private bool RenewPrepared => !string.IsNullOrWhiteSpace(RenewPendingLoginId);
private int SelectedReportsCount => SelectedIds.Count;
private bool CanImportSelected => CanUseGlobalLeaks && SelectedReportsCount > 0 && !ImportBusy;
private string SessionStatusText => SessionInfo is null
@@ -454,9 +458,12 @@
: SessionInfo.HasActiveSession
? "Activa"
: "Pendiente de renovacion 2FA";
private string SessionRenewButtonText => SessionInfo?.HasActiveSession == true
? "Renovar sesion"
: "Activar sesion";
private string SessionRenewButtonText => RenewPrepared
? "Validar 2FA y activar"
: "Preparar renovacion";
private string SessionRenewBusyText => RenewPrepared
? "Validando 2FA..."
: "Preparando...";
private string SelectAllLabel => VisibleReports.Count > 0 && VisibleReports.All(report => SelectedIds.Contains(report.Id))
? "Deseleccionar todas"
: "Seleccionar todas";
@@ -485,6 +492,12 @@
SessionInfo = string.IsNullOrWhiteSpace(CurrentUsername)
? null
: await ApiDenuncias.GetGlobalLeaksSessionAsync();
if (CanUseGlobalLeaks)
{
RenewPendingLoginId = string.Empty;
RenewAuthcode = string.Empty;
}
}
private async Task RenewSessionAsync()
@@ -495,6 +508,12 @@
return;
}
if (!RenewPrepared)
{
await PrepareRenewSessionAsync();
return;
}
if (string.IsNullOrWhiteSpace(RenewAuthcode) || RenewAuthcode.Trim().Length != 6)
{
SetStatus("Introduce un codigo 2FA valido de 6 digitos.", "alert-warning");
@@ -505,11 +524,15 @@
try
{
using var busy = Busy.Show(
"Renovando sesion GlobalLeaks",
"Validando el nuevo codigo 2FA para continuar descargando denuncias.");
"Validando 2FA",
"Completando la renovacion con el codigo actual de GlobalLeaks.");
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(RenewAuthcode.Trim(), CancellationToken.None);
SessionInfo = await ApiDenuncias.RenewGlobalLeaksSessionAsync(
RenewAuthcode.Trim(),
RenewPendingLoginId,
CancellationToken.None);
RenewAuthcode = string.Empty;
RenewPendingLoginId = string.Empty;
Busy.Update(message: "Sesion renovada. Actualizando la bandeja de entrada.");
await LoadReportsAsync();
SetStatus("Sesion de GlobalLeaks renovada correctamente.", "alert-success");
@@ -524,6 +547,30 @@
}
}
private async Task PrepareRenewSessionAsync()
{
RenewBusy = true;
try
{
using var busy = Busy.Show(
"Preparando renovacion",
"Validando credenciales guardadas y preparando GlobalLeaks antes de pedir el codigo 2FA.");
var prepared = await ApiDenuncias.PrepareGlobalLeaksSessionRenewalAsync(CancellationToken.None);
RenewPendingLoginId = prepared.PendingLoginId;
RenewAuthcode = string.Empty;
SetStatus("Renovacion preparada. Introduce ahora el codigo 2FA que este activo.", "alert-info");
}
catch (Exception ex)
{
SetStatus(ex.Message, "alert-danger");
}
finally
{
RenewBusy = false;
}
}
private async Task LoadReportsAsync()
{
if (!CanUseGlobalLeaks)
@@ -545,6 +592,7 @@
Reports.Clear();
Reports.AddRange(inbox.Reports);
DetailCache.Clear();
UserInboxState = inbox.UserState;
ApplyFilters();
}
@@ -577,6 +625,7 @@
ImportBusy = true;
var importedCount = 0;
var errors = new List<string>();
var importWarnings = new List<string>();
try
{
@@ -605,6 +654,10 @@
var result = await ApiDenuncias.ImportReportAsync(report, CancellationToken.None);
importedCount += result.ImportedCount;
errors.AddRange(result.Errors.Select(error => $"#{report.Progressive ?? 0}: {error}"));
if (result.Warnings is not null)
{
importWarnings.AddRange(result.Warnings.Select(warning => $"#{report.Progressive ?? 0}: {warning}"));
}
}
catch (UnauthorizedAccessException ex)
{
@@ -626,6 +679,7 @@
.Select(report => $"#{report.Progressive ?? 0}: {report.TrackingNote}")
.Where(message => !string.IsNullOrWhiteSpace(message))
.ToList();
warnings.AddRange(importWarnings);
if (errors.Count == 0 && warnings.Count == 0)
{
@@ -678,7 +732,13 @@
"Leyendo detalle de denuncia",
"Consultando mensajes y ficheros para identificar que contenido es nuevo.");
DetailData = await ApiDenuncias.GetReportDetailAsync(report.Id, CancellationToken.None);
if (!DetailCache.TryGetValue(report.Id, out var cachedDetail))
{
cachedDetail = await ApiDenuncias.GetReportDetailAsync(report.Id, report.LastAccess, CancellationToken.None);
DetailCache[report.Id] = cachedDetail;
}
DetailData = cachedDetail;
}
catch (UnauthorizedAccessException ex)
{

View File

@@ -9,7 +9,6 @@
@inject NavigationManager Navigation
@inject IHostEnvironment HostEnvironment
@inject IDenunciaStore DenunciaStore
@inject ApiDenunciasClient ApiDenuncias
<PageTitle>Denuncias Gesti<74>n</PageTitle>
@@ -269,8 +268,7 @@ else
<td>@fichero.NombreFichero</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -319,7 +317,7 @@ else
if (firstRender)
{
await CargarGestionaAsync();
await CargarFicherosAdjuntosAsync();
await CargarFicherosAdjuntosAsync(denunciasGestiona.Select(d => d.Id_Denuncia));
hasLoaded = true;
StateHasChanged();
}
@@ -331,28 +329,31 @@ else
denunciasGestiona = todas
.Where(d => d.EnGestiona)
.ToList();
await SincronizarExpedientesGestionaAsync();
}
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.InGestiona);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
}
private async Task CargarFicherosAdjuntosAsync()
ficherosAdjuntos.Clear();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var listaFicheros = await CargarFicherosJsonAsync();
ficherosAdjuntos = listaFicheros.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
ficherosAdjuntos[denunciaId] = ficheros;
}
}
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
@@ -367,48 +368,5 @@ else
};
}
private async Task SincronizarExpedientesGestionaAsync()
{
foreach (var denuncia in denunciasGestiona.Where(d =>
string.IsNullOrWhiteSpace(d.CodigoExpedienteGestiona) &&
!string.IsNullOrWhiteSpace(d.Expediente_Gestiona) &&
!string.Equals(d.Expediente_Gestiona, "Pendiente", StringComparison.OrdinalIgnoreCase)))
{
try
{
var expediente = await ApiDenuncias.GetGestionaExpedienteAsync(denuncia.Expediente_Gestiona);
if (expediente is null)
{
continue;
}
var changed = false;
denuncia.Expediente_Gestiona = expediente.FileUrl;
if (!string.IsNullOrWhiteSpace(expediente.CodigoExpediente) &&
!string.Equals(denuncia.CodigoExpedienteGestiona, expediente.CodigoExpediente, StringComparison.Ordinal))
{
denuncia.CodigoExpedienteGestiona = expediente.CodigoExpediente;
changed = true;
}
if (!string.IsNullOrWhiteSpace(expediente.FreeTitle) &&
!string.Equals(denuncia.NombreDenuncia, expediente.FreeTitle, StringComparison.Ordinal))
{
denuncia.NombreDenuncia = expediente.FreeTitle;
changed = true;
}
if (changed)
{
await DenunciaStore.UpsertDenunciaAsync(denuncia);
}
}
catch
{
// No bloqueamos la pantalla si Gestiona no devuelve metadatos.
}
}
}
}

View File

@@ -1,122 +1,168 @@
@page "/Instrucciones"
@attribute [Authorize]
@attribute [StreamRendering]
@inject GestionaDenunciasAN.Models.UserState userState
@inject NavigationManager Navigation
<PageTitle>Instrucciones</PageTitle>
<div class="container mt-4">
<h1 class="mb-4">Gu<EFBFBD>a de Uso <20> Gesti<74>n de Denuncias</h1>
<div class="container-fluid px-0">
<div class="mb-4">
<h1 class="mb-2">Instrucciones</h1>
<p class="text-muted mb-0">
Guia practica para usar la aplicacion en el trabajo diario con denuncias de GlobalLeaks y expedientes de Gestiona.
</p>
</div>
<div class="card mb-3">
<div class="card-body">
<h2 class="h5">Flujo habitual</h2>
<ol class="mb-0">
<li>Entrar en <strong>Entrada</strong> y renovar el codigo 2FA si la sesion de GlobalLeaks lo pide.</li>
<li>Actualizar la bandeja y revisar las denuncias disponibles.</li>
<li>Importar las denuncias que se vayan a gestionar.</li>
<li>Tramitar las denuncias nuevas desde <strong>Pendientes</strong>.</li>
<li>Tramitar las ampliaciones desde <strong>Actualizaciones</strong>.</li>
<li>Consultar lo enviado en <strong>Gestiona</strong> o lo descartado en <strong>Rechazados</strong>.</li>
</ol>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Entrada</h2>
<p>
Esta aplicaci<63>n permite procesar denuncias desde archivos ZIP y gestionarlas en tres etapas:
<strong>Pendientes</strong>, <strong>Gesti<74>n</strong> (aceptadas) y <strong>Rechazadas</strong>.
Esta pantalla sirve para revisar la bandeja de GlobalLeaks e importar denuncias a la aplicacion.
</p>
<ul class="mb-0">
<li>Si aparece la sesion caducada, pulsa la opcion de renovacion e introduce el codigo 2FA activo.</li>
<li>Pulsa <strong>Actualizar denuncias</strong> para cargar la bandeja.</li>
<li>Usa el filtro para ver todo el buzon, solo lo posterior a tu ultima descarga o un intervalo de fechas.</li>
<li>Pulsa <strong>Ver detalle</strong> cuando necesites revisar mensajes o adjuntos antes de importar.</li>
<li>Marca una o varias denuncias y pulsa <strong>Importar seleccionadas</strong>.</li>
</ul>
</div>
</div>
</div>
<h2>1. Carga de ZIPs</h2>
<ul>
<li>
Sit<EFBFBD>ate en la pesta<74>a <strong>Gesti<74>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.
</li>
<li>
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<69>genes.
</li>
<li>
Tras el procesado, la app lee los <code>report.txt</code> y actualiza la base de datos:
- El listado de <strong>Pendientes</strong>.
- El registro de denuncias.
- El registro de ficheros adjuntos.
</li>
</ul>
<h2>2. Pesta<74>a <strong>Pendientes</strong></h2>
<ul>
<li>
Ver<65>s cada denuncia en una tarjeta colapsable con sus datos y el listado de ficheros adjuntos.
</li>
<li>
Hay dos acciones:
<ul>
<li>
<strong>Configurar subida</strong> (verde): abre un modal donde puedes:
<ol>
<li>Poner un nombre descriptivo.</li>
<li>
Elegir el modo de subida:
<ul>
<li><em>Unir</em> todos los ficheros en un <20>nico PDF.</li>
<li><em>Subir</em> cada fichero de forma independiente.</li>
</ul>
</li>
<li>Seleccionar el grupo de destino (600, 510 o 700).</li>
<li>
Confirmar. La denuncia se crea y abre en Gesti<74>na, sube los documentos
y pasa a la pesta<74>a <strong>Gesti<74>n</strong>.
</li>
</ol>
</li>
<li>
<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<74>a
<strong>Rechazados</strong>.
</li>
</ul>
</li>
</ul>
<h2>3. Pesta<74>a <strong>Gesti<74>n</strong></h2>
<ul>
<li>
Aqu<71> se listan las denuncias que ya han sido <em>enviadas a Gesti<74>n</em>.
Aparecen con fondo verde.
</li>
<li>
Cada tarjeta muestra:
<ul>
<li>ID, nombre, archivo subido</li>
<li>Fecha y hora de subida</li>
<li>Detalles completos y enlaces <20>Ver<65> a los PDFs/im<69>genes</li>
</ul>
</li>
</ul>
<h2>4. Pesta<74>a <strong>Rechazadas</strong></h2>
<ul>
<li>
Aqu<71> ver<65>s todas las denuncias que han sido rechazadas. Fondo rojo.
</li>
<li>
Cada tarjeta muestra el motivo de rechazo y la fecha/hora en que se marc<72>.
</li>
</ul>
<h2>5. Flujo completo</h2>
<ol>
<li>Subes uno o varios ZIP en la pesta<74>a <strong>Gesti<74>n de ZIP</strong>.</li>
<li>La aplicaci<63>n extrae y parsea informes, los a<>ade a <strong>Pendientes</strong>.</li>
<li>
En <strong>Pendientes</strong> eliges qu<71> hacer con cada denuncia:
<ul>
<li><strong>Configurar subida</strong> ? pasa a <strong>Gesti<74>n</strong>.</li>
<li><strong>Rechazar denuncia</strong> ? pasa a <strong>Rechazadas</strong>.</li>
</ul>
</li>
<li>
En <strong>Gesti<74>n</strong> puedes revisar lo ya subido; en
<strong>Rechazadas</strong> ves los motivos.
</li>
</ol>
<p class="mt-4">
Con este flujo tienes control total sobre:
<strong>nombre</strong>, <strong>modo de subida</strong>, <strong>grupo destino</strong> y
<strong>estado final</strong> de cada denuncia.
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Pendientes</h2>
<p>
Aqui aparecen las denuncias nuevas que todavia no se han enviado a Gestiona.
</p>
<ul class="mb-0">
<li>Abre la denuncia para revisar sus datos y adjuntos.</li>
<li>En la lista de ficheros, deja marcado solo lo que quieras subir.</li>
<li>El report de la denuncia se sube siempre y no se puede desmarcar.</li>
<li>Pulsa <strong>Configurar subida</strong> para indicar asunto, grupo destino y modo de subida.</li>
<li>Confirma para crear el expediente, vincular el tercero y subir los documentos a Gestiona.</li>
<li>Si la denuncia no procede, usa <strong>Rechazar denuncia</strong> e indica el motivo.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Modal de subida a Gestiona</h2>
<p>
Antes de confirmar la subida, revisa estos puntos:
</p>
<ul class="mb-0">
<li><strong>Asunto</strong>: texto que identificara el expediente/documentos en Gestiona.</li>
<li><strong>Grupo destino</strong>: unidad a la que se asignara el expediente.</li>
<li><strong>Modo de subida</strong>: puedes unir adjuntos en un PDF o subirlos de forma independiente.</li>
<li><strong>Tercero</strong>: la app lo rellena desde la denuncia. Si es anonima, se usa el tercero anonimo configurado.</li>
<li><strong>Expedientes del tercero</strong>: puedes consultarlos antes de confirmar si necesitas contexto.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Actualizaciones</h2>
<p>
Esta pantalla recoge comunicaciones o adjuntos nuevos sobre denuncias que ya tienen expediente en Gestiona.
</p>
<ul class="mb-0">
<li>Revisa la actualizacion y sus ficheros.</li>
<li>La app propone los adjuntos que parecen nuevos.</li>
<li>Puedes desmarcar los adjuntos que no quieras subir.</li>
<li>El report de la actualizacion se mantiene obligatorio.</li>
<li>Confirma la subida para a<>adir los nuevos documentos al expediente existente.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Gestiona</h2>
<p>
Aqui se consultan las denuncias que ya se han enviado a Gestiona.
</p>
<ul class="mb-0">
<li>Comprueba el numero de expediente y la fecha de envio.</li>
<li>Accede al enlace del expediente cuando necesites revisar la tramitacion en Gestiona.</li>
<li>Usa esta pantalla como seguimiento de lo que ya salio de Pendientes o Actualizaciones.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Rechazados</h2>
<p>
Aqui quedan las denuncias que se han descartado desde Pendientes.
</p>
<ul class="mb-0">
<li>Consulta el motivo indicado al rechazar.</li>
<li>Usa este listado para revisar descartes y trazabilidad interna.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Buscador de terceros</h2>
<p>
Permite consultar informacion de terceros y expedientes vinculados.
</p>
<ul class="mb-0">
<li>Introduce el documento o dato disponible.</li>
<li>Revisa los resultados antes de tomar decisiones sobre una nueva denuncia o actualizacion.</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<h2 class="h5">Configuracion</h2>
<p>
Esta pantalla contiene ajustes compartidos para todos los usuarios.
</p>
<ul class="mb-0">
<li><strong>Fecha de corte</strong>: escribela en formato <code>dd/mm/aaaa</code>. Se usa para actualizaciones de expedientes que no nacieron en esta app.</li>
<li><strong>Purga manual</strong>: accion critica. Usala solo cuando se indique expresamente.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-warning mt-3 mb-0">
Si una pantalla indica que una denuncia no esta disponible, no intentes tramitarla desde otra ruta: revisa la bandeja de Entrada o consulta con soporte del sistema.
</div>
</div>
@code {
}

View File

@@ -1,4 +1,4 @@
@page "/Pendientes"
@page "/Pendientes"
@rendermode InteractiveServer
@attribute [Authorize]
@@ -160,6 +160,10 @@
{
<div class="alert alert-danger">@loadError</div>
}
@if (!string.IsNullOrWhiteSpace(operationNotice))
{
<div class="alert alert-warning">@operationNotice</div>
}
<input type="text"
class="form-control"
@@ -243,7 +247,7 @@ else
}
@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>
}
@if (denuncia.Id_Persona_Gestiona != 0)
@@ -283,7 +287,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.RazonSocialResuelta))
{
<dt class="col-sm-3">Razón social</dt>
<dt class="col-sm-3">Raz<EFBFBD>n social</dt>
<dd class="col-sm-9">@denuncia.RazonSocialResuelta</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.NombreResuelto))
@@ -293,12 +297,12 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PrimerApellidoResuelto))
{
<dt class="col-sm-3">1º Apellido</dt>
<dt class="col-sm-3">1<EFBFBD> Apellido</dt>
<dd class="col-sm-9">@denuncia.PrimerApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SegundoApellidoResuelto))
{
<dt class="col-sm-3">2º Apellido</dt>
<dt class="col-sm-3">2<EFBFBD> Apellido</dt>
<dd class="col-sm-9">@denuncia.SegundoApellidoResuelto</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.ApellidosResueltos))
@@ -318,7 +322,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.PaisOrigen))
{
<dt class="col-sm-3">País de origen</dt>
<dt class="col-sm-3">Pa<EFBFBD>s de origen</dt>
<dd class="col-sm-9">@denuncia.PaisOrigen</dd>
}
</dl>
@@ -335,7 +339,7 @@ else
<dt class="col-sm-3">Detalle denunciado</dt>
<dd class="col-sm-9">@denuncia.DenunciadoDetalle</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>
<dt class="col-sm-3">Denunciado Ante Inst</dt>
<dd class="col-sm-9">@denuncia.Denunciado_Ante_Inst</dd>
@@ -346,7 +350,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.SolicitaProteccion))
{
<dt class="col-sm-3">Solicita protección</dt>
<dt class="col-sm-3">Solicita protecci<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.SolicitaProteccion</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.MedidasProteccionSolicitadas))
@@ -356,7 +360,7 @@ else
}
@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>
}
<dt class="col-sm-3">Lugar Hechos</dt>
@@ -368,7 +372,7 @@ else
}
@if (!string.IsNullOrWhiteSpace(denuncia.AutorizaRemision))
{
<dt class="col-sm-3">Autoriza remisión</dt>
<dt class="col-sm-3">Autoriza remisi<EFBFBD>n</dt>
<dd class="col-sm-9">@denuncia.AutorizaRemision</dd>
}
@if (!string.IsNullOrWhiteSpace(denuncia.PreferenciaRemision))
@@ -378,16 +382,16 @@ else
}
</dl>
<h5 class="section-heading">Datos de Notificación</h5>
<h5 class="section-heading">Datos de Notificaci<EFBFBD>n</h5>
<dl class="row">
@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>
}
@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>
}
@if (!string.IsNullOrWhiteSpace(denuncia.SeguimientoOnline))
@@ -402,17 +406,17 @@ else
}
@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>
}
@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>
}
@if (HasPostalAddress(denuncia))
{
<dt class="col-sm-3">Dirección postal</dt>
<dt class="col-sm-3">Direcci<EFBFBD>n postal</dt>
<dd class="col-sm-9">@BuildPostalAddressSummary(denuncia)</dd>
}
</dl>
@@ -422,7 +426,7 @@ else
@if (denuncia.Condiciones)
{
<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))
{
@@ -437,14 +441,14 @@ else
@if (camposFormulario.Count > 0)
{
<h5 class="section-heading">Formulario Original</h5>
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin sección" : field.Section))
@foreach (var grupoCampos in camposFormulario.GroupBy(field => string.IsNullOrWhiteSpace(field.Section) ? "Sin secci<EFBFBD>n" : field.Section))
{
<h6 class="mt-3">@grupoCampos.Key</h6>
<dl class="row">
@foreach (var campo in grupoCampos)
{
<dt class="col-sm-4">@campo.Label</dt>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "" : campo.Value)</dd>
<dd class="col-sm-8">@(string.IsNullOrWhiteSpace(campo.Value) ? "<EFBFBD>" : campo.Value)</dd>
}
</dl>
}
@@ -456,34 +460,37 @@ else
<table class="table table-striped">
<thead>
<tr>
<th class="seleccionar-col">Seleccionar</th>
<th class="seleccionar-col">Subir</th>
<th>Nombre</th>
<th>Tamaño (bytes)</th>
<th>Fecha</th>
<th>Tama<6D>o (bytes)</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
@foreach (var fichero in ficherosAdjuntos[denuncia.Id_Denuncia])
{
var isReport = IsReportFileName(fichero.NombreFichero);
<tr>
<td class="seleccionar-col">
<button class="preselect-btn @(preselectedFicheros.ContainsKey(denuncia.Id_Denuncia) && preselectedFicheros[denuncia.Id_Denuncia] == fichero.NombreFichero ? "selected" : "")"
@onclick="() => PreselectFichero(denuncia.Id_Denuncia, fichero.NombreFichero)">
@if (preselectedFicheros.ContainsKey(denuncia.Id_Denuncia) && preselectedFicheros[denuncia.Id_Denuncia] == fichero.NombreFichero)
{
<i class="bi bi-check-circle-fill"></i>
}
else
{
<i class="bi bi-circle"></i>
}
</button>
<input class="form-check-input"
type="checkbox"
checked="@IsFileSelectedForUpload(denuncia.Id_Denuncia, fichero.NombreFichero)"
disabled="@isReport"
title='@(isReport ? "El report se sube siempre" : "Incluir este fichero en la subida")'
@onchange="args => ToggleFileSelection(denuncia.Id_Denuncia, fichero.NombreFichero, args.Value is bool selected && selected)" />
</td>
<td>@fichero.NombreFichero</td>
<td>
@fichero.NombreFichero
@if (isReport)
{
<span class="badge bg-primary ms-2">Obligatorio</span>
}
</td>
<td>@FormatFileDate(fichero.Fecha)</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -527,7 +534,14 @@ else
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<div class="modal-body custom-modal-body">
<h6 class="modal-section-heading">Descripción</h6>
@if (!string.IsNullOrWhiteSpace(operationError))
{
<div class="alert alert-danger" role="alert">
@operationError
</div>
}
<h6 class="modal-section-heading">Descripci<63>n</h6>
<div class="mb-3">
<input type="text"
class="form-control"
@@ -540,9 +554,9 @@ else
<input type="text"
class="form-control"
@bind="nombreDocumentos"
placeholder="Ej.: Gestión AN (lo verás como: Documento Adjunto 1 Gestión AN, ...)" />
placeholder="Ej.: Gesti<EFBFBD>n AN (lo ver<EFBFBD>s como: Documento Adjunto 1 Gesti<EFBFBD>n AN, ...)" />
<small class="text-muted">
Se aplicará al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subirá como <strong>Denuncia</strong>.
Se aplicar<EFBFBD> al subir en modo <strong>individual</strong>. El <em>report.txt</em> se subir<EFBFBD> como <strong>Denuncia</strong>.
</small>
</div>
@@ -555,7 +569,7 @@ else
checked='@(uploadMode == "merge")'
@onclick='() => uploadMode = "merge"' />
<label class="form-check-label" for="modoMerge">
Unir todos los ficheros en un único PDF
Unir todos los ficheros en un <EFBFBD>nico PDF
</label>
</div>
<div class="form-check">
@@ -579,7 +593,7 @@ else
checked='@(selectedGroup == "600")'
@onclick='() => selectedGroup = "600"' />
<label class="form-check-label" for="grupo600">
600. Asuntos Jurídicos y Protección a la Persona Denunciante
600. Asuntos Jur<EFBFBD>dicos y Protecci<EFBFBD>n a la Persona Denunciante
</label>
</div>
@* <div class="form-check">
@@ -590,7 +604,7 @@ else
checked='@(selectedGroup == "510")'
@onclick='() => selectedGroup = "510"' />
<label class="form-check-label" for="grupo510">
510. SDI Investigación Entradas
510. SDI <EFBFBD> Investigaci<EFBFBD>n Entradas
</label>
</div>
<div class="form-check">
@@ -612,13 +626,13 @@ else
<h6 class="modal-section-heading mt-3">Tercero (denunciante)</h6>
<div class="alert alert-light border mb-3">
Los datos del tercero se cargan automáticamente desde la denuncia y no se pueden editar aquí.
Los datos del tercero se cargan autom<EFBFBD>ticamente desde la denuncia y no se pueden editar aqu<EFBFBD>.
</div>
@if (modalThirdParty.IsAnonymous)
{
<div class="alert alert-warning mb-3">
Denuncia anónima. Se enlazará automáticamente el tercero <strong>00000000T</strong>.
Denuncia an<EFBFBD>nima. Se enlazar<EFBFBD> autom<EFBFBD>ticamente el tercero <strong>00000000T</strong>.
</div>
}
@@ -647,7 +661,7 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Razón social</label>
<label class="form-label">Raz<EFBFBD>n social</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.RazonSocialResuelta)" readonly />
</div>
</div>
@@ -660,11 +674,11 @@ else
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.NombreResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">1º apellido</label>
<label class="form-label">1<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.PrimerApellidoResuelto)" readonly />
</div>
<div class="col-4 mb-2">
<label class="form-label">2º apellido</label>
<label class="form-label">2<EFBFBD> apellido</label>
<input class="form-control" value="@GetReadOnlyValue(selectedDenuncias.SegundoApellidoResuelto)" readonly />
</div>
</div>
@@ -674,14 +688,14 @@ else
{
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label">Dirección postal</label>
<label class="form-label">Direcci<EFBFBD>n postal</label>
<textarea class="form-control" rows="2" readonly>@BuildPostalAddressSummary(selectedDenuncias)</textarea>
</div>
</div>
}
<small class="text-muted">
Al confirmar, se enlazará en Gestiona el tercero obtenido del formulario de la denuncia.
Al confirmar, se enlazar<EFBFBD> en Gestiona el tercero obtenido del formulario de la denuncia.
</small>
@if (!modalThirdParty.IsAnonymous && !string.IsNullOrWhiteSpace(modalThirdParty.DocumentId))
@@ -754,7 +768,7 @@ else
{
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Cargando expedientes</span>
<span>Cargando expedientes<EFBFBD></span>
</div>
}
else if (!string.IsNullOrWhiteSpace(errorExpedientes))
@@ -774,7 +788,7 @@ else
<tr>
<th>Expediente</th>
<th>Asunto</th>
<th>Fecha creación</th>
<th>Fecha creaci<EFBFBD>n</th>
<th>Estado</th>
<th></th>
</tr>
@@ -816,22 +830,22 @@ else
private string uploadMode = "merge";
private string selectedGroup = "600";
private const string reportTxt = "report.txt";
private string busqueda = "";
private List<DenunciasGestiona> pendientes = new();
private Dictionary<int, List<FicherosDenuncias>> ficherosAdjuntos = new();
private Dictionary<int, string> preselectedFicheros = new();
private Dictionary<int, HashSet<string>> excludedUploadFiles = new();
private bool hasLoaded = false;
private string loadError = string.Empty;
private string operationError = string.Empty;
private string operationNotice = string.Empty;
private bool showModal = false;
private bool showModalRechazo = false;
private DenunciasGestiona? selectedDenuncias;
private ThirdPartyIdentityData? selectedThirdParty;
private string nuevoAsunto = string.Empty;
private string archivoSeleccionado = reportTxt;
private string motivoRechazo = string.Empty;
private string terceroDni = "";
@@ -870,12 +884,7 @@ else
.ToList();
// Adjuntos SOLO de las pendientes visibles
var listaF = await CargarFicherosJsonAsync();
var idsPend = pendientes.Select(p => p.Id_Denuncia).ToHashSet();
ficherosAdjuntos = listaF
.Where(f => idsPend.Contains(f.Id_Denuncia))
.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
ficherosAdjuntos = await CargarFicherosPorDenunciaAsync(pendientes.Select(p => p.Id_Denuncia));
}
catch (Exception ex)
{
@@ -890,12 +899,22 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Pending);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task<Dictionary<int, List<FicherosDenuncias>>> CargarFicherosPorDenunciaAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
var result = new Dictionary<int, List<FicherosDenuncias>>();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
result[denunciaId] = ficheros;
}
}
return result;
}
private static bool IsReportFileName(string? fileName)
@@ -940,6 +959,8 @@ else
try
{
isUploading = true;
operationError = string.Empty;
operationNotice = string.Empty;
using var busy = Busy.Show(
"Enviando a Gestiona",
"Preparando expediente, tercero y documentos de la denuncia.");
@@ -947,14 +968,29 @@ else
await Task.Yield();
Busy.Update(message: "Cargando ficheros de la denuncia.", detail: "Paso 1 de 7");
var existentesF = await CargarFicherosJsonAsync();
var todos = existentesF
.Where(f => f.Id_Denuncia == selectedDenuncias.Id_Denuncia)
var existentesF = await DenunciaStore.GetFicherosByDenunciaAsync(selectedDenuncias.Id_Denuncia);
var ficherosNoSeleccionados = GetExcludedUploadFileNames(selectedDenuncias.Id_Denuncia, existentesF);
var ficherosSeleccionados = GetSelectedUploadFiles(selectedDenuncias.Id_Denuncia, existentesF);
var todos = ficherosSeleccionados
.Select(f => (FileName: f.NombreFichero!, Content: f.Fichero!))
.ToList();
var ficherosVacios = todos
.Where(t => t.Content is null || t.Content.Length == 0)
.Select(t => string.IsNullOrWhiteSpace(t.FileName) ? "(sin nombre)" : t.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
todos = todos
.Where(t => t.Content is { Length: > 0 })
.ToList();
if (!todos.Any())
{
operationError = ficherosVacios.Count == 0
? $"La denuncia #{selectedDenuncias.Id_Denuncia} no tiene ficheros para subir."
: $"La denuncia #{selectedDenuncias.Id_Denuncia} solo tiene ficheros vac<61>os y no se ha subido nada. Ficheros omitidos: {string.Join(", ", ficherosVacios)}.";
return;
}
string fileUrl;
if (selectedDenuncias.EnGestiona && !string.IsNullOrWhiteSpace(selectedDenuncias.Expediente_Gestiona))
@@ -991,8 +1027,9 @@ else
selectedDenuncias.EnGestiona = true;
}
Busy.Update(message: "Sincronizando numero de expediente de Gestiona.", detail: "Paso 3 de 7");
await SincronizarExpedienteGestionaAsync(selectedDenuncias, fileUrl);
Busy.Update(message: "Guardando referencia local del expediente.", detail: "Paso 3 de 7");
selectedDenuncias.Expediente_Gestiona = fileUrl;
selectedDenuncias.EnGestiona = true;
var thirdParty = selectedThirdParty ?? ThirdPartyIdentityData.FromComplaint(selectedDenuncias);
Busy.Update(message: "Resolviendo tercero y enlazandolo al expediente.", detail: "Paso 4 de 7");
@@ -1120,13 +1157,30 @@ else
selectedDenuncias.FechaSubidaAGestiona = ahoraUtc;
await ActualizarDenunciaAsync(selectedDenuncias);
var denunciaProcesadaId = selectedDenuncias.Id_Denuncia;
pendientes.Remove(selectedDenuncias);
excludedUploadFiles.Remove(denunciaProcesadaId);
CloseModal();
var avisos = new List<string>();
if (ficherosVacios.Count > 0)
{
avisos.Add($"Se omitieron ficheros vac<61>os: {string.Join(", ", ficherosVacios)}.");
}
if (ficherosNoSeleccionados.Count > 0)
{
avisos.Add($"No se subieron por selecci<63>n del usuario: {string.Join(", ", ficherosNoSeleccionados)}.");
}
if (avisos.Count > 0)
{
operationNotice = $"Denuncia #{denunciaProcesadaId} enviada. {string.Join(" ", avisos)}";
}
StateHasChanged();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error al confirmar envío: {ex}");
Console.Error.WriteLine($"Error al confirmar env<EFBFBD>o: {ex}");
operationError = $"No se ha podido completar el env<6E>o de la denuncia #{selectedDenuncias?.Id_Denuncia}: {ex.Message}";
}
finally
{
@@ -1208,10 +1262,11 @@ else
{
selectedDenuncias = d;
nuevoAsunto = $"Denuncia {d.Id_Denuncia}-CD";
archivoSeleccionado = preselectedFicheros.GetValueOrDefault(d.Id_Denuncia, reportTxt);
selectedThirdParty = ThirdPartyIdentityData.FromComplaint(d);
terceroDni = selectedThirdParty.DocumentId;
operationError = string.Empty;
operationNotice = string.Empty;
showModal = true;
}
@@ -1229,16 +1284,79 @@ else
selectedDenuncias = null;
selectedThirdParty = null;
nuevoAsunto = string.Empty;
archivoSeleccionado = reportTxt;
terceroDni = string.Empty;
operationError = string.Empty;
}
private void CloseRechazoModal() =>
(showModalRechazo, selectedDenuncias, motivoRechazo) =
(false, null, string.Empty);
private void SelectArchivo(string f) => archivoSeleccionado = f;
private void PreselectFichero(int id, string f) => preselectedFicheros[id] = f;
private bool IsFileSelectedForUpload(int denunciaId, string? fileName)
{
if (IsReportFileName(fileName))
{
return true;
}
return string.IsNullOrWhiteSpace(fileName) ||
!excludedUploadFiles.TryGetValue(denunciaId, out var excluded) ||
!excluded.Contains(fileName);
}
private void ToggleFileSelection(int denunciaId, string? fileName, bool selected)
{
if (string.IsNullOrWhiteSpace(fileName) || IsReportFileName(fileName))
{
return;
}
if (!excludedUploadFiles.TryGetValue(denunciaId, out var excluded))
{
excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
excludedUploadFiles[denunciaId] = excluded;
}
if (selected)
{
excluded.Remove(fileName);
if (excluded.Count == 0)
{
excludedUploadFiles.Remove(denunciaId);
}
}
else
{
excluded.Add(fileName);
}
}
private List<FicherosDenuncias> GetSelectedUploadFiles(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => IsReportFileName(file.NombreFichero) || IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.ToList();
}
private List<string> GetExcludedUploadFileNames(int denunciaId, IEnumerable<FicherosDenuncias> files)
{
return files
.Where(file => !IsReportFileName(file.NombreFichero) && !IsFileSelectedForUpload(denunciaId, file.NombreFichero))
.Select(file => string.IsNullOrWhiteSpace(file.NombreFichero) ? "(sin nombre)" : file.NombreFichero)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private static string FormatFileDate(DateTime date)
=> date == DateTime.MinValue
? "-"
: date.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
private string GetContentType(string fileName)
{
@@ -1261,7 +1379,7 @@ else
}
private static string GetReadOnlyValue(string? value) =>
string.IsNullOrWhiteSpace(value) ? "" : value;
string.IsNullOrWhiteSpace(value) ? "<EFBFBD>" : value;
private static bool HasPostalAddress(DenunciasGestiona denuncia)
{
@@ -1331,7 +1449,7 @@ else
StateHasChanged();
}
// ========= LÓGICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
// ========= L<EFBFBD>GICA BUSCADOR DE EXPEDIENTES POR TERCERO =========
private void CloseExpedientesModal()
{
@@ -1350,7 +1468,7 @@ else
if (string.IsNullOrWhiteSpace(nif) || nif == "00000000T")
{
errorExpedientes = "NIF no válido para búsqueda (anónimo o vacío).";
errorExpedientes = "NIF no v<EFBFBD>lido para b<EFBFBD>squeda (an<EFBFBD>nimo o vac<EFBFBD>o).";
showExpedientesModal = true;
StateHasChanged();
return;

View File

@@ -264,8 +264,7 @@ else
<td>@fichero.NombreFichero</td>
<td>@fichero.Fichero.Length</td>
<td>
<a class="btn btn-primary btn-sm" href="#"
onclick="openFile(event, '@Convert.ToBase64String(fichero.Fichero)', '@GetContentType(fichero.NombreFichero)');">
<a class="btn btn-primary btn-sm" href="@BuildAttachmentContentUrl(denuncia.Id_Denuncia, fichero.NombreFichero)" target="_blank" rel="noopener">
<i class="bi bi-eye"></i> Ver
</a>
</td>
@@ -314,7 +313,7 @@ else
if (firstRender)
{
await CargarRechazadasAsync();
await CargarFicherosAdjuntosAsync();
await CargarFicherosAdjuntosAsync(denunciasRechazadas.Select(d => d.Id_Denuncia));
hasLoaded = true;
StateHasChanged();
}
@@ -332,21 +331,26 @@ else
private async Task<List<DenunciasGestiona>> CargarDenunciasJsonAsync()
{
return await DenunciaStore.GetAllDenunciasAsync();
return await DenunciaStore.GetDenunciasByScopeAsync(DenunciaListScope.Rejected);
}
private async Task<List<FicherosDenuncias>> CargarFicherosJsonAsync()
private async Task CargarFicherosAdjuntosAsync(IEnumerable<int> denunciaIds)
{
return await DenunciaStore.GetAllFicherosAsync();
}
private async Task CargarFicherosAdjuntosAsync()
ficherosAdjuntos.Clear();
foreach (var denunciaId in denunciaIds.Where(id => id > 0).Distinct())
{
var listaFicheros = await CargarFicherosJsonAsync();
ficherosAdjuntos = listaFicheros.GroupBy(f => f.Id_Denuncia)
.ToDictionary(g => g.Key, g => g.ToList());
var ficheros = await DenunciaStore.GetFicherosByDenunciaAsync(denunciaId);
if (ficheros.Count > 0)
{
ficherosAdjuntos[denunciaId] = ficheros;
}
}
}
private static string BuildAttachmentContentUrl(int denunciaId, string? fileName)
{
return $"/api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName ?? string.Empty)}";
}
private string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();

View File

@@ -77,7 +77,7 @@ app.Use(async (context, next) =>
context.Response.Headers.XContentTypeOptions = "nosniff";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers.ContentSecurityPolicy =
"default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
"default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data: https://cdn.jsdelivr.net; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net";
await next();
});
@@ -403,6 +403,48 @@ api.MapPost("/auth/logout", async (
return Results.Ok(new { ok = true });
}).DisableAntiforgery();
api.MapGet("/denuncias/{denunciaId:int}/ficheros/content", async (
int denunciaId,
string fileName,
HttpContext httpContext,
IHttpClientFactory httpClientFactory,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(fileName))
{
return Results.BadRequest(new ApiError("Nombre de fichero obligatorio."));
}
var token = httpContext.User.FindFirst(ApiDenunciasClient.AccessTokenClaim)?.Value;
if (string.IsNullOrWhiteSpace(token))
{
return Results.Unauthorized();
}
var client = httpClientFactory.CreateClient(ApiDenunciasClient.HttpClientName);
var path = $"api/denuncias/{denunciaId}/ficheros/content?fileName={Uri.EscapeDataString(fileName)}";
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await client.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return Results.NotFound();
}
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken);
return Results.Problem(message, statusCode: (int)response.StatusCode);
}
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
httpContext.Response.Headers.CacheControl = "no-store";
return Results.File(bytes, contentType, enableRangeProcessing: true);
}).RequireAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();

View File

@@ -15,7 +15,19 @@ public sealed class ApiDenunciaStore : IDenunciaStore
=> _api.PostAsync("api/denuncias/schema/ensure", body: null, cancellationToken);
public async Task<List<DenunciasGestiona>> GetAllDenunciasAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<DenunciasGestiona>>("api/denuncias", cancellationToken)) ?? [];
=> await GetDenunciasByScopeAsync(DenunciaListScope.All, cancellationToken);
public async Task<List<DenunciasGestiona>> GetDenunciasByScopeAsync(
DenunciaListScope scope,
CancellationToken cancellationToken = default)
{
var path = scope == DenunciaListScope.All
? "api/denuncias"
: $"api/denuncias?scope={Uri.EscapeDataString(scope.ToString())}";
return (await _api.GetAsync<List<DenunciasGestiona>>(path, cancellationToken)) ?? [];
}
public async Task<List<FicherosDenuncias>> GetAllFicherosAsync(CancellationToken cancellationToken = default)
=> (await _api.GetAsync<List<FicherosDenuncias>>("api/denuncias/ficheros", cancellationToken)) ?? [];

View File

@@ -47,11 +47,22 @@ public sealed class ApiDenunciasClient
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
public Task<ApiGlobalLeaksSessionDto> RenewGlobalLeaksSessionAsync(string authcode, CancellationToken cancellationToken = default)
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post,
"api/inbox/session/renew/prepare",
body: null,
authorize: true,
cancellationToken);
public Task<ApiGlobalLeaksSessionDto> RenewGlobalLeaksSessionAsync(
string authcode,
string? pendingLoginId = null,
CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto>(
HttpMethod.Post,
"api/inbox/session/renew",
new RenewGlobalLeaksSessionRequest(authcode),
new RenewGlobalLeaksSessionRequest(authcode, pendingLoginId),
authorize: true,
cancellationToken);
@@ -69,13 +80,24 @@ public sealed class ApiDenunciasClient
authorize: true,
cancellationToken);
public Task<ReportDetailDto> GetReportDetailAsync(string reportId, CancellationToken cancellationToken = default)
=> SendAsync<ReportDetailDto>(
public Task<ReportDetailDto> GetReportDetailAsync(
string reportId,
string? lastAccess = null,
CancellationToken cancellationToken = default)
{
var path = $"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail";
if (!string.IsNullOrWhiteSpace(lastAccess))
{
path += $"?lastAccess={Uri.EscapeDataString(lastAccess)}";
}
return SendAsync<ReportDetailDto>(
HttpMethod.Get,
$"api/inbox/reports/{Uri.EscapeDataString(reportId)}/detail",
path,
body: null,
authorize: true,
cancellationToken);
}
public Task EnsureStorageReadyAsync(CancellationToken cancellationToken = default)
=> SendAsync<object?>(HttpMethod.Post, "api/inbox/local/ensure-storage", body: null, authorize: true, cancellationToken);
@@ -199,6 +221,34 @@ public sealed class ApiDenunciasClient
return GetAsync<List<ExpedienteTerceroDto>>(path, cancellationToken);
}
public Task<ManualPurgeResponse> ExecuteManualPurgeAsync(
string date,
CancellationToken cancellationToken = default)
=> PostAsync<ManualPurgeResponse>(
"api/purge/manual",
new ManualPurgeRequest(date),
cancellationToken);
public Task<ManualPurgeResponse> ExecuteCurrentManualPurgeAsync(
CancellationToken cancellationToken = default)
=> PostAsync<ManualPurgeResponse>(
"api/purge/manual/current",
body: null,
cancellationToken);
public Task<AppConfigurationDto> GetAppConfigurationAsync(CancellationToken cancellationToken = default)
=> GetAsync<AppConfigurationDto>("api/configuration", cancellationToken);
public Task<AppConfigurationDto> UpdateExternalUpdateCutoffDateAsync(
string? date,
CancellationToken cancellationToken = default)
=> SendAsync<AppConfigurationDto>(
HttpMethod.Put,
"api/configuration/external-update-cutoff",
new UpdateExternalUpdateCutoffRequest(date),
authorize: true,
cancellationToken);
internal Task<T> GetAsync<T>(string path, CancellationToken cancellationToken = default, bool allowNull = false)
=> SendAsync<T>(HttpMethod.Get, path, body: null, authorize: true, cancellationToken, allowNull);

View File

@@ -138,7 +138,6 @@
mostrar = false;
}
}
private EditContext editContext = default!;
private ValidationMessageStore? messageStore;