P6: quote posts, received and sent (FEP-044f and the older keys)
- Received quotes: read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 Link tag; the quoted post is fetched once; a quoteAuthorization stamp is verified field by field on the quoted author's origin; a consent quote without a stamp is pending; an older-key quote of a public post is shown; Delete of a stamp revokes. Counts and a `quote` notification follow the accepted state. The `quote-inline` fallback survives sanitising and is removed from content when the real quote is shown. - Personas quote through `quoted_status_id`: posts that state a quote policy get a QuoteRequest and stay pending until an Accept brings a stamp we can verify, then an Update adds quoteAuthorization; posts that state none are quoted the older way, without `quote`; another persona's posts cannot be quoted yet (we issue no stamps). Quoting posts are delivered to the quoted author too. - Mastodon API: Status.quote (with the quoted status one level deep), quotes_count, quote_approval from the remote policy, GET /api/v1/statuses/:id/quotes, `quote` notifications, and api_versions.mastodon = 7. Checked live: GoToSocial's author-only quote policy is respected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
001fdac3be
commit
6f1ab0073c
29 files changed
+678
-45
No files matched your search
@@ -1,5 +1,6 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
@@ -15,17 +16,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors)
|
||||
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_quotes = quotes;
|
||||
}
|
||||
|
||||
public virtual string Type => "Accept";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token))
|
||||
return;
|
||||
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
|
||||
if (following == default)
|
||||
return;
|
||||
@@ -56,7 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
|
||||
public class RejectHandler : AcceptHandler
|
||||
{
|
||||
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors) : base(dbEntities, localActors)
|
||||
public RejectHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes) : base(dbEntities, localActors, quotes)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IObjectRecords _records;
|
||||
readonly IPollService _polls;
|
||||
readonly ILinkPreviews _previews;
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews)
|
||||
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes)
|
||||
{
|
||||
_quotes = quotes;
|
||||
_previews = previews;
|
||||
_records = records;
|
||||
_polls = polls;
|
||||
@@ -152,6 +154,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
}
|
||||
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
|
||||
await _previews.Wanted(post, token);
|
||||
await _quotes.Resolve(post, note, token);
|
||||
|
||||
if (parent != default)
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
@@ -27,10 +28,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
readonly IGroupDistributor _groups;
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||
IGroupDistributor groups)
|
||||
IGroupDistributor groups, IQuoteService quotes)
|
||||
{
|
||||
_quotes = quotes;
|
||||
_groups = groups;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
@@ -72,7 +75,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
.ExecuteAsync(token);
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
{
|
||||
await _quotes.Revoke(objectUri, token);
|
||||
return;
|
||||
}
|
||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Models.Post;
|
||||
@@ -24,10 +25,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IGroupDistributor _groups;
|
||||
readonly IObjectRecords _records;
|
||||
readonly IQuoteService _quotes;
|
||||
|
||||
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
|
||||
IObjectRecords records)
|
||||
IObjectRecords records, IQuoteService quotes)
|
||||
{
|
||||
_quotes = quotes;
|
||||
_records = records;
|
||||
_groups = groups;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -56,6 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
return;
|
||||
|
||||
post.Poll = note.Poll ?? post.Poll;
|
||||
post.QuotePolicy = note.QuotePolicy;
|
||||
post.Video = note.Video ?? post.Video;
|
||||
post.Audio = note.Audio ?? post.Audio;
|
||||
post.Event = note.Event ?? post.Event;
|
||||
@@ -63,6 +67,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await _records.Revise(note, Id(activity), token);
|
||||
await Requote(post, note, token);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,11 +107,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
post.UpdateDate = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await _records.Revise(note, Id(activity), token);
|
||||
await Requote(post, note, token);
|
||||
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
|
||||
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
|
||||
}
|
||||
|
||||
static bool IsEdit(NoteDocument note, PostEntity post) =>
|
||||
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
|
||||
|
||||
async Task Requote(PostEntity post, NoteDocument note, CancellationToken token)
|
||||
{
|
||||
if (note.QuoteUri != post.QuoteURI || note.QuoteAuthorization != post.QuoteAuthorizationURI || post.QuoteState != QuoteState.Accepted)
|
||||
await _quotes.Resolve(post, note, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
Excerpt = note.Excerpt,
|
||||
Source = note.Source,
|
||||
Poll = note.Poll,
|
||||
QuotePolicy = note.QuotePolicy,
|
||||
Emojis = note.Emojis.ToList(),
|
||||
CoverURL = note.CoverURL,
|
||||
Link = note.Link,
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace PrivaPub.Federation.Objects
|
||||
{
|
||||
KeepChildNodes = true
|
||||
};
|
||||
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" })
|
||||
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible", "quote-inline" })
|
||||
sanitizer.AllowedClasses.Add(allowed);
|
||||
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
|
||||
sanitizer.RemovingTag += (_, e) =>
|
||||
|
||||
@@ -25,6 +25,9 @@ namespace PrivaPub.Federation.Objects
|
||||
public string Context { get; init; }
|
||||
public string Audience { get; init; }
|
||||
public string QuoteUri { get; init; }
|
||||
public string QuoteAuthorization { get; init; }
|
||||
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
|
||||
public bool QuoteDeleted { get; init; }
|
||||
public DateTime Published { get; init; }
|
||||
public DateTime? Updated { get; init; }
|
||||
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
|
||||
@@ -34,6 +37,7 @@ namespace PrivaPub.Federation.Objects
|
||||
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
||||
public PostSource Source { get; init; }
|
||||
public PostPoll Poll { get; init; }
|
||||
public InteractionRule QuotePolicy { get; init; }
|
||||
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
|
||||
public string CoverURL { get; init; }
|
||||
public PostLink Link { get; init; }
|
||||
@@ -79,7 +83,10 @@ namespace PrivaPub.Federation.Objects
|
||||
InReplyTo = Id(note["inReplyTo"]),
|
||||
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
||||
Audience = Id(note["audience"]),
|
||||
QuoteUri = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"),
|
||||
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
|
||||
QuoteAuthorization = Id(note["quoteAuthorization"]),
|
||||
QuotesByConsent = note.ContainsKey("quote"),
|
||||
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
|
||||
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
|
||||
Updated = Time(Value(note, "updated")),
|
||||
To = List(note["to"]),
|
||||
@@ -97,6 +104,7 @@ namespace PrivaPub.Federation.Objects
|
||||
Attachments = Attachments(note),
|
||||
Source = ObjectShapes.Source(note),
|
||||
Poll = ObjectShapes.Poll(note),
|
||||
QuotePolicy = ObjectShapes.QuotePolicy(note),
|
||||
Emojis = ObjectShapes.Emojis(note["tag"]),
|
||||
CoverURL = ObjectShapes.Cover(note),
|
||||
Link = ObjectShapes.Link(note),
|
||||
@@ -169,6 +177,12 @@ namespace PrivaPub.Federation.Objects
|
||||
};
|
||||
}
|
||||
|
||||
static string QuoteLink(JsonObject note) =>
|
||||
Tags(note, "Link")
|
||||
.Where(t => Value(t, "mediaType") is { } type && (type.StartsWith("application/activity+json") || type.StartsWith("application/ld+json")))
|
||||
.Select(t => Value(t, "href"))
|
||||
.FirstOrDefault(href => Origin.Of(href) != default);
|
||||
|
||||
static string FirstOf(JsonNode map) =>
|
||||
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace PrivaPub.Federation.Objects
|
||||
const int CoverMaxWidth = 1280;
|
||||
const int MaxEmojis = 64;
|
||||
const int MaxPollOptions = 20;
|
||||
const int MaxAudience = 32;
|
||||
|
||||
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -97,6 +98,23 @@ namespace PrivaPub.Federation.Objects
|
||||
.Take(MaxEmojis)
|
||||
.ToList();
|
||||
|
||||
public static InteractionRule QuotePolicy(JsonObject note)
|
||||
{
|
||||
if (note["interactionPolicy"] is not JsonObject policy || policy["canQuote"] is not JsonObject canQuote)
|
||||
return default;
|
||||
static List<string> Who(JsonNode node) => node switch
|
||||
{
|
||||
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
|
||||
JsonNode single when Id(single) is { } id => new List<string> { id },
|
||||
_ => new List<string>()
|
||||
};
|
||||
return new InteractionRule
|
||||
{
|
||||
Automatic = Who(canQuote["automaticApproval"] ?? canQuote["always"]),
|
||||
Manual = Who(canQuote["manualApproval"] ?? canQuote["approvalRequired"])
|
||||
};
|
||||
}
|
||||
|
||||
public static PostPoll Poll(JsonObject note)
|
||||
{
|
||||
if (Value(note, "type") != "Question")
|
||||
|
||||
@@ -60,6 +60,16 @@ namespace PrivaPub.Federation.Outbox
|
||||
inboxes.Add(parentAuthor.InboxURL);
|
||||
}
|
||||
|
||||
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
|
||||
{
|
||||
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
|
||||
var quotedAuthor = quoted is { IsFederatedCopy: true }
|
||||
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
|
||||
: default;
|
||||
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
|
||||
inboxes.Add(quotedAuthor.InboxURL);
|
||||
}
|
||||
|
||||
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ namespace PrivaPub.Federation.Rendering
|
||||
["blurhash"] = "toot:blurhash",
|
||||
["votersCount"] = "toot:votersCount",
|
||||
["Emoji"] = "toot:Emoji",
|
||||
["quote"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quote", ["@type"] = "@id" },
|
||||
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
||||
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
||||
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
||||
["misskey"] = "https://misskey-hub.net/ns#",
|
||||
["_misskey_quote"] = "misskey:_misskey_quote",
|
||||
["fedibird"] = "http://fedibird.com/ns#",
|
||||
["quoteUri"] = "fedibird:quoteUri",
|
||||
["quoteUrl"] = "as:quoteUrl",
|
||||
["litepub"] = "http://litepub.social/ns#",
|
||||
["EmojiReact"] = "litepub:EmojiReact",
|
||||
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
||||
@@ -160,6 +169,47 @@ namespace PrivaPub.Federation.Rendering
|
||||
return note;
|
||||
}
|
||||
|
||||
public static JsonObject UpdateOf(PostEntity post, LocalActor author, string reason)
|
||||
{
|
||||
var note = post.Visibility == PostVisibility.Direct
|
||||
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
||||
: Note(post, author, default, post.InReplyToURI);
|
||||
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
||||
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
||||
return new JsonObject
|
||||
{
|
||||
["@context"] = Context(),
|
||||
["id"] = author.ActivityUri($"update-{post.ID}-{reason}"),
|
||||
["type"] = "Update",
|
||||
["actor"] = author.Uri,
|
||||
["to"] = note["to"]!.DeepClone(),
|
||||
["cc"] = note["cc"]!.DeepClone(),
|
||||
["object"] = note
|
||||
};
|
||||
}
|
||||
|
||||
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(post.QuoteURI))
|
||||
return;
|
||||
var link = WebUtility.HtmlEncode(post.QuoteURI);
|
||||
content += $"<p class=\"quote-inline\">RE: <a href=\"{link}\">{link}</a></p>";
|
||||
if (post.QuoteByConsent)
|
||||
note["quote"] = post.QuoteURI;
|
||||
if (!string.IsNullOrEmpty(post.QuoteAuthorizationURI))
|
||||
note["quoteAuthorization"] = post.QuoteAuthorizationURI;
|
||||
note["_misskey_quote"] = post.QuoteURI;
|
||||
note["quoteUri"] = post.QuoteURI;
|
||||
note["quoteUrl"] = post.QuoteURI;
|
||||
(note["tag"] as JsonArray)?.Add(new JsonObject
|
||||
{
|
||||
["type"] = "Link",
|
||||
["mediaType"] = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
||||
["href"] = post.QuoteURI,
|
||||
["name"] = $"RE: {post.QuoteURI}"
|
||||
});
|
||||
}
|
||||
|
||||
static void AddPoll(JsonObject note, PostPoll poll)
|
||||
{
|
||||
note["type"] = "Question";
|
||||
@@ -211,6 +261,8 @@ namespace PrivaPub.Federation.Rendering
|
||||
}))
|
||||
.ToArray())
|
||||
};
|
||||
AddQuote(note, post, ref content);
|
||||
note["content"] = content;
|
||||
if (!string.IsNullOrEmpty(post.Language))
|
||||
note["contentMap"] = new JsonObject { [post.Language] = content };
|
||||
if (post.Poll is { } poll)
|
||||
|
||||
Reference in new issue
Block a user