909 lines
33 KiB
C#
909 lines
33 KiB
C#
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using ApiDenuncias.Configuration;
|
|
using GestionaDenuncias.Shared.Models;
|
|
using Konscious.Security.Cryptography;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ApiDenuncias.Services;
|
|
|
|
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword);
|
|
|
|
public sealed class GlobalLeaksClient
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<GlobalLeaksClient> _logger;
|
|
private readonly GlobalLeaksOptions _options;
|
|
|
|
public GlobalLeaksClient(IOptions<GlobalLeaksOptions> options, ILogger<GlobalLeaksClient> logger)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
|
|
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(
|
|
string username,
|
|
string password,
|
|
string authcode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var prepared = await PrepareLoginAsync(username, password, cancellationToken);
|
|
return await CompleteLoginAsync(prepared.Username, prepared.FinalPassword, authcode, cancellationToken);
|
|
}
|
|
|
|
public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
|
|
string username,
|
|
string password,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loginWatch = Stopwatch.StartNew();
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}",
|
|
username,
|
|
_options.BaseUrl);
|
|
|
|
|
|
using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
|
|
typeRequest.Content = CreateJsonContent(new { username });
|
|
using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
|
|
await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);
|
|
|
|
var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
|
|
?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);
|
|
|
|
var passwordWatch = Stopwatch.StartNew();
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
|
|
username,
|
|
authType.Type);
|
|
var finalPassword = authType.Type == "key"
|
|
? DerivePassword(password, authType.Salt)
|
|
: password;
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
|
|
username,
|
|
passwordWatch.ElapsedMilliseconds);
|
|
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: credenciales preparadas para {Username}. Tiempo total={ElapsedMs} ms",
|
|
username,
|
|
loginWatch.ElapsedMilliseconds);
|
|
|
|
return new PreparedGlobalLeaksCredentials(username, finalPassword);
|
|
}
|
|
|
|
public async Task<GlSession> CompleteLoginAsync(
|
|
string username,
|
|
string finalPassword,
|
|
string authcode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loginWatch = Stopwatch.StartNew();
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: completando autenticacion para {Username}. AuthcodeLength={AuthcodeLength}",
|
|
username,
|
|
authcode?.Length ?? 0);
|
|
|
|
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
|
using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
|
|
await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
|
|
var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
|
|
?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502);
|
|
|
|
var proofWatch = Stopwatch.StartNew();
|
|
_logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username);
|
|
var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken);
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
|
|
username,
|
|
proofWatch.ElapsedMilliseconds);
|
|
|
|
using var authRequest = CreateRequest(
|
|
HttpMethod.Post,
|
|
$"/api/auth/authentication?token={Uri.EscapeDataString(tokenAnswer)}");
|
|
authRequest.Content = CreateJsonContent(new
|
|
{
|
|
tid = 1,
|
|
username,
|
|
password = finalPassword,
|
|
authcode = authcode?.Trim() ?? string.Empty,
|
|
});
|
|
authRequest.Headers.TryAddWithoutValidation("X-Token", tokenAnswer);
|
|
|
|
using var authResponse = await SendLoginRequestAsync(authRequest, "/api/auth/authentication", cancellationToken);
|
|
if (!authResponse.IsSuccessStatusCode)
|
|
{
|
|
var body = await ReadBodySafeAsync(authResponse, cancellationToken);
|
|
throw authResponse.StatusCode switch
|
|
{
|
|
HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
|
|
"Credenciales incorrectas o código 2FA inválido.",
|
|
StatusCodes.Status401Unauthorized),
|
|
(HttpStatusCode)429 => new GlobalLeaksValidationException(
|
|
"Demasiados intentos en GlobalLeaks. Espera unos minutos.",
|
|
StatusCodes.Status429TooManyRequests),
|
|
HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
|
|
new GlobalLeaksValidationException(
|
|
"Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
|
|
StatusCodes.Status406NotAcceptable),
|
|
_ => new GlobalLeaksValidationException(
|
|
string.IsNullOrWhiteSpace(body)
|
|
? $"Login fallido (código {(int)authResponse.StatusCode})."
|
|
: $"Login fallido (código {(int)authResponse.StatusCode}): {body}",
|
|
(int)authResponse.StatusCode),
|
|
};
|
|
}
|
|
|
|
var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
|
|
var session = ParseAuthSession(authBody, username);
|
|
_logger.LogInformation(
|
|
"Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
|
|
session.Username,
|
|
session.Role ?? "(sin rol)",
|
|
loginWatch.ElapsedMilliseconds);
|
|
return session;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
|
|
string sessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = CreateRequest(HttpMethod.Get, "/api/public");
|
|
using var response = await SendGlRequestAsync(request, cancellationToken);
|
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
var contexts = ParseContexts(body);
|
|
_logger.LogInformation("GlobalLeaks /api/public devolvió {Count} contextos", contexts.Count);
|
|
return contexts;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(
|
|
string sessionId,
|
|
string? filter,
|
|
string? dateFrom,
|
|
string? dateTo,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
filter ??= "all";
|
|
|
|
using var reportsRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
|
|
using var reportsResponse = await SendGlRequestAsync(reportsRequest, cancellationToken);
|
|
var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken);
|
|
var tips = ParseReports(body);
|
|
_logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);
|
|
|
|
var contexts = await GetContextsAsync(sessionId, cancellationToken);
|
|
var contextNames = contexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);
|
|
|
|
IEnumerable<RawReport> filtered = tips;
|
|
|
|
filtered = filter switch
|
|
{
|
|
"new" => filtered.Where(t => string.IsNullOrWhiteSpace(t.AccessDate) || t.Status == "new"),
|
|
"updated" or "updated_citizen" or "updated_receiver" => filtered.Where(t => t.Updated),
|
|
_ => filtered,
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(dateFrom))
|
|
{
|
|
if (!DateOnly.TryParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from))
|
|
{
|
|
throw new GlobalLeaksValidationException("Formato de fecha inválido para date_from (use YYYY-MM-DD)");
|
|
}
|
|
|
|
filtered = filtered.Where(t =>
|
|
{
|
|
var date = ParseDate(t.CreationDate);
|
|
return date is not null && DateOnly.FromDateTime(date.Value.DateTime) >= from;
|
|
});
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(dateTo))
|
|
{
|
|
if (!DateOnly.TryParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
|
|
{
|
|
throw new GlobalLeaksValidationException("Formato de fecha inválido para date_to (use YYYY-MM-DD)");
|
|
}
|
|
|
|
filtered = filtered.Where(t =>
|
|
{
|
|
var date = ParseDate(t.CreationDate);
|
|
return date is not null && DateOnly.FromDateTime(date.Value.DateTime) <= to;
|
|
});
|
|
}
|
|
|
|
return filtered
|
|
.OrderByDescending(t => t.Progressive ?? 0)
|
|
.Select(t => new ReportDto
|
|
{
|
|
Id = t.Id,
|
|
Progressive = t.Progressive,
|
|
ContextId = t.ContextId,
|
|
ContextName = t.ContextId is not null && contextNames.TryGetValue(t.ContextId, out var name)
|
|
? name
|
|
: t.ContextId,
|
|
CreationDate = t.CreationDate,
|
|
UpdateDate = t.UpdateDate,
|
|
ExpirationDate = t.ExpirationDate,
|
|
ReminderDate = t.ReminderDate,
|
|
AccessDate = t.AccessDate,
|
|
LastAccess = t.LastAccess,
|
|
Status = t.Status,
|
|
Updated = t.Updated,
|
|
Label = t.Label,
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
public async Task<ReportDetailDto> GetReportDetailAsync(
|
|
string sessionId,
|
|
string reportId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ValidateUuid(reportId);
|
|
|
|
using var listRequest = CreateAuthenticatedRequest(HttpMethod.Get, "/api/recipient/rtips", sessionId);
|
|
using var listResponse = await SendGlRequestAsync(listRequest, cancellationToken);
|
|
var listBody = await listResponse.Content.ReadAsStringAsync(cancellationToken);
|
|
var metadata = ParseReports(listBody)
|
|
.FirstOrDefault(report => string.Equals(report.Id, reportId, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (metadata is null)
|
|
{
|
|
throw new GlobalLeaksValidationException("Denuncia no encontrada en GlobalLeaks.", 404);
|
|
}
|
|
|
|
using var detailRequest = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
|
|
using var detailResponse = await SendGlRequestAsync(detailRequest, cancellationToken);
|
|
|
|
var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
|
var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
|
|
{
|
|
throw new GlobalLeaksValidationException(
|
|
"No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
|
|
422);
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(content);
|
|
return ParseReportDetail(reportId, metadata.LastAccess, document.RootElement);
|
|
}
|
|
|
|
public async Task<FileDownloadResult> DownloadReportZipAsync(
|
|
string sessionId,
|
|
string reportId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ValidateUuid(reportId);
|
|
|
|
using var request = CreateAuthenticatedRequest(
|
|
HttpMethod.Get,
|
|
$"/api/recipient/rtips/{reportId}/export",
|
|
sessionId);
|
|
|
|
using var response = await SendGlRequestAsync(
|
|
request,
|
|
cancellationToken,
|
|
HttpCompletionOption.ResponseHeadersRead);
|
|
|
|
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
if (content.Length > _options.MaxDownloadBytes)
|
|
{
|
|
throw new GlobalLeaksValidationException("El fichero supera el límite de 500 MB.", 413);
|
|
}
|
|
|
|
var fileName = SanitizeFileName(
|
|
ExtractFileName(response.Content.Headers.ContentDisposition),
|
|
$"report-{reportId}.zip");
|
|
|
|
return new FileDownloadResult(content, fileName);
|
|
}
|
|
|
|
public async Task<FileDownloadResult> ExportReportJsonAsync(
|
|
string sessionId,
|
|
string reportId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ValidateUuid(reportId);
|
|
|
|
using var request = CreateAuthenticatedRequest(HttpMethod.Get, $"/api/recipient/rtips/{reportId}", sessionId);
|
|
using var response = await SendGlRequestAsync(request, cancellationToken);
|
|
|
|
var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
|
var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
|
if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
|
|
{
|
|
throw new GlobalLeaksValidationException(
|
|
"GlobalLeaks no devolvió JSON (tip sin clave de descifrado).",
|
|
422);
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(content);
|
|
var progressive = document.RootElement.TryGetProperty("progressive", out var value) && value.TryGetInt32(out var number)
|
|
? number.ToString(CultureInfo.InvariantCulture)
|
|
: reportId[..8];
|
|
|
|
return new FileDownloadResult(content, $"report-{progressive}.json");
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> SendLoginRequestAsync(
|
|
HttpRequestMessage request,
|
|
string endpoint,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stepWatch = Stopwatch.StartNew();
|
|
_logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint);
|
|
|
|
try
|
|
{
|
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
|
_logger.LogInformation(
|
|
"GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms",
|
|
endpoint,
|
|
(int)response.StatusCode,
|
|
stepWatch.ElapsedMilliseconds);
|
|
return response;
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
_logger.LogWarning(
|
|
"GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms",
|
|
endpoint,
|
|
stepWatch.ElapsedMilliseconds);
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms",
|
|
endpoint,
|
|
stepWatch.ElapsedMilliseconds);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<HttpResponseMessage> SendGlRequestAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken,
|
|
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
|
|
{
|
|
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken);
|
|
if ((int)response.StatusCode == 412)
|
|
{
|
|
response.Dispose();
|
|
throw new GlobalLeaksSessionExpiredException();
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var message = $"Error de GlobalLeaks (código {(int)response.StatusCode}).";
|
|
response.Dispose();
|
|
throw new GlobalLeaksValidationException(message, (int)response.StatusCode);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
|
|
{
|
|
var request = new HttpRequestMessage(method, path)
|
|
{
|
|
Version = HttpVersion.Version11,
|
|
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
|
|
};
|
|
|
|
request.Headers.TryAddWithoutValidation("Accept", "*/*");
|
|
request.Headers.TryAddWithoutValidation("User-Agent", "python-requests/2.32.5");
|
|
return request;
|
|
}
|
|
|
|
private static HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string path, string sessionId)
|
|
{
|
|
var request = CreateRequest(method, path);
|
|
request.Headers.Add("X-Session", sessionId);
|
|
return request;
|
|
}
|
|
|
|
private static ByteArrayContent CreateJsonContent<T>(T payload)
|
|
{
|
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
|
|
var content = new ByteArrayContent(bytes);
|
|
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
return content;
|
|
}
|
|
|
|
private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
|
|
{
|
|
var idBytes = Encoding.UTF8.GetBytes(tokenId);
|
|
var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
|
|
var n = 0;
|
|
|
|
while (true)
|
|
{
|
|
if ((n & 15) == 0)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
|
|
var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
|
|
if (hash[^1] == 0)
|
|
{
|
|
return $"{tokenId}:{n}";
|
|
}
|
|
|
|
n++;
|
|
}
|
|
}
|
|
|
|
private static string DerivePassword(string password, string salt)
|
|
{
|
|
var saltBytes = Convert.FromBase64String(salt).Take(16).ToArray();
|
|
var hash = ComputeArgon2(Encoding.UTF8.GetBytes(password), saltBytes, iterations: 16, memoryKb: 131072);
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
|
|
private static byte[] ComputeArgon2(byte[] input, byte[] salt, int iterations, int memoryKb)
|
|
{
|
|
var argon2 = new Argon2id(input)
|
|
{
|
|
Salt = salt,
|
|
DegreeOfParallelism = 1,
|
|
Iterations = iterations,
|
|
MemorySize = memoryKb,
|
|
};
|
|
|
|
return argon2.GetBytes(32);
|
|
}
|
|
|
|
private static void ValidateUuid(string value)
|
|
{
|
|
if (!Guid.TryParse(value, out _))
|
|
{
|
|
throw new GlobalLeaksValidationException("ID de denuncia inválido");
|
|
}
|
|
}
|
|
|
|
private static DateTimeOffset? ParseDate(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return DateTimeOffset.TryParse(value.Replace("Z", "+00:00", StringComparison.Ordinal), out var parsed)
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private static string ExtractName(JsonElement? name, string fallback)
|
|
{
|
|
if (name is null)
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
if (name.Value.ValueKind == JsonValueKind.String)
|
|
{
|
|
return name.Value.GetString() ?? fallback;
|
|
}
|
|
|
|
if (name.Value.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (name.Value.TryGetProperty("es", out var es) && es.GetString() is { Length: > 0 } esName)
|
|
{
|
|
return esName;
|
|
}
|
|
|
|
if (name.Value.TryGetProperty("en", out var en) && en.GetString() is { Length: > 0 } enName)
|
|
{
|
|
return enName;
|
|
}
|
|
|
|
foreach (var property in name.Value.EnumerateObject())
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(property.Value.GetString()))
|
|
{
|
|
return property.Value.GetString()!;
|
|
}
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
private static List<ContextDto> ParseContexts(string body)
|
|
{
|
|
using var document = JsonDocument.Parse(body);
|
|
var contextsElement = document.RootElement;
|
|
|
|
if (contextsElement.ValueKind == JsonValueKind.Object &&
|
|
contextsElement.TryGetProperty("contexts", out var contextsProperty))
|
|
{
|
|
contextsElement = contextsProperty;
|
|
}
|
|
|
|
if (contextsElement.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var contexts = new List<ContextDto>();
|
|
foreach (var item in contextsElement.EnumerateArray())
|
|
{
|
|
var id = GetString(item, "id") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
JsonElement? name = null;
|
|
if (item.TryGetProperty("name", out var nameProp))
|
|
{
|
|
name = nameProp;
|
|
}
|
|
|
|
contexts.Add(new ContextDto(id, ExtractName(name, id)));
|
|
}
|
|
|
|
return contexts;
|
|
}
|
|
|
|
private static List<RawReport> ParseReports(string body)
|
|
{
|
|
using var document = JsonDocument.Parse(body);
|
|
var root = document.RootElement;
|
|
var array = root.ValueKind == JsonValueKind.Array
|
|
? root
|
|
: FindArrayProperty(root, "rtips", "tips", "items", "data", "entries", "results");
|
|
|
|
if (array is null || array.Value.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var reports = new List<RawReport>();
|
|
foreach (var item in array.Value.EnumerateArray())
|
|
{
|
|
var id = GetString(item, "id") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
reports.Add(new RawReport
|
|
{
|
|
Id = id,
|
|
Progressive = GetInt32(item, "progressive"),
|
|
ContextId = GetString(item, "context_id", "contextId"),
|
|
CreationDate = GetString(item, "creation_date", "creationDate"),
|
|
UpdateDate = GetString(item, "update_date", "updateDate"),
|
|
ExpirationDate = GetString(item, "expiration_date", "expirationDate"),
|
|
ReminderDate = GetString(item, "reminder_date", "reminderDate"),
|
|
AccessDate = GetString(item, "access_date", "accessDate"),
|
|
LastAccess = GetString(item, "last_access", "lastAccess"),
|
|
Status = GetString(item, "status"),
|
|
Updated = GetBool(item, "updated"),
|
|
Label = GetString(item, "label"),
|
|
});
|
|
}
|
|
|
|
return reports;
|
|
}
|
|
|
|
private static ReportDetailDto ParseReportDetail(string reportId, string? lastAccess, JsonElement root)
|
|
{
|
|
var lastAccessDate = ParseDate(lastAccess);
|
|
|
|
bool IsNew(string? value)
|
|
{
|
|
if (lastAccessDate is null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var itemDate = ParseDate(value);
|
|
return itemDate is not null && itemDate > lastAccessDate;
|
|
}
|
|
|
|
var comments = EnumerateArray(root, "comments")
|
|
.Select(item => new ReportCommentDto(
|
|
GetString(item, "id"),
|
|
GetString(item, "type"),
|
|
GetString(item, "content", "text", "message"),
|
|
GetString(item, "creation_date", "creationDate"),
|
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
|
.ToArray();
|
|
|
|
var whistleblowerFiles = EnumerateArray(root, "wbfiles", "files")
|
|
.Select(item => new ReportFileDto(
|
|
GetString(item, "id"),
|
|
GetLocalizedString(item, "name", "file_name", "filename"),
|
|
GetInt64(item, "size"),
|
|
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
|
GetString(item, "creation_date", "creationDate"),
|
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
|
.ToArray();
|
|
|
|
var receiverFiles = EnumerateArray(root, "rfiles")
|
|
.Select(item => new ReportFileDto(
|
|
GetString(item, "id"),
|
|
GetLocalizedString(item, "name", "file_name", "filename"),
|
|
GetInt64(item, "size"),
|
|
GetString(item, "content_type", "contentType", "mime_type", "mimetype"),
|
|
GetString(item, "creation_date", "creationDate"),
|
|
IsNew(GetString(item, "creation_date", "creationDate"))))
|
|
.ToArray();
|
|
|
|
return new ReportDetailDto(reportId, lastAccess, comments, whistleblowerFiles, receiverFiles);
|
|
}
|
|
|
|
private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
|
|
{
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (var name in names)
|
|
{
|
|
if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in property.EnumerateArray())
|
|
{
|
|
yield return item;
|
|
}
|
|
|
|
yield break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
|
|
{
|
|
return property;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? GetString(JsonElement element, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var property))
|
|
{
|
|
return property.ValueKind switch
|
|
{
|
|
JsonValueKind.String => property.GetString(),
|
|
JsonValueKind.Number => property.GetRawText(),
|
|
_ => null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? GetLocalizedString(JsonElement element, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (!element.TryGetProperty(name, out var property))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (property.ValueKind == JsonValueKind.String)
|
|
{
|
|
return property.GetString();
|
|
}
|
|
|
|
if (property.ValueKind == JsonValueKind.Object)
|
|
{
|
|
var value = ExtractName(property, string.Empty);
|
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static int? GetInt32(JsonElement element, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var property))
|
|
{
|
|
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
if (property.ValueKind == JsonValueKind.String &&
|
|
int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static long? GetInt64(JsonElement element, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var property))
|
|
{
|
|
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
if (property.ValueKind == JsonValueKind.String &&
|
|
long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool GetBool(JsonElement element, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (element.TryGetProperty(name, out var property))
|
|
{
|
|
if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
|
{
|
|
return property.GetBoolean();
|
|
}
|
|
|
|
if (property.ValueKind == JsonValueKind.String &&
|
|
bool.TryParse(property.GetString(), out var value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string SanitizeFileName(string? name, string fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
var sanitized = Regex.Replace(name, "[\\r\\n\\0\"\\\\]", "_").Trim();
|
|
return string.IsNullOrWhiteSpace(sanitized)
|
|
? fallback
|
|
: sanitized[..Math.Min(200, sanitized.Length)];
|
|
}
|
|
|
|
private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition)
|
|
=> contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"');
|
|
|
|
private static GlSession ParseAuthSession(string body, string fallbackUsername)
|
|
{
|
|
using var document = JsonDocument.Parse(body);
|
|
var root = document.RootElement;
|
|
|
|
var id = GetString(root, "id")
|
|
?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502);
|
|
var username = GetString(root, "username", "name");
|
|
if (string.IsNullOrWhiteSpace(username))
|
|
{
|
|
username = fallbackUsername.Trim();
|
|
}
|
|
|
|
var role = GetString(root, "role", "user_role", "userRole");
|
|
|
|
return new GlSession(id, username, role);
|
|
}
|
|
|
|
private static async Task EnsureSuccessOrThrowAsync(
|
|
HttpResponseMessage response,
|
|
string endpoint,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var body = await ReadBodySafeAsync(response, cancellationToken);
|
|
throw new GlobalLeaksValidationException(
|
|
string.IsNullOrWhiteSpace(body)
|
|
? $"Error en {endpoint} (código {(int)response.StatusCode})."
|
|
: $"Error en {endpoint} (código {(int)response.StatusCode}): {body}",
|
|
(int)response.StatusCode);
|
|
}
|
|
|
|
private static async Task<string> ReadBodySafeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
|
|
}
|
|
catch
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
private static bool IsTwoFactorRequiredError(string body)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(body))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
|
|
body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
|
|
body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private sealed record TokenResponse(string Id, string Salt);
|
|
private sealed record AuthTypeResponse(string Type, string Salt);
|
|
|
|
private sealed record RawReport
|
|
{
|
|
public string Id { get; init; } = string.Empty;
|
|
public int? Progressive { get; init; }
|
|
public string? ContextId { get; init; }
|
|
public string? CreationDate { get; init; }
|
|
public string? UpdateDate { get; init; }
|
|
public string? ExpirationDate { get; init; }
|
|
public string? ReminderDate { get; init; }
|
|
public string? AccessDate { get; init; }
|
|
public string? LastAccess { get; init; }
|
|
public string? Status { get; init; }
|
|
public bool Updated { get; init; }
|
|
public string? Label { get; init; }
|
|
}
|
|
}
|