From b54cdf78b2d5bc24fa20ebdc17ca9bc95588a191 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:55:22 +0200 Subject: [PATCH] One StatusService behind both client APIs, with outbound likes and boosts 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 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- PrivaPub.Tests/Support/Harness.cs | 5 +- PrivaPub/Domain/Statuses/StatusService.cs | 489 ++++++++++++++++++ .../Controllers/PeasantsController.cs | 34 +- .../Controllers/WellKnownController.cs | 2 +- .../Middleware/SocialPubConfigurations.cs | 2 + PrivaPub/Models/Post/ContentFormat.cs | 3 +- PrivaPub/Services/PostsService.cs | 373 +++---------- PrivaPub/Web/Pages/Pages.cs | 3 +- 8 files changed, 591 insertions(+), 320 deletions(-) create mode 100644 PrivaPub/Domain/Statuses/StatusService.cs diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index a6b4bda..9339f6a 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -6,6 +6,7 @@ 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; @@ -55,7 +56,8 @@ namespace PrivaPub.Tests.Support 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, Fanout, new KeyLocalizer(), NullLogger.Instance); + Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout); + Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer(), NullLogger.Instance); Timelines = new TimelineService(Db, new KeyLocalizer()); } @@ -71,6 +73,7 @@ namespace PrivaPub.Tests.Support 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; } diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs new file mode 100644 index 0000000..104b5ea --- /dev/null +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -0,0 +1,489 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Content; +using PrivaPub.Domain.Privacy; +using PrivaPub.Domain.Social; +using PrivaPub.Domain.Timelines; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Group; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Domain.Statuses +{ + public sealed class StatusDraft + { + public string Text { get; init; } + public bool PlainText { get; init; } + public string Title { get; init; } + public string SpoilerText { get; init; } + public bool Sensitive { get; init; } + public PostVisibility Visibility { get; init; } = PostVisibility.Public; + public string InReplyTo { get; init; } + public string GroupId { get; init; } + public string Language { get; init; } + public string ConversationId { get; init; } + public IReadOnlyList Recipients { get; init; } = Array.Empty(); + } + + public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) + { + public bool Ok => Error == default; + public static StatusOutcome Fail(int status, string error) => new(default, status, error); + } + + public interface IStatusService + { + Task Publish(LocalActor author, StatusDraft draft, CancellationToken token); + Task Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token); + Task Remove(LocalActor author, string postId, CancellationToken token); + Task Favourite(LocalActor me, string postId, bool on, CancellationToken token); + Task Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token); + } + + public class StatusService : IStatusService + { + const int MaxRevisions = 20; + + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + readonly IDeliveryService _delivery; + readonly IContentRenderer _content; + readonly IOutboxPublisher _outbox; + readonly IFanout _fanout; + + public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, + IContentRenderer content, IOutboxPublisher outbox, IFanout fanout) + { + _dbEntities = dbEntities; + _localActors = localActors; + _remoteActors = remoteActors; + _delivery = delivery; + _content = content; + _outbox = outbox; + _fanout = fanout; + } + + public async Task Publish(LocalActor author, StatusDraft draft, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(draft.Text)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); + + LocalActor group = default; + if (!string.IsNullOrEmpty(draft.GroupId)) + { + var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token); + if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found"); + group = _localActors.FromGroup(groupEntity); + } + + var parent = await Parent(draft.InReplyTo, token); + if (!string.IsNullOrEmpty(draft.InReplyTo) && parent == default && !IsRemoteUri(draft.InReplyTo)) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + + var rendered = draft.PlainText ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token); + var isLocalOnly = group is { IsFederated: false }; + var visibility = isLocalOnly ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; + var post = new PostEntity + { + GroupUserId = author.Id, + AuthorAccountId = author.Id, + GroupId = group?.Id, + Visibility = visibility, + Title = Clean(draft.Title), + SpoilerText = Clean(draft.SpoilerText), + HasContentWarning = draft.Sensitive || Clean(draft.SpoilerText) != default, + Text = draft.Text, + ContentHtml = rendered.Html, + ContentFormat = draft.PlainText ? ContentFormat.Plain : ContentFormat.Markdown, + Language = Clean(draft.Language), + Mentions = rendered.Mentions.Select(ToMention).ToList(), + Tags = rendered.Tags.ToList(), + AnsweringToPostId = parent?.ID, + InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default), + InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, + IsLocalOnly = isLocalOnly, + ActorURI = author.Uri + }; + post.ID = (string)post.GenerateNewID(); + post.ObjectURI = author.PostUri(post.ID); + post.Url = author.PostHtmlUrl(post.ID); + + JsonObject create = default; + if (visibility == PostVisibility.Direct) + { + var conversation = await Conversation(author, draft, rendered.Mentions, token); + if (!conversation.Ok) + return StatusOutcome.Fail(conversation.Status, conversation.Error); + var (dmGroup, recipients) = (conversation.Group, conversation.Recipients); + post.ConversationId = dmGroup.ID; + post.ContextURI = dmGroup.ConversationURI; + post.Mentions = recipients.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.LocalId != default, AccountId = r.LocalId ?? r.ForeignId }) + .Concat(post.Mentions) + .DistinctBy(m => m.ActorURI) + .ToList(); + var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI); + create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); + post.To = Strings(note["to"]); + await DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); + } + else if (!isLocalOnly) + { + var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI); + create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); + post.To = Strings(note["to"]); + post.Cc = Strings(note["cc"]); + } + post.ActivityURI = create?["id"]?.GetValue(); + + await DB.Default.SaveAsync(post, token); + if (parent != default) + await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); + await _fanout.Distribute(post, token); + if (create != default) + await _outbox.Publish(author, post, create, token); + if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted) + await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); + return new StatusOutcome(post); + } + + public async Task Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token) + { + var post = await Own(author, postId, token); + if (post == default) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + if (string.IsNullOrWhiteSpace(draft.Text)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); + + post.Revisions.Add(new PostRevision + { + Title = post.Title, + SpoilerText = post.SpoilerText, + ContentHtml = post.ContentHtml, + HasContentWarning = post.HasContentWarning, + EditedAt = post.EditedAt ?? post.CreationDate + }); + if (post.Revisions.Count > MaxRevisions) + post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions); + + var plain = draft.PlainText || post.ContentFormat == ContentFormat.Plain; + var rendered = plain ? await _content.PlainText(draft.Text, token) : await _content.Markdown(draft.Text, token); + post.Title = draft.Title == default ? post.Title : Clean(draft.Title); + post.SpoilerText = Clean(draft.SpoilerText); + post.HasContentWarning = draft.Sensitive || post.SpoilerText != default; + post.Text = draft.Text; + post.ContentHtml = rendered.Html; + post.Language = Clean(draft.Language) ?? post.Language; + 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); + } + return new StatusOutcome(post); + } + + public async Task Remove(LocalActor author, string postId, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy).ExecuteFirstAsync(token); + if (post == default || post.DeletedAt.HasValue) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + if (post.ReblogOfPostId != default) + return await Reblog(author, post.ReblogOfPostId, false, post.Visibility, token); + + 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 || e.ReblogOfPostId == 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 (audience.Count > 0) + { + 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); + } + return new StatusOutcome(post); + } + + public async Task Favourite(LocalActor me, string postId, bool on, CancellationToken token) + { + var post = await Visible(me, postId, token); + if (post == default) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + + var activityId = me.ActivityUri($"like-{post.ID}"); + if (on) + { + try + { + await DB.Default.SaveAsync(new Favourite { AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, ActivityURI = activityId }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new StatusOutcome(post); + } + await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); + post.FavouritesCount++; + if (!post.IsFederatedCopy) + await Notifications.Add(post.GroupUserId, NotificationType.Favourite, me.Id, me.Uri, post.ID, token); + } + else + { + var removed = await DB.Default.DeleteAsync(f => f.AccountId == me.Id && f.PostId == post.ID); + if (removed.DeletedCount == 0) + return new StatusOutcome(post); + await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); + post.FavouritesCount--; + } + + if (post.IsFederatedCopy && await AuthorInbox(post, token) is { } inbox) + { + var like = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = activityId, + ["type"] = "Like", + ["actor"] = me.Uri, + ["object"] = post.ObjectURI + }; + await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token); + } + return new StatusOutcome(post); + } + + public async Task Reblog(LocalActor me, string postId, bool on, PostVisibility visibility, CancellationToken token) + { + var original = await Visible(me, postId, token); + if (original?.ReblogOfPostId != default) + original = await Visible(me, original.ReblogOfPostId, token); + if (original == default) + return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); + if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted"); + + var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + var announceId = me.ActivityUri($"announce-{original.ID}"); + var announce = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = announceId, + ["type"] = "Announce", + ["actor"] = me.Uri, + ["published"] = ActivityPubRenderer.Timestamp(DateTime.UtcNow), + ["to"] = new JsonArray(visibility == PostVisibility.Unlisted ? me.Followers : ActivityPubRenderer.Public), + ["cc"] = new JsonArray(visibility == PostVisibility.Unlisted ? ActivityPubRenderer.Public : me.Followers, original.ActorURI), + ["object"] = original.ObjectURI + }; + + if (on) + { + if (existing != default) + return new StatusOutcome(existing); + var reblog = new PostEntity + { + GroupUserId = me.Id, + AuthorAccountId = me.Id, + ReblogOfPostId = original.ID, + Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public, + ObjectURI = announceId, + ActivityURI = announceId, + ActorURI = me.Uri, + To = Strings(announce["to"]), + Cc = Strings(announce["cc"]) + }; + try + { + await DB.Default.SaveAsync(reblog, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new StatusOutcome(await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteFirstAsync(token)); + } + await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); + if (!original.IsFederatedCopy) + await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token); + await _fanout.Distribute(reblog, token); + await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token) is { } inbox ? new[] { inbox } : default); + return new StatusOutcome(reblog); + } + + if (existing == default) + return new StatusOutcome(original); + await DB.Default.DeleteAsync(existing.ID); + await DB.Default.DeleteAsync(e => e.PostId == existing.ID); + await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); + await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token, + await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default); + return new StatusOutcome(original); + } + + sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox); + + sealed record ConversationResult(DmGroup Group, IReadOnlyList Recipients, int Status = StatusCodes.Status200OK, string Error = default) + { + public bool Ok => Error == default; + } + + async Task Conversation(LocalActor author, StatusDraft draft, IReadOnlyList mentioned, CancellationToken token) + { + DmGroup dmGroup = default; + if (!string.IsNullOrEmpty(draft.ConversationId)) + { + dmGroup = await _dbEntities.DmGroups.MatchID(draft.ConversationId).ExecuteFirstAsync(token); + if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) + return new(default, default, StatusCodes.Status404NotFound, "Conversation not found"); + } + else + { + var members = new List { new() { AvatarId = author.Id } }; + foreach (var handle in draft.Recipients.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var member = await ResolveRecipient(handle, token); + if (member == default) + return new(default, default, StatusCodes.Status404NotFound, $"Recipient '{handle}' not found"); + if (members.All(m => m.AvatarId != member.AvatarId)) + members.Add(member); + } + foreach (var mention in mentioned.Where(m => m.AccountId != author.Id)) + { + var member = mention.IsLocal ? new GroupMember { AvatarId = mention.AccountId } : new GroupMember { AvatarId = mention.ActorUri, IsForeign = true }; + if (members.All(m => m.AvatarId != member.AvatarId)) + members.Add(member); + } + if (members.Count < 2) + return new(default, default, StatusCodes.Status422UnprocessableEntity, "Validation failed: A direct message needs at least one recipient"); + + var key = DmGroup.KeyOf(members); + dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); + if (dmGroup == default) + { + dmGroup = new DmGroup { Members = members, ParticipantsKey = key }; + dmGroup.ID = (string)dmGroup.GenerateNewID(); + dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID); + await DB.Default.SaveAsync(dmGroup, token); + } + } + + var recipients = new List(); + foreach (var member in dmGroup.Members.Where(m => !(m.AvatarId == author.Id && !m.IsForeign))) + { + if (member.IsForeign) + { + var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token); + if (foreign != default) + recipients.Add(new Recipient(foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}", default, foreign.ID, foreign.InboxURL)); + } + else if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } local) + recipients.Add(new Recipient(local.Uri, local.Handle, local.Id, default, default)); + } + return new(dmGroup, recipients); + } + + async Task ResolveRecipient(string recipient, CancellationToken token) + { + var handle = recipient.Trim().TrimStart('@'); + if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase)) + { + var local = await _localActors.FindByUserName(handle.Split('@')[0], token); + return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default; + } + var actorUri = await _remoteActors.ResolveHandle(handle, token); + var foreign = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token); + return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; + } + + async Task Parent(string reference, CancellationToken token) + { + if (string.IsNullOrEmpty(reference)) + return default; + return IsRemoteUri(reference) + ? await _dbEntities.Posts.Match(p => p.ObjectURI == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) + : await _dbEntities.Posts.Match(p => p.ID == reference && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + } + + async Task Own(LocalActor author, string postId, CancellationToken token) => + await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupUserId == author.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) + .ExecuteFirstAsync(token); + + async Task Visible(LocalActor me, string postId, CancellationToken token) + { + var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + return post != default && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default; + } + + async Task AuthorInbox(PostEntity post, CancellationToken token) + { + if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId)) + return default; + return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL; + } + + static JsonObject Undo(LocalActor actor, JsonObject inner, string activityId) => new() + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = actor.ActivityUri(activityId), + ["type"] = "Undo", + ["actor"] = actor.Uri, + ["object"] = inner.DeepClone() + }; + + static PostMention ToMention(ResolvedMention mention) => new() + { + ActorURI = mention.ActorUri, + Handle = "@" + mention.Handle, + IsLocal = mention.IsLocal, + AccountId = mention.AccountId + }; + + static bool IsRemoteUri(string reference) => + reference != default && (reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase)); + + static string Clean(string value) => string.IsNullOrWhiteSpace(value) ? default : value.Trim(); + + static List Strings(JsonNode node) => + node is JsonArray array ? array.Select(n => n?.GetValue()).Where(s => s != default).ToList() : new List(); + } +} diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 34ba306..f5f646a 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -80,6 +80,12 @@ namespace PrivaPub.Federation.Controllers items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}")); continue; } + if (post.ReblogOfPostId != default) + { + if (await AnnounceFor(post, token) is { } announce) + items.Add(announce); + continue; + } items.Add(await CreateFor(post, local, token)); } @@ -124,6 +130,14 @@ namespace PrivaPub.Federation.Controllers [HttpGet, Route("{actor}/grunts/{activityId}")] public async Task Grunt(string actor, string activityId, CancellationToken token) { + if (activityId.StartsWith("announce-", StringComparison.Ordinal)) + { + var booster = await _localActors.FindByUserName(actor, token); + var uri = booster?.ActivityUri(activityId); + var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + var rendered = reblog == default ? default : await AnnounceFor(reblog, token); + return rendered == default ? NotFound() : Activity(rendered); + } if (!activityId.StartsWith("create-", StringComparison.Ordinal)) return NotFound(); var (local, post) = await PublicPost(actor, activityId["create-".Length..], token); @@ -153,7 +167,7 @@ namespace PrivaPub.Federation.Controllers if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) return default; var post = await _dbEntities.Posts - .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy) + .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null) .Match(VisibilityPolicy.IsPublic) .ExecuteFirstAsync(token); return (local, post); @@ -181,6 +195,24 @@ namespace PrivaPub.Federation.Controllers return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone }; } + async Task AnnounceFor(PostEntity reblog, CancellationToken token) + { + var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token); + if (original == default || original.DeletedAt.HasValue) + return default; + return new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = reblog.ObjectURI, + ["type"] = "Announce", + ["actor"] = reblog.ActorURI, + ["published"] = ActivityPubRenderer.Timestamp(reblog.CreationDate), + ["to"] = new JsonArray(reblog.To.Select(t => (JsonNode)t).ToArray()), + ["cc"] = new JsonArray(reblog.Cc.Select(c => (JsonNode)c).ToArray()), + ["object"] = original.ObjectURI + }; + } + 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/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 1aa45ea..b877a8d 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -85,7 +85,7 @@ namespace PrivaPub.Federation.Controllers public async Task NodeInfo(string version, CancellationToken token) { var users = await DB.Default.CountAsync(a => !a.DeletionAt.HasValue, token); - var posts = await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token); + var posts = await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy && p.ReblogOfPostId == null) & f.Where(VisibilityPolicy.IsPublic), token); var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref }; if (version == "2.1") { diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 7aaff47..ebea1ba 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -20,6 +20,7 @@ using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Domain.Content; using PrivaPub.Domain.Social; +using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Timelines; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; @@ -136,6 +137,7 @@ namespace PrivaPub.Middleware .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddSingleton() diff --git a/PrivaPub/Models/Post/ContentFormat.cs b/PrivaPub/Models/Post/ContentFormat.cs index 8d63605..d0f1034 100644 --- a/PrivaPub/Models/Post/ContentFormat.cs +++ b/PrivaPub/Models/Post/ContentFormat.cs @@ -3,6 +3,7 @@ namespace PrivaPub.Models.Post public enum ContentFormat { Markdown, - Html + Html, + Plain } } diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 844224a..27c08c0 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -14,7 +14,7 @@ using PrivaPub.StaticServices; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; -using PrivaPub.Domain.Content; +using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; @@ -38,225 +38,62 @@ namespace PrivaPub.Services readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; - readonly IRemoteActorService _remoteActors; - readonly IDeliveryService _delivery; - readonly IContentRenderer _content; - readonly IOutboxPublisher _outbox; - readonly IFanout _fanout; + readonly IStatusService _statuses; readonly IStringLocalizer _localizer; readonly ILogger _logger; public PostsService(DbEntities dbEntities, ILocalActorService localActors, - IRemoteActorService remoteActors, - IDeliveryService delivery, - IContentRenderer content, - IOutboxPublisher outbox, - IFanout fanout, + IStatusService statuses, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _localActors = localActors; - _remoteActors = remoteActors; - _delivery = delivery; - _content = content; - _outbox = outbox; - _fanout = fanout; + _statuses = statuses; _localizer = localizer; _logger = logger; } public async Task InsertPost(string rootUserId, InsertPostForm form, CancellationToken token) { - var result = new WebResult(); - try + var author = await OwnedAvatar(rootUserId, form.AvatarId, token); + if (author == default) + return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); + return await Answer(() => _statuses.Publish(author, new StatusDraft { - var author = await OwnedAvatar(rootUserId, form.AvatarId, token); - if (author == default) - return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); - - LocalActor group = default; - var isLocalOnly = false; - if (!string.IsNullOrEmpty(form.GroupId)) - { - var groupEntity = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token); - if (groupEntity == default || groupEntity.DeletionAt.HasValue - || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId)) - return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); - group = _localActors.FromGroup(groupEntity); - isLocalOnly = !group.IsFederated; - } - - var parent = await Parent(form.AnsweringToPostId, token); - var rendered = await _content.Markdown(form.Text, token); - var post = new PostEntity - { - GroupUserId = author.Id, - AuthorAccountId = author.Id, - GroupId = group?.Id, - 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 || !string.IsNullOrWhiteSpace(form.SpoilerText), - AnsweringToPostId = parent?.ID, - InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId), - InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, - IsLocalOnly = isLocalOnly, - ActorURI = author.Uri - }; - post.ID = (string)post.GenerateNewID(); - post.ObjectURI = author.PostUri(post.ID); - post.Url = author.PostHtmlUrl(post.ID); - - if (isLocalOnly) - { - await DB.Default.SaveAsync(post, token); - await _fanout.Distribute(post, token); - result.Data = ToView(post); - return result; - } - - var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI); - var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); - post.ActivityURI = create["id"]!.GetValue(); - post.To = Strings(note["to"]); - post.Cc = Strings(note["cc"]); - await DB.Default.SaveAsync(post, token); - if (parent != default) - await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); - - await _fanout.Distribute(post, token); - 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); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertPost)}"); - return result.Invalidate(_localizer["Something went wrong."], exception: ex); - } + Text = form.Text, + Title = form.Title, + SpoilerText = form.SpoilerText, + Sensitive = form.HasContentWarning, + Visibility = LocalVisibility(form.Visibility), + InReplyTo = form.AnsweringToPostId, + GroupId = form.GroupId + }, token), nameof(InsertPost)); } public async Task DeletePost(string rootUserId, DeletePostForm 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) - .ExecuteFirstAsync(token); - if (post == default) - return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound); - - 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 (audience.Count > 0) - { - 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); - } - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}"); - return result.Invalidate(_localizer["Something went wrong."], exception: ex); - } + var author = await OwnedAvatar(rootUserId, form.AvatarId, token); + if (author == default) + return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); + var result = await Answer(() => _statuses.Remove(author, form.PostId, token), nameof(DeletePost)); + result.Data = default; + return result; } 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 new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); + return await Answer(() => _statuses.Edit(author, form.PostId, new StatusDraft { - 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); - } + Text = form.Text, + Title = form.Title, + SpoilerText = form.SpoilerText, + Sensitive = form.HasContentWarning + }, token), nameof(UpdatePost)); } public async Task GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token) @@ -294,97 +131,19 @@ namespace PrivaPub.Services public async Task InsertDm(string rootUserId, InsertDmForm form, CancellationToken token) { - var result = new WebResult(); - try + var author = await OwnedAvatar(rootUserId, form.AvatarId, token); + if (author == default) + return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); + if (string.IsNullOrEmpty(form.DmGroupId) && form.Recipients.Count == 0) + return new WebResult().Invalidate(_localizer["At least one recipient is required."]); + return await Answer(() => _statuses.Publish(author, new StatusDraft { - var author = await OwnedAvatar(rootUserId, form.AvatarId, token); - if (author == default) - return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); - - DmGroup dmGroup; - if (!string.IsNullOrEmpty(form.DmGroupId)) - { - dmGroup = await _dbEntities.DmGroups.MatchID(form.DmGroupId).ExecuteFirstAsync(token); - if (dmGroup == default || !dmGroup.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) - return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound); - } - else - { - if (form.Recipients.Count == 0) - return result.Invalidate(_localizer["At least one recipient is required."]); - var members = new List { new() { AvatarId = author.Id } }; - foreach (var recipient in form.Recipients.Distinct(StringComparer.OrdinalIgnoreCase)) - { - var member = await ResolveRecipient(recipient, token); - if (member == default) - return result.Invalidate(_localizer["Recipient '{0}' not found.", recipient], StatusCodes.Status404NotFound); - if (members.All(m => m.AvatarId != member.AvatarId)) - members.Add(member); - } - var key = DmGroup.KeyOf(members); - dmGroup = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); - if (dmGroup == default) - { - dmGroup = new DmGroup { Members = members, ParticipantsKey = key }; - dmGroup.ID = (string)dmGroup.GenerateNewID(); - dmGroup.ConversationURI = author.ConversationUri(dmGroup.ID); - await DB.Default.SaveAsync(dmGroup, token); - } - } - - var dm = new PostEntity - { - GroupUserId = author.Id, - AuthorAccountId = author.Id, - ConversationId = dmGroup.ID, - Visibility = PostVisibility.Direct, - ContextURI = dmGroup.ConversationURI, - Text = form.Text, - ContentHtml = (await _content.Markdown(form.Text, token)).Html, - ContentFormat = ContentFormat.Markdown, - HasContentWarning = form.HasContentWarning, - ActorURI = author.Uri - }; - dm.ID = (string)dm.GenerateNewID(); - dm.ObjectURI = author.PostUri(dm.ID); - dm.Url = author.PostHtmlUrl(dm.ID); - - var remote = new List<(string Uri, string Handle)>(); - var inboxes = new List(); - foreach (var member in dmGroup.Members.Where(m => m.IsForeign)) - { - var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token); - if (foreign == default) - continue; - remote.Add((foreign.ActorURI, $"{foreign.UserName}@{foreign.Domain}")); - inboxes.Add(foreign.InboxURL); - } - foreach (var member in dmGroup.Members.Where(m => !m.IsForeign && m.AvatarId != author.Id)) - { - var local = await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token); - if (local != default) - remote.Add((local.Uri, local.Handle)); - } - - var directNote = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI); - var directCreate = ActivityPubRenderer.Create(author, directNote, $"create-{dm.ID}"); - dm.ActivityURI = directCreate["id"]!.GetValue(); - dm.To = Strings(directNote["to"]); - dm.Mentions = remote.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.Uri.StartsWith(author.BaseAddress + "/") }).ToList(); - await DB.Default.SaveAsync(dm, token); - await DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); - await _fanout.Distribute(dm, token); - if (inboxes.Count > 0) - await _delivery.Enqueue(author, inboxes, directCreate, token); - - result.Data = ToView(dm); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}"); - return result.Invalidate(_localizer["Something went wrong."], exception: ex); - } + Text = form.Text, + Sensitive = form.HasContentWarning, + Visibility = PostVisibility.Direct, + ConversationId = form.DmGroupId, + Recipients = form.Recipients + }, token), nameof(InsertDm)); } public async Task GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token) @@ -432,29 +191,24 @@ namespace PrivaPub.Services } } - async Task ResolveRecipient(string recipient, CancellationToken token) + + + async Task Answer(Func> action, string operation) { - var handle = recipient.Trim().TrimStart('@'); - if (!handle.Contains('@') || handle.EndsWith("@" + new Uri(_localActors.BaseAddress).Authority, StringComparison.OrdinalIgnoreCase)) + var result = new WebResult(); + try { - var local = await _localActors.FindByUserName(handle.Split('@')[0], token); - return local is { Kind: LocalActorKind.Person } ? new GroupMember { AvatarId = local.Id } : default; + var outcome = await action(); + if (!outcome.Ok) + return result.Invalidate(_localizer[outcome.Error], outcome.Status); + result.Data = ToView(outcome.Post); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "{Service}.{Operation}", nameof(PostsService), operation); + return result.Invalidate(_localizer["Something went wrong."], exception: ex); } - - var actorUri = await _remoteActors.ResolveHandle(handle, token); - if (actorUri == default) - return default; - var foreign = await _remoteActors.GetActor(actorUri, refresh: false, token); - return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true }; - } - - async Task Parent(string answeringTo, CancellationToken token) - { - if (string.IsNullOrEmpty(answeringTo)) - return default; - return answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - ? await _dbEntities.Posts.Match(p => p.ObjectURI == answeringTo).ExecuteFirstAsync(token) - : await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token); } static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch @@ -464,19 +218,8 @@ namespace PrivaPub.Services _ => PostVisibility.Public }; - static PostMention ToMention(ResolvedMention mention) => new() - { - ActorURI = mention.ActorUri, - Handle = "@" + mention.Handle, - IsLocal = mention.IsLocal, - AccountId = mention.AccountId - }; - static string RemoteUri(string answeringTo) => - answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default; - static List Strings(JsonNode node) => - node is JsonArray array ? array.Select(n => n?.GetValue()).Where(s => s != default).ToList() : new List(); async Task OwnedAvatar(string rootUserId, string avatarId, CancellationToken token) { diff --git a/PrivaPub/Web/Pages/Pages.cs b/PrivaPub/Web/Pages/Pages.cs index 0d25af2..930ea38 100644 --- a/PrivaPub/Web/Pages/Pages.cs +++ b/PrivaPub/Web/Pages/Pages.cs @@ -72,6 +72,7 @@ namespace PrivaPub.Web.Pages ? _dbEntities.Posts.Match(p => p.GroupId == Actor.Id) : _dbEntities.Posts.Match(p => p.GroupUserId == Actor.Id && !p.IsFederatedCopy)) .Match(VisibilityPolicy.IsPublic) + .Match(p => p.ReblogOfPostId == null) .Sort(p => p.ID, Order.Descending) .Limit(PageSize) .ExecuteAsync(token); @@ -112,7 +113,7 @@ namespace PrivaPub.Web.Pages if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person }) return NotFound(); var post = await _dbEntities.Posts - .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy) + .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null) .Match(VisibilityPolicy.IsPublic) .ExecuteFirstAsync(token); if (post == default)