S1 and S2 of the roadmap. RemoteActorService: - FetchObject accepts a document only when its id is the address it was served from; a same-origin document naming another address is asked for at that address once (how GoToSocial serves its key URIs), anything else is dropped; - GetActorByKeyId accepts a key only when the actor lists it, its owner is the actor and it lives on the actor's origin, whether the keyId points at the actor or at a key document; - a refetch for a key or an actor happens at most once per five minutes, so a bad signature cannot make us hammer a host; - the cache row is written by one atomic upsert on ActorURI; - every fetch is signed by the instance actor, never by the persona that happened to receive the activity. The inbox refuses an activity whose id is not on its actor's origin, and an Undo of someone else's activity; a Create's object, an Update and a Delete must be on the actor's origin too, and a cross-origin object is refetched from its own origin before it is trusted. Tests: a fake peer on two origins serves forged actors, foreign-owned keys, cross-origin key documents, aliases and a GoToSocial-style key address (integration, PRIVAPUB_TEST_MONGOD=1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
369 lines
13 KiB
C#
369 lines
13 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.Resources;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
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;
|
|
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);
|
|
}
|
|
|
|
var post = new PostEntity
|
|
{
|
|
GroupUserId = author.Id,
|
|
GroupId = group?.Id,
|
|
Title = form.Title,
|
|
Text = form.Text,
|
|
HasContentWarning = form.HasContentWarning,
|
|
AnsweringToPostId = form.AnsweringToPostId,
|
|
ActorURI = author.Uri
|
|
};
|
|
post.ID = (string)post.GenerateNewID();
|
|
post.ObjectURI = author.PostUri(post.ID);
|
|
await DB.Default.SaveAsync(post, token);
|
|
|
|
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
|
|
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
|
|
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
|
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["Error: {0}", ex.Message], 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);
|
|
|
|
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["Error: {0}", ex.Message], 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);
|
|
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["Error: {0}", ex.Message], 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);
|
|
}
|
|
dmGroup = new DmGroup { Members = members };
|
|
dmGroup.ID = (string)dmGroup.GenerateNewID();
|
|
dmGroup.ConversationURI = $"{author.Uri}/conversations/{dmGroup.ID}";
|
|
await DB.Default.SaveAsync(dmGroup, token);
|
|
}
|
|
|
|
var dm = new DmPostEntity
|
|
{
|
|
GroupUserId = author.Id,
|
|
GroupId = dmGroup.ID,
|
|
Text = form.Text,
|
|
HasContentWarning = form.HasContentWarning,
|
|
ActorURI = author.Uri
|
|
};
|
|
dm.ID = (string)dm.GenerateNewID();
|
|
dm.ObjectURI = author.PostUri(dm.ID);
|
|
await DB.Default.SaveAsync(dm, token);
|
|
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
|
|
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));
|
|
}
|
|
|
|
if (inboxes.Count > 0)
|
|
{
|
|
var note = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
|
|
var create = ActivityPubRenderer.Create(author, note, $"create-{dm.ID}");
|
|
await _delivery.Enqueue(author, inboxes, create, token);
|
|
}
|
|
|
|
result.Data = ToView(dm);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(InsertDm)}");
|
|
return result.Invalidate(_localizer["Error: {0}", ex.Message], 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.DmPosts
|
|
.Match(p => p.GroupId == dmGroupId)
|
|
.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["Error: {0}", ex.Message], 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<string> ReplyTarget(string answeringToPostId, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(answeringToPostId))
|
|
return default;
|
|
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
return answeringToPostId;
|
|
var parent = await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token);
|
|
return parent?.ObjectURI;
|
|
}
|
|
|
|
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,
|
|
AnsweringToPostId = post.AnsweringToPostId,
|
|
Title = post.Title,
|
|
Text = post.Text,
|
|
HasContentWarning = post.HasContentWarning,
|
|
IsFederatedCopy = post.IsFederatedCopy,
|
|
CreationDate = post.CreationDate
|
|
};
|
|
|
|
static ViewPost ToView(DmPostEntity post) => new()
|
|
{
|
|
Id = post.ID,
|
|
ObjectURI = post.ObjectURI,
|
|
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
|
|
AuthorActorURI = post.ActorURI,
|
|
DmGroupId = post.GroupId,
|
|
AnsweringToPostId = post.AnsweringToPostId,
|
|
Title = post.Title,
|
|
Text = post.Text,
|
|
HasContentWarning = post.HasContentWarning,
|
|
IsFederatedCopy = post.IsFederatedCopy,
|
|
CreationDate = post.CreationDate
|
|
};
|
|
}
|
|
}
|