Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001fdac3be | ||
|
|
fdcf5176bc |
No files matched your search
@@ -258,6 +258,9 @@ 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`).
|
||||||
- **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
|
||||||
|
|||||||
+14
-1
@@ -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`.
|
||||||
@@ -127,6 +128,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.
|
||||||
|
|||||||
@@ -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 & 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;
|
||||||
@@ -54,8 +55,8 @@ namespace PrivaPub.Tests.Federation
|
|||||||
_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), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue), new NoPreviews()), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue), new NoPreviews()),
|
||||||
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
||||||
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
|
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,19 +45,21 @@ 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());
|
||||||
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),
|
||||||
new RejectHandler(Db, Local),
|
new RejectHandler(Db, Local),
|
||||||
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()),
|
||||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||||
new UpdateHandler(Db, Local, Remote, Groups, Records),
|
new UpdateHandler(Db, Local, Remote, Groups, Records),
|
||||||
new FlagHandler(Db, Local)
|
new FlagHandler(Db, Local)
|
||||||
@@ -67,7 +69,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
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());
|
||||||
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 +95,7 @@ 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 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 +148,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,7 +163,8 @@ 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"
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly MastodonMapper _mapper;
|
readonly MastodonMapper _mapper;
|
||||||
@@ -234,7 +235,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,20 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public int Down { get; set; }
|
public int Down { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +251,8 @@ 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 class MediaAttachment
|
public class MediaAttachment
|
||||||
@@ -305,6 +321,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
|
||||||
|
|||||||
@@ -36,6 +36,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";
|
||||||
|
|
||||||
@@ -166,6 +168,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>>()
|
||||||
@@ -210,6 +225,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
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()
|
||||||
};
|
};
|
||||||
@@ -372,11 +389,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
|
|
||||||
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" or "Video" or "Audio" && post.Url != default && post.Media.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,13 @@ 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;
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
_previews = previews;
|
||||||
_polls = polls;
|
_polls = polls;
|
||||||
_media = media;
|
_media = media;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
@@ -207,6 +210,7 @@ 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);
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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,12 @@ 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;
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
_previews = previews;
|
||||||
_records = records;
|
_records = records;
|
||||||
_polls = polls;
|
_polls = polls;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
@@ -148,6 +151,7 @@ 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);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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.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;
|
||||||
@@ -143,6 +146,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,6 +38,9 @@ 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",
|
||||||
|
["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",
|
||||||
|
|||||||
@@ -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,7 @@ 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> 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 +182,66 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>()
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ namespace PrivaPub.Models.Jobs
|
|||||||
FetchAncestors,
|
FetchAncestors,
|
||||||
DescribeInstance,
|
DescribeInstance,
|
||||||
PollRefresh,
|
PollRefresh,
|
||||||
PollClose
|
PollClose,
|
||||||
|
FetchPreview
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum JobState
|
public enum JobState
|
||||||
|
|||||||
@@ -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,7 @@ namespace PrivaPub.Models.Social
|
|||||||
Favourite,
|
Favourite,
|
||||||
Reblog,
|
Reblog,
|
||||||
Update,
|
Update,
|
||||||
Poll
|
Poll,
|
||||||
|
Reaction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -456,10 +456,13 @@ it, raw where it doesn't.
|
|||||||
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation;
|
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation;
|
||||||
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps;
|
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps;
|
||||||
- then advertise `api_versions.mastodon ≥ 7` (4.5.0).
|
- then advertise `api_versions.mastodon ≥ 7` (4.5.0).
|
||||||
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions`.
|
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions` (done in
|
||||||
- **Link cards:**
|
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
|
||||||
|
for 7 days across the whole server, `Federation:FetchLinkPreviews` to switch it off. Checked live against a
|
||||||
|
GoToSocial profile page.
|
||||||
- **Media:**
|
- **Media:**
|
||||||
- video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card;
|
- video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card;
|
||||||
- audio attachments;
|
- audio attachments;
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ 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 "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"
|
||||||
|
|||||||
Reference in new issue
Block a user