Domain/Statuses/StatusService takes a persona, not a root, and is what
/clientapi and the Mastodon API share:
- Publish renders Markdown (clientapi) or plain text (Mastodon clients),
checks the persona may see the post it replies to, opens or reuses the
conversation for a direct post from its recipients and mentions, fans
out and hands the Create to the outbox;
- Edit keeps a revision and sends Update{Note}; Remove soft-deletes and
returns the post, so a client can delete and redraft;
- Favourite and Reblog work on anything the persona can see and send Like,
Announce and their Undo to the author (and, for boosts, to followers);
boosts are refused for anything but public and unlisted posts.
PostsService is now the clientapi wrapper that checks the root owns the
persona. A persona's own boost is a local row: the outbox renders it as
Announce, /grunts/announce-{id} resolves, and object endpoints, profile
pages and NodeInfo skip it. ContentFormat gains Plain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
130 lines
5.2 KiB
C#
130 lines
5.2 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Localization;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Social;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Inbox.Handlers;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class Harness : IAsyncDisposable
|
|
{
|
|
public const string Host = "privapub.test";
|
|
public const string Base = "https://" + Host;
|
|
|
|
Harness(Peer peer)
|
|
{
|
|
Peer = peer;
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
|
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
|
Delivery = new DeliveryService(Db, Queue);
|
|
Fanout = new Fanout(Db);
|
|
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
|
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
|
{
|
|
new FollowHandler(Db, Local, Remote, Delivery),
|
|
new AcceptHandler(Db, Local),
|
|
new RejectHandler(Db, Local),
|
|
new UndoHandler(Db, Local),
|
|
new LikeHandler(Db),
|
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
|
new DeleteHandler(Db, Local, Remote, Delivery),
|
|
new UpdateHandler(Db, Local, Remote)
|
|
}, NullLogger<InboxProcessor>.Instance);
|
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
|
Content = new ContentRenderer(Local, Remote);
|
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
|
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout);
|
|
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
|
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
|
}
|
|
|
|
public Peer Peer { get; }
|
|
public DbEntities Db { get; } = new();
|
|
public JobQueue Queue { get; } = new();
|
|
public LocalActorService Local { get; }
|
|
public RemoteActorService Remote { get; }
|
|
public DeliveryService Delivery { get; }
|
|
public InboxReceiver Receiver { get; }
|
|
public InboxProcessor Processor { get; }
|
|
public FollowService Follows { get; }
|
|
public ContentRenderer Content { get; }
|
|
public OutboxPublisher Outbox { get; }
|
|
public PostsService Posts { get; }
|
|
public StatusService Statuses { get; }
|
|
public Fanout Fanout { get; }
|
|
public RemotePosts RemotePosts { get; }
|
|
public TimelineService Timelines { get; }
|
|
|
|
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
|
await DB.Default.SaveAsync(new Follower
|
|
{
|
|
LocalActorId = local.Id,
|
|
LocalActorKind = local.Kind,
|
|
ActorURI = follower.Id,
|
|
InboxURL = follower.Id + "/inbox",
|
|
SharedInboxURL = follower.SharedInbox
|
|
});
|
|
|
|
public static async Task<Harness> Start() => new(await Peer.Start());
|
|
|
|
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
|
|
{
|
|
rootId ??= Guid.NewGuid().ToString("N")[..24];
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(avatar);
|
|
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
|
|
return (rootId, Local.FromAvatar(avatar));
|
|
}
|
|
|
|
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
|
{
|
|
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
|
|
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
|
|
if (job != default)
|
|
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver).ExecuteAsync())
|
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
|
.Where(p => p.Inbox == inbox)
|
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
|
.ToList();
|
|
|
|
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
|
}
|
|
|
|
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
|
{
|
|
public LocalizedString this[string name] => new(name, name);
|
|
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
|
|
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
|
|
}
|
|
}
|