2026-10-01 10:46:09 +02:00
|
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
|
2026-10-01 11:25:05 +02:00
|
|
|
using PrivaPub.Federation.Moderation;
|
2026-10-03 11:10:42 +02:00
|
|
|
using PrivaPub.Infrastructure.Statistics;
|
|
|
|
|
using PrivaPub.Models.Statistics;
|
2026-10-01 11:25:05 +02:00
|
|
|
|
2026-10-01 10:46:09 +02:00
|
|
|
using System.Net;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Infrastructure.Http
|
|
|
|
|
{
|
|
|
|
|
public sealed class FetchedJson : IDisposable
|
|
|
|
|
{
|
|
|
|
|
public Uri FinalUri { get; init; }
|
|
|
|
|
public JsonDocument Document { get; init; }
|
|
|
|
|
public JsonElement Root => Document.RootElement;
|
|
|
|
|
|
|
|
|
|
public void Dispose() => Document?.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public interface IFederationHttp
|
|
|
|
|
{
|
|
|
|
|
bool IsAllowed(Uri target);
|
|
|
|
|
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
2026-10-01 18:25:18 +02:00
|
|
|
bool FailedTemporarily(string url);
|
2026-10-01 18:43:39 +02:00
|
|
|
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
|
2026-10-01 18:59:14 +02:00
|
|
|
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
|
2026-10-01 10:46:09 +02:00
|
|
|
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
2026-10-01 12:22:08 +02:00
|
|
|
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
2026-10-03 12:19:29 +02:00
|
|
|
Task<(int Status, string Text)> GetText(string url, int maxBytes, CancellationToken token);
|
|
|
|
|
Task<IReadOnlyList<string>> GetStringArray(string url, int maxItems, CancellationToken token);
|
2026-10-01 10:46:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public class FederationHttp : IFederationHttp
|
|
|
|
|
{
|
|
|
|
|
public const string ClientName = "Federation";
|
|
|
|
|
public const int MaxResponseBytes = 1024 * 1024;
|
|
|
|
|
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15);
|
|
|
|
|
|
|
|
|
|
const int MaxRedirects = 3;
|
|
|
|
|
static readonly TimeSpan NegativeCacheLifetime = TimeSpan.FromMinutes(5);
|
|
|
|
|
static readonly string[] JsonMediaTypes =
|
|
|
|
|
{
|
|
|
|
|
"application/activity+json",
|
|
|
|
|
"application/ld+json",
|
|
|
|
|
"application/jrd+json",
|
|
|
|
|
"application/json"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
|
readonly IMemoryCache _cache;
|
|
|
|
|
readonly IOptionsMonitor<FederationOptions> _options;
|
2026-10-01 11:25:05 +02:00
|
|
|
readonly IDomainBlocks _domainBlocks;
|
2026-10-01 10:46:09 +02:00
|
|
|
readonly ILogger<FederationHttp> _logger;
|
2026-10-03 11:10:42 +02:00
|
|
|
readonly IInteractionLedger _ledger;
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
|
2026-10-03 11:10:42 +02:00
|
|
|
IDomainBlocks domainBlocks, ILogger<FederationHttp> logger, IInteractionLedger ledger = default)
|
2026-10-01 10:46:09 +02:00
|
|
|
{
|
|
|
|
|
_httpClientFactory = httpClientFactory;
|
|
|
|
|
_cache = cache;
|
|
|
|
|
_options = options;
|
2026-10-01 11:25:05 +02:00
|
|
|
_domainBlocks = domainBlocks;
|
2026-10-01 10:46:09 +02:00
|
|
|
_logger = logger;
|
2026-10-03 11:10:42 +02:00
|
|
|
_ledger = ledger;
|
2026-10-01 10:46:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 11:10:42 +02:00
|
|
|
sealed class Exchange
|
|
|
|
|
{
|
|
|
|
|
public Exchange(string url, string purpose)
|
|
|
|
|
{
|
|
|
|
|
Host = Interactions.HostOf(url);
|
|
|
|
|
Purpose = purpose;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public readonly string Host;
|
|
|
|
|
public readonly string Purpose;
|
|
|
|
|
public readonly long Started = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
|
|
|
public int? Status;
|
|
|
|
|
public long? Bytes;
|
|
|
|
|
public int Hops;
|
|
|
|
|
public string Outcome = Interactions.Ok;
|
|
|
|
|
public string Reason;
|
|
|
|
|
|
|
|
|
|
public void Refused(string reason) => (Outcome, Reason) = (Interactions.Refused, reason);
|
|
|
|
|
|
|
|
|
|
public void Failed(string reason) => (Outcome, Reason) = (Interactions.Failed, reason);
|
|
|
|
|
|
|
|
|
|
public void Answered(HttpResponseMessage response) => Status = (int)response.StatusCode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Record(Exchange exchange)
|
|
|
|
|
{
|
|
|
|
|
if (_ledger == default)
|
|
|
|
|
return;
|
|
|
|
|
var trigger = HttpScope.Trigger;
|
|
|
|
|
if (trigger == HttpScope.Request)
|
|
|
|
|
{
|
|
|
|
|
_ledger.Count(exchange.Host, $"http:{exchange.Purpose}:{exchange.Outcome}", exchange.Bytes ?? 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_ledger.Record(new InteractionEvent
|
|
|
|
|
{
|
|
|
|
|
Channel = Interactions.Http,
|
|
|
|
|
Host = exchange.Host,
|
|
|
|
|
Purpose = exchange.Purpose,
|
|
|
|
|
Trigger = trigger,
|
|
|
|
|
Outcome = exchange.Outcome,
|
|
|
|
|
Reason = exchange.Reason,
|
|
|
|
|
Status = exchange.Status,
|
|
|
|
|
LatencyMs = (int)System.Diagnostics.Stopwatch.GetElapsedTime(exchange.Started).TotalMilliseconds,
|
|
|
|
|
Bytes = exchange.Bytes,
|
|
|
|
|
Redirects = exchange.Hops,
|
|
|
|
|
Crawl = HttpScope.Crawling
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 12:19:29 +02:00
|
|
|
static HttpRequestMessage Request(Uri target, string accept)
|
|
|
|
|
{
|
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, target);
|
|
|
|
|
request.Headers.Accept.ParseAdd(accept);
|
|
|
|
|
if (HttpScope.UserAgent is { } userAgent)
|
|
|
|
|
request.Headers.UserAgent.ParseAdd(userAgent);
|
|
|
|
|
return request;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//robots.txt: the status alone matters when it is not 2xx (RFC 9309), so it is returned whatever it is; 0 when unreachable
|
|
|
|
|
public async Task<(int Status, string Text)> GetText(string url, int maxBytes, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "text");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("disallowed");
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
|
timeout.CancelAfter(RequestTimeout);
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
|
|
|
|
using var request = Request(target, "text/plain");
|
|
|
|
|
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
|
|
|
|
exchange.Answered(response);
|
|
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("bad-redirect");
|
|
|
|
|
return ((int)response.StatusCode, default);
|
|
|
|
|
}
|
|
|
|
|
target = next;
|
|
|
|
|
exchange.Hops++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused(StatusReason(response));
|
|
|
|
|
return ((int)response.StatusCode, default);
|
|
|
|
|
}
|
|
|
|
|
var bytes = await ReadPrefix(response.Content, maxBytes, timeout.Token);
|
|
|
|
|
exchange.Bytes = bytes.Length;
|
|
|
|
|
return ((int)response.StatusCode, System.Text.Encoding.UTF8.GetString(bytes));
|
|
|
|
|
}
|
|
|
|
|
exchange.Refused("too-many-redirects");
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Record(exchange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//a JSON array of strings such as /api/v1/instance/peers, read from at most the first MaxResponseBytes: a large server's
|
|
|
|
|
//list is cut short rather than refused
|
|
|
|
|
public async Task<IReadOnlyList<string>> GetStringArray(string url, int maxItems, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "list");
|
|
|
|
|
var items = new List<string>();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("disallowed");
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
|
timeout.CancelAfter(RequestTimeout);
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
|
|
|
|
using var request = Request(target, "application/json");
|
|
|
|
|
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
|
|
|
|
exchange.Answered(response);
|
|
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("bad-redirect");
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
target = next;
|
|
|
|
|
exchange.Hops++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused(StatusReason(response));
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
|
|
|
|
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("content-type");
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
var bytes = await ReadPrefix(response.Content, MaxResponseBytes, timeout.Token);
|
|
|
|
|
exchange.Bytes = bytes.Length;
|
|
|
|
|
ReadStrings(bytes, maxItems, items);
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
exchange.Refused("too-many-redirects");
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Record(exchange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static void ReadStrings(byte[] json, int maxItems, List<string> items)
|
|
|
|
|
{
|
|
|
|
|
var reader = new Utf8JsonReader(json, isFinalBlock: false, state: default);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
|
|
|
|
|
return;
|
|
|
|
|
while (items.Count < maxItems && reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
|
|
|
|
{
|
|
|
|
|
if (reader.TokenType == JsonTokenType.String)
|
|
|
|
|
items.Add(reader.GetString());
|
|
|
|
|
else if (!reader.TrySkip())
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 11:10:42 +02:00
|
|
|
static string StatusReason(HttpResponseMessage response) => ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
|
|
2026-10-01 10:46:09 +02:00
|
|
|
public bool IsAllowed(Uri target)
|
|
|
|
|
{
|
|
|
|
|
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
|
|
|
|
|
return false;
|
2026-10-01 11:25:05 +02:00
|
|
|
if (_domainBlocks?.IsSuspended(target.Host) == true)
|
|
|
|
|
return false;
|
2026-10-01 10:46:09 +02:00
|
|
|
var options = _options.CurrentValue;
|
|
|
|
|
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
|
|
|
|
|
return false;
|
|
|
|
|
if (options.AllowPrivateNetworks)
|
|
|
|
|
return true;
|
|
|
|
|
return target.HostNameType == UriHostNameType.Dns
|
|
|
|
|
&& target.Host.Contains('.')
|
|
|
|
|
&& !target.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
&& !target.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
&& !target.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token)
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return await GetJson(url, accept, sign, exchange, token);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Record(exchange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token)
|
2026-10-01 10:46:09 +02:00
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("disallowed");
|
2026-10-01 10:46:09 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 10:46:09 +02:00
|
|
|
var negativeKey = NegativeKey(target);
|
|
|
|
|
if (_cache.TryGetValue(negativeKey, out _))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("remembered");
|
2026-10-01 10:46:09 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
|
timeout.CancelAfter(RequestTimeout);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
2026-10-03 12:19:29 +02:00
|
|
|
using var request = Request(target, accept);
|
2026-10-01 10:46:09 +02:00
|
|
|
sign?.Invoke(request);
|
|
|
|
|
|
|
|
|
|
using var response = await _httpClientFactory.CreateClient(ClientName)
|
|
|
|
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Answered(response);
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, "a redirect to a disallowed location", exchange, "bad-redirect");
|
2026-10-01 10:46:09 +02:00
|
|
|
target = next;
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Hops++;
|
2026-10-01 10:46:09 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}", exchange, StatusReason(response),
|
2026-10-01 18:25:18 +02:00
|
|
|
transient: (int)response.StatusCode is >= 500 or 429 or 408);
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
|
|
|
|
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, $"content type '{mediaType}'", exchange, "content-type");
|
2026-10-01 10:46:09 +02:00
|
|
|
if (response.Content.Headers.ContentLength > MaxResponseBytes)
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
var body = await ReadBounded(response.Content, MaxResponseBytes, timeout.Token);
|
|
|
|
|
if (body == default)
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, "a body over the size limit", exchange, "too-large");
|
|
|
|
|
exchange.Bytes = body.Length;
|
2026-10-01 10:46:09 +02:00
|
|
|
|
|
|
|
|
return new FetchedJson { FinalUri = target, Document = JsonDocument.Parse(body) };
|
|
|
|
|
}
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, "too many redirects", exchange, "too-many-redirects");
|
2026-10-01 10:46:09 +02:00
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
|
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, "a timeout", exchange, "timeout", transient: true);
|
2026-10-01 10:46:09 +02:00
|
|
|
}
|
2026-10-01 18:25:18 +02:00
|
|
|
catch (HttpRequestException ex)
|
|
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, ex.Message, exchange, "network", transient: true);
|
2026-10-01 18:25:18 +02:00
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
|
2026-10-01 10:46:09 +02:00
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
return Refuse(negativeKey, url, ex.Message, exchange, ex is JsonException ? "bad-json" : "private-address");
|
2026-10-01 10:46:09 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 18:25:18 +02:00
|
|
|
public bool FailedTemporarily(string url) =>
|
|
|
|
|
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
|
|
|
|
|
|
2026-10-01 12:22:08 +02:00
|
|
|
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
var exchange = new Exchange(url, "media");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var media = await GetMedia(url, maxBytes, exchange, token);
|
|
|
|
|
if (media.Bytes == default && exchange.Outcome == Interactions.Ok)
|
|
|
|
|
exchange.Refused("unusable");
|
|
|
|
|
return media;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Record(exchange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, Exchange exchange, CancellationToken token)
|
2026-10-01 12:22:08 +02:00
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("disallowed");
|
2026-10-01 12:22:08 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 12:22:08 +02:00
|
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
|
timeout.CancelAfter(TimeSpan.FromSeconds(60));
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
|
|
|
|
request.Headers.Accept.ParseAdd("image/*, video/*, audio/*");
|
|
|
|
|
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Answered(response);
|
2026-10-01 12:22:08 +02:00
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("bad-redirect");
|
2026-10-01 12:22:08 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 12:22:08 +02:00
|
|
|
target = next;
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Hops++;
|
2026-10-01 12:22:08 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
|
2026-10-03 11:10:42 +02:00
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
exchange.Failed(StatusReason(response));
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
if (mediaType == default || !(mediaType.StartsWith("image/") || mediaType.StartsWith("video/") || mediaType.StartsWith("audio/"))
|
|
|
|
|
|| mediaType.Contains("svg"))
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("content-type");
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
if (response.Content.Headers.ContentLength > maxBytes)
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("too-large");
|
2026-10-01 12:22:08 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 12:22:08 +02:00
|
|
|
var bytes = await ReadBounded(response.Content, (int)Math.Min(maxBytes, int.MaxValue), timeout.Token);
|
2026-10-03 11:10:42 +02:00
|
|
|
if (bytes == default)
|
|
|
|
|
{
|
|
|
|
|
exchange.Refused("too-large");
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
exchange.Bytes = bytes.Length;
|
|
|
|
|
return (bytes, mediaType);
|
2026-10-01 12:22:08 +02:00
|
|
|
}
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Refused("too-many-redirects");
|
2026-10-01 12:22:08 +02:00
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
|
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
|
2026-10-01 12:22:08 +02:00
|
|
|
_logger.LogInformation("Media {Url} refused: {Reason}", url, ex.Message);
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 18:43:39 +02:00
|
|
|
public async Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
|
|
|
|
return default;
|
|
|
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
|
|
|
|
timeout.CancelAfter(RequestTimeout);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
|
|
|
|
request.Headers.Accept.ParseAdd("text/html, application/xhtml+xml");
|
|
|
|
|
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
|
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
|
|
|
|
return default;
|
|
|
|
|
target = next;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
|
|
|
|
|
if (!response.IsSuccessStatusCode || mediaType is not ("text/html" or "application/xhtml+xml"))
|
|
|
|
|
return default;
|
|
|
|
|
var bytes = await ReadPrefix(response.Content, MaxPageBytes, timeout.Token);
|
|
|
|
|
var charset = response.Content.Headers.ContentType?.CharSet?.Trim('"');
|
|
|
|
|
var encoding = System.Text.Encoding.UTF8;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(charset))
|
|
|
|
|
encoding = System.Text.Encoding.GetEncoding(charset);
|
|
|
|
|
}
|
|
|
|
|
catch (ArgumentException)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
return (target, encoding.GetString(bytes));
|
|
|
|
|
}
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogInformation("Page {Url} refused: {Reason}", url, ex.Message);
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 18:59:14 +02:00
|
|
|
public async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token)
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
var exchange = new Exchange(url, "stream");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var response = await OpenMedia(url, range, exchange, token);
|
|
|
|
|
if (response == default && exchange.Outcome == Interactions.Ok)
|
|
|
|
|
exchange.Refused("unusable");
|
|
|
|
|
exchange.Bytes = response?.Content.Headers.ContentLength;
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Record(exchange);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, Exchange exchange, CancellationToken token)
|
2026-10-01 18:59:14 +02:00
|
|
|
{
|
|
|
|
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("disallowed");
|
2026-10-01 18:59:14 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 18:59:14 +02:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (var hop = 0; hop <= MaxRedirects; hop++)
|
|
|
|
|
{
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
|
|
|
|
request.Headers.Accept.ParseAdd("video/*, audio/*, image/*");
|
|
|
|
|
request.Headers.Range = range;
|
|
|
|
|
var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Answered(response);
|
2026-10-01 18:59:14 +02:00
|
|
|
if (IsRedirect(response.StatusCode))
|
|
|
|
|
{
|
|
|
|
|
var location = response.Headers.Location;
|
|
|
|
|
response.Dispose();
|
|
|
|
|
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
|
|
|
|
|
if (!IsAllowed(next))
|
2026-10-03 11:10:42 +02:00
|
|
|
{
|
|
|
|
|
exchange.Refused("bad-redirect");
|
2026-10-01 18:59:14 +02:00
|
|
|
return default;
|
2026-10-03 11:10:42 +02:00
|
|
|
}
|
2026-10-01 18:59:14 +02:00
|
|
|
target = next;
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Hops++;
|
2026-10-01 18:59:14 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
|
|
|
|
|
if (!response.IsSuccessStatusCode || mediaType == default || mediaType.Contains("svg")
|
|
|
|
|
|| !(mediaType.StartsWith("video/") || mediaType.StartsWith("audio/") || mediaType.StartsWith("image/") || mediaType == "application/octet-stream"))
|
|
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
exchange.Refused("content-type");
|
|
|
|
|
else
|
|
|
|
|
exchange.Failed(StatusReason(response));
|
2026-10-01 18:59:14 +02:00
|
|
|
response.Dispose();
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
|
|
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
exchange.Failed(ex switch { BlockedDestinationException => "private-address", OperationCanceledException => "timeout", _ => "network" });
|
2026-10-01 18:59:14 +02:00
|
|
|
_logger.LogInformation("Media stream {Url} refused: {Reason}", url, ex.Message);
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 18:43:39 +02:00
|
|
|
const int MaxPageBytes = 512 * 1024;
|
|
|
|
|
|
|
|
|
|
static async Task<byte[]> ReadPrefix(HttpContent content, int limit, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
await using var stream = await content.ReadAsStreamAsync(token);
|
|
|
|
|
var buffer = new byte[limit];
|
|
|
|
|
var total = 0;
|
|
|
|
|
int read;
|
|
|
|
|
while (total < limit && (read = await stream.ReadAsync(buffer.AsMemory(total, limit - total), token)) > 0)
|
|
|
|
|
total += read;
|
|
|
|
|
return buffer[..total];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 10:46:09 +02:00
|
|
|
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
if (!IsAllowed(request.RequestUri))
|
|
|
|
|
throw new BlockedDestinationException(request.RequestUri?.Host);
|
|
|
|
|
return await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async Task<byte[]> ReadBounded(HttpContent content, int limit, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
await using var stream = await content.ReadAsStreamAsync(token);
|
|
|
|
|
using var buffer = new MemoryStream();
|
|
|
|
|
var chunk = new byte[16 * 1024];
|
|
|
|
|
int read;
|
|
|
|
|
while ((read = await stream.ReadAsync(chunk, token)) > 0)
|
|
|
|
|
{
|
|
|
|
|
if (buffer.Length + read > limit)
|
|
|
|
|
return default;
|
|
|
|
|
buffer.Write(chunk, 0, read);
|
|
|
|
|
}
|
|
|
|
|
return buffer.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool IsRedirect(HttpStatusCode status) =>
|
|
|
|
|
status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther
|
|
|
|
|
or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect;
|
|
|
|
|
|
|
|
|
|
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
|
|
|
|
|
|
2026-10-03 11:10:42 +02:00
|
|
|
FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false)
|
2026-10-01 10:46:09 +02:00
|
|
|
{
|
2026-10-03 11:10:42 +02:00
|
|
|
if (transient)
|
|
|
|
|
exchange.Failed(code);
|
|
|
|
|
else
|
|
|
|
|
exchange.Refused(code);
|
2026-10-01 18:25:18 +02:00
|
|
|
_cache.Set(negativeKey, transient, NegativeCacheLifetime);
|
2026-10-01 10:46:09 +02:00
|
|
|
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
|
|
|
|
|
return default;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|