Files
SocialPub/PrivaPub/Services/PostsService.cs
T
thepraandClaude Opus 5.5 e99b76dbd7 Home timelines, mention notifications, and threads that fetch their parents
Fanout writes a TimelineEntry for every persona a post should reach: the
author, local followers of a local author, local followers of a remote one,
the members of a direct conversation or a circle. Mastodon's home rules
apply when it is written: a reply shows only to followers of both sides
(or to the one replied to), a reblog only where reblogs are wanted. A
mention of a local persona becomes a Mention notification.

Inbound posts are now also kept when a persona follows their author.

RemotePosts holds what CreateHandler and backfill share: building a Post
from a note, and fetching a public parent the first time a reply to it
arrives, so its author is known (a reply to an unknown or non-public
parent stays out of home timelines). Each fetched ancestor queues a
FetchAncestors job for the next one, up to ten deep.

/clientapi/timeline/home and /clientapi/notifications (with
/notifications/read) page by max_id for the persona's own root only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:37:41 +02:00

509 lines
20 KiB
C#

using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
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;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services
{
public interface IPostsService
{
Task<WebResult> InsertPost(string rootUserId, InsertPostForm 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> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
}
public class PostsService : IPostsService
{
const int PageSize = 50;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IContentRenderer _content;
readonly IOutboxPublisher _outbox;
readonly IFanout _fanout;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
public PostsService(DbEntities dbEntities,
ILocalActorService localActors,
IRemoteActorService remoteActors,
IDeliveryService delivery,
IContentRenderer content,
IOutboxPublisher outbox,
IFanout fanout,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_content = content;
_outbox = outbox;
_fanout = fanout;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> InsertPost(string rootUserId, InsertPostForm 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);
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<string>();
post.To = Strings(note["to"]);
post.Cc = Strings(note["cc"]);
await DB.Default.SaveAsync(post, token);
if (parent != default)
await DB.Default.Update<PostEntity>().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);
}
}
public async Task<WebResult> 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<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 (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);
}
}
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)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.Posts;
if (string.IsNullOrEmpty(groupId))
query.Match(p => p.GroupUserId == avatar.Id && p.Visibility != PostVisibility.Direct);
else
{
var isMember = await _dbEntities.Groups
.Match(g => g.ID == groupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
query.Match(p => p.GroupId == groupId);
}
var posts = await query.Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token);
result.Data = posts.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> InsertDm(string rootUserId, InsertDmForm 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);
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<GroupMember> { 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<string>();
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<string>();
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<DmGroup>().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);
}
}
public async Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (string.IsNullOrEmpty(dmGroupId))
{
var groups = await _dbEntities.DmGroups
.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.Sort(g => g.UpdatedAt, Order.Descending)
.ExecuteAsync(token);
result.Data = groups.Select(g => new ViewDmGroup
{
Id = g.ID,
Members = g.Members.Select(m => m.AvatarId).ToList(),
UpdatedAt = g.UpdatedAt
}).ToList();
return result;
}
var isMember = await _dbEntities.DmGroups
.Match(g => g.ID == dmGroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
var dms = await _dbEntities.Posts
.Match(p => p.ConversationId == dmGroupId && p.Visibility == PostVisibility.Direct)
.Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
result.Data = dms.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
async Task<GroupMember> 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);
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<PostEntity> 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
{
"unlisted" => PostVisibility.Unlisted,
"followersonly" or "private" => PostVisibility.FollowersOnly,
_ => 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<string> Strings(JsonNode node) =>
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
}
static ViewPost ToView(PostEntity post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
DmGroupId = post.ConversationId,
Visibility = post.Visibility.ToString(),
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
}
}