Posts reach their audience, edits follow them, deletes leave a tombstone
OutboxPublisher works out who a post goes to, Mastodon's way: followers'
shared inboxes for public, unlisted and followers-only; every mentioned
remote account's own inbox; the recipients only for direct; the parent's
author for a reply; a community's followers for a post in it; nobody for
a circle. Creates, updates and deletes all use it.
- Local posts take a visibility (public, unlisted, followers-only) and a
spoiler text next to the content-warning flag.
- /clientapi/post/update keeps the previous version as a revision,
re-renders, and sends Update{Note} with `updated` to the same to/cc.
- Deleting is now soft: the content, title, spoiler, media and revisions
are cleared, timeline entries removed, the parent's reply count goes
down, Delete goes to the stored audience, and the object answers 410
with a Tombstone instead of 404.
- Editing a persona's profile sends Update{Person} to its followers.
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
61f6ca0676
commit
a76cc34557
10 files changed
+367
-32
No files matched your search
@@ -19,6 +19,32 @@ namespace PrivaPub.ClientModels.Post
|
|||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
public string AnsweringToPostId { get; set; }
|
public string AnsweringToPostId { get; set; }
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
|
||||||
|
public string Visibility { get; set; }//public (default), unlisted, followersonly
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdatePostForm
|
||||||
|
{
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string AvatarId { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string PostId { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string Text { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
public bool HasContentWarning { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InsertDmForm
|
public class InsertDmForm
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels.Post;
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Domain
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class OutboxTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
Harness _harness;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_harness = await Harness.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_harness != default)
|
||||||
|
await _harness.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<ForeignAvatar> Known(RemoteActor actor) =>
|
||||||
|
await _harness.Remote.GetActor(actor.Id, refresh: false, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_audience_follows_mastodons_table()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||||
|
var mentioned = await Known(new RemoteActor(_harness.Peer, "mentioned"));
|
||||||
|
var parentAuthor = await Known(new RemoteActor(_harness.Peer, "parent"));
|
||||||
|
await _harness.FollowedBy(alice, follower);
|
||||||
|
var mention = new PostMention { ActorURI = mentioned.ActorURI };
|
||||||
|
|
||||||
|
async Task<IReadOnlyList<string>> Audience(PostVisibility visibility, string replyTo = default) =>
|
||||||
|
await _harness.Outbox.Audience(alice, new Post { Visibility = visibility, Mentions = new() { mention }, To = new() { mentioned.ActorURI }, InReplyToAccountId = replyTo }, token);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.Public));
|
||||||
|
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL, parentAuthor.InboxURL }, await Audience(PostVisibility.Unlisted, parentAuthor.ID));
|
||||||
|
Assert.Equal(new[] { follower.SharedInbox, mentioned.InboxURL }, await Audience(PostVisibility.FollowersOnly));
|
||||||
|
Assert.Equal(new[] { mentioned.InboxURL }, await Audience(PostVisibility.Direct, parentAuthor.ID));
|
||||||
|
Assert.Empty(await Audience(PostVisibility.Circle));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_post_is_created_edited_and_deleted_for_its_audience()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (root, alice) = await _harness.Persona("alice");
|
||||||
|
var follower = new RemoteActor(_harness.Peer, "follower");
|
||||||
|
await _harness.FollowedBy(alice, follower);
|
||||||
|
|
||||||
|
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "first", Visibility = "followersonly" }, token);
|
||||||
|
var postId = ((ViewPost)created.Data).Id;
|
||||||
|
await _harness.Posts.UpdatePost(root, new UpdatePostForm { AvatarId = alice.Id, PostId = postId, Text = "second", SpoilerText = "cw" }, token);
|
||||||
|
await _harness.Posts.DeletePost(root, new DeletePostForm { AvatarId = alice.Id, PostId = postId }, token);
|
||||||
|
|
||||||
|
var sent = await _harness.Outgoing(follower.SharedInbox);
|
||||||
|
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
||||||
|
Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue<string>().EndsWith("#Public"));
|
||||||
|
Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue<string>());
|
||||||
|
Assert.NotNull(sent[1]["object"]!["updated"]);
|
||||||
|
Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString());
|
||||||
|
|
||||||
|
var stored = await DB.Default.Find<Post>().OneAsync(postId, token);
|
||||||
|
Assert.NotNull(stored.DeletedAt);
|
||||||
|
Assert.Null(stored.ContentHtml);
|
||||||
|
Assert.Empty(stored.Revisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Someone_elses_post_cannot_be_edited()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (root, alice) = await _harness.Persona("alice");
|
||||||
|
var (otherRoot, bob) = await _harness.Persona("bob");
|
||||||
|
var created = await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "mine" }, token);
|
||||||
|
|
||||||
|
var result = await _harness.Posts.UpdatePost(otherRoot, new UpdatePostForm { AvatarId = bob.Id, PostId = ((ViewPost)created.Data).Id, Text = "yours" }, token);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Equal(404, result.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
|
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Domain.Content;
|
||||||
using PrivaPub.Domain.Social;
|
using PrivaPub.Domain.Social;
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
@@ -11,9 +12,11 @@ using PrivaPub.Federation.Inbox.Handlers;
|
|||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models;
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.Resources;
|
using PrivaPub.Resources;
|
||||||
|
using PrivaPub.Services;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -45,6 +48,9 @@ namespace PrivaPub.Tests.Support
|
|||||||
new UpdateHandler(Db, Local, Remote)
|
new UpdateHandler(Db, Local, Remote)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.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);
|
||||||
|
Posts = new PostsService(Db, Local, Remote, Delivery, Content, Outbox, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Peer Peer { get; }
|
public Peer Peer { get; }
|
||||||
@@ -56,6 +62,19 @@ namespace PrivaPub.Tests.Support
|
|||||||
public InboxReceiver Receiver { get; }
|
public InboxReceiver Receiver { get; }
|
||||||
public InboxProcessor Processor { get; }
|
public InboxProcessor Processor { get; }
|
||||||
public FollowService Follows { get; }
|
public FollowService Follows { get; }
|
||||||
|
public ContentRenderer Content { get; }
|
||||||
|
public OutboxPublisher Outbox { get; }
|
||||||
|
public PostsService Posts { 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 static async Task<Harness> Start() => new(await Peer.Start());
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
public string KeyId => Id + "#main-key";
|
public string KeyId => Id + "#main-key";
|
||||||
|
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
|
||||||
|
|
||||||
public JsonObject Document() => new()
|
public JsonObject Document() => new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
public async Task<IActionResult> Insert(InsertPostForm form, CancellationToken token) =>
|
||||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
!ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token));
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/post/update")]
|
||||||
|
public async Task<IActionResult> Update(UpdatePostForm form, CancellationToken token) =>
|
||||||
|
!ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token));
|
||||||
|
|
||||||
[HttpPost, Route("/clientapi/post/delete")]
|
[HttpPost, Route("/clientapi/post/delete")]
|
||||||
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
|
public async Task<IActionResult> Delete(DeletePostForm form, CancellationToken token) =>
|
||||||
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
|
!ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token));
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
{
|
{
|
||||||
var (local, post) = await PublicPost(actor, postId, token);
|
var (local, post) = await PublicPost(actor, postId, token);
|
||||||
if (post == default)
|
if (post == default)
|
||||||
return NotFound();
|
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||||
if (WantsHtml())
|
if (WantsHtml())
|
||||||
return Redirect(local.PostHtmlUrl(post.ID));
|
return Redirect(local.PostHtmlUrl(post.ID));
|
||||||
|
|
||||||
@@ -159,6 +159,28 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return (local, post);
|
return (local, post);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var local = await _localActors.FindByUserName(actor, token);
|
||||||
|
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
|
||||||
|
return default;
|
||||||
|
var deleted = await _dbEntities.Posts
|
||||||
|
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
|
||||||
|
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||||
|
.ExecuteFirstAsync(token);
|
||||||
|
if (deleted == default)
|
||||||
|
return default;
|
||||||
|
var tombstone = new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
|
["id"] = local.PostUri(deleted.ID),
|
||||||
|
["type"] = "Tombstone",
|
||||||
|
["formerType"] = "Note",
|
||||||
|
["deleted"] = ActivityPubRenderer.Timestamp(deleted.DeletedAt.Value)
|
||||||
|
};
|
||||||
|
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
|
||||||
|
}
|
||||||
|
|
||||||
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
|
async Task<JsonObject> CreateFor(PostEntity post, LocalActor author, CancellationToken token)
|
||||||
{
|
{
|
||||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Rendering;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Outbox
|
||||||
|
{
|
||||||
|
public interface IOutboxPublisher
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
||||||
|
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
||||||
|
Task PublishProfile(LocalActor actor, CancellationToken token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OutboxPublisher : IOutboxPublisher
|
||||||
|
{
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IDeliveryService _delivery;
|
||||||
|
|
||||||
|
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||||
|
{
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
_localActors = localActors;
|
||||||
|
_delivery = delivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly)
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
var inboxes = new List<string>();
|
||||||
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
||||||
|
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
|
||||||
|
|
||||||
|
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
|
||||||
|
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
|
||||||
|
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
foreach (var uri in addressed)
|
||||||
|
{
|
||||||
|
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
||||||
|
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
|
||||||
|
inboxes.Add(actor.InboxURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
|
||||||
|
{
|
||||||
|
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
|
||||||
|
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
|
||||||
|
inboxes.Add(parentAuthor.InboxURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||||
|
{
|
||||||
|
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||||
|
if (group is { IsFederated: true })
|
||||||
|
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
||||||
|
{
|
||||||
|
var inboxes = await Audience(author, post, token);
|
||||||
|
if (inboxes.Count > 0)
|
||||||
|
await _delivery.Enqueue(author, inboxes, activity, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
||||||
|
{
|
||||||
|
var document = ActivityPubRenderer.Actor(actor);
|
||||||
|
document.Remove("@context");
|
||||||
|
var update = new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.Context(),
|
||||||
|
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
|
||||||
|
["type"] = "Update",
|
||||||
|
["actor"] = actor.Uri,
|
||||||
|
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
||||||
|
["cc"] = new JsonArray(actor.Followers),
|
||||||
|
["object"] = document
|
||||||
|
};
|
||||||
|
await _delivery.EnqueueToFollowers(actor, update, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<ILocalActorService, LocalActorService>()
|
.AddSingleton<ILocalActorService, LocalActorService>()
|
||||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||||
|
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
|
||||||
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
||||||
.AddSingleton<IActivityHandler, FollowHandler>()
|
.AddSingleton<IActivityHandler, FollowHandler>()
|
||||||
.AddSingleton<IActivityHandler, AcceptHandler>()
|
.AddSingleton<IActivityHandler, AcceptHandler>()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using PrivaPub.Models.User;
|
|||||||
using PrivaPub.Resources;
|
using PrivaPub.Resources;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
|
|
||||||
namespace PrivaPub.Services.ClientToServer.Private
|
namespace PrivaPub.Services.ClientToServer.Private
|
||||||
{
|
{
|
||||||
@@ -23,16 +24,19 @@ namespace PrivaPub.Services.ClientToServer.Private
|
|||||||
public class PrivateAvatarUsersService : IPrivateAvatarUsersService
|
public class PrivateAvatarUsersService : IPrivateAvatarUsersService
|
||||||
{
|
{
|
||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IOutboxPublisher _outbox;
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly IStringLocalizer<GenericRes> _localizer;
|
readonly IStringLocalizer<GenericRes> _localizer;
|
||||||
readonly ILogger<PrivateAvatarUsersService> _logger;
|
readonly ILogger<PrivateAvatarUsersService> _logger;
|
||||||
|
|
||||||
public PrivateAvatarUsersService(ILocalActorService localActors,
|
public PrivateAvatarUsersService(ILocalActorService localActors,
|
||||||
|
IOutboxPublisher outbox,
|
||||||
DbEntities dbEntities,
|
DbEntities dbEntities,
|
||||||
IStringLocalizer<GenericRes> localizer,
|
IStringLocalizer<GenericRes> localizer,
|
||||||
ILogger<PrivateAvatarUsersService> logger)
|
ILogger<PrivateAvatarUsersService> logger)
|
||||||
{
|
{
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
|
_outbox = outbox;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_localizer = localizer;
|
_localizer = localizer;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -115,6 +119,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
|||||||
avatar.Settings = ToSettings(form.Settings);
|
avatar.Settings = ToSettings(form.Settings);
|
||||||
avatar.UpdatedAt = DateTime.UtcNow;
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
await DB.Default.SaveAsync(avatar);
|
await DB.Default.SaveAsync(avatar);
|
||||||
|
await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default);
|
||||||
|
|
||||||
result.Data = ToView(avatar);
|
result.Data = ToView(avatar);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using PrivaPub.ClientModels.Post;
|
|||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Group;
|
using PrivaPub.Models.Group;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.Resources;
|
using PrivaPub.Resources;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ namespace PrivaPub.Services
|
|||||||
{
|
{
|
||||||
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
|
||||||
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
|
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
|
||||||
|
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
|
||||||
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
|
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
|
||||||
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
|
||||||
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
|
||||||
@@ -38,6 +40,7 @@ namespace PrivaPub.Services
|
|||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IDeliveryService _delivery;
|
readonly IDeliveryService _delivery;
|
||||||
readonly IContentRenderer _content;
|
readonly IContentRenderer _content;
|
||||||
|
readonly IOutboxPublisher _outbox;
|
||||||
readonly IStringLocalizer<GenericRes> _localizer;
|
readonly IStringLocalizer<GenericRes> _localizer;
|
||||||
readonly ILogger<PostsService> _logger;
|
readonly ILogger<PostsService> _logger;
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ namespace PrivaPub.Services
|
|||||||
IRemoteActorService remoteActors,
|
IRemoteActorService remoteActors,
|
||||||
IDeliveryService delivery,
|
IDeliveryService delivery,
|
||||||
IContentRenderer content,
|
IContentRenderer content,
|
||||||
|
IOutboxPublisher outbox,
|
||||||
IStringLocalizer<GenericRes> localizer,
|
IStringLocalizer<GenericRes> localizer,
|
||||||
ILogger<PostsService> logger)
|
ILogger<PostsService> logger)
|
||||||
{
|
{
|
||||||
@@ -54,6 +58,7 @@ namespace PrivaPub.Services
|
|||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
_delivery = delivery;
|
_delivery = delivery;
|
||||||
_content = content;
|
_content = content;
|
||||||
|
_outbox = outbox;
|
||||||
_localizer = localizer;
|
_localizer = localizer;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -86,14 +91,15 @@ namespace PrivaPub.Services
|
|||||||
GroupUserId = author.Id,
|
GroupUserId = author.Id,
|
||||||
AuthorAccountId = author.Id,
|
AuthorAccountId = author.Id,
|
||||||
GroupId = group?.Id,
|
GroupId = group?.Id,
|
||||||
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
|
Visibility = isLocalOnly ? PostVisibility.Circle : LocalVisibility(form.Visibility),
|
||||||
Title = form.Title,
|
Title = form.Title,
|
||||||
|
SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(),
|
||||||
Text = form.Text,
|
Text = form.Text,
|
||||||
ContentHtml = rendered.Html,
|
ContentHtml = rendered.Html,
|
||||||
ContentFormat = ContentFormat.Markdown,
|
ContentFormat = ContentFormat.Markdown,
|
||||||
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
||||||
Tags = rendered.Tags.ToList(),
|
Tags = rendered.Tags.ToList(),
|
||||||
HasContentWarning = form.HasContentWarning,
|
HasContentWarning = form.HasContentWarning || !string.IsNullOrWhiteSpace(form.SpoilerText),
|
||||||
AnsweringToPostId = parent?.ID,
|
AnsweringToPostId = parent?.ID,
|
||||||
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
|
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
|
||||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||||
@@ -120,11 +126,8 @@ namespace PrivaPub.Services
|
|||||||
if (parent != default)
|
if (parent != default)
|
||||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||||
|
|
||||||
var addressedInboxes = rendered.Mentions.Where(m => !m.IsLocal).Select(m => m.Inbox).ToList();
|
await _outbox.Publish(author, post, create, token);
|
||||||
if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId))
|
if (group is { IsFederated: true } && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||||
addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL);
|
|
||||||
await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes);
|
|
||||||
if (group != default)
|
|
||||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
||||||
|
|
||||||
result.Data = ToView(post);
|
result.Data = ToView(post);
|
||||||
@@ -152,35 +155,28 @@ namespace PrivaPub.Services
|
|||||||
if (post == default)
|
if (post == default)
|
||||||
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
if (post.DeletedAt.HasValue)
|
||||||
if (post.IsLocalOnly)
|
|
||||||
return result;
|
return result;
|
||||||
|
var audience = await _outbox.Audience(author, post, token);
|
||||||
|
await DB.Default.Update<PostEntity>().MatchID(post.ID)
|
||||||
|
.Modify(p => p.DeletedAt, DateTime.UtcNow)
|
||||||
|
.Modify(p => p.Text, null)
|
||||||
|
.Modify(p => p.ContentHtml, null)
|
||||||
|
.Modify(p => p.Title, null)
|
||||||
|
.Modify(p => p.SpoilerText, null)
|
||||||
|
.Modify(p => p.Media, new List<PostMedia>())
|
||||||
|
.Modify(p => p.Revisions, new List<PostRevision>())
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID);
|
||||||
|
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||||
|
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||||
|
|
||||||
if (post.Visibility == PostVisibility.Direct)
|
if (audience.Count > 0)
|
||||||
{
|
{
|
||||||
var recipients = new List<string>();
|
|
||||||
foreach (var uri in post.To.Concat(post.Cc).Distinct())
|
|
||||||
{
|
|
||||||
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
|
||||||
if (foreign != default)
|
|
||||||
recipients.Add(foreign.InboxURL);
|
|
||||||
}
|
|
||||||
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
|
||||||
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
|
|
||||||
await _delivery.Enqueue(author, recipients, directDelete, token);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||||
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
|
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()));
|
||||||
var extraInboxes = Enumerable.Empty<string>();
|
await _delivery.Enqueue(author, audience, delete, token);
|
||||||
if (!string.IsNullOrEmpty(post.GroupId))
|
|
||||||
{
|
|
||||||
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
|
||||||
if (group != default)
|
|
||||||
extraInboxes = await _delivery.FollowerInboxes(group, token);
|
|
||||||
}
|
}
|
||||||
await _delivery.EnqueueToFollowers(author, delete, token, extraInboxes);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -190,6 +186,73 @@ namespace PrivaPub.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var result = new WebResult();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
|
||||||
|
if (author == default)
|
||||||
|
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
|
||||||
|
var post = await _dbEntities.Posts
|
||||||
|
.Match(p => p.ID == form.PostId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
||||||
|
.ExecuteFirstAsync(token);
|
||||||
|
if (post == default)
|
||||||
|
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
post.Revisions.Add(new PostRevision
|
||||||
|
{
|
||||||
|
Title = post.Title,
|
||||||
|
SpoilerText = post.SpoilerText,
|
||||||
|
ContentHtml = post.ContentHtml,
|
||||||
|
HasContentWarning = post.HasContentWarning,
|
||||||
|
EditedAt = post.EditedAt ?? post.CreationDate
|
||||||
|
});
|
||||||
|
var rendered = await _content.Markdown(form.Text, token);
|
||||||
|
post.Title = form.Title;
|
||||||
|
post.SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim();
|
||||||
|
post.HasContentWarning = form.HasContentWarning || post.SpoilerText != default;
|
||||||
|
post.Text = form.Text;
|
||||||
|
post.ContentHtml = rendered.Html;
|
||||||
|
post.Mentions = post.Visibility == PostVisibility.Direct
|
||||||
|
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
|
||||||
|
: rendered.Mentions.Select(ToMention).ToList();
|
||||||
|
post.Tags = rendered.Tags.ToList();
|
||||||
|
post.EditedAt = DateTime.UtcNow;
|
||||||
|
post.UpdateDate = post.EditedAt;
|
||||||
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
|
||||||
|
if (!post.IsLocalOnly)
|
||||||
|
{
|
||||||
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||||
|
var note = post.Visibility == PostVisibility.Direct
|
||||||
|
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
||||||
|
: ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
|
||||||
|
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||||
|
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||||
|
var update = new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.Context(),
|
||||||
|
["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"),
|
||||||
|
["type"] = "Update",
|
||||||
|
["actor"] = author.Uri,
|
||||||
|
["to"] = note["to"]!.DeepClone(),
|
||||||
|
["cc"] = note["cc"]!.DeepClone(),
|
||||||
|
["object"] = note
|
||||||
|
};
|
||||||
|
await _outbox.Publish(author, post, update, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Data = ToView(post);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(UpdatePost)}");
|
||||||
|
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
|
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
|
||||||
{
|
{
|
||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
@@ -387,6 +450,13 @@ namespace PrivaPub.Services
|
|||||||
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
|
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"unlisted" => PostVisibility.Unlisted,
|
||||||
|
"followersonly" or "private" => PostVisibility.FollowersOnly,
|
||||||
|
_ => PostVisibility.Public
|
||||||
|
};
|
||||||
|
|
||||||
static PostMention ToMention(ResolvedMention mention) => new()
|
static PostMention ToMention(ResolvedMention mention) => new()
|
||||||
{
|
{
|
||||||
ActorURI = mention.ActorUri,
|
ActorURI = mention.ActorUri,
|
||||||
|
|||||||
Reference in new issue
Block a user