Compare commits

...
4 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 f9658a8e7a P6: remote video and audio play through the proxy
Build / Build (push) Successful in 37s
Deploy / privapub.thepra.dev (push) Successful in 57s
- The media proxy streams ranged requests from the origin (passing the range on, never caching), downloads and caches
  whole files otherwise, and serves cached files with range support. A PeerTube video is never fetched whole for one
  viewer, and clients still never contact the remote host.
- PeerTube's fragmented MP4 files inside an HLS entry are read as variants, so HLS-only instances play too.
- A remote Video or Audio post becomes one playable Mastodon attachment: the best MP4 up to 720p that carries both
  sound and picture, with its poster and duration; the card is kept only when nothing is playable.
- nginx: /media/proxy/ with proxy_buffering off and a 600 s read timeout (applied on the box, with a backup).

Checked live: a GoToSocial image through the proxy answers 206 with exactly the asked range when streamed, 200 when
cached, and 206 with the right Content-Range from the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:59:14 +02:00
thepraandClaude Opus 5.5 6f1ab0073c 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
2026-10-01 18:53:28 +02:00
thepraandClaude Opus 5.5 001fdac3be P6: link previews read by the server, as the owner decided
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 56s
- A public post that links to a page gets a preview card: from the post's own data first (FEP-8967 Link preview, the
  object's image, title and summary); otherwise the server reads the page once, 0-60 s after the post arrives, never
  when someone reads it. OpenGraph and Twitter tags give title, description and image; one LinkPreview per address
  is cached for 7 days and shared by the whole server, so a fetch never points at a persona.
- Lemmy link posts, which carry no title or description, get them filled in.
- The page fetch uses the guarded client (public addresses, three redirects, HTML only, first 512 KB).
- Federation:FetchLinkPreviews switches page fetching off.
- Local public posts get cards too.

Checked live: a link to a GoToSocial profile page becomes a card with its title, description and proxied image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:43:39 +02:00
thepraandClaude Opus 5.5 fdcf5176bc P6: emoji reactions in and out
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 57s
- Incoming reactions in all three shapes: Misskey and Sharkey Likes carrying an emoji (`content` or
  `_misskey_reaction`), Pleroma, Akkoma and Iceshrimp.NET EmojiReacts, and their Undo. Unicode reactions are one
  grapheme; custom ones need their Emoji tag and keep its image; `:name@host:` is read as `name`. A Like whose
  content is a heart stays a favourite.
- Reactions are kept per account and emoji on our posts and on remote ones we hold, and the author of a local post
  gets a `pleroma:emoji_reaction` notification with the emoji.
- Personas react through Pleroma's API (PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji, GET for the list),
  which sends an EmojiReact (or its Undo) to the post's author.
- Statuses carry `emoji_reactions` (read by Phanpy) and `pleroma.emoji_reactions`, with counts and whether the viewer
  reacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 18:39:18 +02:00
47 changed files with 1684 additions and 84 deletions

No files matched your search

