Circles (owner decision 2026-10-04: fix them for compatibility):
- Mastodon 4.7 and GoToSocial drop a post that names none of their accounts, and a circle post named only the circle
and its /flock. OutboxPublisher.Publish now sends each member a copy that also names that member in `cc`, on the
activity and on the object, and names no other member. The Create, every Update (edit, poll, quote approval, policy,
through the new PublishUpdate) and the Delete (StatusService.Remove now uses Publish) all go that way.
- UpdateOf renders with the post's group, so an Update keeps a circle post's `audience` and a community post's `Page`
and title.
- A reply to a circle post stays in the circle, whichever client wrote it.
- A circle post can no longer quote a post that needs permission: asking would show the circle post to its author.
Posts that are not public, on refetch (SignedFetchAuthorizer.MayRead):
- Followers-only, direct and circle posts are served to a signed request from someone they were for, or from the
instance actor of a server where one of them lives. That is a follower or an addressed account, an addressed
account, or a member. Everyone else still gets 404.
- Once deleted they answer those readers 410. Mastodon deletes its copy when a refetch answers 404.
- A circle refetch names the requesting member, or the members on the requesting server, as the delivered copy did.
- /grunts/create-{id} serves the same.
- /peasants/{name}/whispers/{id}, a DM's `context`, was never routed. It is now the conversation's posts, for its
participants only.
SecureMode lets browsers through to the redirect to the public page, instead of answering them 401.
653 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
646 lines
30 KiB
C#
646 lines
30 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Media;
|
|
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.Media;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
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<string> Recipients { get; init; } = Array.Empty<string>();
|
|
public IReadOnlyList<string> MediaIds { get; init; }
|
|
public double? Latitude { get; init; }
|
|
public double? Longitude { get; init; }
|
|
public double? RangeKm { get; init; }
|
|
public PollDraft Poll { get; init; }
|
|
public string QuotedStatusId { get; init; }
|
|
public string QuotePolicy { get; init; }
|
|
}
|
|
|
|
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<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token);
|
|
Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token);
|
|
Task<StatusOutcome> Remove(LocalActor author, string postId, CancellationToken token);
|
|
Task<StatusOutcome> Favourite(LocalActor me, string postId, bool on, CancellationToken token);
|
|
Task<StatusOutcome> 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;
|
|
readonly IMediaService _media;
|
|
readonly IGroupDistributor _groups;
|
|
readonly IPollService _polls;
|
|
readonly ILinkPreviews _previews;
|
|
readonly IQuoteService _quotes;
|
|
|
|
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
|
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
|
|
ILinkPreviews previews, IQuoteService quotes)
|
|
{
|
|
_previews = previews;
|
|
_quotes = quotes;
|
|
_polls = polls;
|
|
_media = media;
|
|
_groups = groups;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
_delivery = delivery;
|
|
_content = content;
|
|
_outbox = outbox;
|
|
_fanout = fanout;
|
|
}
|
|
|
|
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
|
|
{
|
|
var media = await Media(author, draft.MediaIds, default, token);
|
|
if (media == default)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
|
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
|
if (draft.Poll != default && _polls.Invalid(draft.Poll) is { } invalidPoll)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, invalidPoll);
|
|
|
|
LocalActor group = default;
|
|
if (!string.IsNullOrEmpty(draft.GroupId))
|
|
{
|
|
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
|
|
if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token))
|
|
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");
|
|
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
|
|
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
|
|
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
|
|
&& await MayPost(parentCircle, author, token))
|
|
group = _localActors.FromGroup(parentCircle);
|
|
|
|
PostEntity quoted = default;
|
|
var quotePermission = QuotePermission.Denied;
|
|
if (!string.IsNullOrEmpty(draft.QuotedStatusId))
|
|
{
|
|
quoted = await _dbEntities.Posts.Match(p => p.ID == draft.QuotedStatusId && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
|
if (quoted == default || !await VisibilityPolicy.CanSee(quoted, author.Id, token))
|
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
|
quotePermission = await _quotes.Permission(quoted, author, token);
|
|
if (quotePermission == QuotePermission.Denied)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
|
|
}
|
|
|
|
var located = draft.Latitude.HasValue || draft.Longitude.HasValue;
|
|
if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|
|
|| draft.Visibility == PostVisibility.Direct))
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients");
|
|
|
|
var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
|
string audienceUri = default;
|
|
if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
|
foreach (var mention in rendered.Mentions)
|
|
{
|
|
if (mention.IsLocal)
|
|
{
|
|
var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token);
|
|
if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token))
|
|
{
|
|
group = _localActors.FromGroup(mentioned);
|
|
break;
|
|
}
|
|
}
|
|
else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup)
|
|
{
|
|
audienceUri = remoteGroup.ActorURI;
|
|
break;
|
|
}
|
|
}
|
|
var isLocalOnly = located;
|
|
var visibility = located ? PostVisibility.LocalGeo
|
|
: group is { IsCircle: true } ? PostVisibility.Circle
|
|
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
|
// asking a quoted post's author for permission would show them the circle post
|
|
if (visibility == PostVisibility.Circle && quoted != default && quotePermission != QuotePermission.Granted)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A circle post can only quote a post that needs no permission");
|
|
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(),
|
|
Media = media.Select(ToPostMedia).ToList(),
|
|
AudienceURI = audienceUri,
|
|
AnsweringToPostId = parent?.ID,
|
|
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
|
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
|
IsLocalOnly = isLocalOnly,
|
|
ActorURI = author.Uri,
|
|
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll),
|
|
QuoteURI = quoted?.ObjectURI,
|
|
QuotedPostId = quoted?.ID,
|
|
QuoteByConsent = quoted != default && (quoted.QuotePolicy != default || !quoted.IsFederatedCopy),
|
|
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending
|
|
};
|
|
post.ID = (string)post.GenerateNewID();
|
|
post.ObjectURI = author.PostUri(post.ID);
|
|
post.Url = author.PostHtmlUrl(post.ID);
|
|
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
|
|
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
|
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
|
post.QuoteAuthorizationURI = await _quotes.Grant(quotedAuthor, quoted, post.ObjectURI, author.Uri, token);
|
|
if (located)
|
|
{
|
|
post.Geo = new Coordinates2D(Math.Round(draft.Longitude.Value, 2), Math.Round(draft.Latitude.Value, 2));
|
|
post.RangeKm = (float)Math.Clamp(draft.RangeKm ?? 5, 1, 50);
|
|
}
|
|
|
|
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<DmGroup>().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<string>();
|
|
|
|
await DB.Default.SaveAsync(post, token);
|
|
await Attach(media, post.ID, 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 _polls.Scheduled(post, token);
|
|
if (post.QuoteState == QuoteState.Accepted)
|
|
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
|
if (post.QuoteState == QuoteState.Pending && create?["object"] is JsonObject quotingNote)
|
|
await _quotes.Request(author, post, quoted, quotingNote, token);
|
|
await _previews.Wanted(post, token);
|
|
if (create != default)
|
|
await _outbox.Publish(author, post, create, token);
|
|
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
|
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token);
|
|
return new StatusOutcome(post);
|
|
}
|
|
|
|
public async Task<StatusOutcome> 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");
|
|
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token);
|
|
if (draft.MediaIds != default && media == default)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
|
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).Count == 0 && post.Media.Count == 0)
|
|
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 ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
|
if (media != default)
|
|
{
|
|
post.Media = media.Select(ToPostMedia).ToList();
|
|
await Attach(media, post.ID, 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);
|
|
if (group is { IsCircle: false })
|
|
await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, token);
|
|
}
|
|
return new StatusOutcome(post);
|
|
}
|
|
|
|
public async Task<StatusOutcome> 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<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 || e.ReblogOfPostId == 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 _outbox.Publish(author, post, delete, token);//the post in hand still has its group and mentions
|
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
|
if (group is { IsCircle: false })
|
|
await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, token);
|
|
}
|
|
return new StatusOutcome(post);
|
|
}
|
|
|
|
public async Task<StatusOutcome> 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<PostEntity>().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<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID);
|
|
if (removed.DeletedCount == 0)
|
|
return new StatusOutcome(post);
|
|
await DB.Default.Update<PostEntity>().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<StatusOutcome> 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<PostEntity>().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<PostEntity>(existing.ID);
|
|
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == existing.ID);
|
|
await DB.Default.Update<PostEntity>().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<Recipient> Recipients, int Status = StatusCodes.Status200OK, string Error = default)
|
|
{
|
|
public bool Ok => Error == default;
|
|
}
|
|
|
|
async Task<ConversationResult> Conversation(LocalActor author, StatusDraft draft, IReadOnlyList<ResolvedMention> 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<GroupMember> { 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<Recipient>();
|
|
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<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);
|
|
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<PostEntity> 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<PostEntity> 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<PostEntity> 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<string> AuthorInbox(PostEntity post, CancellationToken token)
|
|
{
|
|
if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId))
|
|
return default;
|
|
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
|
|
}
|
|
|
|
async Task<bool> MayPost(Models.Group.Group group, LocalActor author, CancellationToken token)
|
|
{
|
|
var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id);
|
|
if (group.Kind == GroupKind.Circle)
|
|
return member != default;
|
|
return group.PostingPolicy switch
|
|
{
|
|
PostingPolicy.Anyone => true,
|
|
PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator,
|
|
_ => member != default || await _dbEntities.Followings
|
|
.Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
|
|
};
|
|
}
|
|
|
|
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
|
|
{
|
|
if (ids == default || ids.Count == 0)
|
|
return new List<MediaAttachment>();
|
|
if (ids.Count > 4)
|
|
return default;
|
|
var wanted = ids.Distinct().ToList();
|
|
var found = await DB.Default.Find<MediaAttachment>()
|
|
.Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId))
|
|
.ExecuteAsync(token);
|
|
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
|
}
|
|
|
|
static async Task Attach(IEnumerable<MediaAttachment> media, string postId, CancellationToken token)
|
|
{
|
|
var ids = media.Select(m => m.ID).ToList();
|
|
if (ids.Count > 0)
|
|
await DB.Default.Update<MediaAttachment>().Match(m => ids.Contains(m.ID))
|
|
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
}
|
|
|
|
PostMedia ToPostMedia(MediaAttachment attachment) => new()
|
|
{
|
|
AttachmentId = attachment.ID,
|
|
ContentType = attachment.ContentType,
|
|
URL = _media.Url(attachment.FilePath),
|
|
PreviewURL = _media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
|
Description = attachment.Description,
|
|
Blurhash = attachment.Blurhash,
|
|
Width = attachment.Width,
|
|
Height = attachment.Height,
|
|
Focus = attachment.Focus
|
|
};
|
|
|
|
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<string> Strings(JsonNode node) =>
|
|
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
|
|
}
|
|
}
|