P6: quote posts, received and sent (FEP-044f and the older keys)
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 54s

- 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:
thepraandClaude Opus 5.5 committed 2026-10-01 18:53:28 +02:00
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);
}
}
}
+1
View File
@@ -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) =>
+15 -1
View File
@@ -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)