+21
View File
@@ -212,6 +212,16 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
sibling `media-proxy`, which `/media/files` does not serve. sibling `media-proxy`, which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through 3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`. `IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
4. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
5. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.
## Privacy invariants ## Privacy invariants
@@ -258,6 +268,17 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler` - **A poll vote is a `Note` with a `name`, an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
go only to the poll's author, without `published` (PieFed counts a vote only then). go only to the poll's author, without `published` (PieFed counts a vote only then).
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs`): only public posts, queued on arrival with
0–60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler`, `StoreContext`, `StatusService.Publish`).
- **Quote states come from `QuoteService.Resolve`:**
- with a stamp: accepted only if `Verified` (fetched, on the quoted author's origin, naming both posts exactly);
- a FEP-044f `quote` without a stamp: pending;
- older keys only: accepted when the quoted post is public.
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its - **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything. NodeInfo, at most once a week, into `RemoteInstance`), so opening the details view tells nobody anything.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post - **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
+31 -2
View File
@@ -92,12 +92,13 @@ Received:
| `Update{Person}` | refetches the actor | | `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back | | `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) | | `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
`Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`. `Reject{Follow}`, `Like`, `Announce` and their `Undo`, `Flag`. A deleted post answers 410 with a `Tombstone`.
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`. - **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
@@ -116,6 +117,21 @@ content, one per choice, counted once per voter. Counts are refreshed with an `U
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`. minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit. An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
**Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
- With a `quoteAuthorization`, the stamp is fetched and must be a `QuoteAuthorization` on the quoted author's origin,
attributed to them, naming exactly the two posts.
- A FEP-044f `quote` without a stamp is pending until an `Update` brings one.
- A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
- A `Delete` of a stamp revokes the quote.
- **Sent.** Our accounts quote with every key, plus the `RE:` fallback.
- When the quoted post states an `interactionPolicy.canQuote` that allows us, `quote` is included and a `QuoteRequest`
(with the quoting post as `instrument`) goes to the quoted author. The quote stays pending until their `Accept` brings a
stamp we can verify; the post is then updated with `quoteAuthorization`.
- Posts that state no policy are quoted the older way, without `quote`.
- Quoting posts are also delivered to the quoted author.
- **Not yet.** Our own posts cannot be quoted by others yet: we do not issue stamps.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
@@ -127,6 +143,18 @@ Received `summary` is a content warning only on a `Note` or `Question`, or when
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
followers-only posts are recognised by the author's own `followers` collection. followers-only posts are recognised by the author's own `followers` collection.
## Link previews
For a public post that links to a page, PrivaPub builds a preview card. It uses, in order:
1. a Link attachment's own `preview` (FEP-8967) or the post's own image, title and summary;
2. otherwise, the linked page, read once by the server between 0 and 60 seconds after the post arrives (never when
someone reads it), with an `Accept: text/html` request that reads at most 512 KB, through the same address checks as
every other fetch.
The page's OpenGraph and Twitter tags give the title, description and image; the result is cached per address for 7
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
`Federation:FetchLinkPreviews=false` turns page fetching off.
## Local-only posts ## Local-only posts
Posts with a location (shown to nearby users of this server) never leave the server, in any form. Posts with a location (shown to nearby users of this server) never leave the server, in any form.
@@ -153,6 +181,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser
## Known limitations ## Known limitations
- Our own posts carry no custom emoji: emoji are received and shown, not offered. - Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. - Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
- Collections expose counts, not members. - Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. - Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
+43
View File
@@ -0,0 +1,43 @@
using PrivaPub.Domain.Content;
using PrivaPub.Models.Post;
namespace PrivaPub.Tests.Domain
{
public class LinkPreviewTests
{
[Fact]
public void Reads_open_graph_then_twitter_then_the_page_title()
{
var preview = LinkPreviews.Read("https://news.example/a?utm_source=x", new Uri("https://news.example/a"), """
<html><head><title>Fallback title</title>
<meta property="og:title" content="Rain &amp; sun">
<meta name="twitter:description" content="A <b>long</b> story">
<meta property="og:image" content="/img/cover.jpg">
<meta property="og:site_name" content="News">
</head><body></body></html>
""");
Assert.Equal("Rain & sun", preview.Title);
Assert.Equal("A long story", preview.Description);
Assert.Equal("https://news.example/img/cover.jpg", preview.ImageURL);
Assert.Equal("News", preview.SiteName);
Assert.False(preview.Failed);
Assert.True(LinkPreviews.Read("https://x.example", new Uri("https://x.example"), "<html><body>nothing</body></html>").Failed);
}
[Fact]
public void Picks_the_first_real_link_and_skips_mentions_tags_quotes_and_ourselves() =>
Assert.Equal("https://blog.example/post", LinkPreviews.FirstLink(new Post
{
Url = "https://m.example/@a/1",
ContentHtml = """
<p><span class="h-card"><a href="https://m.example/@bob" class="u-url mention">@bob</a></span>
<a href="https://m.example/tags/x" class="mention hashtag" rel="tag">#x</a>
<span class="quote-inline">RE: <a href="https://k.example/notes/1">link</a></span>
<a href="https://privapub.test/@me">me</a>
<a href="https://m.example/@a/1">self</a>
<a href="https://blog.example/post">read this</a></p>
"""
}, "https://privapub.test"));
}
}
@@ -8,6 +8,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -51,13 +52,15 @@ namespace PrivaPub.Tests.Federation
var db = new DbEntities(); var db = new DbEntities();
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance); _blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance); _receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
var remotePosts = new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews());
var quotes = new QuoteService(db, remote, remotePosts, _local, delivery, new OutboxPublisher(db, _local, delivery));
_processor = new InboxProcessor(remote, new IActivityHandler[] _processor = new InboxProcessor(remote, new IActivityHandler[]
{ {
new FollowHandler(db, _local, remote, delivery), new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local), new UndoHandler(db, _local, new Reactions(db, delivery)),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue)), new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), remotePosts, new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews(), quotes),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)), new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery), quotes),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue)) new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue), quotes)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
} }
@@ -214,6 +214,30 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt); Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
} }
[Fact]
public void Finds_playable_files_inside_an_hls_only_peertube_video_and_picks_one_with_sound()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/2", "type": "Video", "name": "HLS only", "attributedTo": "https://tube.example/accounts/ann",
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/def" },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8", "tag": [
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/1080-fragmented.mp4", "height": 1080,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720-fragmented.mp4", "height": 720,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/480-fragmented.mp4", "height": 480,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] }
] }
]
}
""");
Assert.Equal(3, note.Video.Variants.Count);
Assert.Equal("https://tube.example/480-fragmented.mp4", MastodonMapper.BestVideo(note.Video.Variants).Url);
}
[Fact] [Fact]
public void Normalises_hashtags_like_mastodon() => public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse(""" Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
+159
View File
@@ -0,0 +1,159 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class QuoteTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> Delivered(RemoteActor author, string mentioned, Action<JsonObject> shape = default)
{
var note = new JsonObject
{
["id"] = NewId(author, "notes"), ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "<p>hello</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(mentioned),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned, ["name"] = "@someone" })
};
shape?.Invoke(note);
await _harness.Deliver(author, "/human-centipede", new JsonObject { ["id"] = NewId(author, "activities"), ["type"] = "Create", ["actor"] = author.Id, ["object"] = note });
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
string Stamp(RemoteActor quotedAuthor, string quoting, string quoted)
{
var path = $"/stamps/{Guid.NewGuid():N}";
var id = Origin(quotedAuthor) + path;
_harness.Peer.Serve(path, new JsonObject
{
["@context"] = "https://www.w3.org/ns/activitystreams", ["id"] = id, ["type"] = "QuoteAuthorization",
["attributedTo"] = quotedAuthor.Id, ["interactingObject"] = quoting, ["interactionTarget"] = quoted
}.ToJsonString());
return id;
}
[Fact]
public async Task A_misskey_quote_of_a_public_post_is_shown_and_counted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var original = await Delivered(ann, alice.Uri);
var quoting = await Delivered(kitty, alice.Uri, n =>
{
n["_misskey_quote"] = original.ObjectURI;
n["content"] = $"<p>look<span class=\"quote-inline\"><br>RE: <a href=\"{original.ObjectURI}\">{original.ObjectURI}</a></span></p>";
});
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(original.ID, quoting.QuotedPostId);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
var status = await new PrivaPub.Api.Mastodon.Mappers.MastodonMapper(_harness.Db, _harness.Local).Status(quoting, alice.Id, token);
Assert.Equal("accepted", status.Quote.State);
Assert.Equal(original.ID, status.Quote.QuotedStatus.Id);
Assert.DoesNotContain("RE:", status.Content);
}
[Fact]
public async Task A_stamped_quote_is_accepted_a_forged_one_is_not_and_revoking_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var bob = new RemoteActor(_harness.Peer, "bob");
var original = await Delivered(ann, alice.Uri);
var quotingId = NewId(bob, "notes");
var stamp = Stamp(ann, quotingId, original.ObjectURI);
var forged = Stamp(ann, NewId(bob, "notes"), original.ObjectURI);
var quoting = await Delivered(bob, alice.Uri, n => { n["id"] = quotingId; n["quote"] = original.ObjectURI; n["quoteAuthorization"] = stamp; });
var cheat = await Delivered(bob, alice.Uri, n => { n["quote"] = original.ObjectURI; n["quoteAuthorization"] = forged; });
var waiting = await Delivered(bob, alice.Uri, n => n["quote"] = original.ObjectURI);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.Equal(QuoteState.Unauthorized, cheat.QuoteState);
Assert.Equal(QuoteState.Pending, waiting.QuoteState);
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Delete", ["actor"] = ann.Id, ["object"] = stamp
});
Assert.Equal(QuoteState.Revoked, (await DB.Default.Find<Post>().OneAsync(quoting.ID, token)).QuoteState);
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
}
[Fact]
public async Task A_persona_asks_before_quoting_and_is_stamped_when_accepted()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var ann = new RemoteActor(_harness.Peer, "ann");
var original = await Delivered(ann, alice.Uri, n => n["interactionPolicy"] = new JsonObject
{
["canQuote"] = new JsonObject { ["automaticApproval"] = new JsonArray(Addressing.Public) }
});
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "this", QuotedStatusId = original.ID }, token);
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
Assert.Equal(original.ObjectURI, request["object"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, request["instrument"]!["quote"]!.GetValue<string>());
await _harness.Deliver(ann, "/human-centipede", new JsonObject
{
["id"] = NewId(ann, "activities"), ["type"] = "Accept", ["actor"] = ann.Id,
["object"] = request["id"]!.GetValue<string>(), ["result"] = Stamp(ann, outcome.Post.ObjectURI, original.ObjectURI)
});
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
Assert.NotNull(quoting.QuoteAuthorizationURI);
Assert.Contains(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Update"
&& a["object"]!["quoteAuthorization"]?.GetValue<string>() == quoting.QuoteAuthorizationURI);
}
[Fact]
public async Task A_legacy_post_is_quoted_without_asking_and_another_persona_cannot_be_quoted_yet()
{
var token = TestContext.Current.CancellationToken;
var (aliceRoot, alice) = await _harness.Persona("alice");
var (bobRoot, bob) = await _harness.Persona("bob");
var kitty = new RemoteActor(_harness.Peer, "kitty");
var legacy = await Delivered(kitty, alice.Uri);
await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = "mine" }, token);
var bobs = await DB.Default.Find<Post>().Match(p => p.GroupUserId == bob.Id).ExecuteFirstAsync(token);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nice", QuotedStatusId = legacy.ID }, token);
Assert.Equal(QuoteState.Accepted, outcome.Post.QuoteState);
Assert.False(outcome.Post.QuoteByConsent);
Assert.False((await _harness.Statuses.Publish(alice, new StatusDraft { Text = "and this", QuotedStatusId = bobs.ID }, token)).Ok);
Assert.True((await _harness.Statuses.Publish(bob, new StatusDraft { Text = "me again", QuotedStatusId = bobs.ID }, token)).Ok);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ReactionNamesTests
{
[Theory]
[InlineData(":blobcat@k.example:", "blobcat")]
[InlineData("👍🏽", "👍🏽")]
[InlineData("❤️", "❤️")]
[InlineData("nope", null)]
[InlineData("👍👍", null)]
[InlineData(":unknown:", null)]
public void Normalises_what_peers_send(string content, string expected) =>
Assert.Equal(expected, Reactions.Normalise(content, new List<CustomEmoji> { new() { Shortcode = "blobcat", URL = "https://k.example/e.png" } })?.Name);
}
[Trait("Category", "Integration")]
public sealed class ReactionTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost()
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "react to me" }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task Misskey_and_pleroma_reactions_are_kept_apart_from_favourites()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost();
var misskey = new RemoteActor(_harness.Peer, "kitty");
var pleroma = new RemoteActor(_harness.Peer, "pleroma");
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = ":blobcat:",
["_misskey_reaction"] = ":blobcat:",
["tag"] = new JsonArray(new JsonObject { ["type"] = "Emoji", ["name"] = ":blobcat:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = $"{Origin(misskey)}/emoji/blobcat.png" } })
});
await _harness.Deliver(misskey, "/human-centipede", new JsonObject
{
["id"] = NewId(misskey, "likes"), ["type"] = "Like", ["actor"] = misskey.Id, ["object"] = post.ObjectURI, ["content"] = "❤"
});
var fire = new JsonObject { ["id"] = NewId(pleroma, "activities"), ["type"] = "EmojiReact", ["actor"] = pleroma.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" };
await _harness.Deliver(pleroma, "/human-centipede", fire);
var kept = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
Assert.Equal(new HashSet<string> { "blobcat", "🔥" }, kept.Select(r => r.Emoji).ToHashSet());
Assert.Equal($"{Origin(misskey)}/emoji/blobcat.png", kept.Single(r => r.Emoji == "blobcat").EmojiURL);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
Assert.True(await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Reaction && n.Emoji == "🔥").ExecuteAnyAsync(token));
await _harness.Deliver(pleroma, "/human-centipede", new JsonObject
{
["id"] = NewId(pleroma, "activities"), ["type"] = "Undo", ["actor"] = pleroma.Id, ["object"] = fire.DeepClone()
});
Assert.Equal("blobcat", Assert.Single(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token)).Emoji);
}
[Fact]
public async Task A_persona_reacts_to_a_remote_post_and_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>hi</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token);
Assert.True(await _harness.Reactions.React(alice, post, "🎉", token));
Assert.False(await _harness.Reactions.React(alice, post, "not an emoji", token));
Assert.True(await _harness.Reactions.Unreact(alice, post, "🎉", token));
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
var react = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "EmojiReact");
Assert.Equal("🎉", react["content"]!.GetValue<string>());
Assert.Equal(noteId, react["object"]!.GetValue<string>());
Assert.Equal(react["id"]!.GetValue<string>(), Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo")["object"]!["id"]!.GetValue<string>());
}
}
}
+20 -10
View File
@@ -45,29 +45,32 @@ namespace PrivaPub.Tests.Support
Groups = new GroupDistributor(Delivery); Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue); Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue); Polls = new PollService(Db, Local, Delivery, Queue);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records); Reactions = new Reactions(Db, Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[] Processor = new InboxProcessor(Remote, new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
new AcceptHandler(Db, Local), new AcceptHandler(Db, Local, Quotes),
new RejectHandler(Db, Local), new RejectHandler(Db, Local, Quotes),
new UndoHandler(Db, Local), new UndoHandler(Db, Local, Reactions),
new LikeHandler(Db), new LikeHandler(Db, Reactions),
new EmojiReactHandler(Db, Reactions),
new DislikeHandler(Db), new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery), new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
new DeleteHandler(Db, Local, Remote, Delivery, Groups), new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local) new FlagHandler(Db, Local)
}, NullLogger<InboxProcessor>.Instance); }, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Outbox = new OutboxPublisher(Db, Local, Delivery);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
Local, default, NullLogger<MediaService>.Instance); Local, default, NullLogger<MediaService>.Instance);
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls); Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>()); Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
Relationships = new RelationshipService(Db, Follows, Delivery); Relationships = new RelationshipService(Db, Follows, Delivery);
@@ -93,6 +96,8 @@ namespace PrivaPub.Tests.Support
public RemotePosts RemotePosts { get; } public RemotePosts RemotePosts { get; }
public ObjectRecords Records { get; } public ObjectRecords Records { get; }
public PollService Polls { get; } public PollService Polls { get; }
public Reactions Reactions { get; }
public QuoteService Quotes { get; }
public TimelineService Timelines { get; } public TimelineService Timelines { get; }
public RelationshipService Relationships { get; } public RelationshipService Relationships { get; }
public ReportService Reports { get; } public ReportService Reports { get; }
@@ -145,4 +150,9 @@ namespace PrivaPub.Tests.Support
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments)); public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>(); public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
} }
public sealed class NoPreviews : ILinkPreviews
{
public Task Wanted(PrivaPub.Models.Post.Post post, CancellationToken token) => Task.CompletedTask;
}
} }
@@ -73,6 +73,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
domain = Domain, domain = Domain,
title = "PrivaPub", title = "PrivaPub",
version = Version, version = Version,
api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub", source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description, description = Description,
usage = new { users = new { active_month = 0 } }, usage = new { users = new { active_month = 0 } },
@@ -60,13 +60,38 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)] [HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token) public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
{ {
var (path, contentType) = await _proxy.Fetch(signature, encoded, token); var url = _proxy.Verified(signature, encoded);
if (path == default) if (url == default)
return NotFound(); return NotFound();
Response.Headers["X-Content-Type-Options"] = "nosniff"; Response.Headers["X-Content-Type-Options"] = "nosniff";
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox"; Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
Response.Headers["Cache-Control"] = "public, max-age=604800"; Response.Headers["Cache-Control"] = "public, max-age=604800";
return PhysicalFile(path, contentType); if (_proxy.Cached(url) is { Path: not null } cached)
return PhysicalFile(cached.Path, cached.ContentType, enableRangeProcessing: true);
if (Request.Headers.Range.Count == 0)
{
var (path, contentType) = await _proxy.Fetch(signature, encoded, token);
if (path != default)
return PhysicalFile(path, contentType, enableRangeProcessing: true);
}
return await Stream(url, token);
}
async Task<IActionResult> Stream(string url, CancellationToken token)
{
var range = System.Net.Http.Headers.RangeHeaderValue.TryParse(Request.Headers.Range.ToString(), out var asked) ? asked : default;
using var upstream = await _proxy.Open(url, range, token);
if (upstream == default)
return NotFound();
Response.StatusCode = (int)upstream.StatusCode;
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
if (upstream.Content.Headers.ContentLength is { } length)
Response.ContentLength = length;
if (upstream.Content.Headers.ContentRange is { } contentRange)
Response.Headers.ContentRange = contentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
await upstream.Content.CopyToAsync(Response.Body, token);
return new EmptyResult();
} }
object View(MediaAttachment attachment) => new object View(MediaAttachment attachment) => new
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class ReactionsController : MastodonController
{
readonly DbEntities _dbEntities;
readonly MastodonMapper _mapper;
readonly IReactions _reactions;
public ReactionsController(DbEntities dbEntities, MastodonMapper mapper, IReactions reactions)
{
_dbEntities = dbEntities;
_mapper = mapper;
_reactions = reactions;
}
[HttpGet("/api/v1/pleroma/statuses/{id}/reactions"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> List(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
var reactions = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
var accounts = await _mapper.Accounts(reactions.Select(r => r.AccountId), token);
return Json(reactions.GroupBy(r => r.Emoji).Select(group => new
{
name = group.Key,
count = group.Count(),
me = MyId != default && group.Any(r => r.AccountId == MyId),
url = _mapper.ProxiedUrl(group.First().EmojiURL),
accounts = group.Select(r => accounts.GetValueOrDefault(r.AccountId)).Where(a => a != default).ToList()
}).OrderByDescending(r => r.count).ToList());
}
[HttpPut("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
public async Task<IActionResult> React(string id, string emoji, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
if (!await _reactions.React(Me, post, Uri.UnescapeDataString(emoji), token))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: That emoji cannot be used here");
return Json(await _mapper.Status(post, MyId, token));
}
[HttpDelete("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
public async Task<IActionResult> Unreact(string id, string emoji, CancellationToken token)
{
var post = await Visible(id, token);
if (post == default)
return NotFoundError();
await _reactions.Unreact(Me, post, Uri.UnescapeDataString(emoji), token);
return Json(await _mapper.Status(post, MyId, token));
}
async Task<PostEntity> Visible(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
}
}
@@ -54,6 +54,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
InReplyTo = Params.Get("in_reply_to_id"), InReplyTo = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
MediaIds = Params.List("media_ids"), MediaIds = Params.List("media_ids"),
QuotedStatusId = Params.Get("quoted_status_id"),
Poll = Params.Has("poll[options]") Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true, ? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
Params.Bool("poll[hide_totals]") == true) Params.Bool("poll[hide_totals]") == true)
@@ -226,6 +227,21 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
} }
[HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Quotes(string id, CancellationToken token)
{
if (await Visible(id, token) == default)
return NotFoundError();
var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue);
var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
var visible = new List<PostEntity>();
foreach (var quote in quotes)
if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token))
visible.Add(quote);
Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID);
return Json(await _mapper.Statuses(visible, MyId, token));
}
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
public async Task<IActionResult> Bookmark(string id, CancellationToken token) public async Task<IActionResult> Bookmark(string id, CancellationToken token)
{ {
@@ -163,7 +163,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.Favourite] = "favourite", [NotificationType.Favourite] = "favourite",
[NotificationType.Reblog] = "reblog", [NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update", [NotificationType.Update] = "update",
[NotificationType.Poll] = "poll" [NotificationType.Poll] = "poll",
[NotificationType.Reaction] = "pleroma:emoji_reaction",
[NotificationType.Quote] = "quote"
}; };
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
@@ -234,7 +236,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
CreatedAt = MastodonJson.Time(n.CreatedAt), CreatedAt = MastodonJson.Time(n.CreatedAt),
GroupKey = $"ungrouped-{n.ID}", GroupKey = $"ungrouped-{n.ID}",
Account = accounts[n.FromAccountId], Account = accounts[n.FromAccountId],
Status = n.PostId == default ? default : statuses[n.PostId] Status = n.PostId == default ? default : statuses[n.PostId],
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
}) })
.ToList(); .ToList();
} }
@@ -161,6 +161,34 @@ namespace PrivaPub.Api.Mastodon.Entities
public int Down { get; set; } public int Down { get; set; }
} }
public class QuoteEntity
{
public string State { get; set; }
public Status QuotedStatus { get; set; }
public string QuotedStatusId { get; set; }
}
public class QuoteApprovalEntity
{
public List<string> Automatic { get; set; } = new();
public List<string> Manual { get; set; } = new();
public string CurrentUser { get; set; } = "denied";
}
public class EmojiReactionEntity
{
public string Name { get; set; }
public int Count { get; set; }
public bool Me { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
}
public class PleromaStatus
{
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
}
public class PollEntity public class PollEntity
{ {
public string Id { get; set; } public string Id { get; set; }
@@ -237,6 +265,11 @@ namespace PrivaPub.Api.Mastodon.Entities
public string Text { get; set; } public string Text { get; set; }
public List<object> Filtered { get; set; } = new(); public List<object> Filtered { get; set; } = new();
public PrivaPubStatus Privapub { get; set; } public PrivaPubStatus Privapub { get; set; }
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
public PleromaStatus Pleroma { get; set; }
public QuoteEntity Quote { get; set; }
public int QuotesCount { get; set; }
public QuoteApprovalEntity QuoteApproval { get; set; }
} }
public class MediaAttachment public class MediaAttachment
@@ -305,6 +338,8 @@ namespace PrivaPub.Api.Mastodon.Entities
public string GroupKey { get; set; } public string GroupKey { get; set; }
public Account Account { get; set; } public Account Account { get; set; }
public Status Status { get; set; } public Status Status { get; set; }
public string Emoji { get; set; }
public string EmojiUrl { get; set; }
} }
public class Context public class Context
+109 -5
View File
@@ -7,6 +7,8 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
@@ -36,6 +38,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url); string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
public string ProxiedUrl(string url) => Proxied(url);
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png"; string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png"; string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
@@ -144,7 +148,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) => public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault(); (await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
Statuses(posts, viewerId, nested: false, token);
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token)
{ {
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
var originals = originalIds.Count == 0 var originals = originalIds.Count == 0
@@ -166,6 +173,19 @@ namespace PrivaPub.Api.Mastodon.Mappers
? new HashSet<string>() ? new HashSet<string>()
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet(); : (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet(); var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
var reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
.GroupBy(r => r.PostId)
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
.Select(e => new EmojiReactionEntity
{
Name = e.Key,
Count = e.Count(),
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
Url = Proxied(e.First().EmojiURL),
StaticUrl = Proxied(e.First().EmojiURL)
})
.OrderByDescending(e => e.Count)
.ToList());
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList(); var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0 var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>() ? new Dictionary<string, List<int>>()
@@ -205,17 +225,43 @@ namespace PrivaPub.Api.Mastodon.Mappers
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
Visibility = Visibility(post.Visibility), Visibility = Visibility(post.Visibility),
Language = post.Language, Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(), MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
Card = Card(post), Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)), Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis), Emojis = Emojis(post.Emojis),
Privapub = Extension(post), Privapub = Extension(post),
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList() Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
}; };
return status; return status;
} }
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
var quotedPosts = new List<PostEntity>();
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token))
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
quotedPosts.Add(quotedPost);
var quotedStatuses = quotedPosts.Count == 0
? new Dictionary<string, Status>()
: (await Statuses(quotedPosts, viewerId, nested: true, token)).ToDictionary(q => q.Id);
void Quote(Status status, PostEntity post)
{
status.QuotesCount = post.QuotesCount;
status.QuoteApproval = Approval(post, viewerId);
if (post.QuoteState == QuoteState.None)
return;
var state = post.QuoteState.ToString().ToLowerInvariant();
var quoted = post.QuotedPostId != default && quotedStatuses.TryGetValue(post.QuotedPostId, out var shown) ? shown : default;
status.Quote = nested
? new QuoteEntity { State = state, QuotedStatusId = post.QuotedPostId }
: new QuoteEntity { State = state, QuotedStatus = post.QuoteState == QuoteState.Accepted ? quoted : default, QuotedStatusId = post.QuotedPostId };
if (!nested && post.QuoteState == QuoteState.Accepted && quoted != default)
status.Content = WithoutQuoteFallback(status.Content);
}
var mapped = new List<Status>(); var mapped = new List<Status>();
foreach (var post in posts) foreach (var post in posts)
{ {
@@ -225,10 +271,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
var status = Map(post); var status = Map(post);
if (status == default) if (status == default)
continue; continue;
Quote(status, post);
if (post.ReblogOfPostId != default) if (post.ReblogOfPostId != default)
{ {
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner) if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
continue; continue;
Quote(inner, original);
status.Reblog = inner; status.Reblog = inner;
status.Content = string.Empty; status.Content = string.Empty;
status.Reblogged = reblogged.Contains(original.ID); status.Reblogged = reblogged.Contains(original.ID);
@@ -240,6 +288,33 @@ namespace PrivaPub.Api.Mastodon.Mappers
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId; public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
static readonly AngleSharp.Html.Parser.HtmlParser Fragments = new();
public static string WithoutQuoteFallback(string html)
{
if (string.IsNullOrEmpty(html) || !html.Contains("quote-inline", StringComparison.Ordinal))
return html;
var document = Fragments.ParseDocument($"<body>{html}</body>");
foreach (var fallback in document.QuerySelectorAll(".quote-inline").ToList())
fallback.Remove();
return document.Body!.InnerHtml;
}
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
{
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return new QuoteApprovalEntity();
if (post.QuotePolicy is not { } policy)
return new QuoteApprovalEntity { Automatic = new List<string> { "public" }, CurrentUser = viewerId == default ? "unknown" : "automatic" };
static List<string> Named(IEnumerable<string> who) => who.Select(w => Addressing.IsPublic(w) ? "public" : w.EndsWith("/followers") ? "followers" : "unsupported_policy").Distinct().ToList();
return new QuoteApprovalEntity
{
Automatic = Named(policy.Automatic),
Manual = Named(policy.Manual),
CurrentUser = viewerId == default ? "unknown" : policy.Automatic.Any(Addressing.IsPublic) ? "automatic" : policy.Manual.Any(Addressing.IsPublic) ? "manual" : "denied"
};
}
public static string Content(PostEntity post) public static string Content(PostEntity post)
{ {
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text); var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
@@ -370,13 +445,42 @@ namespace PrivaPub.Api.Mastodon.Mappers
Streams = variant.Streams Streams = variant.Streams
}; };
List<MediaAttachment> Playable(PostEntity post)
{
var (variant, kind, duration) = post.Video is { } video ? (BestVideo(video.Variants), "video", video.DurationSeconds)
: post.Audio is { } audio ? (audio.Variants.FirstOrDefault(), "audio", audio.DurationSeconds)
: default;
if (variant == default)
return new List<MediaAttachment>();
return new List<MediaAttachment>
{
Media(new PostMedia
{
Id = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(variant.Url))),
RemoteURL = variant.Url,
ContentType = variant.MediaType ?? kind + "/*",
RemotePreviewURL = post.CoverURL,
Width = variant.Width,
Height = variant.Height,
DurationSeconds = duration,
Description = post.Title
})
};
}
public static MediaVariant BestVideo(IReadOnlyCollection<MediaVariant> variants)
{
var playable = variants.Where(v => v.MediaType?.StartsWith("video/") == true && (v.Streams.Count == 0 || v.Streams.Contains("audio") && v.Streams.Contains("video"))).ToList();
return playable.Where(v => v.Height <= 720).OrderByDescending(v => v.Height).FirstOrDefault()
?? playable.OrderBy(v => v.Height ?? int.MaxValue).FirstOrDefault();
}
PreviewCard Card(PostEntity post) PreviewCard Card(PostEntity post)
{ {
if (!post.IsFederatedCopy)
return default;
if (post.Link is { } link) if (post.Link is { } link)
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL); return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
if (post.ObjectType is "Article" or "Event" or "Video" or "Audio" && post.Url != default && post.Media.Count == 0) if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" && post.Url != default && post.Media.Count == 0
|| post.IsFederatedCopy && post.ObjectType is "Video" or "Audio" && post.Url != default && post.Media.Count == 0 && Playable(post).Count == 0)
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL); return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
return default; return default;
} }
+155
View File
@@ -0,0 +1,155 @@
using AngleSharp.Html.Parser;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Security.Cryptography;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Content
{
public class LinkPreview : Entity
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string ImageURL { get; set; }
public string SiteName { get; set; }
public bool Failed { get; set; }
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
}
public interface ILinkPreviews
{
Task Wanted(PostEntity post, CancellationToken token);
}
public class LinkPreviews : ILinkPreviews, IJobHandler
{
const int MaxJitterSeconds = 60;
static readonly TimeSpan Freshness = TimeSpan.FromDays(7);
static readonly HtmlParser Parser = new();
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IFederationHttp _http;
readonly IJobQueue _queue;
readonly IOptionsMonitor<FederationOptions> _options;
public LinkPreviews(DbEntities dbEntities, ILocalActorService localActors, IFederationHttp http, IJobQueue queue,
IOptionsMonitor<FederationOptions> options)
{
_dbEntities = dbEntities;
_localActors = localActors;
_http = http;
_queue = queue;
_options = options;
}
public JobKind Kind => JobKind.FetchPreview;
public int Concurrency => 2;
public int MaxAttempts => 1;
public int PerHostLimit => 1;
public async Task Wanted(PostEntity post, CancellationToken token)
{
if (!_options.CurrentValue.FetchLinkPreviews || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted) || post.IsLocalOnly
|| post.ReblogOfPostId != default || post.Link?.Title != default || post.Link == default && post.Media.Count > 0)
return;
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
if (url == default)
return;
await _queue.EnqueueMany(new[]
{
new Job
{
Kind = JobKind.FetchPreview, Payload = post.ID, Host = new Uri(url).Host.ToLowerInvariant(), DedupeKey = $"preview|{post.ID}",
RunAt = DateTime.UtcNow.AddSeconds(RandomNumberGenerator.GetInt32(0, MaxJitterSeconds + 1))
}
}, token);
}
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue || post.Link?.Title != default)
return JobOutcome.Done;
var url = post.Link?.Href ?? FirstLink(post, _localActors.BaseAddress);
if (url == default)
return JobOutcome.Done;
var preview = await DB.Default.Find<LinkPreview>().Match(p => p.Url == url).ExecuteFirstAsync(token);
if (preview == default || preview.FetchedAt < DateTime.UtcNow - Freshness)
preview = await Fetch(url, token);
if (preview == default || preview.Failed)
return JobOutcome.Done;
var link = post.Link ?? new PostLink { Href = url };
link.Title ??= preview.Title;
link.Description ??= preview.Description;
link.ImageURL ??= preview.ImageURL;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Link, link).ExecuteAsync(token);
return JobOutcome.Done;
}
async Task<LinkPreview> Fetch(string url, CancellationToken token)
{
var (finalUri, html) = await _http.GetPage(url, token);
var preview = html == default ? new LinkPreview { Url = url, Failed = true } : Read(url, finalUri, html);
await DB.Default.Update<LinkPreview>()
.Match(p => p.Url == url)
.Modify(p => p.Title, preview.Title)
.Modify(p => p.Description, preview.Description)
.Modify(p => p.ImageURL, preview.ImageURL)
.Modify(p => p.SiteName, preview.SiteName)
.Modify(p => p.Failed, preview.Failed)
.Modify(p => p.FetchedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
return preview;
}
public static LinkPreview Read(string url, Uri finalUri, string html)
{
var document = Parser.ParseDocument(html);
string Meta(params string[] names) => names
.Select(name => document.QuerySelector($"meta[property='{name}'], meta[name='{name}']")?.GetAttribute("content"))
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
var image = Meta("og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src");
var imageUri = image != default && Uri.TryCreate(finalUri, image, out var resolved) && resolved.Scheme is "https" or "http" ? resolved.AbsoluteUri : default;
var preview = new LinkPreview
{
Url = url,
Title = ObjectShapes.Text(Meta("og:title", "twitter:title") ?? document.Title, 300),
Description = ObjectShapes.Text(Meta("og:description", "twitter:description", "description"), 1000),
SiteName = ObjectShapes.Text(Meta("og:site_name"), 200),
ImageURL = imageUri
};
preview.Failed = preview.Title == default && preview.Description == default && preview.ImageURL == default;
return preview;
}
public static string FirstLink(PostEntity post, string ownBase)
{
if (string.IsNullOrEmpty(post.ContentHtml))
return default;
var document = Parser.ParseDocument(post.ContentHtml);
return document.QuerySelectorAll("a[href]")
.Where(a => !(a.ClassList.Contains("mention") || a.ClassList.Contains("hashtag") || a.GetAttribute("rel")?.Contains("tag") == true
|| a.Closest(".quote-inline") != default))
.Select(a => a.GetAttribute("href"))
.FirstOrDefault(href => Uri.TryCreate(href, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http"
&& href != post.Url && href != post.ObjectURI && !href.StartsWith(ownBase + "/", StringComparison.OrdinalIgnoreCase));
}
}
}
+33 -11
View File
@@ -15,6 +15,9 @@ namespace PrivaPub.Domain.Media
{ {
string Wrap(string remoteUrl); string Wrap(string remoteUrl);
Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token); Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token);
string Verified(string signature, string encodedUrl);
(string Path, string ContentType) Cached(string url);
Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
} }
public class MediaProxy : IMediaProxy public class MediaProxy : IMediaProxy
@@ -43,7 +46,7 @@ namespace PrivaPub.Domain.Media
return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}"; return $"{_localActors.BaseAddress}/media/proxy/{Sign(remoteUrl)}/{encoded}";
} }
public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token) public string Verified(string signature, string encodedUrl)
{ {
string url; string url;
try try
@@ -54,18 +57,37 @@ namespace PrivaPub.Domain.Media
{ {
return default; return default;
} }
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url)))) return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(signature ?? string.Empty), Encoding.ASCII.GetBytes(Sign(url))) ? url : default;
return default; }
public (string Path, string ContentType) Cached(string url)
{
var (path, typePath) = CachePaths(url);
if (!File.Exists(path) || !File.Exists(typePath))
return default;
File.SetLastWriteTimeUtc(path, DateTime.UtcNow);
return (path, File.ReadAllText(typePath));
}
public Task<HttpResponseMessage> Open(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token) =>
_http.OpenMedia(url, range, token);
(string Path, string TypePath) CachePaths(string url)
{
var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url))); var name = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(url)));
var directory = System.IO.Path.Combine(_media.ProxyRoot, name[..2]); var path = System.IO.Path.Combine(_media.ProxyRoot, name[..2], name);
var path = System.IO.Path.Combine(directory, name); return (path, path + ".type");
var typePath = path + ".type"; }
if (File.Exists(path) && File.Exists(typePath))
{ public async Task<(string Path, string ContentType)> Fetch(string signature, string encodedUrl, CancellationToken token)
File.SetLastWriteTimeUtc(path, DateTime.UtcNow); {
return (path, await File.ReadAllTextAsync(typePath, token)); var url = Verified(signature, encodedUrl);
} if (url == default)
return default;
if (Cached(url) is { Path: not null } cached)
return cached;
var (path, typePath) = CachePaths(url);
var directory = System.IO.Path.GetDirectoryName(path);
var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token); var (bytes, contentType) = await _http.GetMedia(url, _options.CurrentValue.MaxProxiedBytes, token);
if (bytes == default) if (bytes == default)
+4 -2
View File
@@ -8,7 +8,7 @@ namespace PrivaPub.Domain.Social
public static class Notifications public static class Notifications
{ {
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
CancellationToken token) CancellationToken token, string emoji = default, string emojiUrl = default)
{ {
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll) if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
return; return;
@@ -23,7 +23,9 @@ namespace PrivaPub.Domain.Social
FromAccountId = fromAccountId, FromAccountId = fromAccountId,
FromActorURI = fromActorUri, FromActorURI = fromActorUri,
PostId = postId, PostId = postId,
DedupeKey = $"{type}|{avatarId}|{fromActorUri}|{postId}" Emoji = emoji,
EmojiURL = emojiUrl,
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}"
}, token); }, token);
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
+179
View File
@@ -0,0 +1,179 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Social
{
public interface IReactions
{
Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token);
Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token);
Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token);
Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token);
}
public class Reactions : IReactions
{
static readonly string[] Hearts = { "❤", "❤️", "♥", "♥️" };
readonly DbEntities _dbEntities;
readonly IDeliveryService _delivery;
public Reactions(DbEntities dbEntities, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_delivery = delivery;
}
public static bool IsHeart(string content) => content == default || Hearts.Contains(content.Trim());
public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token)
{
if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
return;
try
{
await DB.Default.SaveAsync(new Reaction
{
PostId = post.ID, AccountId = actor.ID, ActorURI = actor.ActorURI, Emoji = emoji.Name, EmojiURL = emoji.Url, ActivityURI = activityId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
}
public async Task<bool> Withdraw(ForeignAvatar actor, string activityId, string objectUri, string content, CancellationToken token)
{
var reaction = activityId == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.ActivityURI == activityId && r.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (reaction == default && objectUri != default && Normalise(content, new()) is { } emoji)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
reaction = post == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == actor.ActorURI && r.Emoji == emoji.Name).ExecuteFirstAsync(token);
}
if (reaction == default)
return false;
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
return true;
}
public async Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token)
{
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
.ToList();
if (Normalise(emoji, known) is not { } picked)
return false;
var reaction = new Reaction
{
PostId = post.ID, AccountId = me.Id, ActorURI = me.Uri, IsLocal = true, Emoji = picked.Name, EmojiURL = picked.Url,
ActivityURI = me.ActivityUri($"react-{post.ID}-{Guid.NewGuid():N}")
};
try
{
await DB.Default.SaveAsync(reaction, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return true;
}
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, me.Id, me.Uri, post.ID, token, picked.Name, picked.Url);
else
await Tell(me, post, Activity(me, post, reaction), token);
return true;
}
public async Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token)
{
var name = Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
var reaction = name == default
? default
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == me.Uri && r.Emoji == name).ExecuteFirstAsync(token);
if (reaction == default)
return false;
await DB.Default.DeleteAsync<Reaction>(reaction.ID);
if (post.IsFederatedCopy)
await Tell(me, post, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = me.ActivityUri($"undo-{reaction.ID}"),
["type"] = "Undo",
["actor"] = me.Uri,
["object"] = Activity(me, post, reaction)
}, token);
return true;
}
static JsonObject Activity(LocalActor me, PostEntity post, Reaction reaction)
{
var react = new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = reaction.ActivityURI,
["type"] = "EmojiReact",
["actor"] = me.Uri,
["object"] = post.ObjectURI,
["to"] = new JsonArray(post.ActorURI),
["content"] = reaction.EmojiURL == default ? reaction.Emoji : $":{reaction.Emoji}:"
};
if (reaction.EmojiURL != default)
react["tag"] = new JsonArray(new JsonObject
{
["type"] = "Emoji",
["name"] = $":{reaction.Emoji}:",
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
});
return react;
}
async Task Tell(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
{
var author = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
if (!string.IsNullOrEmpty(author?.InboxURL))
await _delivery.Enqueue(me, new[] { author.InboxURL }, activity, token);
}
public sealed record Picked(string Name, string Url);
public static Picked Normalise(string content, List<Models.Post.CustomEmoji> emojis, bool requireKnownCustom = true)
{
var text = content?.Trim();
if (string.IsNullOrEmpty(text) || text.Length > 100)
return default;
if (text.StartsWith(':') && text.EndsWith(':') && text.Length > 2)
{
var shortcode = text.Trim(':');
var bare = shortcode.Split('@')[0];
var emoji = emojis.FirstOrDefault(e => e.Shortcode == shortcode) ?? emojis.FirstOrDefault(e => e.Shortcode.Split('@')[0] == bare);
if (emoji == default)
return requireKnownCustom ? default : new Picked(bare, default);
return new Picked(bare, emoji.URL);
}
if (text.Any(c => c < 128 || char.IsWhiteSpace(c)) || new StringInfo(text).LengthInTextElements != 1)
return default;
return new Picked(text, default);
}
}
}
+1 -20
View File
@@ -233,30 +233,11 @@ namespace PrivaPub.Domain.Statuses
return JobOutcome.Done; return JobOutcome.Done;
await Closing(post, author, token); await Closing(post, author, token);
if (!post.IsLocalOnly) if (!post.IsLocalOnly)
await _outbox.Publish(author, post, Update(post, author), token); await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, $"poll-{DateTime.UtcNow.Ticks}"), token);
return JobOutcome.Done; return JobOutcome.Done;
} }
protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask; protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask;
static JsonObject Update(PostEntity post, LocalActor author)
{
var question = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, default, post.InReplyToURI);
question["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
question["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-poll-{DateTime.UtcNow.Ticks}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = question["to"]!.DeepClone(),
["cc"] = question["cc"]!.DeepClone(),
["object"] = question
};
}
} }
public class PollCloseJob : PollRefreshJob public class PollCloseJob : PollRefreshJob
+196
View File
@@ -0,0 +1,196 @@
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public interface IQuoteService
{
Task Resolve(PostEntity post, NoteDocument note, CancellationToken token);
Task Revoke(string stampUri, CancellationToken token);
Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token);
QuotePermission Permission(PostEntity quoted, LocalActor author);
Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token);
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
}
public enum QuotePermission
{
Denied,
Granted,
AskFirst
}
public class QuoteService : IQuoteService
{
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IOutboxPublisher _outbox;
public QuoteService(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts, ILocalActorService localActors,
IDeliveryService delivery, IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
_localActors = localActors;
_delivery = delivery;
_outbox = outbox;
}
const string RequestPrefix = "quote-request-";
public QuotePermission Permission(PostEntity quoted, LocalActor author)
{
if (!quoted.IsFederatedCopy)
return quoted.GroupUserId == author.Id && quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted
? QuotePermission.Granted
: QuotePermission.Denied;
if (quoted.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return QuotePermission.Denied;
if (quoted.QuotePolicy is not { } policy)
return QuotePermission.Granted;
return policy.Automatic.Concat(policy.Manual).Any(Addressing.IsPublic) || policy.Automatic.Concat(policy.Manual).Contains(author.Uri)
? QuotePermission.AskFirst
: QuotePermission.Denied;
}
public async Task Request(LocalActor author, PostEntity post, PostEntity quoted, JsonObject note, CancellationToken token)
{
var owner = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(owner?.InboxURL))
return;
await _delivery.Enqueue(author, new[] { owner.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri(RequestPrefix + post.ID),
["type"] = "QuoteRequest",
["actor"] = author.Uri,
["object"] = quoted.ObjectURI,
["instrument"] = note.DeepClone(),
["to"] = new JsonArray(quoted.ActorURI)
}, token);
}
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
{
var request = answer["object"];
var requestId = Id(request);
var marker = requestId?.LastIndexOf("/grunts/" + RequestPrefix, StringComparison.Ordinal) ?? -1;
if (marker < 0 && !(request is JsonObject && Value(request, "type") == "QuoteRequest"))
return false;
if (marker < 0)
return true;
var postId = requestId[(marker + "/grunts/".Length + RequestPrefix.Length)..];
var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var author = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author == default || author.ActivityUri(RequestPrefix + post.ID) != requestId || post.QuoteState != QuoteState.Pending)
return true;
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
if (quoted?.ActorURI != actor.ActorURI)
return true;
if (!accepted)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Rejected).ExecuteAsync(token);
return true;
}
var stamp = Id(answer["result"]);
if (stamp == default || !await Verified(stamp, post.ObjectURI, quoted, token))
return true;
post.QuoteAuthorizationURI = stamp;
post.QuoteState = QuoteState.Accepted;
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.QuoteAuthorizationURI, stamp)
.Modify(p => p.QuoteState, QuoteState.Accepted)
.ExecuteAsync(token);
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, "quote-approved"), token);
return true;
}
public async Task Resolve(PostEntity post, NoteDocument note, CancellationToken token)
{
if (note.QuoteUri == default && !note.QuoteDeleted)
return;
var quoted = note.QuoteUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.QuoteUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
?? await _remotePosts.StoreContext(note.QuoteUri, RemotePosts.MaxDepth, token);
var state = note.QuoteDeleted ? QuoteState.Deleted
: quoted == default ? QuoteState.Pending
: note.QuoteAuthorization != default
? !quoted.IsFederatedCopy || !await Verified(note.QuoteAuthorization, post.ObjectURI, quoted, token) ? QuoteState.Unauthorized : QuoteState.Accepted
: note.QuotesByConsent ? QuoteState.Pending
: quoted.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? QuoteState.Accepted
: QuoteState.Unauthorized;
var wasCounted = post.QuoteState == QuoteState.Accepted ? post.QuotedPostId : default;
await DB.Default.Update<PostEntity>().MatchID(post.ID)
.Modify(p => p.QuoteURI, note.QuoteUri)
.Modify(p => p.QuotedPostId, quoted?.ID)
.Modify(p => p.QuoteState, state)
.Modify(p => p.QuoteAuthorizationURI, state == QuoteState.Accepted ? note.QuoteAuthorization : default)
.ExecuteAsync(token);
post.QuotedPostId = quoted?.ID;
post.QuoteState = state;
var nowCounted = state == QuoteState.Accepted ? quoted?.ID : default;
if (wasCounted == nowCounted)
return;
if (wasCounted != default)
await DB.Default.Update<PostEntity>().MatchID(wasCounted).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
if (nowCounted != default)
{
await DB.Default.Update<PostEntity>().MatchID(nowCounted).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (!quoted.IsFederatedCopy)
await Notifications.Add(quoted.GroupUserId, NotificationType.Quote, post.AuthorAccountId, post.ActorURI, post.ID, token);
}
}
public async Task Revoke(string stampUri, CancellationToken token)
{
var revoked = await _dbEntities.Posts.Match(p => p.QuoteAuthorizationURI == stampUri && p.QuoteState == QuoteState.Accepted).ExecuteAsync(token);
foreach (var post in revoked)
{
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.QuoteState, QuoteState.Revoked).ExecuteAsync(token);
if (post.QuotedPostId != default)
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
}
}
public async Task<bool> Verified(string stampUri, string quotingUri, PostEntity quoted, CancellationToken token)
{
if (!Origin.Same(stampUri, quoted.ActorURI))
return false;
using var fetched = await _remoteActors.FetchObject(stampUri, token);
if (fetched == default)
return false;
var stamp = JsonNode.Parse(fetched.Root.GetRawText());
return Value(stamp, "type") == "QuoteAuthorization"
&& Id(stamp["attributedTo"]) == quoted.ActorURI
&& Id(stamp["interactingObject"]) == quotingUri
&& Id(stamp["interactionTarget"]) == quoted.ObjectURI;
}
}
}
+29 -2
View File
@@ -40,6 +40,7 @@ namespace PrivaPub.Domain.Statuses
public double? Longitude { get; init; } public double? Longitude { get; init; }
public double? RangeKm { get; init; } public double? RangeKm { get; init; }
public PollDraft Poll { get; init; } public PollDraft Poll { get; init; }
public string QuotedStatusId { get; init; }
} }
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -71,10 +72,15 @@ namespace PrivaPub.Domain.Statuses
readonly IMediaService _media; readonly IMediaService _media;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IPollService _polls; readonly IPollService _polls;
readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls) IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls,
ILinkPreviews previews, IQuoteService quotes)
{ {
_previews = previews;
_quotes = quotes;
_polls = polls; _polls = polls;
_media = media; _media = media;
_groups = groups; _groups = groups;
@@ -112,6 +118,18 @@ namespace PrivaPub.Domain.Statuses
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token)) if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
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 = _quotes.Permission(quoted, author);
if (quotePermission == QuotePermission.Denied)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post cannot be quoted");
}
var located = draft.Latitude.HasValue || draft.Longitude.HasValue; 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 if (located && (draft.Latitude is not (>= -90 and <= 90) || draft.Longitude is not (>= -180 and <= 180) || group != default
|| draft.Visibility == PostVisibility.Direct)) || draft.Visibility == PostVisibility.Direct))
@@ -163,7 +181,11 @@ namespace PrivaPub.Domain.Statuses
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly, IsLocalOnly = isLocalOnly,
ActorURI = author.Uri, ActorURI = author.Uri,
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll) 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.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
@@ -207,6 +229,11 @@ namespace PrivaPub.Domain.Statuses
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
await _polls.Scheduled(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) if (create != default)
await _outbox.Publish(author, post, create, token); await _outbox.Publish(author, post, create, token);
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
@@ -1,5 +1,6 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
@@ -15,17 +16,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IQuoteService _quotes;
public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors) public AcceptHandler(DbEntities dbEntities, ILocalActorService localActors, IQuoteService quotes)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_quotes = quotes;
} }
public virtual string Type => "Accept"; public virtual string Type => "Accept";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) 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); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
return; return;
@@ -56,7 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class RejectHandler : AcceptHandler 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)
{ {
} }
@@ -4,6 +4,7 @@ using MongoDB.Entities;
using PrivaPub.Domain.Timelines; using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -37,10 +38,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IPollService _polls; readonly IPollService _polls;
readonly ILinkPreviews _previews;
readonly IQuoteService _quotes;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls) IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes)
{ {
_quotes = quotes;
_previews = previews;
_records = records; _records = records;
_polls = polls; _polls = polls;
_groups = groups; _groups = groups;
@@ -148,6 +153,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
await _previews.Wanted(post, token);
await _quotes.Resolve(post, note, token);
if (parent != default) if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); 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 MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
@@ -27,10 +28,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery; readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IQuoteService _quotes;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups) IGroupDistributor groups, IQuoteService quotes)
{ {
_quotes = quotes;
_groups = groups; _groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -72,7 +75,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
.ExecuteAsync(token); .ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
{
await _quotes.Revoke(objectUri, token);
return; return;
}
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri); 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); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
@@ -0,0 +1,34 @@
using PrivaPub.Domain.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class EmojiReactHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly IReactions _reactions;
public EmojiReactHandler(DbEntities dbEntities, IReactions reactions)
{
_dbEntities = dbEntities;
_reactions = reactions;
}
public string Type => "EmojiReact";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post != default)
await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token);
}
}
}
@@ -18,10 +18,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
public class LikeHandler : IActivityHandler public class LikeHandler : IActivityHandler
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly IReactions _reactions;
public LikeHandler(DbEntities dbEntities) public LikeHandler(DbEntities dbEntities, IReactions reactions)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_reactions = reactions;
} }
public string Type => "Like"; public string Type => "Like";
@@ -34,6 +36,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor)) if (post == default || !MaySee(post, actor))
return; return;
var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content");
if (!Reactions.IsHeart(content))
{
await _reactions.Receive(post, actor, content, activity["tag"], Id(activity), token);
return;
}
try try
{ {
@@ -1,5 +1,6 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
@@ -17,11 +18,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
public class UndoHandler : IActivityHandler public class UndoHandler : IActivityHandler
{ {
readonly IReactions _reactions;
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
public UndoHandler(DbEntities dbEntities, ILocalActorService localActors) public UndoHandler(DbEntities dbEntities, ILocalActorService localActors, IReactions reactions)
{ {
_reactions = reactions;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
} }
@@ -42,6 +45,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
await UndoAnnounce(innerId, actor, token); await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike") if (innerType is null or "Dislike")
await UndoDislike(inner, innerId, actor, token); await UndoDislike(inner, innerId, actor, token);
if (innerType is null or "Like" or "EmojiReact")
await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default,
inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token);
} }
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
@@ -1,6 +1,7 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
@@ -24,10 +25,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IQuoteService _quotes;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups, public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records) IObjectRecords records, IQuoteService quotes)
{ {
_quotes = quotes;
_records = records; _records = records;
_groups = groups; _groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -56,6 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
post.Poll = note.Poll ?? post.Poll; post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy;
post.Video = note.Video ?? post.Video; post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio; post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
@@ -63,6 +67,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token); await _records.Revise(note, Id(activity), token);
await Requote(post, note, token);
return; return;
} }
@@ -102,11 +107,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.UpdateDate = DateTime.UtcNow; post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), 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) 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); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
static bool IsEdit(NoteDocument note, PostEntity post) => static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate); 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);
}
} }
} }
+6 -1
View File
@@ -1,6 +1,7 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
@@ -39,10 +40,12 @@ namespace PrivaPub.Federation.Inbox
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue; readonly IJobQueue _queue;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly ILinkPreviews _previews;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks, public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue, IObjectRecords records) IJobQueue queue, IObjectRecords records, ILinkPreviews previews)
{ {
_previews = previews;
_records = records; _records = records;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
@@ -79,6 +82,7 @@ namespace PrivaPub.Federation.Inbox
Excerpt = note.Excerpt, Excerpt = note.Excerpt,
Source = note.Source, Source = note.Source,
Poll = note.Poll, Poll = note.Poll,
QuotePolicy = note.QuotePolicy,
Emojis = note.Emojis.ToList(), Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL, CoverURL = note.CoverURL,
Link = note.Link, Link = note.Link,
@@ -143,6 +147,7 @@ namespace PrivaPub.Federation.Inbox
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token); return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
} }
await _records.Record(note, post, ObjectPath.Fetched, refetched: false, token); await _records.Record(note, post, ObjectPath.Fetched, refetched: false, token);
await _previews.Wanted(post, token);
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth) if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host, await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
@@ -38,7 +38,7 @@ namespace PrivaPub.Federation.Objects
{ {
KeepChildNodes = true 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.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass); sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) => sanitizer.RemovingTag += (_, e) =>
+15 -1
View File
@@ -25,6 +25,9 @@ namespace PrivaPub.Federation.Objects
public string Context { get; init; } public string Context { get; init; }
public string Audience { get; init; } public string Audience { get; init; }
public string QuoteUri { 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 Published { get; init; }
public DateTime? Updated { get; init; } public DateTime? Updated { get; init; }
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>(); 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 IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; } public PostSource Source { get; init; }
public PostPoll Poll { get; init; } public PostPoll Poll { get; init; }
public InteractionRule QuotePolicy { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>(); public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; } public string CoverURL { get; init; }
public PostLink Link { get; init; } public PostLink Link { get; init; }
@@ -79,7 +83,10 @@ namespace PrivaPub.Federation.Objects
InReplyTo = Id(note["inReplyTo"]), InReplyTo = Id(note["inReplyTo"]),
Context = Id(note["context"]) ?? Value(note, "conversation"), Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]), 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, Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
Updated = Time(Value(note, "updated")), Updated = Time(Value(note, "updated")),
To = List(note["to"]), To = List(note["to"]),
@@ -97,6 +104,7 @@ namespace PrivaPub.Federation.Objects
Attachments = Attachments(note), Attachments = Attachments(note),
Source = ObjectShapes.Source(note), Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note), Poll = ObjectShapes.Poll(note),
QuotePolicy = ObjectShapes.QuotePolicy(note),
Emojis = ObjectShapes.Emojis(note["tag"]), Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note), CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(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) => 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; 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;
+22 -1
View File
@@ -18,6 +18,7 @@ namespace PrivaPub.Federation.Objects
const int CoverMaxWidth = 1280; const int CoverMaxWidth = 1280;
const int MaxEmojis = 64; const int MaxEmojis = 64;
const int MaxPollOptions = 20; const int MaxPollOptions = 20;
const int MaxAudience = 32;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase) static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{ {
@@ -97,6 +98,23 @@ namespace PrivaPub.Federation.Objects
.Take(MaxEmojis) .Take(MaxEmojis)
.ToList(); .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) public static PostPoll Poll(JsonObject note)
{ {
if (Value(note, "type") != "Question") if (Value(note, "type") != "Question")
@@ -199,10 +217,13 @@ namespace PrivaPub.Federation.Objects
if (Value(note, "type") != "Video") if (Value(note, "type") != "Video")
return default; return default;
var links = Objects(note["url"]).ToList(); var links = Objects(note["url"]).ToList();
var nested = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.SelectMany(l => Objects(l["tag"]))
.ToList();
return new VideoDetails return new VideoDetails
{ {
DurationSeconds = Seconds(note["duration"]), DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links, "video/", "audio/"), Variants = Variants(links.Concat(nested).ToList(), "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true) PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb), .Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true, IsLive = Bool(note, "isLiveBroadcast") == true,
@@ -60,6 +60,16 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL); 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(); return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
} }
@@ -38,6 +38,18 @@ namespace PrivaPub.Federation.Rendering
["indexable"] = "toot:indexable", ["indexable"] = "toot:indexable",
["blurhash"] = "toot:blurhash", ["blurhash"] = "toot:blurhash",
["votersCount"] = "toot:votersCount", ["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" }, ["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
["schema"] = "http://schema.org#", ["schema"] = "http://schema.org#",
["PropertyValue"] = "schema:PropertyValue", ["PropertyValue"] = "schema:PropertyValue",
@@ -157,6 +169,47 @@ namespace PrivaPub.Federation.Rendering
return note; 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) static void AddPoll(JsonObject note, PostPoll poll)
{ {
note["type"] = "Question"; note["type"] = "Question";
@@ -208,6 +261,8 @@ namespace PrivaPub.Federation.Rendering
})) }))
.ToArray()) .ToArray())
}; };
AddQuote(note, post, ref content);
note["content"] = content;
if (!string.IsNullOrEmpty(post.Language)) if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content }; note["contentMap"] = new JsonObject { [post.Language] = content };
if (post.Poll is { } poll) if (post.Poll is { } poll)
+4
View File
@@ -113,6 +113,10 @@ namespace PrivaPub.Infrastructure.Data
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending) await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.ActorURI, KeyType.Ascending).Key(v => v.Choice, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token); .Option(o => o.Unique = true).CreateAsync(token);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(token); await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(token);
await DB.Default.Index<Reaction>().Key(r => r.PostId, KeyType.Ascending).Key(r => r.ActorURI, KeyType.Ascending).Key(r => r.Emoji, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await Plain<Reaction>(token, r => r.ActivityURI);
await Unique<Domain.Content.LinkPreview>(p => p.Url, Builders<Domain.Content.LinkPreview>.Filter.Type(p => p.Url, BsonType.String), token);
} }
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial, static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
@@ -22,6 +22,8 @@ namespace PrivaPub.Infrastructure.Http
bool IsAllowed(Uri target); bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url); bool FailedTemporarily(string url);
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token); Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token); Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
} }
@@ -181,6 +183,106 @@ namespace PrivaPub.Infrastructure.Http
} }
} }
public async Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
return default;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(RequestTimeout);
try
{
for (var hop = 0; hop <= MaxRedirects; hop++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd("text/html, application/xhtml+xml");
using var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
if (IsRedirect(response.StatusCode))
{
var location = response.Headers.Location;
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next))
return default;
target = next;
continue;
}
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (!response.IsSuccessStatusCode || mediaType is not ("text/html" or "application/xhtml+xml"))
return default;
var bytes = await ReadPrefix(response.Content, MaxPageBytes, timeout.Token);
var charset = response.Content.Headers.ContentType?.CharSet?.Trim('"');
var encoding = System.Text.Encoding.UTF8;
try
{
if (!string.IsNullOrEmpty(charset))
encoding = System.Text.Encoding.GetEncoding(charset);
}
catch (ArgumentException)
{
}
return (target, encoding.GetString(bytes));
}
return default;
}
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{
_logger.LogInformation("Page {Url} refused: {Reason}", url, ex.Message);
return default;
}
}
public async Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
return default;
try
{
for (var hop = 0; hop <= MaxRedirects; hop++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, target);
request.Headers.Accept.ParseAdd("video/*, audio/*, image/*");
request.Headers.Range = range;
var response = await _httpClientFactory.CreateClient(ClientName).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
if (IsRedirect(response.StatusCode))
{
var location = response.Headers.Location;
response.Dispose();
var next = location == default ? default : location.IsAbsoluteUri ? location : new Uri(target, location);
if (!IsAllowed(next))
return default;
target = next;
continue;
}
var mediaType = response.Content.Headers.ContentType?.MediaType?.ToLowerInvariant();
if (!response.IsSuccessStatusCode || mediaType == default || mediaType.Contains("svg")
|| !(mediaType.StartsWith("video/") || mediaType.StartsWith("audio/") || mediaType.StartsWith("image/") || mediaType == "application/octet-stream"))
{
response.Dispose();
return default;
}
return response;
}
return default;
}
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or OperationCanceledException && !token.IsCancellationRequested)
{
_logger.LogInformation("Media stream {Url} refused: {Reason}", url, ex.Message);
return default;
}
}
const int MaxPageBytes = 512 * 1024;
static async Task<byte[]> ReadPrefix(HttpContent content, int limit, CancellationToken token)
{
await using var stream = await content.ReadAsStreamAsync(token);
var buffer = new byte[limit];
var total = 0;
int read;
while (total < limit && (read = await stream.ReadAsync(buffer.AsMemory(total, limit - total), token)) > 0)
total += read;
return buffer[..total];
}
public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token) public async Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token)
{ {
if (!IsAllowed(request.RequestUri)) if (!IsAllowed(request.RequestUri))
@@ -6,5 +6,6 @@ namespace PrivaPub.Infrastructure.Http
public bool AllowPlainHttp { get; set; } public bool AllowPlainHttp { get; set; }
public bool SecureMode { get; set; } public bool SecureMode { get; set; }
public bool AcceptAnyCertificate { get; set; } public bool AcceptAnyCertificate { get; set; }
public bool FetchLinkPreviews { get; set; } = true;//owner decision 1: the server reads linked pages of public posts
} }
} }
@@ -77,6 +77,11 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, UndoHandler>() .AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>() .AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>() .AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, EmojiReactHandler>()
.AddSingleton<IReactions, Reactions>()
.AddSingleton<LinkPreviews>()
.AddSingleton<ILinkPreviews>(services => services.GetRequiredService<LinkPreviews>())
.AddSingleton<IJobHandler>(services => services.GetRequiredService<LinkPreviews>())
.AddSingleton<IActivityHandler, JoinHandler>() .AddSingleton<IActivityHandler, JoinHandler>()
.AddSingleton<IActivityHandler, AnnounceHandler>() .AddSingleton<IActivityHandler, AnnounceHandler>()
.AddSingleton<IActivityHandler, FlagHandler>() .AddSingleton<IActivityHandler, FlagHandler>()
@@ -87,6 +92,7 @@ namespace PrivaPub.Middleware
.AddSingleton<IRemotePosts, RemotePosts>() .AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IObjectRecords, ObjectRecords>() .AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IPollService, PollService>() .AddSingleton<IPollService, PollService>()
.AddSingleton<IQuoteService, QuoteService>()
.AddSingleton<IJobHandler, PollRefreshJob>() .AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, PollCloseJob>() .AddSingleton<IJobHandler, PollCloseJob>()
.AddSingleton<IJobHandler, InstanceDescriber>() .AddSingleton<IJobHandler, InstanceDescriber>()
+2 -1
View File
@@ -25,7 +25,8 @@ namespace PrivaPub.Models.Jobs
FetchAncestors, FetchAncestors,
DescribeInstance, DescribeInstance,
PollRefresh, PollRefresh,
PollClose PollClose,
FetchPreview
} }
public enum JobState public enum JobState
+18
View File
@@ -57,6 +57,13 @@ namespace PrivaPub.Models.Post
public string Url { get; set; } public string Url { get; set; }
public string ContextURI { get; set; } public string ContextURI { get; set; }
public string QuoteURI { get; set; } public string QuoteURI { get; set; }
public string QuotedPostId { get; set; }//our copy of the quoted post, once fetched
public QuoteState QuoteState { get; set; }
public string QuoteAuthorizationURI { get; set; }//FEP-044f: the quoted author's stamp
public bool QuoteByConsent { get; set; }//our quote of a post whose server asks for consent (FEP-044f), so `quote` is sent
public int QuotesCount { get; set; }
[BsonIgnoreIfNull]
public InteractionRule QuotePolicy { get; set; }//a remote post's interactionPolicy.canQuote; null when it states none
public List<string> To { get; set; } = new(); public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new(); public List<string> Cc { get; set; } = new();
@@ -73,6 +80,17 @@ namespace PrivaPub.Models.Post
public string UpdateReason { get; set; } public string UpdateReason { get; set; }
} }
public enum QuoteState
{
None,
Pending,
Accepted,
Rejected,
Revoked,
Deleted,
Unauthorized
}
public enum PostVisibility public enum PostVisibility
{ {
Public, Public,
+6
View File
@@ -23,6 +23,12 @@ namespace PrivaPub.Models.Post
public int Votes { get; set; } public int Votes { get; set; }
} }
public class InteractionRule
{
public List<string> Automatic { get; set; } = new();//actor or collection URIs; Public for anyone
public List<string> Manual { get; set; } = new();
}
public class PostSource public class PostSource
{ {
public string Content { get; set; } public string Content { get; set; }
+5 -1
View File
@@ -10,6 +10,8 @@ namespace PrivaPub.Models.Social
public string FromActorURI { get; set; } public string FromActorURI { get; set; }
public string PostId { get; set; } public string PostId { get; set; }
public string DedupeKey { get; set; } public string DedupeKey { get; set; }
public string Emoji { get; set; }//a reaction's emoji
public string EmojiURL { get; set; }
public bool IsRead { get; set; } public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -22,6 +24,8 @@ namespace PrivaPub.Models.Social
Favourite, Favourite,
Reblog, Reblog,
Update, Update,
Poll Poll,
Reaction,
Quote
} }
} }
+16
View File
@@ -0,0 +1,16 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Reaction : Entity
{
public string PostId { get; set; }
public string AccountId { get; set; }//Avatar.ID for a local persona, ForeignAvatar.ID for a remote account
public string ActorURI { get; set; }
public bool IsLocal { get; set; }
public string Emoji { get; set; }//a single Unicode emoji, or a custom emoji's shortcode without colons or host
public string EmojiURL { get; set; }//set for a custom emoji
public string ActivityURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+12
View File
@@ -48,6 +48,18 @@ server {
proxy_read_timeout 300s; proxy_read_timeout 300s;
} }
location ^~ /media/proxy/ {
proxy_buffering off;
proxy_pass http://127.0.0.1:6970;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
location / { location / {
proxy_pass http://127.0.0.1:6970; proxy_pass http://127.0.0.1:6970;
proxy_http_version 1.1; proxy_http_version 1.1;
+17 -10
View File
@@ -453,18 +453,25 @@ it, raw where it doesn't.
- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit (done in - **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit (done in
v1.10.0; checked live both ways against GoToSocial). v1.10.0; checked live both ways against GoToSocial).
- **Quotes (FEP-044f):** - **Quotes (FEP-044f):**
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation; - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation (done in v1.13.0);
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps; - personas quote others: `quoted_status_id`, `QuoteRequest`, `Accept{result}` verified, `Update` with
- then advertise `api_versions.mastodon ≥ 7` (4.5.0). `quoteAuthorization`; legacy quotes for posts that state no policy (done in v1.13.0); `api_versions.mastodon = 7`;
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions`. - still open: be quotable (`canQuote` on our posts, answer `QuoteRequest`, serve and revoke stamps). It needs a themed
- **Link cards:** route name for stamps, agreed with the owner, and a default quote policy.
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
v1.11.0; Pleroma's `/api/v1/pleroma/statuses/:id/reactions` endpoints and `pleroma:emoji_reaction` notifications).
- **Link cards** (done in v1.12.0):
- from the object, or from FEP-8967 `preview`, without fetching; - from the object, or from FEP-8967 `preview`, without fetching;
- fetching the page itself is the owner's decision (INTEROP §6.1). - otherwise the server reads the page (owner decision 1): public posts only, 0–60 s after arrival, cached per address
- **Media:** for 7 days across the whole server, `Federation:FetchLinkPreviews` to switch it off. Checked live against a
- video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card; GoToSocial profile page.
- **Media** (done in v1.14.0 except where noted):
- video playback through the proxy: range requests streamed from the origin, the poster, a playable attachment picked
from MP4 or fragmented MP4 files (HLS playlists are not rewritten; not needed while PeerTube publishes the files);
- audio attachments; - audio attachments;
- an article reader view; - JPEG/PNG for Pixelfed: uploads were already re-encoded to JPEG, PNG or GIF;
- JPEG/PNG renditions kept for Pixelfed. - an article reader view: the data is in `Status.privapub` (title, excerpt, cover, full HTML in `content`); the view
itself belongs to the client.
#### P7 Threads, communities and the social graph #### P7 Threads, communities and the social graph
- **Thread backfill:** - **Thread backfill:**
+9
View File
@@ -114,6 +114,15 @@ gts_poll_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0' curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/polls/$gts_poll_on_pp/votes" -d 'choices[]=0'
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial" until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/statuses/$gts_poll" | j "print(d[\"poll\"][\"options\"][0][\"votes_count\"])")" = "1" ]' && ok "alice's vote counts on GoToSocial" || ko "vote not counted on GoToSocial"
echo "quotes"
quote_target=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'red or blue' in s['content']))")
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$PH" $P/api/v1/statuses -d "status=quoting&quoted_status_id=$quote_target")" = "422" ] \
&& ok "GoToSocial's author-only quote policy is respected" || ko "quoted a post whose author forbids it"
echo "link previews"
linked=$(curl -s -X POST -H "$PH" $P/api/v1/statuses --data-urlencode 'status=have a look https://gts.test/@gtsuser' -d 'visibility=public' | j "print(d['id'])")
until_true 45 '[ -n "$(curl -s -H "$PH" "$P/api/v1/statuses/$linked" | j "print((d[\"card\"] or {}).get(\"title\") or \"\")")" ]' && ok "the server builds a card for a linked page" || ko "no card for the linked page"
echo "edits and deletes" echo "edits and deletes"
curl -s -o /dev/null -X PUT -H "$PH" $P/api/v1/statuses/$pp_post -d 'status=Hello from PrivaPub, edited' curl -s -o /dev/null -X PUT -H "$PH" $P/api/v1/statuses/$pp_post -d 'status=Hello from PrivaPub, edited'
until_true 20 'gcurl -s -H "$GH" "$G/api/v1/statuses/$pp_on_gts" | grep -q "edited"' && ok "alice's edit reaches GoToSocial" || ko "edit not applied" until_true 20 'gcurl -s -H "$GH" "$G/api/v1/statuses/$pp_on_gts" | grep -q "edited"' && ok "alice's edit reaches GoToSocial" || ko "edit not applied"