diff --git a/PrivaPub.ClientModels/Post/InsertPostForm.cs b/PrivaPub.ClientModels/Post/InsertPostForm.cs index 546566c..24b0a23 100644 --- a/PrivaPub.ClientModels/Post/InsertPostForm.cs +++ b/PrivaPub.ClientModels/Post/InsertPostForm.cs @@ -19,6 +19,32 @@ namespace PrivaPub.ClientModels.Post public string GroupId { get; set; } public string AnsweringToPostId { 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 diff --git a/PrivaPub.Tests/Domain/OutboxTests.cs b/PrivaPub.Tests/Domain/OutboxTests.cs new file mode 100644 index 0000000..f34aeae --- /dev/null +++ b/PrivaPub.Tests/Domain/OutboxTests.cs @@ -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 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> 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())); + Assert.DoesNotContain(sent[0]["to"]!.AsArray(), n => n!.GetValue().EndsWith("#Public")); + Assert.Equal("cw", sent[1]["object"]!["summary"]!.GetValue()); + Assert.NotNull(sent[1]["object"]!["updated"]); + Assert.Equal(sent[0]["to"]!.ToJsonString(), sent[2]["to"]!.ToJsonString()); + + var stored = await DB.Default.Find().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); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 6b51e14..a41cc4b 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; +using PrivaPub.Domain.Content; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; @@ -11,9 +12,11 @@ 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; @@ -45,6 +48,9 @@ namespace PrivaPub.Tests.Support new UpdateHandler(Db, Local, Remote) }, NullLogger.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); + Content = new ContentRenderer(Local, Remote); + Outbox = new OutboxPublisher(Db, Local, Delivery); + Posts = new PostsService(Db, Local, Remote, Delivery, Content, Outbox, new KeyLocalizer(), NullLogger.Instance); } public Peer Peer { get; } @@ -56,6 +62,19 @@ namespace PrivaPub.Tests.Support 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 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 Start() => new(await Peer.Start()); diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index e0dc90c..991eaef 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -24,6 +24,7 @@ namespace PrivaPub.Tests.Support public string Name { get; } public string Id { get; } public string KeyId => Id + "#main-key"; + public string SharedInbox => Id.Split("/users/")[0] + "/inbox"; public JsonObject Document() => new() { diff --git a/PrivaPub/Controllers/ClientToServer/PostController.cs b/PrivaPub/Controllers/ClientToServer/PostController.cs index 1f45431..075aa60 100644 --- a/PrivaPub/Controllers/ClientToServer/PostController.cs +++ b/PrivaPub/Controllers/ClientToServer/PostController.cs @@ -32,6 +32,10 @@ namespace PrivaPub.Controllers.ClientToServer public async Task Insert(InsertPostForm form, CancellationToken token) => !ModelState.IsValid ? Invalid() : Answer(await _postsService.InsertPost(User.GetUserId(), form, token)); + [HttpPost, Route("/clientapi/post/update")] + public async Task Update(UpdatePostForm form, CancellationToken token) => + !ModelState.IsValid ? Invalid() : Answer(await _postsService.UpdatePost(User.GetUserId(), form, token)); + [HttpPost, Route("/clientapi/post/delete")] public async Task Delete(DeletePostForm form, CancellationToken token) => !ModelState.IsValid ? Invalid() : Answer(await _postsService.DeletePost(User.GetUserId(), form, token)); diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 56e9fe2..34ba306 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -111,7 +111,7 @@ namespace PrivaPub.Federation.Controllers { var (local, post) = await PublicPost(actor, postId, token); if (post == default) - return NotFound(); + return await Tombstone(actor, postId, token) ?? NotFound(); if (WantsHtml()) return Redirect(local.PostHtmlUrl(post.ID)); @@ -159,6 +159,28 @@ namespace PrivaPub.Federation.Controllers return (local, post); } + async Task 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 CreateFor(PostEntity post, LocalActor author, CancellationToken token) { var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs new file mode 100644 index 0000000..774c7b6 --- /dev/null +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -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> 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> Audience(LocalActor author, PostEntity post, CancellationToken token) + { + if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly) + return Array.Empty(); + + var inboxes = new List(); + 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()) + .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); + } + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index f097727..9909abf 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -57,6 +57,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs index 9eb2b21..30f14b1 100644 --- a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs +++ b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs @@ -10,6 +10,7 @@ using PrivaPub.Models.User; using PrivaPub.Resources; using PrivaPub.StaticServices; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; namespace PrivaPub.Services.ClientToServer.Private { @@ -23,16 +24,19 @@ namespace PrivaPub.Services.ClientToServer.Private public class PrivateAvatarUsersService : IPrivateAvatarUsersService { readonly ILocalActorService _localActors; + readonly IOutboxPublisher _outbox; readonly DbEntities _dbEntities; readonly IStringLocalizer _localizer; readonly ILogger _logger; public PrivateAvatarUsersService(ILocalActorService localActors, + IOutboxPublisher outbox, DbEntities dbEntities, IStringLocalizer localizer, ILogger logger) { _localActors = localActors; + _outbox = outbox; _dbEntities = dbEntities; _localizer = localizer; _logger = logger; @@ -115,6 +119,7 @@ namespace PrivaPub.Services.ClientToServer.Private avatar.Settings = ToSettings(form.Settings); avatar.UpdatedAt = DateTime.UtcNow; await DB.Default.SaveAsync(avatar); + await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default); result.Data = ToView(avatar); return result; diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 5d0be2b..a607af8 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -7,6 +7,7 @@ using PrivaPub.ClientModels.Post; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.Resources; using PrivaPub.StaticServices; @@ -24,6 +25,7 @@ namespace PrivaPub.Services { Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token); Task DeletePost(string rootUserId, DeletePostForm form, CancellationToken token); + Task UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token); Task GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token); Task InsertDm(string rootUserId, InsertDmForm form, CancellationToken token); Task GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token); @@ -38,6 +40,7 @@ namespace PrivaPub.Services readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; readonly IContentRenderer _content; + readonly IOutboxPublisher _outbox; readonly IStringLocalizer _localizer; readonly ILogger _logger; @@ -46,6 +49,7 @@ namespace PrivaPub.Services IRemoteActorService remoteActors, IDeliveryService delivery, IContentRenderer content, + IOutboxPublisher outbox, IStringLocalizer localizer, ILogger logger) { @@ -54,6 +58,7 @@ namespace PrivaPub.Services _remoteActors = remoteActors; _delivery = delivery; _content = content; + _outbox = outbox; _localizer = localizer; _logger = logger; } @@ -86,14 +91,15 @@ namespace PrivaPub.Services GroupUserId = author.Id, AuthorAccountId = author.Id, GroupId = group?.Id, - Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public, + Visibility = isLocalOnly ? PostVisibility.Circle : LocalVisibility(form.Visibility), Title = form.Title, + SpoilerText = string.IsNullOrWhiteSpace(form.SpoilerText) ? default : form.SpoilerText.Trim(), Text = form.Text, ContentHtml = rendered.Html, ContentFormat = ContentFormat.Markdown, Mentions = rendered.Mentions.Select(ToMention).ToList(), Tags = rendered.Tags.ToList(), - HasContentWarning = form.HasContentWarning, + HasContentWarning = form.HasContentWarning || !string.IsNullOrWhiteSpace(form.SpoilerText), AnsweringToPostId = parent?.ID, InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId), InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, @@ -120,11 +126,8 @@ namespace PrivaPub.Services if (parent != default) await DB.Default.Update().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(); - if (parent is { IsFederatedCopy: true } && !string.IsNullOrEmpty(parent.AuthorAccountId)) - addressedInboxes.Add((await _dbEntities.ForeignAvatars.MatchID(parent.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL); - await _delivery.EnqueueToFollowers(author, create, token, addressedInboxes); - if (group != default) + await _outbox.Publish(author, post, create, token); + if (group is { IsFederated: true } && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted) await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); result.Data = ToView(post); @@ -152,35 +155,28 @@ namespace PrivaPub.Services if (post == default) return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); - await DB.Default.DeleteAsync(post.ID); - if (post.IsLocalOnly) + if (post.DeletedAt.HasValue) return result; + var audience = await _outbox.Audience(author, post, token); + await DB.Default.Update().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()) + .Modify(p => p.Revisions, new List()) + .ExecuteAsync(token); + await DB.Default.DeleteAsync(e => e.PostId == post.ID); + if (!string.IsNullOrEmpty(post.AnsweringToPostId)) + await DB.Default.Update().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(); - 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}", + new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray())); + await _delivery.Enqueue(author, audience, delete, token); } - - var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", - new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers)); - var extraInboxes = Enumerable.Empty(); - 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; } catch (Exception ex) @@ -190,6 +186,73 @@ namespace PrivaPub.Services } } + public async Task 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 GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token) { var result = new WebResult(); @@ -387,6 +450,13 @@ namespace PrivaPub.Services : 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() { ActorURI = mention.ActorUri,