The media proxy is bounded

Anyone could mint signed proxy URLs (a remote account changes its icon, an anonymous lookup returns the URL), and each
anonymous request held up to 40 MB in memory; the cache grew without bound between hourly trims; two clients asking
for the same new file downloaded it twice and wrote over each other in place, so a reader could get half a file with a
7-day cache header; a file over the limit was downloaded twice on every request; a failed fetch, a 404, was cached by
browsers for a week; cached media of a server suspended later were still served, and RejectMedia skipped avatars,
emoji, covers, video variants, link cards and remote edits; /clientapi/group/members returned remote pictures raw.

Now a download is shared by everyone asking at once, streamed into a .part file and renamed into place
(FederationHttp.DownloadMedia copies bounded, never into memory), at most eight at a time; a file too big to cache is
remembered for an hour and only streamed, a failure for five minutes; the cache's size is counted as it grows and
trimmed as soon as it passes the cap; a cached file is opened before it is answered; browsers may cache only a
success; nothing of a suspended server, or of one whose media are rejected, is proxied (everything remote a client
sees goes through the proxy, so that covers every kind), and blocking one purges its cache; the proxy has its own rate
limit per client address; group members' pictures are proxied; the proxy's key is loaded once, the oldest if two
were made. This changes what PrivaPub serves its clients, not what it sends to other servers.

Tests: clients asking at once share one download, a failure isn't cached by browsers, an over-limit file is fetched
three times for two requests instead of four, a blocked server's media are refused and its cache purged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:54:33 +02:00
1 parent 8e59145826
commit 3ca29603ed
11 files changed
+399 -73

No files matched your search

