Domain blocks: suspend, silence, reject media

DomainBlock (domain, severity, reject-media, public and private comment)
covers the domain and its subdomains. Admins manage them under
/clientapi/admin/domainblocks/{list,insert,delete}; the set is kept in
memory, reloaded on every change and at most five minutes stale.

A suspended domain is refused by FederationHttp.IsAllowed, so nothing is
fetched from it and no job delivers to it, and the inbox drops its
activities with a 202 before fetching any key. Reject-media strips the
attachments of posts from that domain. Silence is recorded for the
timelines and notifications that arrive in P1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:25:05 +02:00
1 parent 9ec1f9930b
commit e6a362c0b8
13 files changed
+318 -13

No files matched your search

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Federation.Moderation;
using PrivaPub.Models.Federation;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/admin/domainblocks"),
Authorize(Policy = Policies.IsAdmin)]
public class DomainBlockController : ControllerBase
{
readonly IDomainBlocks _domainBlocks;
readonly IStringLocalizer _localizer;
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer)
{
_domainBlocks = domainBlocks;
_localizer = localizer;
}
[HttpGet, Route("/clientapi/admin/domainblocks/list")]
public async Task<IActionResult> List(CancellationToken token) =>
Ok((await DB.Default.Find<DomainBlock>().Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(ToView).ToList());
[HttpPost, Route("/clientapi/admin/domainblocks/insert")]
public async Task<IActionResult> Insert(DomainBlockForm form, CancellationToken token)
{
var domain = DomainBlocks.Normalise(form.Domain);
if (!ModelState.IsValid || Uri.CheckHostName(domain) != UriHostNameType.Dns)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var block = await DB.Default.UpdateAndGet<DomainBlock>()
.Match(b => b.Domain == domain)
.Modify(b => b.Domain, domain)
.Modify(b => b.Severity, form.Suspend ? DomainBlockSeverity.Suspend : DomainBlockSeverity.Silence)
.Modify(b => b.RejectMedia, form.RejectMedia)
.Modify(b => b.PublicComment, form.PublicComment)
.Modify(b => b.PrivateComment, form.PrivateComment)
.Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
await _domainBlocks.Reload(token);
return Ok(ToView(block));
}
[HttpPost, Route("/clientapi/admin/domainblocks/delete")]
public async Task<IActionResult> Delete([FromQuery] string domain, CancellationToken token)
{
domain = DomainBlocks.Normalise(domain);
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
await _domainBlocks.Reload(token);
return Ok();
}
static ViewDomainBlock ToView(DomainBlock block) => new()
{
Id = block.ID,
Domain = block.Domain,
Severity = block.Severity.ToString(),
RejectMedia = block.RejectMedia,
PublicComment = block.PublicComment,
PrivateComment = block.PrivateComment,
CreatedAt = block.CreatedAt
};
}
}
@@ -2,6 +2,7 @@ using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
@@ -26,13 +27,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IDomainBlocks _domainBlocks;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_domainBlocks = domainBlocks;
}
public string Type => "Create";
@@ -130,7 +134,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
Language = note.Language,
Mentions = mentions,
Tags = note.Tags.ToList(),
Media = note.Attachments.ToList(),
Media = _domainBlocks.Find(new Uri(author.ActorURI).Host)?.RejectMedia == true ? new() : note.Attachments.ToList(),
InReplyToURI = note.InReplyTo,
AnsweringToPostId = parent?.ID,
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
+9 -1
View File
@@ -1,4 +1,5 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Jobs;
@@ -28,13 +29,16 @@ namespace PrivaPub.Federation.Inbox
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IJobQueue _queue;
readonly IDomainBlocks _domainBlocks;
readonly ILogger<InboxReceiver> _logger;
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, ILogger<InboxReceiver> logger)
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks,
ILogger<InboxReceiver> logger)
{
_localActors = localActors;
_remoteActors = remoteActors;
_queue = queue;
_domainBlocks = domainBlocks;
_logger = logger;
}
@@ -69,6 +73,8 @@ namespace PrivaPub.Federation.Inbox
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default)
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri)))
return new(StatusCodes.Status202Accepted);
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
if (requestProblem != default)
@@ -99,6 +105,8 @@ namespace PrivaPub.Federation.Inbox
return new(StatusCodes.Status202Accepted);
}
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default;
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
{
var activityId = Id(activity);
@@ -0,0 +1,77 @@
using MongoDB.Entities;
using PrivaPub.Models.Federation;
namespace PrivaPub.Federation.Moderation
{
public interface IDomainBlocks
{
DomainBlock Find(string host);
bool IsSuspended(string host);
Task Reload(CancellationToken token);
}
public class DomainBlocks : IDomainBlocks
{
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
readonly ILogger<DomainBlocks> _logger;
IReadOnlyDictionary<string, DomainBlock> _blocks = new Dictionary<string, DomainBlock>();
DateTime _loadedAt = DateTime.MinValue;
int _reloading;
public DomainBlocks(ILogger<DomainBlocks> logger)
{
_logger = logger;
}
public static string Normalise(string domain) => domain?.Trim().Trim('.').ToLowerInvariant();
public DomainBlock Find(string host)
{
RefreshIfStale();
host = Normalise(host);
var blocks = _blocks;
while (!string.IsNullOrEmpty(host))
{
if (blocks.TryGetValue(host, out var block))
return block;
var dot = host.IndexOf('.');
host = dot < 0 ? default : host[(dot + 1)..];
}
return default;
}
public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend;
public async Task Reload(CancellationToken token)
{
var blocks = await DB.Default.Find<DomainBlock>().ExecuteAsync(token);
_blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain))
.GroupBy(b => Normalise(b.Domain))
.ToDictionary(g => g.Key, g => g.First());
_loadedAt = DateTime.UtcNow;
}
void RefreshIfStale()
{
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
return;
_ = Task.Run(async () =>
{
try
{
await Reload(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Domain blocks could not be reloaded");
}
finally
{
Interlocked.Exchange(ref _reloading, 0);
}
});
}
}
}
+1
View File
@@ -59,6 +59,7 @@ namespace PrivaPub.Infrastructure.Data
.Key(j => j.FinishedAt, KeyType.Ascending)
.Option(o => o.ExpireAfter = TimeSpan.FromDays(7))
.CreateAsync(token);
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
}
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using PrivaPub.Federation.Moderation;
using System.Net;
using System.Text.Json;
@@ -41,14 +43,16 @@ namespace PrivaPub.Infrastructure.Http
readonly IHttpClientFactory _httpClientFactory;
readonly IMemoryCache _cache;
readonly IOptionsMonitor<FederationOptions> _options;
readonly IDomainBlocks _domainBlocks;
readonly ILogger<FederationHttp> _logger;
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
ILogger<FederationHttp> logger)
IDomainBlocks domainBlocks, ILogger<FederationHttp> logger)
{
_httpClientFactory = httpClientFactory;
_cache = cache;
_options = options;
_domainBlocks = domainBlocks;
_logger = logger;
}
@@ -56,6 +60,8 @@ namespace PrivaPub.Infrastructure.Http
{
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
return false;
if (_domainBlocks?.IsSuspended(target.Host) == true)
return false;
var options = _options.CurrentValue;
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
return false;
@@ -14,6 +14,7 @@ using PrivaPub.Services.ClientToServer.Public;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content;
using PrivaPub.Infrastructure.Http;
@@ -50,6 +51,7 @@ namespace PrivaPub.Middleware
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
return service
.AddSingleton<IFederationHttp, FederationHttp>()
.AddSingleton<IDomainBlocks, DomainBlocks>()
.AddSingleton<IContentRenderer, ContentRenderer>()
.AddSingleton<ILocalActorService, LocalActorService>()
.AddSingleton<IRemoteActorService, RemoteActorService>()
+21
View File
@@ -0,0 +1,21 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class DomainBlock : Entity
{
public string Domain { get; set; }
public DomainBlockSeverity Severity { get; set; }
public bool RejectMedia { get; set; }
public string PublicComment { get; set; }
public string PrivateComment { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum DomainBlockSeverity
{
None,
Silence,
Suspend
}
}
+1
View File
@@ -154,6 +154,7 @@ try
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
await dbClient.Init(passwordHasher);
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
}
catch (Exception ex)
{