One Post model for every visibility, read from notes the way peers write them
Post gains what federation and the Mastodon API need: Visibility (Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account id, to/cc, the Create's id, url, context, quote, InReplyToURI and the parent's author, a separate SpoilerText next to the title, language, mentions, hashtags, remote attachments (alt text, blurhash, focus, size), reply/favourite/reblog counters, revisions, EditedAt and DeletedAt. Direct messages are Posts with Visibility Direct and a ConversationId; migration _005 copies DmPost rows across with their ids and fills the new fields of existing posts. DmPost is left in place so a rollback still sees the old messages. Inbound: - NoteParser reads Note, Article, Page, Question and media types: content, then contentMap, then _misskey_content; summary as the spoiler and name as the title; Mention and Hashtag tags; attachments; a PeerTube-style list attribution prefers the person over the channel; quote URIs. - Addressing classifies like Mastodon, finding followers-only by the author's own followers URL (now stored on ForeignAvatar), not by a "/followers" suffix. - Create keeps a post when a local persona is addressed or mentioned, when it replies to a local post (the parent's reply count goes up) or when a community it follows is addressed; an unsolicited public post is not stored. Update keeps the previous version as a revision. The outbox and object endpoints serve only Public and Unlisted posts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
796f06acf4
commit
e9529310fe
21 files changed
+777
-192
No files matched your search
@@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Post
|
|||||||
public string AuthorActorURI { get; set; }
|
public string AuthorActorURI { get; set; }
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
public string DmGroupId { get; set; }
|
public string DmGroupId { get; set; }
|
||||||
|
public string Visibility { get; set; }
|
||||||
public string AnsweringToPostId { get; set; }
|
public string AnsweringToPostId { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MongoDB.Entities;
|
|||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Inbox.Handlers;
|
using PrivaPub.Federation.Inbox.Handlers;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
@@ -50,7 +51,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
new UndoHandler(db, _local, remote, delivery),
|
new UndoHandler(db, _local, remote, delivery),
|
||||||
new CreateHandler(db, _local, remote, delivery),
|
new CreateHandler(db, _local, remote, delivery),
|
||||||
new DeleteHandler(db, _local, remote, delivery),
|
new DeleteHandler(db, _local, remote, delivery),
|
||||||
new UpdateHandler(db, _local, remote, delivery)
|
new UpdateHandler(db, _local, remote)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +120,10 @@ namespace PrivaPub.Tests.Federation
|
|||||||
|
|
||||||
Assert.Equal(202, first.StatusCode);
|
Assert.Equal(202, first.StatusCode);
|
||||||
Assert.Equal(202, injected.StatusCode);
|
Assert.Equal(202, injected.StatusCode);
|
||||||
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
var bobDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token);
|
||||||
var malloryDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
||||||
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId);
|
Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
|
||||||
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, token);
|
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.ConversationId, token);
|
||||||
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +137,9 @@ namespace PrivaPub.Tests.Federation
|
|||||||
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||||
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
||||||
|
|
||||||
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token);
|
var dms = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token);
|
||||||
Assert.Equal(2, dms.Count);
|
Assert.Equal(2, dms.Count);
|
||||||
Assert.Single(dms.Select(d => d.GroupId).Distinct());
|
Assert.Single(dms.Select(d => d.ConversationId).Distinct());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -166,7 +167,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
||||||
|
|
||||||
Assert.Equal(400, result.StatusCode);
|
Assert.Equal(400, result.StatusCode);
|
||||||
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -179,7 +180,78 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
|
||||||
|
|
||||||
Assert.Equal(202, result.StatusCode);
|
Assert.Equal(202, result.StatusCode);
|
||||||
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc)
|
||||||
|
{
|
||||||
|
var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
||||||
|
var note = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["type"] = "Note",
|
||||||
|
["attributedTo"] = author.Id,
|
||||||
|
["content"] = "<p>hello</p>",
|
||||||
|
["to"] = new JsonArray(Addressing.Public),
|
||||||
|
["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray())
|
||||||
|
};
|
||||||
|
if (inReplyTo != default)
|
||||||
|
note["inReplyTo"] = inReplyTo;
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
||||||
|
["type"] = "Create",
|
||||||
|
["actor"] = author.Id,
|
||||||
|
["object"] = note
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_public_reply_to_a_local_post_is_kept_and_counted()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var alice = await LocalAvatar("alice");
|
||||||
|
var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" };
|
||||||
|
parent.ID = (string)parent.GenerateNewID();
|
||||||
|
parent.ObjectURI = alice.PostUri(parent.ID);
|
||||||
|
await DB.Default.SaveAsync(parent, token);
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
|
||||||
|
await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI));
|
||||||
|
|
||||||
|
var reply = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||||
|
Assert.Equal(PostVisibility.Public, reply.Visibility);
|
||||||
|
Assert.Equal(parent.ID, reply.AnsweringToPostId);
|
||||||
|
Assert.Equal(alice.Id, reply.InReplyToAccountId);
|
||||||
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(parent.ID, token)).RepliesCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_public_post_nobody_here_asked_for_is_dropped()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
|
||||||
|
await Deliver(bob, "/human-centipede", PublicCreate(bob));
|
||||||
|
|
||||||
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_mention_is_kept_and_linked_to_the_local_persona()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var alice = await LocalAvatar("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var create = PublicCreate(bob, default, alice.Uri);
|
||||||
|
create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle });
|
||||||
|
|
||||||
|
await Deliver(bob, "/human-centipede", create);
|
||||||
|
|
||||||
|
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
||||||
|
var mention = Assert.Single(post.Mentions);
|
||||||
|
Assert.True(mention.IsLocal);
|
||||||
|
Assert.Equal(alice.Id, mention.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Federation
|
||||||
|
{
|
||||||
|
public class NoteParserTests
|
||||||
|
{
|
||||||
|
const string MastodonNote = """
|
||||||
|
{
|
||||||
|
"id": "https://m.example/users/alice/statuses/1",
|
||||||
|
"type": "Note",
|
||||||
|
"summary": "spoilers",
|
||||||
|
"inReplyTo": "https://privapub.test/peasants/bob/posts/abc",
|
||||||
|
"published": "2026-09-30T10:00:00Z",
|
||||||
|
"url": "https://m.example/@alice/1",
|
||||||
|
"attributedTo": "https://m.example/users/alice",
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"cc": ["https://m.example/users/alice/followers", "https://privapub.test/peasants/bob"],
|
||||||
|
"sensitive": true,
|
||||||
|
"conversation": "tag:m.example,2026-09-30:objectId=1:objectType=Conversation",
|
||||||
|
"context": "https://m.example/contexts/1",
|
||||||
|
"content": "<p><span class=\"h-card\"><a href=\"https://privapub.test/peasants/bob\" class=\"u-url mention\">@<span>bob</span></a></span> hi <a href=\"https://m.example/tags/Fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>Fedi</span></a><script>x</script></p>",
|
||||||
|
"contentMap": { "en": "<p>hi</p>" },
|
||||||
|
"attachment": [{
|
||||||
|
"type": "Document", "mediaType": "image/png", "url": "https://files.m.example/a.png",
|
||||||
|
"name": "a cat", "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH", "focalPoint": [0.5, -2.0], "width": 640, "height": 480
|
||||||
|
}],
|
||||||
|
"tag": [
|
||||||
|
{ "type": "Mention", "href": "https://privapub.test/peasants/bob", "name": "@bob@privapub.test" },
|
||||||
|
{ "type": "Hashtag", "href": "https://m.example/tags/fedi", "name": "#Fedi" },
|
||||||
|
{ "type": "Emoji", "name": ":blob:", "icon": { "type": "Image", "url": "https://files.m.example/blob.png" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_a_mastodon_note()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse(MastodonNote));
|
||||||
|
|
||||||
|
Assert.Equal("https://m.example/users/alice", note.AttributedTo);
|
||||||
|
Assert.Equal("spoilers", note.SpoilerText);
|
||||||
|
Assert.True(note.Sensitive);
|
||||||
|
Assert.Equal("en", note.Language);
|
||||||
|
Assert.Equal("https://m.example/@alice/1", note.Url);
|
||||||
|
Assert.Equal("https://m.example/contexts/1", note.Context);
|
||||||
|
Assert.DoesNotContain("<script>", note.ContentHtml);
|
||||||
|
Assert.Contains("u-url mention", note.ContentHtml);
|
||||||
|
Assert.Equal("https://privapub.test/peasants/bob", Assert.Single(note.Mentions).ActorURI);
|
||||||
|
Assert.Equal("fedi", Assert.Single(note.Tags));
|
||||||
|
var media = Assert.Single(note.Attachments);
|
||||||
|
Assert.Equal("a cat", media.Description);
|
||||||
|
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
|
||||||
|
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_a_lemmy_page_with_a_title()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse("""
|
||||||
|
{
|
||||||
|
"id": "https://l.example/post/7", "type": "Page", "name": "A title",
|
||||||
|
"attributedTo": "https://l.example/u/carol", "audience": "https://l.example/c/cats",
|
||||||
|
"content": "<p>body</p>", "to": ["https://l.example/c/cats", "https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"published": "2026-09-30T10:00:00.123456+00:00"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Equal("A title", note.Title);
|
||||||
|
Assert.Equal("https://l.example/c/cats", note.Audience);
|
||||||
|
Assert.False(note.Sensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Falls_back_to_misskey_content_and_takes_the_person_from_a_peertube_attribution()
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse("""
|
||||||
|
{
|
||||||
|
"id": "https://k.example/notes/9", "type": "Note",
|
||||||
|
"attributedTo": [{ "type": "Group", "id": "https://k.example/c/chan" }, { "type": "Person", "id": "https://k.example/users/9" }],
|
||||||
|
"_misskey_content": "hello <b>there</b>\nnext", "_misskey_quote": "https://k.example/notes/8"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Equal("https://k.example/users/9", note.AttributedTo);
|
||||||
|
Assert.Equal("hello <b>there</b><br>next", note.ContentHtml);
|
||||||
|
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
|
||||||
|
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
|
||||||
|
[InlineData("""[1, 2]""")]
|
||||||
|
public void Refuses_what_is_not_a_note(string json) =>
|
||||||
|
Assert.Null(NoteParser.Parse(JsonNode.Parse(json)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AddressingTests
|
||||||
|
{
|
||||||
|
const string Followers = "https://m.example/users/alice/followers";
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new[] { Addressing.Public }, new[] { Followers }, PostVisibility.Public)]
|
||||||
|
[InlineData(new[] { "as:Public" }, new string[0], PostVisibility.Public)]
|
||||||
|
[InlineData(new[] { Followers }, new[] { Addressing.Public }, PostVisibility.Unlisted)]
|
||||||
|
[InlineData(new[] { Followers }, new[] { "https://privapub.test/peasants/bob" }, PostVisibility.FollowersOnly)]
|
||||||
|
[InlineData(new[] { "https://privapub.test/peasants/bob" }, new string[0], PostVisibility.Direct)]
|
||||||
|
[InlineData(new[] { "https://m.example/users/mallory/followers" }, new string[0], PostVisibility.Direct)]
|
||||||
|
public void Classifies_like_mastodon(string[] to, string[] cc, PostVisibility expected) =>
|
||||||
|
Assert.Equal(expected, Addressing.Classify(to, cc, Followers));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,5 +29,21 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
Assert.Equal("<p><strong>bold</strong> <b>raw</b></p>", migratedLocal.ContentHtml);
|
Assert.Equal("<p><strong>bold</strong> <b>raw</b></p>", migratedLocal.ContentHtml);
|
||||||
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Direct_posts_move_into_posts_with_their_conversation()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
|
||||||
|
await DB.Default.SaveAsync(dm, token);
|
||||||
|
|
||||||
|
await new _005_direct_posts_join_posts().UpgradeAsync();
|
||||||
|
|
||||||
|
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
|
||||||
|
Assert.Equal(PostVisibility.Direct, post.Visibility);
|
||||||
|
Assert.Equal("conversation1", post.ConversationId);
|
||||||
|
Assert.Equal("a1", post.AuthorAccountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public string Url { get; init; }
|
public string Url { get; init; }
|
||||||
public string Inbox { get; init; }
|
public string Inbox { get; init; }
|
||||||
public string Outbox { get; init; }
|
public string Outbox { get; init; }
|
||||||
|
public string Followers { get; init; }
|
||||||
|
public string Following { get; init; }
|
||||||
public string SharedInbox { get; init; }
|
public string SharedInbox { get; init; }
|
||||||
public string Icon { get; init; }
|
public string Icon { get; init; }
|
||||||
public bool Discoverable { get; init; } = true;
|
public bool Discoverable { get; init; } = true;
|
||||||
@@ -45,6 +47,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
Url = RemoteActorService.Text(root, "url") ?? id,
|
Url = RemoteActorService.Text(root, "url") ?? id,
|
||||||
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
Inbox = Origin.Same(inbox, id) ? inbox : default,
|
||||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||||
|
Followers = RemoteActorService.Text(root, "followers"),
|
||||||
|
Following = RemoteActorService.Text(root, "following"),
|
||||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
|
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
|
||||||
.Modify(a => a.InboxURL, actor.Inbox)
|
.Modify(a => a.InboxURL, actor.Inbox)
|
||||||
.Modify(a => a.OutboxURL, actor.Outbox)
|
.Modify(a => a.OutboxURL, actor.Outbox)
|
||||||
|
.Modify(a => a.FollowersURL, actor.Followers)
|
||||||
|
.Modify(a => a.FollowingURL, actor.Following)
|
||||||
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
||||||
.Modify(a => a.PictureURL, actor.Icon)
|
.Modify(a => a.PictureURL, actor.Icon)
|
||||||
.Modify(a => a.IsDiscoverable, actor.Discoverable)
|
.Modify(a => a.IsDiscoverable, actor.Discoverable)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
@@ -51,12 +52,12 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var total = local.Kind == LocalActorKind.Group
|
var total = local.Kind == LocalActorKind.Group
|
||||||
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.IsLocalOnly, token)
|
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token)
|
||||||
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token);
|
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted), token);
|
||||||
|
|
||||||
var latest = await (local.Kind == LocalActorKind.Group
|
var latest = await (local.Kind == LocalActorKind.Group
|
||||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly)
|
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||||
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly))
|
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)))
|
||||||
.Sort(p => p.CreationDate, Order.Descending)
|
.Sort(p => p.CreationDate, Order.Descending)
|
||||||
.Limit(OutboxSize)
|
.Limit(OutboxSize)
|
||||||
.ExecuteAsync(token);
|
.ExecuteAsync(token);
|
||||||
@@ -70,7 +71,7 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||||
var note = ActivityPubRenderer.Note(post, local, group, default);
|
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
|
||||||
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
|
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +104,13 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var post = await _dbEntities.Posts
|
var post = await _dbEntities.Posts
|
||||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly)
|
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||||
.ExecuteFirstAsync(token);
|
.ExecuteFirstAsync(token);
|
||||||
if (post == default)
|
if (post == default)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||||
var replyTo = await ReplyTarget(post.AnsweringToPostId, token);
|
var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI);
|
||||||
var note = ActivityPubRenderer.Note(post, local, group, replyTo);
|
|
||||||
note["@context"] = ActivityPubRenderer.ActivityStreams;
|
note["@context"] = ActivityPubRenderer.ActivityStreams;
|
||||||
return Activity(note);
|
return Activity(note);
|
||||||
}
|
}
|
||||||
@@ -132,15 +132,6 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
||||||
Answer(await _inbox.Receive(Request, default, token));
|
Answer(await _inbox.Receive(Request, default, token));
|
||||||
|
|
||||||
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(answeringToPostId))
|
|
||||||
return default;
|
|
||||||
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return answeringToPostId;
|
|
||||||
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI;
|
|
||||||
}
|
|
||||||
|
|
||||||
IActionResult Answer(InboxResult result)
|
IActionResult Answer(InboxResult result)
|
||||||
{
|
{
|
||||||
if (result.Error != default)
|
if (result.Error != default)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
@@ -15,7 +16,6 @@ using System.Text.Json.Nodes;
|
|||||||
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
@@ -37,105 +37,129 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
|
|
||||||
public string Type => "Create";
|
public string Type => "Create";
|
||||||
|
|
||||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
|
||||||
{
|
{
|
||||||
var create = activity;
|
var node = activity["object"];
|
||||||
var author = actor;
|
if (node is not JsonObject || !Origin.Same(Id(node), author.ActorURI))
|
||||||
|
|
||||||
var note = create["object"];
|
|
||||||
if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI))
|
|
||||||
{
|
{
|
||||||
using var fetched = await _remoteActors.FetchObject(Id(note), token);
|
using var fetched = await _remoteActors.FetchObject(Id(node), token);
|
||||||
note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
||||||
}
|
}
|
||||||
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
|
var note = NoteParser.Parse(node);
|
||||||
|
if (note == default || note.AttributedTo != author.ActorURI)
|
||||||
|
return;
|
||||||
|
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var objectUri = Id(note);
|
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
|
||||||
if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI)
|
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
|
||||||
return;
|
var visibility = Addressing.Classify(to, cc, author.FollowersURL);
|
||||||
|
|
||||||
var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList();
|
|
||||||
var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public");
|
|
||||||
var inReplyTo = Id(note["inReplyTo"]);
|
|
||||||
|
|
||||||
|
var addressed = to.Concat(cc)
|
||||||
|
.Concat(Addresses(activity))
|
||||||
|
.Concat(note.Mentions.Select(m => m.ActorURI))
|
||||||
|
.Append(note.Audience)
|
||||||
|
.Where(a => a != default && !Addressing.IsPublic(a) && a != author.FollowersURL)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
var localTargets = new List<LocalActor>();
|
var localTargets = new List<LocalActor>();
|
||||||
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
|
foreach (var uri in addressed)
|
||||||
{
|
{
|
||||||
var local = await _localActors.FindByUri(uri, token);
|
var local = await _localActors.FindByUri(uri, token);
|
||||||
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
|
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
|
||||||
localTargets.Add(local);
|
localTargets.Add(local);
|
||||||
}
|
}
|
||||||
|
var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
|
||||||
|
|
||||||
if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers")))
|
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
||||||
|
? default
|
||||||
|
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
|
||||||
|
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
|
||||||
|
: default;
|
||||||
|
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
|
||||||
|
group = default;
|
||||||
|
|
||||||
|
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
|
||||||
|
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mentions = new List<PostMention>();
|
||||||
|
foreach (var mention in note.Mentions)
|
||||||
{
|
{
|
||||||
var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group);
|
var local = await _localActors.FindByUri(mention.ActorURI, token);
|
||||||
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
|
mentions.Add(new PostMention
|
||||||
group = default;
|
|
||||||
if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var html = ContentSanitizer.Html(Value(note, "content"));
|
|
||||||
var post = new PostEntity
|
|
||||||
{
|
{
|
||||||
ObjectURI = objectUri,
|
ActorURI = mention.ActorURI,
|
||||||
ActorURI = author.ActorURI,
|
Handle = mention.Handle,
|
||||||
GroupId = group?.Id,
|
IsLocal = local != default,
|
||||||
Title = Value(note, "summary") ?? Value(note, "name"),
|
AccountId = local?.Id
|
||||||
Text = html,
|
});
|
||||||
ContentHtml = html,
|
}
|
||||||
ContentFormat = ContentFormat.Html,
|
|
||||||
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
|
DmGroup conversation = default;
|
||||||
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
|
if (visibility == PostVisibility.Direct)
|
||||||
IsFederatedCopy = true,
|
{
|
||||||
CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow
|
var participants = persons.Select(p => p.Uri)
|
||||||
};
|
.Concat(addressed.Where(a => !IsCollection(a)))
|
||||||
|
.Append(author.ActorURI)
|
||||||
|
.ToList();
|
||||||
|
conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
var post = new PostEntity
|
||||||
|
{
|
||||||
|
ObjectURI = note.Id,
|
||||||
|
ActivityURI = Id(activity),
|
||||||
|
ActorURI = author.ActorURI,
|
||||||
|
AuthorAccountId = author.ID,
|
||||||
|
Visibility = visibility,
|
||||||
|
To = to,
|
||||||
|
Cc = cc,
|
||||||
|
Url = note.Url,
|
||||||
|
ContextURI = note.Context,
|
||||||
|
QuoteURI = note.QuoteUri,
|
||||||
|
GroupId = group?.Id,
|
||||||
|
ConversationId = conversation?.ID,
|
||||||
|
Title = note.Title,
|
||||||
|
SpoilerText = note.SpoilerText,
|
||||||
|
HasContentWarning = note.Sensitive,
|
||||||
|
Text = note.ContentHtml,
|
||||||
|
ContentHtml = note.ContentHtml,
|
||||||
|
ContentFormat = ContentFormat.Html,
|
||||||
|
Language = note.Language,
|
||||||
|
Mentions = mentions,
|
||||||
|
Tags = note.Tags.ToList(),
|
||||||
|
Media = note.Attachments.ToList(),
|
||||||
|
InReplyToURI = note.InReplyTo,
|
||||||
|
AnsweringToPostId = parent?.ID,
|
||||||
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||||
|
IsFederatedCopy = true,
|
||||||
|
CreationDate = note.Published,
|
||||||
|
UpdateDate = note.Updated ?? note.Published,
|
||||||
|
EditedAt = note.Updated
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
await DB.Default.SaveAsync(post, token);
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
}
|
||||||
if (group != default)
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
{
|
{
|
||||||
var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}");
|
|
||||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
|
if (parent != default)
|
||||||
if (recipients.Count == 0)
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||||
return;
|
if (conversation != default)
|
||||||
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||||
return;
|
if (group != default)
|
||||||
|
|
||||||
var participants = recipients.Select(r => r.Uri)
|
|
||||||
.Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following")))
|
|
||||||
.Append(author.ActorURI)
|
|
||||||
.ToList();
|
|
||||||
var context = Value(note, "context") ?? Value(note, "conversation");
|
|
||||||
var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token);
|
|
||||||
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
|
|
||||||
var dm = new DmPostEntity
|
|
||||||
{
|
{
|
||||||
ObjectURI = objectUri,
|
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
|
||||||
ActorURI = author.ActorURI,
|
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||||
GroupId = dmGroup.ID,
|
}
|
||||||
Title = Value(note, "summary"),
|
}
|
||||||
Text = dmHtml,
|
|
||||||
ContentHtml = dmHtml,
|
|
||||||
ContentFormat = ContentFormat.Html,
|
|
||||||
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
|
|
||||||
AnsweringToPostId = inReplyTo,
|
|
||||||
IsFederatedCopy = true,
|
|
||||||
CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow
|
|
||||||
};
|
|
||||||
await DB.Default.SaveAsync(dm, token);
|
|
||||||
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string context, CancellationToken token)
|
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
|
||||||
{
|
{
|
||||||
var members = new List<GroupMember>();
|
var members = new List<GroupMember>();
|
||||||
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
|
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
|
||||||
@@ -153,17 +177,19 @@ async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string con
|
|||||||
if (match != default)
|
if (match != default)
|
||||||
return match;
|
return match;
|
||||||
|
|
||||||
var dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
|
var conversation = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
|
||||||
await DB.Default.SaveAsync(dmGroup, token);
|
await DB.Default.SaveAsync(conversation, token);
|
||||||
return dmGroup;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<string> LocalPostId(string uri, CancellationToken token)
|
static bool IsCollection(string uri) =>
|
||||||
|
uri.EndsWith("/followers", StringComparison.Ordinal) || uri.EndsWith("/following", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
static IEnumerable<string> Strings(JsonNode node) => node switch
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase))
|
JsonArray array => array.Select(Id).Where(id => id != default),
|
||||||
return default;
|
JsonNode single when Id(single) is { } id => new[] { id },
|
||||||
var id = uri[(uri.LastIndexOf('/') + 1)..];
|
_ => Enumerable.Empty<string>()
|
||||||
return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default;
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@ using System.Text.Json.Nodes;
|
|||||||
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
@@ -61,7 +60,6 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
}
|
}
|
||||||
|
|
||||||
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
||||||
await DB.Default.DeleteAsync<DmPostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ using System.Text.Json.Nodes;
|
|||||||
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ using System.Text.Json.Nodes;
|
|||||||
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
|
|||||||
@@ -2,69 +2,83 @@ using MongoDB.Entities;
|
|||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Outbox;
|
|
||||||
using PrivaPub.Federation.Rendering;
|
|
||||||
using PrivaPub.Models.Federation;
|
|
||||||
using PrivaPub.Models.Group;
|
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using static PrivaPub.Federation.Inbox.ForeignMembers;
|
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox.Handlers
|
namespace PrivaPub.Federation.Inbox.Handlers
|
||||||
{
|
{
|
||||||
public class UpdateHandler : IActivityHandler
|
public class UpdateHandler : IActivityHandler
|
||||||
{
|
{
|
||||||
|
const int MaxRevisions = 20;
|
||||||
|
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IDeliveryService _delivery;
|
|
||||||
|
|
||||||
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
|
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
|
||||||
{
|
{
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
_delivery = delivery;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Type => "Update";
|
public string Type => "Update";
|
||||||
|
|
||||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||||
{
|
{
|
||||||
var update = activity;
|
var inner = activity["object"];
|
||||||
|
|
||||||
var inner = update["object"];
|
|
||||||
if (Id(inner) == actor.ActorURI)
|
if (Id(inner) == actor.ActorURI)
|
||||||
{
|
{
|
||||||
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
|
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI))
|
if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI))
|
||||||
|
return;
|
||||||
|
var note = NoteParser.Parse(inner);
|
||||||
|
if (note == default || note.AttributedTo != actor.ActorURI)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var objectUri = Id(inner);
|
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||||
var text = ContentSanitizer.Html(Value(inner, "content"));
|
if (post == default || post.DeletedAt.HasValue)
|
||||||
await DB.Default.Update<PostEntity>()
|
return;
|
||||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
|
||||||
.Modify(p => p.Text, text)
|
post.Revisions.Add(new PostRevision
|
||||||
.Modify(p => p.ContentHtml, text)
|
{
|
||||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
Title = post.Title,
|
||||||
.ExecuteAsync(token);
|
SpoilerText = post.SpoilerText,
|
||||||
await DB.Default.Update<DmPostEntity>()
|
ContentHtml = post.ContentHtml,
|
||||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
HasContentWarning = post.HasContentWarning,
|
||||||
.Modify(p => p.Text, text)
|
EditedAt = post.EditedAt ?? post.CreationDate
|
||||||
.Modify(p => p.ContentHtml, text)
|
});
|
||||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
if (post.Revisions.Count > MaxRevisions)
|
||||||
.ExecuteAsync(token);
|
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||||
return;
|
|
||||||
}
|
var mentions = new List<PostMention>();
|
||||||
|
foreach (var mention in note.Mentions)
|
||||||
|
{
|
||||||
|
var local = await _localActors.FindByUri(mention.ActorURI, token);
|
||||||
|
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
post.Title = note.Title;
|
||||||
|
post.SpoilerText = note.SpoilerText;
|
||||||
|
post.HasContentWarning = note.Sensitive;
|
||||||
|
post.Text = note.ContentHtml;
|
||||||
|
post.ContentHtml = note.ContentHtml;
|
||||||
|
post.Language = note.Language;
|
||||||
|
post.Tags = note.Tags.ToList();
|
||||||
|
post.Mentions = mentions;
|
||||||
|
post.Media = note.Attachments.ToList();
|
||||||
|
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||||
|
post.UpdateDate = DateTime.UtcNow;
|
||||||
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Objects
|
||||||
|
{
|
||||||
|
public static class Addressing
|
||||||
|
{
|
||||||
|
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
|
|
||||||
|
public static bool IsPublic(string address) => address is Public or "as:Public" or "Public";
|
||||||
|
|
||||||
|
public static PostVisibility Classify(IReadOnlyCollection<string> to, IReadOnlyCollection<string> cc, string authorFollowers)
|
||||||
|
{
|
||||||
|
if (to.Any(IsPublic))
|
||||||
|
return PostVisibility.Public;
|
||||||
|
if (cc.Any(IsPublic))
|
||||||
|
return PostVisibility.Unlisted;
|
||||||
|
if (!string.IsNullOrEmpty(authorFollowers) && (to.Contains(authorFollowers) || cc.Contains(authorFollowers)))
|
||||||
|
return PostVisibility.FollowersOnly;
|
||||||
|
return PostVisibility.Direct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
|
|
||||||
|
namespace PrivaPub.Federation.Objects
|
||||||
|
{
|
||||||
|
public sealed class NoteDocument
|
||||||
|
{
|
||||||
|
public string Id { get; init; }
|
||||||
|
public string Type { get; init; }
|
||||||
|
public string AttributedTo { get; init; }
|
||||||
|
public string ContentHtml { get; init; }
|
||||||
|
public string Title { get; init; }
|
||||||
|
public string SpoilerText { get; init; }
|
||||||
|
public bool Sensitive { get; init; }
|
||||||
|
public string Language { get; init; }
|
||||||
|
public string Url { get; init; }
|
||||||
|
public string InReplyTo { get; init; }
|
||||||
|
public string Context { get; init; }
|
||||||
|
public string Audience { get; init; }
|
||||||
|
public string QuoteUri { get; init; }
|
||||||
|
public DateTime Published { get; init; }
|
||||||
|
public DateTime? Updated { get; init; }
|
||||||
|
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
|
||||||
|
public IReadOnlyList<string> Cc { get; init; } = Array.Empty<string>();
|
||||||
|
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
|
||||||
|
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||||
|
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NoteParser
|
||||||
|
{
|
||||||
|
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
|
||||||
|
const int MaxAttachments = 16;
|
||||||
|
const int MaxTags = 64;
|
||||||
|
|
||||||
|
public static NoteDocument Parse(JsonNode node)
|
||||||
|
{
|
||||||
|
if (node is not JsonObject note)
|
||||||
|
return default;
|
||||||
|
var type = Value(note, "type");
|
||||||
|
var id = Id(note);
|
||||||
|
if (!NoteTypes.Contains(type) || Origin.Of(id) == default)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var (contentHtml, language) = Content(note);
|
||||||
|
var summary = Value(note, "summary");
|
||||||
|
return new NoteDocument
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Type = type,
|
||||||
|
AttributedTo = Attribution(note["attributedTo"]),
|
||||||
|
ContentHtml = ContentSanitizer.Html(contentHtml),
|
||||||
|
Title = Plain(Value(note, "name"), 500),
|
||||||
|
SpoilerText = Plain(summary, 500),
|
||||||
|
Sensitive = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s
|
||||||
|
|| !string.IsNullOrWhiteSpace(summary),
|
||||||
|
Language = language,
|
||||||
|
Url = UrlOf(note["url"]) ?? id,
|
||||||
|
InReplyTo = Id(note["inReplyTo"]),
|
||||||
|
Context = Id(note["context"]) ?? Value(note, "conversation"),
|
||||||
|
Audience = Id(note["audience"]),
|
||||||
|
QuoteUri = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"),
|
||||||
|
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
|
||||||
|
Updated = Time(Value(note, "updated")),
|
||||||
|
To = List(note["to"]),
|
||||||
|
Cc = List(note["cc"]),
|
||||||
|
Mentions = Tags(note, "Mention").Select(t => new PostMention { ActorURI = Value(t, "href"), Handle = Value(t, "name") })
|
||||||
|
.Where(m => Origin.Of(m.ActorURI) != default)
|
||||||
|
.DistinctBy(m => m.ActorURI)
|
||||||
|
.Take(MaxTags)
|
||||||
|
.ToList(),
|
||||||
|
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').ToLowerInvariant())
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
|
.Distinct()
|
||||||
|
.Take(MaxTags)
|
||||||
|
.ToList(),
|
||||||
|
Attachments = Attachments(note)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static (string Html, string Language) Content(JsonObject note)
|
||||||
|
{
|
||||||
|
var content = Value(note, "content");
|
||||||
|
string language = default;
|
||||||
|
if (note["contentMap"] is JsonObject map && map.Count > 0)
|
||||||
|
{
|
||||||
|
var first = map.First();
|
||||||
|
language = first.Key;
|
||||||
|
if (string.IsNullOrEmpty(content) && first.Value is JsonValue value && value.TryGetValue<string>(out var mapped))
|
||||||
|
content = mapped;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
|
||||||
|
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
|
||||||
|
return (content, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Attribution(JsonNode node) => node switch
|
||||||
|
{
|
||||||
|
JsonArray array => array.OfType<JsonObject>().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id)
|
||||||
|
.Concat(array.OfType<JsonValue>().Select(Id))
|
||||||
|
.Concat(array.OfType<JsonObject>().Where(o => Value(o, "type") == "Group").Select(Id))
|
||||||
|
.FirstOrDefault(id => id != default),
|
||||||
|
_ => Id(node)
|
||||||
|
};
|
||||||
|
|
||||||
|
static IEnumerable<JsonObject> Tags(JsonObject note, string type) =>
|
||||||
|
(note["tag"] switch
|
||||||
|
{
|
||||||
|
JsonArray array => array.OfType<JsonObject>(),
|
||||||
|
JsonObject single => new[] { single },
|
||||||
|
_ => Enumerable.Empty<JsonObject>()
|
||||||
|
}).Where(t => Value(t, "type") == type);
|
||||||
|
|
||||||
|
static List<PostMedia> Attachments(JsonObject note)
|
||||||
|
{
|
||||||
|
var items = note["attachment"] switch
|
||||||
|
{
|
||||||
|
JsonArray array => array.OfType<JsonObject>(),
|
||||||
|
JsonObject single => new[] { single },
|
||||||
|
_ => Enumerable.Empty<JsonObject>()
|
||||||
|
};
|
||||||
|
return items
|
||||||
|
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
|
||||||
|
.Select(a => new PostMedia
|
||||||
|
{
|
||||||
|
RemoteURL = UrlOf(a["url"]),
|
||||||
|
ContentType = Value(a, "mediaType"),
|
||||||
|
Description = Plain(Value(a, "name") ?? Value(a, "summary"), 1500),
|
||||||
|
Blurhash = Value(a, "blurhash"),
|
||||||
|
Width = Int(a["width"]),
|
||||||
|
Height = Int(a["height"]),
|
||||||
|
Focus = a["focalPoint"] is JsonArray focus && focus.Count == 2
|
||||||
|
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
|
||||||
|
: default
|
||||||
|
})
|
||||||
|
.Where(m => Origin.Of(m.RemoteURL) != default)
|
||||||
|
.Take(MaxAttachments)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string UrlOf(JsonNode node) => node switch
|
||||||
|
{
|
||||||
|
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
||||||
|
JsonObject obj => Value(obj, "href") ?? Value(obj, "id"),
|
||||||
|
JsonArray array => array.Select(item => item is JsonObject link && Value(link, "mediaType") is { } mediaType && !mediaType.StartsWith("text/html")
|
||||||
|
? default
|
||||||
|
: UrlOf(item))
|
||||||
|
.FirstOrDefault(url => url != default) ?? array.Select(UrlOf).FirstOrDefault(url => url != default),
|
||||||
|
_ => default
|
||||||
|
};
|
||||||
|
|
||||||
|
static IReadOnlyList<string> List(JsonNode node) => node switch
|
||||||
|
{
|
||||||
|
JsonArray array => array.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).ToList(),
|
||||||
|
JsonNode single when Id(single) is { } id => new[] { id },
|
||||||
|
_ => Array.Empty<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
static DateTime? Time(string value) =>
|
||||||
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
|
||||||
|
? parsed.UtcDateTime
|
||||||
|
: default;
|
||||||
|
|
||||||
|
static int? Int(JsonNode node) =>
|
||||||
|
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : default;
|
||||||
|
|
||||||
|
static string Plain(string value, int max)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return default;
|
||||||
|
value = value.Trim();
|
||||||
|
return value.Length <= max ? value : value[..max];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ using System.Globalization;
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Rendering
|
namespace PrivaPub.Federation.Rendering
|
||||||
@@ -76,7 +75,7 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JsonObject DirectNote(DmPostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
|
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
|
||||||
string context)
|
string context)
|
||||||
{
|
{
|
||||||
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
|
var note = NoteBody(author.PostUri(post.ID), author, post.Title, post.Text, post.HasContentWarning,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
|
await Plain<Post>(token, p => p.GroupUserId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.GroupId, p => p.ID);
|
await Plain<Post>(token, p => p.GroupId, p => p.ID);
|
||||||
await Plain<Post>(token, p => p.ActorURI);
|
await Plain<Post>(token, p => p.ActorURI);
|
||||||
|
await Plain<Post>(token, p => p.ConversationId, p => p.ID);
|
||||||
|
await Plain<Post>(token, p => p.InReplyToURI);
|
||||||
|
|
||||||
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
await Unique<DmPost>(p => p.ObjectURI, Builders<DmPost>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||||
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
await Plain<DmPost>(token, p => p.GroupId, p => p.ID);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
public class _005_direct_posts_join_posts : IMigration
|
||||||
|
{
|
||||||
|
public async Task UpgradeAsync()
|
||||||
|
{
|
||||||
|
foreach (var dm in await DB.Default.Find<DmPost>().ExecuteAsync())
|
||||||
|
{
|
||||||
|
if (await DB.Default.Find<Post>().Match(p => p.ID == dm.ID || p.ObjectURI == dm.ObjectURI).ExecuteAnyAsync())
|
||||||
|
continue;
|
||||||
|
await DB.Default.SaveAsync(new Post
|
||||||
|
{
|
||||||
|
ID = dm.ID,
|
||||||
|
GroupUserId = dm.GroupUserId,
|
||||||
|
AuthorAccountId = dm.IsFederatedCopy ? await ForeignAccountId(dm.ActorURI) : dm.GroupUserId,
|
||||||
|
ConversationId = dm.GroupId,
|
||||||
|
Visibility = PostVisibility.Direct,
|
||||||
|
AnsweringToPostId = Local(dm.AnsweringToPostId),
|
||||||
|
InReplyToURI = Remote(dm.AnsweringToPostId),
|
||||||
|
Title = dm.Title,
|
||||||
|
Text = dm.Text,
|
||||||
|
ContentHtml = dm.ContentHtml,
|
||||||
|
ContentFormat = dm.ContentFormat,
|
||||||
|
Media = dm.Media,
|
||||||
|
HasContentWarning = dm.HasContentWarning,
|
||||||
|
IsFederatedCopy = dm.IsFederatedCopy,
|
||||||
|
ObjectURI = dm.ObjectURI,
|
||||||
|
ActorURI = dm.ActorURI,
|
||||||
|
Url = dm.ObjectURI,
|
||||||
|
CreationDate = dm.CreationDate,
|
||||||
|
UpdateDate = dm.UpdateDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var post in await DB.Default.Find<Post>().Match(p => p.Visibility != PostVisibility.Direct).ExecuteAsync())
|
||||||
|
{
|
||||||
|
var parent = Local(post.AnsweringToPostId) is { } parentId
|
||||||
|
? await DB.Default.Find<Post>().OneAsync(parentId)
|
||||||
|
: Remote(post.AnsweringToPostId) is { } parentUri
|
||||||
|
? await DB.Default.Find<Post>().Match(p => p.ObjectURI == parentUri).ExecuteFirstAsync()
|
||||||
|
: default;
|
||||||
|
await DB.Default.Update<Post>().MatchID(post.ID)
|
||||||
|
.Modify(p => p.Visibility, post.IsLocalOnly ? PostVisibility.Circle : PostVisibility.Public)
|
||||||
|
.Modify(p => p.AuthorAccountId, post.IsFederatedCopy ? await ForeignAccountId(post.ActorURI) : post.GroupUserId)
|
||||||
|
.Modify(p => p.AnsweringToPostId, parent?.ID)
|
||||||
|
.Modify(p => p.InReplyToURI, parent?.ObjectURI ?? Remote(post.AnsweringToPostId))
|
||||||
|
.Modify(p => p.InReplyToAccountId, parent?.AuthorAccountId ?? parent?.GroupUserId)
|
||||||
|
.Modify(p => p.Url, post.Url ?? post.ObjectURI)
|
||||||
|
.ExecuteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Local(string reference) =>
|
||||||
|
string.IsNullOrEmpty(reference) || reference.Contains("://") ? default : reference;
|
||||||
|
|
||||||
|
static string Remote(string reference) =>
|
||||||
|
reference != default && reference.Contains("://") ? reference : default;
|
||||||
|
|
||||||
|
static async Task<string> ForeignAccountId(string actorUri) =>
|
||||||
|
(await DB.Default.Find<ForeignAvatar>().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync())?.ID;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,23 @@ namespace PrivaPub.Models.Post
|
|||||||
{
|
{
|
||||||
public class Post : Entity
|
public class Post : Entity
|
||||||
{
|
{
|
||||||
public string GroupUserId { get; set; }
|
public string GroupUserId { get; set; }//local author's Avatar.ID
|
||||||
public string AnsweringToPostId { get; set; }
|
public string AuthorAccountId { get; set; }//Avatar.ID or ForeignAvatar.ID
|
||||||
|
public string AnsweringToPostId { get; set; }//the parent's Post.ID when we hold it
|
||||||
|
public string InReplyToURI { get; set; }
|
||||||
|
public string InReplyToAccountId { get; set; }
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
|
public string ConversationId { get; set; }//DmGroup.ID of a direct post
|
||||||
|
public string ReblogOfPostId { get; set; }
|
||||||
|
public PostVisibility Visibility { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public string ContentHtml { get; set; }
|
public string ContentHtml { get; set; }
|
||||||
public ContentFormat ContentFormat { get; set; }
|
public ContentFormat ContentFormat { get; set; }
|
||||||
|
public string Language { get; set; }
|
||||||
|
public List<PostMention> Mentions { get; set; } = new();
|
||||||
|
public List<string> Tags { get; set; } = new();
|
||||||
public List<PostMedia> Media { get; set; } = new();
|
public List<PostMedia> Media { get; set; } = new();
|
||||||
public List<float> Location { get; set; } = new();
|
public List<float> Location { get; set; } = new();
|
||||||
public float RangeKm { get; set; } = 5.0f;
|
public float RangeKm { get; set; } = 5.0f;
|
||||||
@@ -19,10 +29,50 @@ namespace PrivaPub.Models.Post
|
|||||||
|
|
||||||
public bool IsFederatedCopy { get; set; }
|
public bool IsFederatedCopy { get; set; }
|
||||||
public string ObjectURI { get; set; }//the Note's id
|
public string ObjectURI { get; set; }//the Note's id
|
||||||
|
public string ActivityURI { get; set; }//the Create's id
|
||||||
public string ActorURI { get; set; }//attributedTo
|
public string ActorURI { get; set; }//attributedTo
|
||||||
|
public string Url { get; set; }
|
||||||
|
public string ContextURI { get; set; }
|
||||||
|
public string QuoteURI { get; set; }
|
||||||
|
public List<string> To { get; set; } = new();
|
||||||
|
public List<string> Cc { get; set; } = new();
|
||||||
|
|
||||||
|
public int RepliesCount { get; set; }
|
||||||
|
public int FavouritesCount { get; set; }
|
||||||
|
public int ReblogsCount { get; set; }
|
||||||
|
public List<PostRevision> Revisions { get; set; } = new();
|
||||||
|
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
|
public DateTime? UpdateDate { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? EditedAt { get; set; }
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
public string UpdateReason { get; set; }
|
public string UpdateReason { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum PostVisibility
|
||||||
|
{
|
||||||
|
Public,
|
||||||
|
Unlisted,
|
||||||
|
FollowersOnly,
|
||||||
|
Direct,
|
||||||
|
Circle,
|
||||||
|
LocalGeo
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostMention
|
||||||
|
{
|
||||||
|
public string ActorURI { get; set; }
|
||||||
|
public string AccountId { get; set; }
|
||||||
|
public string Handle { get; set; }
|
||||||
|
public bool IsLocal { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostRevision
|
||||||
|
{
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string SpoilerText { get; set; }
|
||||||
|
public string ContentHtml { get; set; }
|
||||||
|
public bool HasContentWarning { get; set; }
|
||||||
|
public DateTime EditedAt { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
namespace PrivaPub.Models.Post
|
namespace PrivaPub.Models.Post
|
||||||
{
|
{
|
||||||
public class PostMedia
|
public class PostMedia
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
public string FileName { get; set; }
|
public string FileName { get; set; }
|
||||||
public string Extension { get; set; }
|
public string Extension { get; set; }
|
||||||
public string Path { get; set; }
|
public string Path { get; set; }
|
||||||
public string URL { get; set; }
|
public string URL { get; set; }
|
||||||
|
public string RemoteURL { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public string Blurhash { get; set; }
|
||||||
|
public int? Width { get; set; }
|
||||||
|
public int? Height { get; set; }
|
||||||
|
public float[] Focus { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,8 @@ namespace PrivaPub.Models.User
|
|||||||
|
|
||||||
public string ActorURI { get; set; }//id
|
public string ActorURI { get; set; }//id
|
||||||
public string PublicKeyId { get; set; }
|
public string PublicKeyId { get; set; }
|
||||||
|
public string FollowersURL { get; set; }
|
||||||
|
public string FollowingURL { get; set; }
|
||||||
public string SharedInboxURL { get; set; }
|
public string SharedInboxURL { get; set; }
|
||||||
public string InboxURL { get; set; }
|
public string InboxURL { get; set; }
|
||||||
public string OutboxURL { get; set; }
|
public string OutboxURL { get; set; }
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ using PrivaPub.StaticServices;
|
|||||||
|
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using DmPostEntity = PrivaPub.Models.Post.DmPost;
|
|
||||||
using PostEntity = PrivaPub.Models.Post.Post;
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Rendering;
|
using PrivaPub.Federation.Rendering;
|
||||||
@@ -76,32 +75,44 @@ namespace PrivaPub.Services
|
|||||||
isLocalOnly = !group.IsFederated;
|
isLocalOnly = !group.IsFederated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parent = await Parent(form.AnsweringToPostId, token);
|
||||||
var post = new PostEntity
|
var post = new PostEntity
|
||||||
{
|
{
|
||||||
GroupUserId = author.Id,
|
GroupUserId = author.Id,
|
||||||
|
AuthorAccountId = author.Id,
|
||||||
GroupId = group?.Id,
|
GroupId = group?.Id,
|
||||||
|
Visibility = isLocalOnly ? PostVisibility.Circle : PostVisibility.Public,
|
||||||
Title = form.Title,
|
Title = form.Title,
|
||||||
Text = form.Text,
|
Text = form.Text,
|
||||||
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
||||||
ContentFormat = ContentFormat.Markdown,
|
ContentFormat = ContentFormat.Markdown,
|
||||||
HasContentWarning = form.HasContentWarning,
|
HasContentWarning = form.HasContentWarning,
|
||||||
AnsweringToPostId = form.AnsweringToPostId,
|
AnsweringToPostId = parent?.ID,
|
||||||
|
InReplyToURI = parent?.ObjectURI ?? RemoteUri(form.AnsweringToPostId),
|
||||||
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||||
IsLocalOnly = isLocalOnly,
|
IsLocalOnly = isLocalOnly,
|
||||||
ActorURI = author.Uri
|
ActorURI = author.Uri
|
||||||
};
|
};
|
||||||
post.ID = (string)post.GenerateNewID();
|
post.ID = (string)post.GenerateNewID();
|
||||||
post.ObjectURI = author.PostUri(post.ID);
|
post.ObjectURI = author.PostUri(post.ID);
|
||||||
await DB.Default.SaveAsync(post, token);
|
post.Url = post.ObjectURI;
|
||||||
|
|
||||||
if (isLocalOnly)
|
if (isLocalOnly)
|
||||||
{
|
{
|
||||||
|
await DB.Default.SaveAsync(post, token);
|
||||||
result.Data = ToView(post);
|
result.Data = ToView(post);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
var inReplyTo = await ReplyTarget(form.AnsweringToPostId, token);
|
var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI);
|
||||||
var note = ActivityPubRenderer.Note(post, author, group, inReplyTo);
|
|
||||||
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
var create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
|
||||||
|
post.ActivityURI = create["id"]!.GetValue<string>();
|
||||||
|
post.To = Strings(note["to"]);
|
||||||
|
post.Cc = Strings(note["cc"]);
|
||||||
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
if (parent != default)
|
||||||
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||||
|
|
||||||
await _delivery.EnqueueToFollowers(author, create, token);
|
await _delivery.EnqueueToFollowers(author, create, token);
|
||||||
if (group != default)
|
if (group != default)
|
||||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
||||||
@@ -135,6 +146,21 @@ namespace PrivaPub.Services
|
|||||||
if (post.IsLocalOnly)
|
if (post.IsLocalOnly)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
if (post.Visibility == PostVisibility.Direct)
|
||||||
|
{
|
||||||
|
var recipients = new List<string>();
|
||||||
|
foreach (var uri in post.To.Concat(post.Cc).Distinct())
|
||||||
|
{
|
||||||
|
var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
||||||
|
if (foreign != default)
|
||||||
|
recipients.Add(foreign.InboxURL);
|
||||||
|
}
|
||||||
|
var directDelete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||||
|
new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray());
|
||||||
|
await _delivery.Enqueue(author, recipients, directDelete, token);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||||
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
|
new JsonArray(ActivityPubRenderer.Public), new JsonArray(author.Followers));
|
||||||
var extraInboxes = Enumerable.Empty<string>();
|
var extraInboxes = Enumerable.Empty<string>();
|
||||||
@@ -165,7 +191,7 @@ namespace PrivaPub.Services
|
|||||||
|
|
||||||
var query = _dbEntities.Posts;
|
var query = _dbEntities.Posts;
|
||||||
if (string.IsNullOrEmpty(groupId))
|
if (string.IsNullOrEmpty(groupId))
|
||||||
query.Match(p => p.GroupUserId == avatar.Id);
|
query.Match(p => p.GroupUserId == avatar.Id && p.Visibility != PostVisibility.Direct);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var isMember = await _dbEntities.Groups
|
var isMember = await _dbEntities.Groups
|
||||||
@@ -227,10 +253,13 @@ namespace PrivaPub.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dm = new DmPostEntity
|
var dm = new PostEntity
|
||||||
{
|
{
|
||||||
GroupUserId = author.Id,
|
GroupUserId = author.Id,
|
||||||
GroupId = dmGroup.ID,
|
AuthorAccountId = author.Id,
|
||||||
|
ConversationId = dmGroup.ID,
|
||||||
|
Visibility = PostVisibility.Direct,
|
||||||
|
ContextURI = dmGroup.ConversationURI,
|
||||||
Text = form.Text,
|
Text = form.Text,
|
||||||
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
ContentHtml = ActivityPubRenderer.Html(form.Text),
|
||||||
ContentFormat = ContentFormat.Markdown,
|
ContentFormat = ContentFormat.Markdown,
|
||||||
@@ -239,8 +268,7 @@ namespace PrivaPub.Services
|
|||||||
};
|
};
|
||||||
dm.ID = (string)dm.GenerateNewID();
|
dm.ID = (string)dm.GenerateNewID();
|
||||||
dm.ObjectURI = author.PostUri(dm.ID);
|
dm.ObjectURI = author.PostUri(dm.ID);
|
||||||
await DB.Default.SaveAsync(dm, token);
|
dm.Url = dm.ObjectURI;
|
||||||
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
||||||
|
|
||||||
var remote = new List<(string Uri, string Handle)>();
|
var remote = new List<(string Uri, string Handle)>();
|
||||||
var inboxes = new List<string>();
|
var inboxes = new List<string>();
|
||||||
@@ -259,12 +287,15 @@ namespace PrivaPub.Services
|
|||||||
remote.Add((local.Uri, local.Handle));
|
remote.Add((local.Uri, local.Handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var directNote = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
|
||||||
|
var directCreate = ActivityPubRenderer.Create(author, directNote, $"create-{dm.ID}");
|
||||||
|
dm.ActivityURI = directCreate["id"]!.GetValue<string>();
|
||||||
|
dm.To = Strings(directNote["to"]);
|
||||||
|
dm.Mentions = remote.Select(r => new PostMention { ActorURI = r.Uri, Handle = "@" + r.Handle, IsLocal = r.Uri.StartsWith(author.BaseAddress + "/") }).ToList();
|
||||||
|
await DB.Default.SaveAsync(dm, token);
|
||||||
|
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||||
if (inboxes.Count > 0)
|
if (inboxes.Count > 0)
|
||||||
{
|
await _delivery.Enqueue(author, inboxes, directCreate, token);
|
||||||
var note = ActivityPubRenderer.DirectNote(dm, author, remote, dmGroup.ConversationURI);
|
|
||||||
var create = ActivityPubRenderer.Create(author, note, $"create-{dm.ID}");
|
|
||||||
await _delivery.Enqueue(author, inboxes, create, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Data = ToView(dm);
|
result.Data = ToView(dm);
|
||||||
return result;
|
return result;
|
||||||
@@ -306,8 +337,8 @@ namespace PrivaPub.Services
|
|||||||
if (!isMember)
|
if (!isMember)
|
||||||
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
|
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
var dms = await _dbEntities.DmPosts
|
var dms = await _dbEntities.Posts
|
||||||
.Match(p => p.GroupId == dmGroupId)
|
.Match(p => p.ConversationId == dmGroupId && p.Visibility == PostVisibility.Direct)
|
||||||
.Sort(p => p.CreationDate, Order.Descending)
|
.Sort(p => p.CreationDate, Order.Descending)
|
||||||
.Limit(PageSize)
|
.Limit(PageSize)
|
||||||
.ExecuteAsync(token);
|
.ExecuteAsync(token);
|
||||||
@@ -337,16 +368,21 @@ namespace PrivaPub.Services
|
|||||||
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
|
return foreign == default ? default : new GroupMember { AvatarId = foreign.ActorURI, IsForeign = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
|
async Task<PostEntity> Parent(string answeringTo, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(answeringToPostId))
|
if (string.IsNullOrEmpty(answeringTo))
|
||||||
return default;
|
return default;
|
||||||
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
return answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||||
return answeringToPostId;
|
? await _dbEntities.Posts.Match(p => p.ObjectURI == answeringTo).ExecuteFirstAsync(token)
|
||||||
var parent = await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token);
|
: await _dbEntities.Posts.MatchID(answeringTo).ExecuteFirstAsync(token);
|
||||||
return parent?.ObjectURI;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string RemoteUri(string answeringTo) =>
|
||||||
|
answeringTo != default && answeringTo.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? answeringTo : default;
|
||||||
|
|
||||||
|
static List<string> Strings(JsonNode node) =>
|
||||||
|
node is JsonArray array ? array.Select(n => n?.GetValue<string>()).Where(s => s != default).ToList() : new List<string>();
|
||||||
|
|
||||||
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
|
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
|
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
|
||||||
@@ -363,22 +399,8 @@ namespace PrivaPub.Services
|
|||||||
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
|
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
|
||||||
AuthorActorURI = post.ActorURI,
|
AuthorActorURI = post.ActorURI,
|
||||||
GroupId = post.GroupId,
|
GroupId = post.GroupId,
|
||||||
AnsweringToPostId = post.AnsweringToPostId,
|
DmGroupId = post.ConversationId,
|
||||||
Title = post.Title,
|
Visibility = post.Visibility.ToString(),
|
||||||
Text = post.Text,
|
|
||||||
ContentHtml = post.ContentHtml,
|
|
||||||
HasContentWarning = post.HasContentWarning,
|
|
||||||
IsFederatedCopy = post.IsFederatedCopy,
|
|
||||||
CreationDate = post.CreationDate
|
|
||||||
};
|
|
||||||
|
|
||||||
static ViewPost ToView(DmPostEntity post) => new()
|
|
||||||
{
|
|
||||||
Id = post.ID,
|
|
||||||
ObjectURI = post.ObjectURI,
|
|
||||||
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
|
|
||||||
AuthorActorURI = post.ActorURI,
|
|
||||||
DmGroupId = post.GroupId,
|
|
||||||
AnsweringToPostId = post.AnsweringToPostId,
|
AnsweringToPostId = post.AnsweringToPostId,
|
||||||
Title = post.Title,
|
Title = post.Title,
|
||||||
Text = post.Text,
|
Text = post.Text,
|
||||||
|
|||||||
Reference in new issue
Block a user