Compare commits

...

4 Commits

79 changed files with 1768 additions and 484 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
@rendermode InteractiveServer
@attribute [Authorize]
@using GestionaDenunciasAN.Models
@using GestionaDenuncias.Shared.Models
@using System.Globalization
@using System.IO
@using System.Linq
@using System.Text
@using GestionaDenunciasAN.Helpers
@using GestionaDenuncias.Shared.Helpers
@using GestionaDenunciasAN.Services
@attribute [StreamRendering]
@@ -175,6 +175,11 @@
<h1>Denuncias Pendientes</h1>
@if (!string.IsNullOrWhiteSpace(loadError))
{
<div class="alert alert-danger">@loadError</div>
}
<input type="text"
class="form-control"
placeholder="Buscar denuncias..."
@@ -849,6 +854,7 @@ else
private Dictionary<int, string> preselectedFicheros = new();
private bool hasLoaded = false;
private string loadError = string.Empty;
private bool showModal = false;
private bool showModalRechazo = false;
@@ -876,27 +882,37 @@ else
private async Task CargarDatosAsync()
{
var todas = await CargarDenunciasJsonAsync();
// Asegura ProcedureId/GroupId por si faltan
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
try
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
loadError = string.Empty;
var todas = await CargarDenunciasJsonAsync();
// Asegura ProcedureId/GroupId por si faltan
foreach (var d in todas.Where(x => x.ProcedureId == Guid.Empty))
{
d.ProcedureId = Guid.Parse("82722c9b-cecc-4299-8a7b-ce5abeb8170b");
d.GroupId = Guid.Parse("6dbfc433-1eb6-4b9a-a533-bfebc652c101");
}
// SOLO pendientes
pendientes = todas
.Where(d => !d.EnGestiona && !d.EnRechazada && !d.EsActualizacion)
.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());
}
catch (Exception ex)
{
pendientes.Clear();
ficherosAdjuntos.Clear();
loadError = $"No se han podido cargar las denuncias pendientes: {ex.Message}";
}
// SOLO pendientes
pendientes = todas
.Where(d => !d.EnGestiona && !d.EnRechazada && !d.EsActualizacion)
.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());
hasLoaded = true;
StateHasChanged();
@@ -912,6 +928,19 @@ else
return await DenunciaStore.GetAllFicherosAsync();
}
private static bool IsReportFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var name = Path.GetFileNameWithoutExtension(fileName);
var extension = Path.GetExtension(fileName);
return name.StartsWith("report", StringComparison.OrdinalIgnoreCase) &&
(extension.Equals(".txt", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase));
}
private string FixFileName(string input)
{
var normalized = input.Normalize(NormalizationForm.FormD);
@@ -995,7 +1024,7 @@ else
string? documentoParaTramitar = null;
var report = todos.FirstOrDefault(t =>
string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase));
IsReportFileName(t.FileName));
if (!string.IsNullOrWhiteSpace(report.FileName))
{
@@ -1013,7 +1042,7 @@ else
}
var adjuntos = todos
.Where(t => !string.Equals(t.FileName, reportTxt, StringComparison.OrdinalIgnoreCase))
.Where(t => !IsReportFileName(t.FileName))
.ToList();
if (adjuntos.Count > 0 && uploadMode == "merge")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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