Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
post whose ActorURI is the actor (one update-many), besides dropping its follows and
timeline rows as before. RemotePosts.Build sets it on a post stored later for an
account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
bookmarks, favourites, polls, reactions, search); provenance, account statuses,
home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
home and post/DM lists, a community's outbox and our Announces filter on IsShown or
IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.
Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
Referrer-Policy and nosniff), circle 404, community page, the instance actor,
ActivityPub redirects, and markup escaped in posts, titles and bios.
Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
189 lines
6.0 KiB
C#
189 lines
6.0 KiB
C#
using Microsoft.Extensions.Options;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Media;
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace PrivaPub.Domain.Media
|
|
{
|
|
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);
|
|
(string Path, string ContentType) Cached(string url);
|
|
Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
|
|
}
|
|
|
|
public class MediaProxy : IMediaProxy
|
|
{
|
|
readonly ILocalActorService _localActors;
|
|
readonly IFederationHttp _http;
|
|
readonly IMediaService _media;
|
|
readonly IOptionsMonitor<MediaOptions> _options;
|
|
byte[] _key;
|
|
|
|
public MediaProxy(ILocalActorService localActors, IFederationHttp http, IMediaService media, IOptionsMonitor<MediaOptions> options)
|
|
{
|
|
_localActors = localActors;
|
|
_http = http;
|
|
_media = media;
|
|
_options = options;
|
|
}
|
|
|
|
byte[] Key => _key ??= LoadKey();
|
|
|
|
public string Wrap(string remoteUrl)
|
|
{
|
|
if (string.IsNullOrEmpty(remoteUrl) || remoteUrl.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
|
return remoteUrl;
|
|
var encoded = Base64Url(Encoding.UTF8.GetBytes(remoteUrl));
|
|
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
|
|
}
|
|
|
|
public string Verified(string signature, string encodedUrl)
|
|
{
|
|
string url;
|
|
try
|
|
{
|
|
url = Encoding.UTF8.GetString(FromBase64Url(encodedUrl));
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return default;
|
|
}
|
|
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))) ? url : default;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
public Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) =>
|
|
_http.OpenMedia(url, range, token);
|
|
|
|
(string Path, string TypePath) CachePaths(string url)
|
|
{
|
|
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
|
|
var path = System.IO.Path.Combine(_media.ProxyRoot, name[..2], name);
|
|
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]);
|
|
|
|
static byte[] LoadKey()
|
|
{
|
|
var secret = DB.Default.Find<MediaSecret>().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();
|
|
}
|
|
return Convert.FromBase64String(secret.Key);
|
|
}
|
|
|
|
static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
|
|
static byte[] FromBase64Url(string value)
|
|
{
|
|
var padded = value.Replace('-', '+').Replace('_', '/');
|
|
return Convert.FromBase64String(padded + new string('=', (4 - padded.Length % 4) % 4));
|
|
}
|
|
}
|
|
|
|
public class MediaJanitor : BackgroundService
|
|
{
|
|
static readonly TimeSpan Interval = TimeSpan.FromHours(1);
|
|
static readonly TimeSpan UnattachedLifetime = TimeSpan.FromDays(1);
|
|
|
|
readonly IMediaService _media;
|
|
readonly IOptionsMonitor<MediaOptions> _options;
|
|
readonly ILogger<MediaJanitor> _logger;
|
|
|
|
public MediaJanitor(IMediaService media, IOptionsMonitor<MediaOptions> options, ILogger<MediaJanitor> logger)
|
|
{
|
|
_media = media;
|
|
_options = options;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(Interval, stoppingToken);
|
|
await Sweep(stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "{Service} pass failed", nameof(MediaJanitor));
|
|
}
|
|
}
|
|
}
|
|
|
|
//one pass: uploads left unattached for a day go, then the proxy cache is trimmed to its size, oldest first
|
|
public async Task Sweep(CancellationToken token)
|
|
{
|
|
var cutoff = DateTime.UtcNow - UnattachedLifetime;
|
|
foreach (var stale in await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == null && m.CreatedAt < cutoff).Limit(500).ExecuteAsync(token))
|
|
await _media.Delete(stale);
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|