PUT /api/v1/media/:id on a picture already posted changed the upload but never the post, which keeps its own copy, so the new description or focal point reached nobody. As on Mastodon, PUT now takes only media not posted yet (404 otherwise), and a status edit takes media_attributes[] (id, description, focus; from a JSON body or a form): the uploads and the post's copies change, and the Update the edit federates carries them. An edit that sends no media keeps them as they were described. No pasture scenario edits a description, so the sweep could not see this. MediaLifecycleTests: PUT on a posted picture is refused, an edit describes it again and keeps its warning, flag and language as sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
794 lines
41 KiB
C#
794 lines
41 KiB
C#
using MongoDB.Bson;
|
|
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
|
|
{
|
|
// an attachment's description or focal point changed by an edit (Mastodon's media_attributes)
|
|
public sealed record MediaChange(string Id, bool HasDescription, string Description, string Focus);
|
|
|
|
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 IReadOnlyList<MediaChange> MediaChanges { get; init; } = Array.Empty<MediaChange>();
|
|
// the scheduled post being published, whose media are reserved for it alone
|
|
public string ScheduledStatusId { 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;
|
|
readonly IInteractionApprovals _approvals;
|
|
|
|
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, IInteractionApprovals approvals = default)
|
|
{
|
|
_approvals = approvals;
|
|
_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 const int MaxCharacters = 5000;//what the instance API advertises as max_characters, and enforced here
|
|
|
|
static StatusOutcome TooLong(StatusDraft draft) =>
|
|
(draft.Text?.Length ?? 0) > MaxCharacters
|
|
? StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Text character limit of {MaxCharacters} exceeded")
|
|
: default;
|
|
|
|
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
|
|
{
|
|
if (TooLong(draft) is { } tooLong)
|
|
return tooLong;
|
|
var media = await Media(author, draft.MediaIds, default, draft.ScheduledStatusId, 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");
|
|
// its community's moderators locked the thread
|
|
if (parent is { LockedAt: not null })
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This thread is locked");
|
|
// its author takes our reply at once, once asked, or not at all (GoToSocial's canReply)
|
|
var replyPermission = await Permission(parent, author, InteractionKind.Reply, token);
|
|
if (replyPermission == QuotePermission.Denied)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take replies from you");
|
|
// 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;
|
|
}
|
|
}
|
|
// a community that banned the persona takes nothing from it, a post or a reply
|
|
var community = audienceUri ?? parent?.AudienceURI;
|
|
if (community != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id && b.ActorURI == community).ExecuteAnyAsync(token))
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This community banned you");
|
|
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,
|
|
InReplyToActorURI = parent is { IsFederatedCopy: true } ? parent.ActorURI : default,
|
|
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,
|
|
Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
|
|
};
|
|
post.ID = (string)post.GenerateNewID();
|
|
post.ObjectURI = author.PostUri(post.ID);
|
|
post.Url = author.PostHtmlUrl(post.ID);
|
|
// the media are this post's before anything is written: another post claiming them meanwhile wins, and this one is refused
|
|
if (!await Claim(media, post.ID, draft.ScheduledStatusId, token))
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
|
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();
|
|
// to one account elsewhere, as a ChatMessage when it writes to us that way, or when its server takes a private message
|
|
// no other way (Lemmy 0.19 and Mbin)
|
|
post.AsChatMessage = recipients is [{ LocalId: null } recipient] && (await TakesOnlyChatMessages(recipient.Uri, token)
|
|
|| await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token));
|
|
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 ConversationStates.Posted(dmGroup.ID, post.ID, author.Id, token);
|
|
}
|
|
else if (!isLocalOnly)
|
|
{
|
|
// the conversation it belongs to (FEP-7888): its parent's, or its own when it starts one
|
|
if (group == default && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
|
post.ContextURI = parent == default ? post.ObjectURI + "/context" : parent.ContextURI;
|
|
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);
|
|
if (parent != default && Counted.Reply(post))
|
|
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);
|
|
// a reply its parent's author must approve goes to that author alone, as a request, until it is approved
|
|
if (create != default && post.Approval == ApprovalState.Pending)
|
|
await _approvals.Ask(author, parent, InteractionKind.Reply, create["object"].AsObject(), post.ID, token);
|
|
else if (create != default)
|
|
await _outbox.Publish(author, post, create, token);
|
|
if (create != default && post.Approval != ApprovalState.Pending && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
|
await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, 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");
|
|
if (TooLong(draft) is { } tooLong)
|
|
return tooLong;
|
|
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, default, 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);
|
|
// descriptions and focal points changed by the edit, on the media it keeps or those the post already has
|
|
var described = media ?? (draft.MediaChanges.Count > 0
|
|
? await DB.Default.Find<MediaAttachment>().Match(m => m.PostId == post.ID && m.TrashedAt == null).ExecuteAsync(token)
|
|
: default);
|
|
foreach (var change in draft.MediaChanges)
|
|
{
|
|
if (described?.FirstOrDefault(m => m.ID == change.Id) is not { } attachment)
|
|
continue;
|
|
if (change.HasDescription)
|
|
attachment.Description = string.IsNullOrWhiteSpace(change.Description) ? default : change.Description.Trim()[..Math.Min(change.Description.Trim().Length, 1500)];
|
|
if (Domain.Media.FocalPoint.Parse(change.Focus) is { } focus)
|
|
attachment.Focus = focus;
|
|
await DB.Default.Update<MediaAttachment>().MatchID(attachment.ID)
|
|
.Modify(m => m.Description, attachment.Description).Modify(m => m.Focus, attachment.Focus).ExecuteAsync(token);
|
|
}
|
|
if (media == default && described != default)
|
|
post.Media = post.Media.Select(copy => described.FirstOrDefault(m => m.ID == copy.AttachmentId) is { } changed ? ToPostMedia(changed) : copy).ToList();
|
|
if (media != default)
|
|
{
|
|
if (!await Claim(media, post.ID, default, token))
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
|
post.Media = media.Select(ToPostMedia).ToList();
|
|
// what the edit left out is no longer held by anything
|
|
var kept = media.Select(m => m.ID).ToList();
|
|
await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token);
|
|
}
|
|
post.Title = draft.Title == default ? post.Title : Clean(draft.Title);
|
|
// as on Mastodon, an edit sends the whole post: no warning sent is no warning (the language alone falls back)
|
|
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);
|
|
await Fanout.Edited(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).ToUnixTimeMilliseconds()}"),
|
|
["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 Fanout.Deleting(post, token);
|
|
await _media.Trash(m => m.PostId == post.ID, "post deleted", token);
|
|
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
|
// boosts of it end with it, as on Mastodon, so no count keeps them
|
|
await DB.Default.Update<PostEntity>().Match(p => p.ReblogOfPostId == post.ID && !p.DeletedAt.HasValue)
|
|
.Modify(p => p.DeletedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Counted.Reply(post))
|
|
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
|
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.PostId == post.ID);//a deleted post no longer takes a pin
|
|
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 likePermission = on ? await Permission(post, me, InteractionKind.Like, token) : QuotePermission.Granted;
|
|
if (likePermission == QuotePermission.Denied)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take likes from you");
|
|
|
|
// each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no
|
|
// server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends
|
|
var favourite = default(Favourite);
|
|
if (on)
|
|
{
|
|
favourite = new Favourite
|
|
{
|
|
ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID,
|
|
Approval = likePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
|
|
};
|
|
favourite.ActivityURI = me.ActivityUri($"like-{favourite.ID}");
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(favourite, 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
|
|
{
|
|
favourite = await DB.Default.Find<Favourite>().Match(f => f.AccountId == me.Id && f.PostId == post.ID).ExecuteFirstAsync(token);
|
|
if (favourite == default || (await DB.Default.DeleteAsync<Favourite>(favourite.ID)).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"] = favourite.ActivityURI ?? me.ActivityUri($"like-{post.ID}"),//a favourite from before every Like had its own id
|
|
["type"] = "Like",
|
|
["actor"] = me.Uri,
|
|
["object"] = post.ObjectURI
|
|
};
|
|
if (on && favourite.Approval == ApprovalState.Pending)
|
|
await _approvals.Ask(me, post, InteractionKind.Like, like, favourite.ID, token);
|
|
else
|
|
await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token);
|
|
}
|
|
return new StatusOutcome(post);
|
|
}
|
|
|
|
// how a remote post's interaction policy takes an interaction of ours: at once, once asked, or not at all
|
|
async Task<QuotePermission> Permission(PostEntity target, LocalActor actor, InteractionKind kind, CancellationToken token) =>
|
|
target is { IsFederatedCopy: true } && _approvals != default ? await _approvals.Judge(target, actor, kind, token) : QuotePermission.Granted;
|
|
|
|
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 announcePermission = on ? await Permission(original, me, InteractionKind.Announce, token) : QuotePermission.Granted;
|
|
if (announcePermission == QuotePermission.Denied)
|
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Its author does not take boosts from you");
|
|
|
|
var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
// each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no
|
|
// server (and no delivery queue) takes for the one already undone; an unboost undoes the boost it ends
|
|
var reblogId = existing?.ID ?? ObjectId.GenerateNewId().ToString();
|
|
var announceId = existing?.ActivityURI ?? me.ActivityUri($"announce-{reblogId}");
|
|
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
|
|
{
|
|
ID = reblogId,
|
|
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"]),
|
|
Approval = announcePermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
|
|
};
|
|
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));
|
|
}
|
|
// two boosts at once: the first one saved stands, and the other goes
|
|
var boosts = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue)
|
|
.Sort(p => p.ID, Order.Ascending).ExecuteAsync(token);
|
|
if (boosts.Count > 1 && boosts[0].ID != reblog.ID)
|
|
{
|
|
await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
|
|
return new StatusOutcome(boosts[0]);
|
|
}
|
|
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);
|
|
if (reblog.Approval == ApprovalState.Pending)
|
|
await _approvals.Ask(me, original, InteractionKind.Announce, announce, reblog.ID, token);
|
|
else
|
|
await _delivery.EnqueueToFollowers(me, announce, token, await AuthorInbox(original, token, shared: true) is { } inbox ? new[] { inbox } : default);
|
|
return new StatusOutcome(reblog);
|
|
}
|
|
|
|
if (existing == default)
|
|
return new StatusOutcome(original);
|
|
await Fanout.Deleting(existing, token);
|
|
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-{existing.ID}"), token,
|
|
await AuthorInbox(original, token, shared: true) is { } authorInbox ? new[] { authorInbox } : default);
|
|
return new StatusOutcome(original);
|
|
}
|
|
|
|
sealed record Recipient(string Uri, string Handle, string LocalId, string ForeignId, string Inbox);
|
|
|
|
// The one place PrivaPub decides by a server's software (owner decision 2026-10-05, G-0008): Lemmy before 1.0 and
|
|
// Mbin answer a direct Note 400 or drop it, and their actors say nothing of what they take, so the first message to
|
|
// one of their accounts goes as a ChatMessage, as NodeInfo names them
|
|
async Task<bool> TakesOnlyChatMessages(string actorUri, CancellationToken token)
|
|
{
|
|
if (!Uri.TryCreate(actorUri, UriKind.Absolute, out var uri))
|
|
return false;
|
|
var host = uri.Host;
|
|
var instance = await DB.Default.Find<Models.Jobs.RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
|
return instance?.Software?.ToLowerInvariant() switch
|
|
{
|
|
"mbin" => true,
|
|
"lemmy" => instance.SoftwareVersion is { } version && version.StartsWith("0.", StringComparison.Ordinal),
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// shared: what goes to the author's followers too (a boost) goes to its server's shared inbox, so the server gets
|
|
// one copy, as Mastodon sends it; two copies at once race in servers that count as they process (Misskey)
|
|
async Task<string> AuthorInbox(PostEntity post, CancellationToken token, bool shared = false)
|
|
{
|
|
if (!post.IsFederatedCopy || string.IsNullOrEmpty(post.AuthorAccountId))
|
|
return default;
|
|
var author = await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token);
|
|
return shared && !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.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)
|
|
};
|
|
}
|
|
|
|
// the author's own uploads, not yet another post's (nor held by another scheduled post), nor trashed, nor pictures
|
|
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, string scheduledId, 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)
|
|
&& (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId) && m.TrashedAt == null && m.ProfileOfAvatarId == null
|
|
&& m.ProcessingState == null)
|
|
.ExecuteAsync(token);
|
|
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
|
}
|
|
|
|
// attaches the media in one conditional update, true when all of them are the post's; when another post took one
|
|
// meanwhile, the ones this call took go back as they were
|
|
static async Task<bool> Claim(IReadOnlyList<MediaAttachment> media, string postId, string scheduledId, CancellationToken token)
|
|
{
|
|
var ids = media.Select(m => m.ID).ToList();
|
|
if (ids.Count == 0)
|
|
return true;
|
|
var claimed = await DB.Default.Update<MediaAttachment>()
|
|
.Match(m => ids.Contains(m.ID) && (m.PostId == null || m.PostId == postId) && m.TrashedAt == null
|
|
&& (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId))
|
|
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
if (claimed.MatchedCount == ids.Count)
|
|
return true;
|
|
var taken = media.Where(m => m.PostId == null).Select(m => m.ID).ToList();
|
|
await DB.Default.Update<MediaAttachment>().Match(m => taken.Contains(m.ID) && m.PostId == postId)
|
|
.Modify(m => m.PostId, null).Modify(m => m.AttachedAt, null).ExecuteAsync(token);
|
|
return false;
|
|
}
|
|
|
|
PostMedia ToPostMedia(MediaAttachment attachment) => new()
|
|
{
|
|
AttachmentId = attachment.ID,
|
|
ContentType = attachment.ContentType,
|
|
Kind = attachment.Kind,
|
|
DurationSeconds = attachment.DurationSeconds,
|
|
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>();
|
|
}
|
|
}
|