Files
Antifraude.Net/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs
2026-05-21 12:07:51 +02:00

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; }
}
}