+220 -48
View File
@@ -1,3 +1,5 @@
using PrivaPub.Models.Federation;
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
@@ -11,32 +13,76 @@ using System.Text;
namespace PrivaPub.Domain.Media
{
public enum ProxyOutcome
{
Cached,
TooBig,
Failed
}
public interface IMediaProxy
{
string Wrap(string remoteUrl);
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
string Verified(string signature, string encodedUrl);
/// <summary>Whether a remote file's server is suspended or has its media rejected: nothing of it is proxied.</summary>
bool Refuses(string url);
(string Path, string ContentType) Cached(string url);
/// <summary>Downloads a remote file into the cache, once however many ask at the same time.</summary>
Task<(ProxyOutcome Outcome, string Path, string ContentType)> Download(string url, CancellationToken token);
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
/// <summary>Deletes what the cache holds of a domain and its subdomains; how many files.</summary>
int Purge(string domain);
/// <summary>Trims the cache to its size, oldest first.</summary>
void Trim();
}
// Remote media, fetched for clients so that they never contact a remote server (CLAUDE.md, media invariants):
// - URLs are HMAC-signed, so only what PrivaPub showed is fetched;
// - a download is shared by everyone asking for the same URL at once, streamed into a temporary file beside its place
// and renamed there (a reader never sees half a file), and never held in memory;
// - what is too big to cache is remembered for an hour (it is streamed instead), what failed for five minutes;
// - the cache's size is counted as it grows, and trimmed as soon as it passes the cap;
// - nothing of a suspended server, or of one whose media are rejected, is proxied, and blocking one purges its files.
public class MediaProxy : IMediaProxy
{
static readonly TimeSpan TooBigFor = TimeSpan.FromHours(1);
static readonly TimeSpan FailedFor = TimeSpan.FromMinutes(5);
const int Downloads = 8;//remote files fetched at once, however many clients ask
readonly ILocalActorService _localActors;
readonly IFederationHttp _http;
readonly IMediaService _media;
readonly IOptionsMonitor<MediaOptions> _options;
readonly Federation.Moderation.IDomainBlocks _domainBlocks;
readonly ConcurrentDictionary<string, Task<(ProxyOutcome, string, string)>> _inFlight = new();
readonly ConcurrentDictionary<string, (ProxyOutcome Outcome, DateTime Until)> _refused = new();
readonly SemaphoreSlim _downloads = new(Downloads);
readonly object _keyLock = new();
byte[] _key;
long _bytes = -1;//what the cache holds, counted once from the disk and then as it changes
int _trimming;
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options)
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options,
Federation.Moderation.IDomainBlocks domainBlocks = default)
{
_localActors = localActors;
_http = http;
_media = media;
_options = options;
_domainBlocks = domainBlocks;
}
byte[] Key => _key ??= LoadKey();
byte[] Key
{
get
{
if (_key != default)
return _key;
lock (_keyLock)
return _key ??= LoadKey();
}
}
public string Wrap(string remoteUrl)
{
@@ -60,18 +106,180 @@ namespace PrivaPub.Domain.Media
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))) ? url : default;
}
public bool Refuses(string url)
{
if (_domainBlocks == default || !Uri.TryCreate(url, UriKind.Absolute, out var target))
return false;
var block = _domainBlocks.Find(target.Host);
return block is { Severity: DomainBlockSeverity.Suspend } or { RejectMedia: true };
}
public (string Path, string ContentType) Cached(string url)
{
var (path, typePath) = CachePaths(url);
if (!File.Exists(path) || !File.Exists(typePath))
return default;
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
return (path, File.ReadAllText(typePath));
try
{
if (!File.Exists(path) || !File.Exists(typePath))
return default;
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
return (path, File.ReadLines(typePath).FirstOrDefault());
}
catch (IOException)
{
return default;//trimmed meanwhile
}
}
public Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) =>
_http.OpenMedia(url, range, token);
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
{
var url = Verified(signature, encodedUrl);
if (url == default || Refuses(url))
return default;
var (outcome, path, contentType) = await Download(url, token);
return outcome == ProxyOutcome.Cached ? (path, contentType) : default;
}
public async Task<(ProxyOutcome Outcome, string Path, string ContentType)> Download(string url, CancellationToken token)
{
if (Cached(url) is { Path: not null } cached)
return (ProxyOutcome.Cached, cached.Path, cached.ContentType);
if (_refused.TryGetValue(url, out var refused))
{
if (refused.Until > DateTime.UtcNow)
return (refused.Outcome, default, default);
_refused.TryRemove(url, out _);
}
// shared by everyone asking for it now; the download itself isn't cancelled when one of them leaves
var download = _inFlight.GetOrAdd(url, key => DownloadOnce(key));
try
{
return await download.WaitAsync(token);
}
finally
{
if (download.IsCompleted)
_inFlight.TryRemove(new KeyValuePair<string, Task<(ProxyOutcome, string, string)>>(url, download));
}
}
async Task<(ProxyOutcome, string, string)> DownloadOnce(string url)
{
await Task.Yield();
if (!await _downloads.WaitAsync(TimeSpan.FromSeconds(30)))
return (ProxyOutcome.Failed, default, default);//too busy: the client tries again
var (path, typePath) = CachePaths(url);
var part = $"{path}.{Guid.NewGuid():N}.part";
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
string contentType, refusal;
await using (var file = new FileStream(part, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true))
(contentType, refusal) = await _http.DownloadMedia(url, _options.CurrentValue.MaxProxiedBytes, file, CancellationToken.None);
if (contentType == default)
{
var outcome = refusal == "too-large" ? ProxyOutcome.TooBig : ProxyOutcome.Failed;
_refused[url] = (outcome, DateTime.UtcNow + (outcome == ProxyOutcome.TooBig ? TooBigFor : FailedFor));
return (outcome, default, default);
}
await File.WriteAllTextAsync(typePath, contentType + "\n" + new Uri(url).Host);
var size = new FileInfo(part).Length;
File.Move(part, path, overwrite: true);
Grew(size);
return (ProxyOutcome.Cached, path, contentType);
}
finally
{
_downloads.Release();
if (File.Exists(part))
File.Delete(part);
}
}
void Grew(long bytes)
{
if (Interlocked.Read(ref _bytes) < 0)
Interlocked.CompareExchange(ref _bytes, Measure(), -1);
if (Interlocked.Add(ref _bytes, bytes) > _options.CurrentValue.ProxyCacheBytes)
_ = Task.Run(Trim);
}
public void Trim()
{
if (Interlocked.Exchange(ref _trimming, 1) == 1)
return;
try
{
Interlocked.Exchange(ref _bytes, TrimDirectory(_media.ProxyRoot, _options.CurrentValue.ProxyCacheBytes));
}
finally
{
Interlocked.Exchange(ref _trimming, 0);
}
}
public int Purge(string domain)
{
var directory = new DirectoryInfo(_media.ProxyRoot);
if (string.IsNullOrEmpty(domain) || !directory.Exists)
return 0;
var purged = 0;
foreach (var type in directory.EnumerateFiles("*.type", SearchOption.AllDirectories))
{
try
{
var host = File.ReadLines(type.FullName).Skip(1).FirstOrDefault();
if (host == default || !(host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)))
continue;
var file = new FileInfo(type.FullName[..^".type".Length]);
if (file.Exists)
file.Delete();
type.Delete();
purged++;
}
catch (IOException)
{
}
}
Interlocked.Exchange(ref _bytes, -1);
return purged;
}
long Measure()
{
var directory = new DirectoryInfo(_media.ProxyRoot);
return directory.Exists ? directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension is not (".type" or ".part")).Sum(f => f.Length) : 0;
}
// deletes the oldest files until the cache is within its cap; what it holds afterwards
public static long TrimDirectory(string root, long cap)
{
var directory = new DirectoryInfo(root);
if (!directory.Exists)
return 0;
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension is not (".type" or ".part")).OrderBy(f => f.LastWriteTimeUtc).ToList();
var total = files.Sum(f => f.Length);
foreach (var file in files)
{
if (total <= cap)
break;
try
{
total -= file.Length;
file.Delete();
var type = new FileInfo(file.FullName + ".type");
if (type.Exists)
type.Delete();
}
catch (IOException)
{
}
}
return total;
}
(string Path, string TypePath) CachePaths(string url)
{
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
@@ -79,35 +287,16 @@ namespace PrivaPub.Domain.Media
return (path, path + ".type");
}
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
{
var url = Verified(signature, encodedUrl);
if (url == default)
return default;
if (Cached(url) is { Path: not null } cached)
return cached;
var (path, typePath) = CachePaths(url);
var directory = System.IO.Path.GetDirectoryName(path);
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
if (bytes == default)
return default;
Directory.CreateDirectory(directory);
await File.WriteAllBytesAsync(path, bytes, token);
await File.WriteAllTextAsync(typePath, contentType, token);
return (path, contentType);
}
string Sign(string url) => Base64Url(HMACSHA256.HashData(Key, Encoding.UTF8.GetBytes(url))[..16]);
// the oldest key, so that two first uses at once agree on one
static byte[] LoadKey()
{
var secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
var secret = DB.Default.Find<MediaSecret>().Sort(m => m.ID, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
if (secret == default)
{
secret = new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) };
DB.Default.SaveAsync(secret).GetAwaiter().GetResult();
secret = DB.Default.Find<MediaSecret>().ExecuteFirstAsync().GetAwaiter().GetResult();
DB.Default.SaveAsync(new MediaSecret { Key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) }).GetAwaiter().GetResult();
secret = DB.Default.Find<MediaSecret>().Sort(m => m.ID, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
}
return Convert.FromBase64String(secret.Key);
}
@@ -192,23 +381,6 @@ namespace PrivaPub.Domain.Media
TrimProxyCache();
}
void TrimProxyCache()
{
var directory = new DirectoryInfo(_media.ProxyRoot);
if (!directory.Exists)
return;
var files = directory.EnumerateFiles("*", SearchOption.AllDirectories).Where(f => f.Extension != ".type").OrderBy(f => f.LastWriteTimeUtc).ToList();
var total = files.Sum(f => f.Length);
foreach (var file in files)
{
if (total <= _options.CurrentValue.ProxyCacheBytes)
break;
total -= file.Length;
file.Delete();
var type = new FileInfo(file.FullName + ".type");
if (type.Exists)
type.Delete();
}
}
void TrimProxyCache() => MediaProxy.TrimDirectory(_media.ProxyRoot, _options.CurrentValue.ProxyCacheBytes);
}
}