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:
1 parent
9ec1f9930b
commit
e6a362c0b8
13 files changed
+318
-13
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
using PrivaPub.ClientModels.Resources;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PrivaPub.ClientModels.Admin
|
||||||
|
{
|
||||||
|
public class DomainBlockForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
StringLength(253, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string Domain { get; set; }
|
||||||
|
|
||||||
|
public bool Suspend { get; set; } = true;
|
||||||
|
public bool RejectMedia { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string PublicComment { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string PrivateComment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewDomainBlock
|
||||||
|
{
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Domain { get; set; }
|
||||||
|
public string Severity { get; set; }
|
||||||
|
public bool RejectMedia { get; set; }
|
||||||
|
public string PublicComment { get; set; }
|
||||||
|
public string PrivateComment { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,18 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Inbox;
|
|
||||||
using PrivaPub.Federation.Objects;
|
|
||||||
using PrivaPub.Federation.Inbox.Handlers;
|
using PrivaPub.Federation.Inbox.Handlers;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models;
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Group;
|
using PrivaPub.Models.Group;
|
||||||
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Models;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
using PrivaPub.Tests.Support;
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
LocalActorService _local;
|
LocalActorService _local;
|
||||||
InboxReceiver _receiver;
|
InboxReceiver _receiver;
|
||||||
InboxProcessor _processor;
|
InboxProcessor _processor;
|
||||||
|
DomainBlocks _blocks;
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
@@ -44,12 +47,13 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var queue = new JobQueue();
|
var queue = new JobQueue();
|
||||||
var delivery = new DeliveryService(new DbEntities(), queue);
|
var delivery = new DeliveryService(new DbEntities(), queue);
|
||||||
var db = new DbEntities();
|
var db = new DbEntities();
|
||||||
_receiver = new InboxReceiver(_local, remote, queue, NullLogger<InboxReceiver>.Instance);
|
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
|
||||||
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
||||||
{
|
{
|
||||||
new FollowHandler(db, _local, remote, delivery),
|
new FollowHandler(db, _local, remote, delivery),
|
||||||
new UndoHandler(db, _local, remote, delivery),
|
new UndoHandler(db, _local, remote, delivery),
|
||||||
new CreateHandler(db, _local, remote, delivery),
|
new CreateHandler(db, _local, remote, delivery, _blocks),
|
||||||
new DeleteHandler(db, _local, remote, delivery),
|
new DeleteHandler(db, _local, remote, delivery),
|
||||||
new UpdateHandler(db, _local, remote)
|
new UpdateHandler(db, _local, remote)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
@@ -254,6 +258,46 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(alice.Id, mention.AccountId);
|
Assert.Equal(alice.Id, mention.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var alice = await LocalAvatar("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
||||||
|
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _blocks.Reload(token);
|
||||||
|
var before = _peer.Requests.Count;
|
||||||
|
|
||||||
|
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||||
|
|
||||||
|
Assert.Equal(202, result.StatusCode);
|
||||||
|
Assert.Equal(before, _peer.Requests.Count);
|
||||||
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
||||||
|
await _blocks.Reload(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_block_covers_subdomains_but_not_lookalikes()
|
||||||
|
{
|
||||||
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||||
|
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||||
|
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
||||||
|
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||||
|
.SetValue(blocks, DateTime.UtcNow);
|
||||||
|
|
||||||
|
Assert.NotNull(blocks.Find("evil.example"));
|
||||||
|
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
||||||
|
Assert.Null(blocks.Find("notevil.example"));
|
||||||
|
Assert.False(blocks.IsSuspended("evil.example"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_bad_signature_is_a_401()
|
public async Task A_bad_signature_is_a_401()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Infrastructure.Http;
|
using PrivaPub.Infrastructure.Http;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
|
||||||
namespace PrivaPub.Tests.Infrastructure
|
namespace PrivaPub.Tests.Infrastructure
|
||||||
{
|
{
|
||||||
@@ -39,7 +41,7 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
|
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
|
||||||
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance);
|
new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -91,6 +93,29 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
|
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
|
||||||
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
|
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAllowed_refuses_a_suspended_domain_and_its_subdomains()
|
||||||
|
{
|
||||||
|
var http = new FederationHttp(new ServiceCollection().AddHttpClient().BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
||||||
|
new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"),
|
||||||
|
NullLogger<FederationHttp>.Instance);
|
||||||
|
|
||||||
|
Assert.False(http.IsAllowed(new Uri("https://evil.example/users/x")));
|
||||||
|
Assert.False(http.IsAllowed(new Uri("https://cdn.evil.example/a.png")));
|
||||||
|
Assert.True(http.IsAllowed(new Uri("https://notevil.example/users/x")));
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class StaticBlocks : IDomainBlocks
|
||||||
|
{
|
||||||
|
readonly string[] _suspended;
|
||||||
|
public StaticBlocks(params string[] suspended) => _suspended = suspended;
|
||||||
|
public DomainBlock Find(string host) => _suspended.Any(s => host == s || host.EndsWith("." + s))
|
||||||
|
? new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }
|
||||||
|
: default;
|
||||||
|
public bool IsSuspended(string host) => Find(host) != default;
|
||||||
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
|
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
|
||||||
{
|
{
|
||||||
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
|
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Infrastructure.Http;
|
using PrivaPub.Infrastructure.Http;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
|
|
||||||
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
||||||
|
|
||||||
public static FederationHttp Http(IMemoryCache cache = default)
|
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
|
||||||
{
|
{
|
||||||
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
@@ -71,12 +73,19 @@ namespace PrivaPub.Tests.Support
|
|||||||
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
||||||
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
||||||
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
||||||
NullLogger<FederationHttp>.Instance);
|
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class NoBlocks : IDomainBlocks
|
||||||
|
{
|
||||||
|
public DomainBlock Find(string host) => default;
|
||||||
|
public bool IsSuspended(string host) => false;
|
||||||
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
|
||||||
|
|
||||||
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
||||||
|
|||||||
@@ -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 MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Federation.Rendering;
|
using PrivaPub.Federation.Rendering;
|
||||||
@@ -26,13 +27,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IDeliveryService _delivery;
|
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;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
_delivery = delivery;
|
_delivery = delivery;
|
||||||
|
_domainBlocks = domainBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Type => "Create";
|
public string Type => "Create";
|
||||||
@@ -130,7 +134,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
Language = note.Language,
|
Language = note.Language,
|
||||||
Mentions = mentions,
|
Mentions = mentions,
|
||||||
Tags = note.Tags.ToList(),
|
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,
|
InReplyToURI = note.InReplyTo,
|
||||||
AnsweringToPostId = parent?.ID,
|
AnsweringToPostId = parent?.ID,
|
||||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Signing;
|
using PrivaPub.Federation.Signing;
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
@@ -28,13 +29,16 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IJobQueue _queue;
|
readonly IJobQueue _queue;
|
||||||
|
readonly IDomainBlocks _domainBlocks;
|
||||||
readonly ILogger<InboxReceiver> _logger;
|
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;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
|
_domainBlocks = domainBlocks;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +73,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
|
||||||
if (parameters == default)
|
if (parameters == default)
|
||||||
return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header");
|
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);
|
var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
|
||||||
if (requestProblem != default)
|
if (requestProblem != default)
|
||||||
@@ -99,6 +105,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
return new(StatusCodes.Status202Accepted);
|
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)
|
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
|
||||||
{
|
{
|
||||||
var activityId = Id(activity);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
.Key(j => j.FinishedAt, KeyType.Ascending)
|
.Key(j => j.FinishedAt, KeyType.Ascending)
|
||||||
.Option(o => o.ExpireAfter = TimeSpan.FromDays(7))
|
.Option(o => o.ExpireAfter = TimeSpan.FromDays(7))
|
||||||
.CreateAsync(token);
|
.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);
|
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.Caching.Memory;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -41,14 +43,16 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
readonly IHttpClientFactory _httpClientFactory;
|
readonly IHttpClientFactory _httpClientFactory;
|
||||||
readonly IMemoryCache _cache;
|
readonly IMemoryCache _cache;
|
||||||
readonly IOptionsMonitor<FederationOptions> _options;
|
readonly IOptionsMonitor<FederationOptions> _options;
|
||||||
|
readonly IDomainBlocks _domainBlocks;
|
||||||
readonly ILogger<FederationHttp> _logger;
|
readonly ILogger<FederationHttp> _logger;
|
||||||
|
|
||||||
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
|
public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor<FederationOptions> options,
|
||||||
ILogger<FederationHttp> logger)
|
IDomainBlocks domainBlocks, ILogger<FederationHttp> logger)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_domainBlocks = domainBlocks;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +60,8 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
{
|
{
|
||||||
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
|
if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo))
|
||||||
return false;
|
return false;
|
||||||
|
if (_domainBlocks?.IsSuspended(target.Host) == true)
|
||||||
|
return false;
|
||||||
var options = _options.CurrentValue;
|
var options = _options.CurrentValue;
|
||||||
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
|
if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp))
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using PrivaPub.Services.ClientToServer.Public;
|
|||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Federation.Inbox.Handlers;
|
using PrivaPub.Federation.Inbox.Handlers;
|
||||||
using PrivaPub.Domain.Content;
|
using PrivaPub.Domain.Content;
|
||||||
using PrivaPub.Infrastructure.Http;
|
using PrivaPub.Infrastructure.Http;
|
||||||
@@ -50,6 +51,7 @@ namespace PrivaPub.Middleware
|
|||||||
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
|
SafeHttpHandlerFactory.Create(provider.GetRequiredService<IOptions<FederationOptions>>().Value));
|
||||||
return service
|
return service
|
||||||
.AddSingleton<IFederationHttp, FederationHttp>()
|
.AddSingleton<IFederationHttp, FederationHttp>()
|
||||||
|
.AddSingleton<IDomainBlocks, DomainBlocks>()
|
||||||
.AddSingleton<IContentRenderer, ContentRenderer>()
|
.AddSingleton<IContentRenderer, ContentRenderer>()
|
||||||
.AddSingleton<ILocalActorService, LocalActorService>()
|
.AddSingleton<ILocalActorService, LocalActorService>()
|
||||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ try
|
|||||||
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
|
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
|
||||||
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
|
var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher;
|
||||||
await dbClient.Init(passwordHasher);
|
await dbClient.Init(passwordHasher);
|
||||||
|
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user