Files
SocialPub/PrivaPub/Services/PostsService.cs
T
thepraandClaude Opus 5.5 e9529310fe One Post model for every visibility, read from notes the way peers write them
Post gains what federation and the Mastodon API need: Visibility (Public,
Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account
id, to/cc, the Create's id, url, context, quote, InReplyToURI and the
parent's author, a separate SpoilerText next to the title, language,
mentions, hashtags, remote attachments (alt text, blurhash, focus, size),
reply/favourite/reblog counters, revisions, EditedAt and DeletedAt.

Direct messages are Posts with Visibility Direct and a ConversationId;
migration _005 copies DmPost rows across with their ids and fills the new
fields of existing posts. DmPost is left in place so a rollback still sees
the old messages.

Inbound:
- NoteParser reads Note, Article, Page, Question and media types: content,
  then contentMap, then _misskey_content; summary as the spoiler and name
  as the title; Mention and Hashtag tags; attachments; a PeerTube-style
  list attribution prefers the person over the channel; quote URIs.
- Addressing classifies like Mastodon, finding followers-only by the
  author's own followers URL (now stored on ForeignAvatar), not by a
  "/followers" suffix.
- Create keeps a post when a local persona is addressed or mentioned, when
  it replies to a local post (the parent's reply count goes up) or when a
  community it follows is addressed; an unsolicited public post is not
  stored. Update keeps the previous version as a revision.

The outbox and object endpoints serve only Public and Unlisted posts.

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

414 lines
16 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.Resources;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
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> 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 IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
public PostsService(DbEntities dbEntities,
ILocalActorService localActors,
IRemoteActorService remoteActors,
IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
_delivery = delivery;
_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 post = new PostEntity
{
GroupUserId = author.Id,
AuthorAccountId = author.Id,
GroupId = group?.Id,
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
Title = form.Title,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
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 = post.ObjectURI;
if (isLocalOnly)
{
await DB.Default.SaveAsync(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 _delivery.EnqueueToFollowers(author, create, token);
if (group != default)
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);
await DB.Default.DeleteAsync<PostEntity>(post.ID);
if (post.IsLocalOnly)
return result;
if (post.Visibility == PostVisibility.Direct)
{
var recipients = new List<string>();
foreach (var uri in post.To.Concat(post.Cc).Distinct())
{
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (foreign != default)
recipients.Add(foreign.InboxURL);
}
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
await _delivery.Enqueue(author, recipients, directDelete, token);
return result;
}
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
var extraInboxes = Enumerable.Empty<string>();
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)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(DeletePost)}");
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.Uri}/conversations/{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 = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri
};
dm.ID = (string)dm.GenerateNewID();
dm.ObjectURI = author.PostUri(dm.ID);
dm.Url = dm.ObjectURI;
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);
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 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
};
}
}