P6: custom emoji, fuller remote profiles, and polls both ways
Build / Build (push) Successful in 39s
Deploy / privapub.thepra.dev (push) Successful in 57s

- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in
  Status.emojis and Account.emojis.
- Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and
  image descriptions (a locked GoToSocial account no longer shows as open).
- Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed;
  our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become
  replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every
  three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST
  /api/v1/polls/:id/votes.
- An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision.

Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 18:35:20 +02:00
1 parent 81de470f64
commit 7be6749a23
33 files changed
+937 -28

No files matched your search

+6
View File
@@ -252,6 +252,12 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed array; Markdown `content`; missing `mediaType`s inferred; thumbnails from any of their five places; and the typed
`Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page `Link`, `Video`, `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6). (that fetch is the owner's decision 1, for P6).
- **An `Update` is an edit only when its `updated` is newer than ours** (`UpdateHandler.IsEdit`). Otherwise it refreshes
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates.
- **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
go only to the poll's author, without `published` (PieFed counts a vote only then).
- **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
+12 -2
View File
@@ -108,7 +108,17 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag` A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show tags.
**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
`name`. A content warning without its own text uses the title, or "Content warning". `name`. A content warning without its own text uses the title, or "Content warning".
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`, Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
@@ -142,7 +152,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser
## Known limitations ## Known limitations
- Polls and custom emoji are not implemented. - Our own posts carry no custom emoji: emoji are received and shown, not offered.
- Remote media is fetched through this server's proxy when a local client displays it. - Remote media is fetched through this server's proxy when a local client displays it.
- Collections expose counts, not members. - Collections expose counts, not members.
- Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. - Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned.
@@ -0,0 +1,41 @@
using PrivaPub.Federation.Actors;
using System.Text.Json;
namespace PrivaPub.Tests.Federation
{
public class ActorProfileTests
{
[Fact]
public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
{
using var json = JsonDocument.Parse("""
{
"id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
"inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
"published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
"icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
"image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
"tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
"attachment": [
{ "type": "PropertyValue", "name": "Site", "value": "<a href=\"https://ann.example\" rel=\"me\">ann.example</a><script>x</script>" },
{ "type": "PropertyValue", "name": "Pronouns", "value": "she/her" }
]
}
""");
var actor = ActorDocument.Parse(json.RootElement);
Assert.True(actor.Locked);
Assert.True(actor.Indexable);
Assert.Equal("https://m.example/h.png", actor.Header);
Assert.Equal("a cat", actor.IconDescription);
Assert.Equal("mountains", actor.HeaderDescription);
Assert.Equal("https://n.example/users/ann", actor.MovedTo);
Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published);
Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode);
Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys);
Assert.DoesNotContain("<script>", actor.Fields["Site"]);
}
}
}
@@ -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.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
@@ -54,7 +55,7 @@ namespace PrivaPub.Tests.Federation
{ {
new FollowHandler(db, _local, remote, delivery), new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local), new UndoHandler(db, _local),
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 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 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);
@@ -169,6 +169,51 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL); Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
} }
[Fact]
public void Keeps_custom_emoji_and_drops_ones_that_could_not_render()
{
var note = Parse("""
{
"id": "https://k.example/notes/1", "type": "Note", "attributedTo": "https://k.example/users/1", "content": ":blobcat: hi :bad:",
"tag": [
{ "type": "Emoji", "name": ":blobcat:", "updated": "2026-01-01T00:00:00Z", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://k.example/emoji/blobcat.png" } },
{ "type": "Emoji", "name": ":blobcat:", "icon": { "type": "Image", "url": "https://k.example/emoji/duplicate.png" } },
{ "type": "Emoji", "name": ":bad name:", "icon": { "type": "Image", "url": "https://k.example/emoji/x.png" } },
{ "type": "Emoji", "name": ":nourl:", "icon": { "type": "Image", "url": "data:image/png;base64,AAAA" } }
]
}
""");
var emoji = Assert.Single(note.Emojis);
Assert.Equal("blobcat", emoji.Shortcode);
Assert.Equal("https://k.example/emoji/blobcat.png", emoji.URL);
}
[Fact]
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
{
var mastodon = Parse("""
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
"oneOf": [{ "type": "Note", "name": "a", "replies": { "type": "Collection", "totalItems": 4 } }, { "type": "Note", "name": "b", "replies": { "type": "Collection", "totalItems": 3 } }] }
""");
var misskey = Parse("""
{ "id": "https://k.example/q/1", "type": "Question", "attributedTo": "https://k.example/u", "_misskey_content": "?",
"anyOf": [{ "type": "Note", "name": "x", "_misskey_votes": 2 }, { "type": "Note", "name": "y", "_misskey_votes": 5 }] }
""");
var closed = Parse("""
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
""");
Assert.False(mastodon.Poll.Multiple);
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
Assert.Equal(7, mastodon.Poll.VotersCount);
Assert.True(misskey.Poll.Multiple);
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
Assert.Null(misskey.Poll.VotersCount);
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
}
[Fact] [Fact]
public void Normalises_hashtags_like_mastodon() => public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse(""" Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
+138
View File
@@ -0,0 +1,138 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class PollTests : 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 Follows(RemoteActor follower, string target) =>
await _harness.Deliver(follower, "/human-centipede", new JsonObject
{
["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target
});
JsonObject Vote(RemoteActor voter, string question, string owner, string choice) => new()
{
["id"] = NewId(voter, "activities"), ["type"] = "Create", ["actor"] = voter.Id, ["to"] = new JsonArray(owner),
["object"] = new JsonObject
{
["id"] = NewId(voter, "votes"), ["type"] = "Note", ["name"] = choice, ["attributedTo"] = voter.Id,
["inReplyTo"] = question, ["to"] = new JsonArray(owner)
}
};
[Fact]
public async Task A_local_poll_goes_out_as_a_question_and_counts_each_remote_voter_once()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
await Follows(mallory, alice.Uri);
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token);
Assert.True(outcome.Ok);
var question = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
Assert.Equal("Question", question["type"]!.GetValue<string>());
Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue<string>()));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "coffee"));
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "tea"));
var poll = (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll;
Assert.Equal(new[] { 0, 1 }, poll.Options.Select(o => o.Votes));
Assert.Equal(1, poll.VotersCount);
Assert.False(await DB.Default.Find<Post>().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_persona_votes_on_a_remote_poll_and_a_count_refresh_is_not_an_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var questionId = NewId(mallory, "questions");
JsonObject Question(int first, int second) => new()
{
["id"] = questionId, ["type"] = "Question", ["attributedTo"] = mallory.Id, ["content"] = "<p>pick</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }),
["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"),
["anyOf"] = new JsonArray(
new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = first } },
new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = second } })
};
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = Question(3, 1)
});
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == questionId).ExecuteFirstAsync(token);
Assert.True(post.Poll.Multiple);
Assert.True((await _harness.Polls.Vote(alice, post, new[] { 0, 1 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, post, new[] { 0 }, token)).Ok);
var votes = (await _harness.Outgoing(mallory.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").Select(a => a["object"]!).ToList();
Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue<string>()).Order().Reverse());
Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue<string>()));
Assert.All(votes, v => Assert.Null(v["published"]));
Assert.Equal(new[] { 0, 1 }, (await _harness.Polls.OwnVotes(post.ID, alice.Id, token)).Order());
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities"), ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = Question(9, 5)
});
var refreshed = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(new[] { 9, 5 }, refreshed.Poll.Options.Select(o => o.Votes));
Assert.Empty(refreshed.Revisions);
Assert.Null(refreshed.EditedAt);
}
[Fact]
public async Task Closing_a_poll_tells_its_voters_and_its_author()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var (_, bob) = await _harness.Persona("bob");
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quick", Poll = new PollDraft(new[] { "yes", "no" }, 300, false, false) }, token);
Assert.True((await _harness.Polls.Vote(bob, outcome.Post, new[] { 0 }, token)).Ok);
Assert.False((await _harness.Polls.Vote(alice, outcome.Post, new[] { 0 }, token)).Ok);
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteFirstAsync(token);
await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token);
Assert.NotNull((await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt);
var told = await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == outcome.Post.ID).ExecuteAsync(token);
Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order());
Assert.False((await _harness.Polls.Vote(bob, await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok);
}
}
}
+4 -2
View File
@@ -44,6 +44,7 @@ namespace PrivaPub.Tests.Support
Fanout = new Fanout(Db); Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery); Groups = new GroupDistributor(Delivery);
Records = new ObjectRecords(Queue); Records = new ObjectRecords(Queue);
Polls = new PollService(Db, Local, Delivery, Queue);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records);
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[]
@@ -56,7 +57,7 @@ namespace PrivaPub.Tests.Support
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), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls),
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)
@@ -66,7 +67,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); Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls);
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);
@@ -91,6 +92,7 @@ namespace PrivaPub.Tests.Support
public Fanout Fanout { get; } public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; } public RemotePosts RemotePosts { get; }
public ObjectRecords Records { get; } public ObjectRecords Records { get; }
public PollService Polls { 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; }
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class PollsController : MastodonController
{
readonly DbEntities _dbEntities;
readonly MastodonMapper _mapper;
readonly IPollService _polls;
public PollsController(DbEntities dbEntities, MastodonMapper mapper, IPollService polls)
{
_dbEntities = dbEntities;
_mapper = mapper;
_polls = polls;
}
[HttpGet("/api/v1/polls/{id}"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var post = await Visible(id, token);
return post?.Poll == default ? NotFoundError() : Json(_mapper.Poll(post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
}
[HttpPost("/api/v1/polls/{id}/votes"), Scope("write:statuses")]
public async Task<IActionResult> Vote(string id, CancellationToken token)
{
var post = await Visible(id, token);
if (post?.Poll == default)
return NotFoundError();
var choices = Params.List("choices").Select(c => int.TryParse(c, out var index) ? index : -1).ToList();
var outcome = await _polls.Vote(Me, post, choices, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
return Json(_mapper.Poll(outcome.Post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
}
async Task<PostEntity> Visible(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
}
}
}
@@ -44,9 +44,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (existing != default) if (existing != default)
return Json(await _mapper.Status(existing, MyId, token)); return Json(await _mapper.Status(existing, MyId, token));
} }
if (Params.Has("poll[options]"))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
var outcome = await _statuses.Publish(Me, new StatusDraft var outcome = await _statuses.Publish(Me, new StatusDraft
{ {
Text = Params.Get("status"), Text = Params.Get("status"),
@@ -56,7 +53,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility), Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
InReplyTo = Params.Get("in_reply_to_id"), InReplyTo = Params.Get("in_reply_to_id"),
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage, Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
MediaIds = Params.List("media_ids") MediaIds = Params.List("media_ids"),
Poll = Params.Has("poll[options]")
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
Params.Bool("poll[hide_totals]") == true)
: default
}, token); }, token);
if (!outcome.Ok) if (!outcome.Ok)
return Error(outcome.Status, outcome.Error); return Error(outcome.Status, outcome.Error);
@@ -162,7 +162,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[NotificationType.FollowRequest] = "follow_request", [NotificationType.FollowRequest] = "follow_request",
[NotificationType.Favourite] = "favourite", [NotificationType.Favourite] = "favourite",
[NotificationType.Reblog] = "reblog", [NotificationType.Reblog] = "reblog",
[NotificationType.Update] = "update" [NotificationType.Update] = "update",
[NotificationType.Poll] = "poll"
}; };
readonly MastodonMapper _mapper; readonly MastodonMapper _mapper;
+36 -4
View File
@@ -25,12 +25,24 @@ namespace PrivaPub.Api.Mastodon.Entities
public string LastStatusAt { get; set; } public string LastStatusAt { get; set; }
public bool? HideCollections { get; set; } public bool? HideCollections { get; set; }
public bool Noindex { get; set; } = true; public bool Noindex { get; set; } = true;
public List<object> Emojis { get; set; } = new(); public List<CustomEmojiEntity> Emojis { get; set; } = new();
public List<object> Roles { get; set; } = new(); public List<object> Roles { get; set; } = new();
public bool? Memorial { get; set; }
public string AvatarDescription { get; set; } = string.Empty;
public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new(); public List<Field> Fields { get; set; } = new();
public Source Source { get; set; } public Source Source { get; set; }
} }
public class CustomEmojiEntity
{
public string Shortcode { get; set; }
public string Url { get; set; }
public string StaticUrl { get; set; }
public bool VisibleInPicker { get; set; }
public string Category { get; set; }
}
public class Field public class Field
{ {
public string Name { get; set; } public string Name { get; set; }
@@ -149,6 +161,26 @@ namespace PrivaPub.Api.Mastodon.Entities
public int Down { get; set; } public int Down { get; set; }
} }
public class PollEntity
{
public string Id { get; set; }
public string ExpiresAt { get; set; }
public bool Expired { get; set; }
public bool Multiple { get; set; }
public int VotesCount { get; set; }
public int? VotersCount { get; set; }
public bool? Voted { get; set; }
public List<int> OwnVotes { get; set; }
public List<PollOptionEntity> Options { get; set; } = new();
public List<CustomEmojiEntity> Emojis { get; set; } = new();
}
public class PollOptionEntity
{
public string Title { get; set; }
public int? VotesCount { get; set; }
}
public class PreviewCard public class PreviewCard
{ {
public string Url { get; set; } public string Url { get; set; }
@@ -183,7 +215,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public string Content { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
public string CreatedAt { get; set; } public string CreatedAt { get; set; }
public string EditedAt { get; set; } public string EditedAt { get; set; }
public List<object> Emojis { get; set; } = new(); public List<CustomEmojiEntity> Emojis { get; set; } = new();
public int RepliesCount { get; set; } public int RepliesCount { get; set; }
public int ReblogsCount { get; set; } public int ReblogsCount { get; set; }
public int FavouritesCount { get; set; } public int FavouritesCount { get; set; }
@@ -199,7 +231,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public List<StatusMention> Mentions { get; set; } = new(); public List<StatusMention> Mentions { get; set; } = new();
public List<StatusTag> Tags { get; set; } = new(); public List<StatusTag> Tags { get; set; } = new();
public PreviewCard Card { get; set; } public PreviewCard Card { get; set; }
public object Poll { get; set; } public PollEntity Poll { get; set; }
public Application Application { get; set; } public Application Application { get; set; }
public string Language { get; set; } public string Language { get; set; }
public string Text { get; set; } public string Text { get; set; }
@@ -289,7 +321,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public string CreatedAt { get; set; } public string CreatedAt { get; set; }
public Account Account { get; set; } public Account Account { get; set; }
public List<MediaAttachment> MediaAttachments { get; set; } = new(); public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<object> Emojis { get; set; } = new(); public List<CustomEmojiEntity> Emojis { get; set; } = new();
} }
public class StatusSource public class StatusSource
@@ -8,6 +8,7 @@ using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Group; using PrivaPub.Models.Group;
using PrivaPub.Models.Post; using PrivaPub.Models.Post;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Social; using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
@@ -103,8 +104,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty, DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application, Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
Group = foreign.AvatarType == AvatarType.Group, Group = foreign.AvatarType == AvatarType.Group,
Locked = foreign.IsLocked,
Discoverable = foreign.IsDiscoverable, Discoverable = foreign.IsDiscoverable,
CreatedAt = MastodonJson.Day(foreign.CreatedAt), Indexable = foreign.IsIndexable,
Memorial = foreign.IsMemorial ? true : default(bool?),
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
Emojis = Emojis(foreign.Emojis),
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
AvatarDescription = foreign.PictureDescription ?? string.Empty,
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
Note = foreign.Biography ?? string.Empty, Note = foreign.Biography ?? string.Empty,
Url = foreign.Url ?? foreign.ActorURI, Url = foreign.Url ?? foreign.ActorURI,
Uri = foreign.ActorURI, Uri = foreign.ActorURI,
@@ -158,6 +166,11 @@ 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 pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
var ownVotes = viewerId == default || pollIds.Count == 0
? new Dictionary<string, List<int>>()
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
var hidden = viewerId == default var hidden = viewerId == default
? new HashSet<string>() ? new HashSet<string>()
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token); : await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
@@ -194,6 +207,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
Language = post.Language, Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(), MediaAttachments = post.Media.Select(Media).ToList(),
Card = Card(post), Card = Card(post),
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
Emojis = Emojis(post.Emojis),
Privapub = Extension(post), Privapub = Extension(post),
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()
@@ -270,6 +285,30 @@ namespace PrivaPub.Api.Mastodon.Mappers
} }
} }
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
{
if (post.Poll is not { } poll)
return default;
var expired = PollService.Ended(poll);
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
return new PollEntity
{
Id = post.ID,
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
Expired = expired,
Multiple = poll.Multiple,
VotesCount = poll.Options.Sum(o => o.Votes),
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
};
}
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
PrivaPubStatus Extension(PostEntity post) => new() PrivaPubStatus Extension(PostEntity post) => new()
{ {
ObjectType = post.ObjectType, ObjectType = post.ObjectType,
+1 -1
View File
@@ -10,7 +10,7 @@ namespace PrivaPub.Domain.Social
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)
{ {
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId) if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
return; return;
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0) if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
return; return;
+300
View File
@@ -0,0 +1,300 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Statuses
{
public sealed record PollDraft(IReadOnlyList<string> Options, int ExpiresIn, bool Multiple, bool HideTotals);
public interface IPollService
{
string Invalid(PollDraft draft);
PostPoll Create(PollDraft draft);
Task Scheduled(PostEntity post, CancellationToken token);
Task<StatusOutcome> Vote(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token);
Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token);
Task<HashSet<int>> OwnVotes(string postId, string avatarId, CancellationToken token);
}
public class PollService : IPollService
{
public const int MaxOptions = 4;
public const int MaxOptionLength = 50;
public const int MinExpiration = 300;
public const int MaxExpiration = 2_629_746;
static readonly TimeSpan RefreshEvery = TimeSpan.FromMinutes(3);
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IJobQueue _queue;
public PollService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IJobQueue queue)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_queue = queue;
}
public string Invalid(PollDraft draft)
{
var options = draft.Options.Select(o => o?.Trim()).ToList();
if (options.Count is < 2 or > MaxOptions)
return $"Validation failed: A poll needs between 2 and {MaxOptions} options";
if (options.Any(o => string.IsNullOrEmpty(o) || o.Length > MaxOptionLength))
return $"Validation failed: Poll options must be between 1 and {MaxOptionLength} characters";
if (options.Distinct(StringComparer.Ordinal).Count() != options.Count)
return "Validation failed: Poll options must be unique";
if (draft.ExpiresIn is < MinExpiration or > MaxExpiration)
return "Validation failed: The poll's duration is out of range";
return default;
}
public PostPoll Create(PollDraft draft) => new()
{
Options = draft.Options.Select(o => new PollOption { Title = o.Trim() }).ToList(),
Multiple = draft.Multiple,
HideTotals = draft.HideTotals,
ExpiresAt = DateTime.UtcNow.AddSeconds(draft.ExpiresIn),
VotersCount = 0
};
public async Task Scheduled(PostEntity post, CancellationToken token)
{
if (post.Poll?.ExpiresAt is not { } ends)
return;
await _queue.EnqueueMany(new[]
{
new Job { Kind = JobKind.PollClose, Payload = post.ID, Host = LocalHost, DedupeKey = $"poll-close|{post.ID}", RunAt = ends }
}, token);
}
public async Task<StatusOutcome> Vote(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token)
{
var poll = post.Poll;
if (poll == default)
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (Ended(poll))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The poll has already ended");
if (!post.IsFederatedCopy && post.GroupUserId == me.Id)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: You cannot vote in your own poll");
var picked = choices.Distinct().ToList();
if (picked.Count == 0 || picked.Any(c => c < 0 || c >= poll.Options.Count) || !poll.Multiple && picked.Count > 1)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The choices are invalid");
if (await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.ActorURI == me.Uri).ExecuteAnyAsync(token))
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: You have already voted on this poll");
foreach (var choice in picked)
await DB.Default.SaveAsync(new PollVote
{
PostId = post.ID, VoterAccountId = me.Id, ActorURI = me.Uri, IsLocalVoter = true, Choice = choice,
ActivityURI = me.Uri + $"#votes/{post.ID}/{choice}"
}, token);
await Count(post, picked, newVoter: true, token);
if (post.IsFederatedCopy)
await SendVotes(me, post, picked, token);
else
await Refreshed(post, token);
await Scheduled(post, token);
return new StatusOutcome(await _dbEntities.Posts.MatchID(post.ID).ExecuteFirstAsync(token));
}
public async Task Receive(PostEntity post, ForeignAvatar voter, string choice, string activityId, CancellationToken token)
{
var poll = post.Poll;
if (poll == default || post.IsFederatedCopy || Ended(poll) || !LikeHandler.MaySee(post, voter))
return;
var index = poll.Options.FindIndex(o => o.Title == choice);
if (index < 0)
return;
var earlier = await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.ActorURI == voter.ActorURI).ExecuteAsync(token);
if (earlier.Count > 0 && (!poll.Multiple || earlier.Any(v => v.Choice == index)))
return;
try
{
await DB.Default.SaveAsync(new PollVote
{
PostId = post.ID, VoterAccountId = voter.ID, ActorURI = voter.ActorURI, Choice = index, ActivityURI = activityId
}, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await Count(post, new[] { index }, newVoter: earlier.Count == 0, token);
await Refreshed(post, token);
}
public async Task<HashSet<int>> OwnVotes(string postId, string avatarId, CancellationToken token) =>
avatarId == default
? new HashSet<int>()
: (await DB.Default.Find<PollVote>().Match(v => v.PostId == postId && v.VoterAccountId == avatarId && v.IsLocalVoter).ExecuteAsync(token))
.Select(v => v.Choice).ToHashSet();
public static bool Ended(PostPoll poll) => poll.ClosedAt.HasValue || poll.ExpiresAt <= DateTime.UtcNow;
async Task Count(PostEntity post, IReadOnlyList<int> choices, bool newVoter, CancellationToken token)
{
var update = DB.Default.Update<PostEntity>().MatchID(post.ID);
foreach (var choice in choices)
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.Options)}.{choice}.{nameof(PollOption.Votes)}", 1));
if (newVoter && post.Poll.VotersCount.HasValue)
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.VotersCount)}", 1));
await update.ExecuteAsync(token);
}
async Task SendVotes(LocalActor me, PostEntity post, IReadOnlyList<int> choices, CancellationToken token)
{
var owner = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == post.ActorURI).ExecuteFirstAsync(token);
if (string.IsNullOrEmpty(owner?.InboxURL))
return;
foreach (var choice in choices)
{
var voteId = me.Uri + $"#votes/{post.ID}/{choice}";
await _delivery.Enqueue(me, new[] { owner.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = voteId + "/activity",
["type"] = "Create",
["actor"] = me.Uri,
["to"] = new JsonArray(owner.ActorURI),
["object"] = new JsonObject
{
["id"] = voteId,
["type"] = "Note",
["name"] = post.Poll.Options[choice].Title,
["attributedTo"] = me.Uri,
["to"] = new JsonArray(owner.ActorURI),
["inReplyTo"] = post.ObjectURI
}
}, token);
}
}
async Task Refreshed(PostEntity post, CancellationToken token)
{
var bucket = DateTime.UtcNow.Ticks / RefreshEvery.Ticks;
await _queue.EnqueueMany(new[]
{
new Job
{
Kind = JobKind.PollRefresh, Payload = post.ID, Host = LocalHost, DedupeKey = $"poll-refresh|{post.ID}|{bucket}",
RunAt = new DateTime((bucket + 1) * RefreshEvery.Ticks, DateTimeKind.Utc)
}
}, token);
}
string LocalHost => new Uri(_localActors.BaseAddress).Host;
}
public class PollRefreshJob : IJobHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IOutboxPublisher _outbox;
public PollRefreshJob(DbEntities dbEntities, ILocalActorService localActors, IOutboxPublisher outbox)
{
_dbEntities = dbEntities;
_localActors = localActors;
_outbox = outbox;
}
public virtual JobKind Kind => JobKind.PollRefresh;
public int Concurrency => 1;
public int MaxAttempts => 3;
public int PerHostLimit => 1;
public virtual async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var post = await _dbEntities.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post?.Poll == default || post.IsFederatedCopy || post.DeletedAt.HasValue)
return JobOutcome.Done;
var author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author == default)
return JobOutcome.Done;
await Closing(post, author, token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, Update(post, author), token);
return JobOutcome.Done;
}
protected virtual Task Closing(PostEntity post, LocalActor author, CancellationToken token) => Task.CompletedTask;
static JsonObject Update(PostEntity post, LocalActor author)
{
var question = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, author, default, post.InReplyToURI);
question["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
question["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"update-{post.ID}-poll-{DateTime.UtcNow.Ticks}"),
["type"] = "Update",
["actor"] = author.Uri,
["to"] = question["to"]!.DeepClone(),
["cc"] = question["cc"]!.DeepClone(),
["object"] = question
};
}
}
public class PollCloseJob : PollRefreshJob
{
readonly DbEntities _posts;
public PollCloseJob(DbEntities dbEntities, ILocalActorService localActors, IOutboxPublisher outbox) : base(dbEntities, localActors, outbox)
{
_posts = dbEntities;
}
public override JobKind Kind => JobKind.PollClose;
public override async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var post = await _posts.Posts.MatchID(job.Payload).ExecuteFirstAsync(token);
if (post?.Poll == default || post.DeletedAt.HasValue)
return JobOutcome.Done;
if (!post.IsFederatedCopy)
return await base.Handle(job, token);
await Notify(post, post.AuthorAccountId, post.ActorURI, token);
return JobOutcome.Done;
}
protected override async Task Closing(PostEntity post, LocalActor author, CancellationToken token)
{
post.Poll.ClosedAt ??= DateTime.UtcNow;
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.Poll.ClosedAt, post.Poll.ClosedAt).ExecuteAsync(token);
await Notify(post, author.Id, author.Uri, token);
}
public static async Task Notify(PostEntity post, string authorAccountId, string authorUri, CancellationToken token)
{
var voters = await DB.Default.Find<PollVote>().Match(v => v.PostId == post.ID && v.IsLocalVoter).ExecuteAsync(token);
foreach (var voter in voters.Select(v => v.VoterAccountId).Distinct())
await Notifications.Add(voter, NotificationType.Poll, authorAccountId, authorUri, post.ID, token);
if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Poll, post.GroupUserId, authorUri, post.ID, token);
}
}
}
+10 -3
View File
@@ -39,6 +39,7 @@ namespace PrivaPub.Domain.Statuses
public double? Latitude { get; init; } public double? Latitude { get; init; }
public double? Longitude { get; init; } public double? Longitude { get; init; }
public double? RangeKm { get; init; } public double? RangeKm { get; init; }
public PollDraft Poll { get; init; }
} }
public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default) public sealed record StatusOutcome(PostEntity Post, int Status = StatusCodes.Status200OK, string Error = default)
@@ -69,10 +70,12 @@ namespace PrivaPub.Domain.Statuses
readonly IFanout _fanout; readonly IFanout _fanout;
readonly IMediaService _media; readonly IMediaService _media;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IPollService _polls;
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) IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups, IPollService polls)
{ {
_polls = polls;
_media = media; _media = media;
_groups = groups; _groups = groups;
_dbEntities = dbEntities; _dbEntities = dbEntities;
@@ -89,8 +92,10 @@ namespace PrivaPub.Domain.Statuses
var media = await Media(author, draft.MediaIds, default, token); var media = await Media(author, draft.MediaIds, default, token);
if (media == default) if (media == default)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0) if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank"); return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
if (draft.Poll != default && _polls.Invalid(draft.Poll) is { } invalidPoll)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, invalidPoll);
LocalActor group = default; LocalActor group = default;
if (!string.IsNullOrEmpty(draft.GroupId)) if (!string.IsNullOrEmpty(draft.GroupId))
@@ -157,7 +162,8 @@ namespace PrivaPub.Domain.Statuses
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default), InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsLocalOnly = isLocalOnly, IsLocalOnly = isLocalOnly,
ActorURI = author.Uri ActorURI = author.Uri,
Poll = draft.Poll == default ? default : _polls.Create(draft.Poll)
}; };
post.ID = (string)post.GenerateNewID(); post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID); post.ObjectURI = author.PostUri(post.ID);
@@ -200,6 +206,7 @@ namespace PrivaPub.Domain.Statuses
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);
await _fanout.Distribute(post, token); await _fanout.Distribute(post, token);
await _polls.Scheduled(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)
+50 -1
View File
@@ -1,6 +1,9 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Actors namespace PrivaPub.Federation.Actors
{ {
@@ -23,6 +26,16 @@ namespace PrivaPub.Federation.Actors
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;
public bool Indexable { get; init; }
public bool Locked { get; init; }
public bool Memorial { get; init; }
public string Header { get; init; }
public string IconDescription { get; init; }
public string HeaderDescription { get; init; }
public string MovedTo { get; init; }
public DateTime? Published { get; init; }
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>(); public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId); public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -52,10 +65,46 @@ namespace PrivaPub.Federation.Actors
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,
Keys = ParseKeys(root, id) Keys = ParseKeys(root, id),
Indexable = Flag(root, "indexable"),
Locked = Flag(root, "manuallyApprovesFollowers"),
Memorial = Flag(root, "memorial"),
Header = root.TryGetProperty("image", out var image) ? ObjectShapes.Image(JsonNode.Parse(image.GetRawText())) : default,
IconDescription = root.TryGetProperty("icon", out var described) ? Alt(described) : default,
HeaderDescription = root.TryGetProperty("image", out var headerImage) ? Alt(headerImage) : default,
MovedTo = RemoteActorService.Text(root, "movedTo"),
Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published)
? published.UtcDateTime
: default(DateTime?),
Emojis = root.TryGetProperty("tag", out var tags) ? ObjectShapes.Emojis(JsonNode.Parse(tags.GetRawText())) : new(),
Fields = root.TryGetProperty("attachment", out var attachment) ? ParseFields(JsonNode.Parse(attachment.GetRawText())) : new()
}; };
} }
const int MaxFields = 16;
static bool Flag(JsonElement root, string property) =>
root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True;
static string Alt(JsonElement image) =>
image.ValueKind == JsonValueKind.Object ? ObjectShapes.Text(RemoteActorService.Text(image, "name") ?? RemoteActorService.Text(image, "summary"), 1500) : default;
static Dictionary<string, string> ParseFields(JsonNode attachment)
{
var fields = new Dictionary<string, string>();
foreach (var field in ObjectShapes.Objects(attachment).Where(a => ActivityJson.Value(a, "type") is "PropertyValue" or "Note"))
{
var name = ObjectShapes.Text(ActivityJson.Value(field, "name"), 255);
var value = ActivityJson.Value(field, "value") ?? ActivityJson.Value(field, "content");
if (name == default || value == default || fields.ContainsKey(name))
continue;
fields[name] = ContentSanitizer.Html(value.Length <= 2048 ? value : value[..2048]);
if (fields.Count == MaxFields)
break;
}
return fields;
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId) static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{ {
var keys = new List<ActorKey>(); var keys = new List<ActorKey>();
@@ -177,7 +177,17 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowingURL, actor.Following) .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.ThumbnailURL, actor.Header)
.Modify(a => a.PictureDescription, actor.IconDescription)
.Modify(a => a.HeaderDescription, actor.HeaderDescription)
.Modify(a => a.IsDiscoverable, actor.Discoverable) .Modify(a => a.IsDiscoverable, actor.Discoverable)
.Modify(a => a.IsIndexable, actor.Indexable)
.Modify(a => a.IsLocked, actor.Locked)
.Modify(a => a.IsMemorial, actor.Memorial)
.Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo)
.Modify(a => a.Published, actor.Published)
.Modify(a => a.Emojis, actor.Emojis)
.Modify(a => a.Fields, actor.Fields)
.Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person) .Modify(a => a.AvatarType, Enum.TryParse<AvatarType>(actor.Type, out var type) ? type : AvatarType.Person)
.Modify(a => a.PublicKeyId, key?.Id) .Modify(a => a.PublicKeyId, key?.Id)
.Modify(a => a.PublicKey, key?.Pem) .Modify(a => a.PublicKey, key?.Pem)
@@ -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.Statuses;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Ids; using PrivaPub.Infrastructure.Ids;
@@ -35,11 +36,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IRemotePosts _remotePosts; readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups; readonly IGroupDistributor _groups;
readonly IObjectRecords _records; readonly IObjectRecords _records;
readonly IPollService _polls;
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) IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls)
{ {
_records = records; _records = records;
_polls = polls;
_groups = groups; _groups = groups;
_fanout = fanout; _fanout = fanout;
_remotePosts = remotePosts; _remotePosts = remotePosts;
@@ -64,6 +67,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
var note = NoteParser.Parse(node); var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI) if (note == default || note.AttributedTo != author.ActorURI)
return; return;
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
{
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default)
{
await _polls.Receive(question, author, note.Title, Id(activity), token);
return;
}
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token) if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
return; return;
@@ -55,6 +55,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (post == default || post.DeletedAt.HasValue) if (post == default || post.DeletedAt.HasValue)
return; return;
post.Poll = note.Poll ?? post.Poll;
post.Video = note.Video ?? post.Video;
post.Audio = note.Audio ?? post.Audio;
post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post))
{
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
return;
}
post.Revisions.Add(new PostRevision post.Revisions.Add(new PostRevision
{ {
Title = post.Title, Title = post.Title,
@@ -77,11 +88,9 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.SpoilerText = note.SpoilerText; post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt; post.Excerpt = note.Excerpt;
post.Source = note.Source; post.Source = note.Source;
post.Emojis = note.Emojis.ToList();
post.CoverURL = note.CoverURL; post.CoverURL = note.CoverURL;
post.Link = note.Link; post.Link = note.Link;
post.Video = note.Video;
post.Audio = note.Audio;
post.Event = note.Event;
post.HasContentWarning = note.Sensitive; post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml; post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml; post.ContentHtml = note.ContentHtml;
@@ -96,5 +105,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
} }
static bool IsEdit(NoteDocument note, PostEntity post) =>
note.Updated is { } updated && updated > (post.EditedAt ?? post.CreationDate);
} }
} }
+2
View File
@@ -78,6 +78,8 @@ namespace PrivaPub.Federation.Inbox
SpoilerText = note.SpoilerText, SpoilerText = note.SpoilerText,
Excerpt = note.Excerpt, Excerpt = note.Excerpt,
Source = note.Source, Source = note.Source,
Poll = note.Poll,
Emojis = note.Emojis.ToList(),
CoverURL = note.CoverURL, CoverURL = note.CoverURL,
Link = note.Link, Link = note.Link,
Video = note.Video, Video = note.Video,
@@ -33,6 +33,8 @@ namespace PrivaPub.Federation.Objects
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>(); public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; } public PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; } public string CoverURL { get; init; }
public PostLink Link { get; init; } public PostLink Link { get; init; }
public VideoDetails Video { get; init; } public VideoDetails Video { get; init; }
@@ -94,6 +96,8 @@ namespace PrivaPub.Federation.Objects
.ToList(), .ToList(),
Attachments = Attachments(note), Attachments = Attachments(note),
Source = ObjectShapes.Source(note), Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note), CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note), Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note), Video = ObjectShapes.Video(note),
@@ -16,6 +16,8 @@ namespace PrivaPub.Federation.Objects
const int MaxCaptions = 20; const int MaxCaptions = 20;
const int MaxPlaces = 4; const int MaxPlaces = 4;
const int CoverMaxWidth = 1280; const int CoverMaxWidth = 1280;
const int MaxEmojis = 64;
const int MaxPollOptions = 20;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase) static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{ {
@@ -81,6 +83,52 @@ namespace PrivaPub.Federation.Objects
?? usable.Select(c => c.Url).FirstOrDefault(); ?? usable.Select(c => c.Url).FirstOrDefault();
} }
public static List<CustomEmoji> Emojis(JsonNode tags) =>
Objects(tags)
.Where(t => Value(t, "type") == "Emoji")
.Select(t => new CustomEmoji
{
Shortcode = Value(t, "name")?.Trim(':'),
URL = MediaLink(t["icon"]).Url,
Updated = Moment(Value(t, "updated"))
})
.Where(e => !string.IsNullOrEmpty(e.Shortcode) && e.Shortcode.Length <= 100 && Shortcode().IsMatch(e.Shortcode) && Origin.Of(e.URL) != default)
.DistinctBy(e => e.Shortcode)
.Take(MaxEmojis)
.ToList();
public static PostPoll Poll(JsonObject note)
{
if (Value(note, "type") != "Question")
return default;
var multiple = note["anyOf"] is JsonArray;
var options = Objects(note[multiple ? "anyOf" : "oneOf"])
.Select(o => new PollOption
{
Title = Text(Value(o, "name"), 200),
Votes = o["replies"] is JsonObject replies && Int(replies, "totalItems") is { } total ? total : Int(o, "_misskey_votes") ?? 0
})
.Where(o => o.Title != default)
.Take(MaxPollOptions)
.ToList();
if (options.Count < 2)
return default;
var closedAt = note["closed"] switch
{
JsonValue value when value.TryGetValue<bool>(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?),
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = Moment(Value(note, "endTime")),
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
}
public static string Cover(JsonObject note) => public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default); Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
@@ -284,6 +332,9 @@ namespace PrivaPub.Federation.Objects
static bool? Bool(JsonObject node, string property) => static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null; node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex(@"^[\w+\-.@]+$")]
private static partial Regex Shortcode();
[GeneratedRegex("<[^>]*>")] [GeneratedRegex("<[^>]*>")]
private static partial Regex Tags(); private static partial Regex Tags();
@@ -37,6 +37,7 @@ namespace PrivaPub.Federation.Rendering
["discoverable"] = "toot:discoverable", ["discoverable"] = "toot:discoverable",
["indexable"] = "toot:indexable", ["indexable"] = "toot:indexable",
["blurhash"] = "toot:blurhash", ["blurhash"] = "toot:blurhash",
["votersCount"] = "toot:votersCount",
["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",
@@ -156,6 +157,23 @@ namespace PrivaPub.Federation.Rendering
return note; return note;
} }
static void AddPoll(JsonObject note, PostPoll poll)
{
note["type"] = "Question";
note[poll.Multiple ? "anyOf" : "oneOf"] = new JsonArray(poll.Options.Select(option => (JsonNode)new JsonObject
{
["type"] = "Note",
["name"] = option.Title,
["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = option.Votes }
}).ToArray());
if (poll.ExpiresAt is { } ends)
note["endTime"] = Timestamp(ends);
if (poll.ClosedAt is { } closed)
note["closed"] = Timestamp(closed);
if (poll.VotersCount is { } voters)
note["votersCount"] = voters;
}
static string Headline(PostEntity post) static string Headline(PostEntity post)
{ {
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " "); var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
@@ -192,6 +210,8 @@ namespace PrivaPub.Federation.Rendering
}; };
if (!string.IsNullOrEmpty(post.Language)) if (!string.IsNullOrEmpty(post.Language))
note["contentMap"] = new JsonObject { [post.Language] = content }; note["contentMap"] = new JsonObject { [post.Language] = content };
if (post.Poll is { } poll)
AddPoll(note, poll);
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m => var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
{ {
var attachment = new JsonObject var attachment = new JsonObject
+3
View File
@@ -110,6 +110,9 @@ namespace PrivaPub.Infrastructure.Data
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token); await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<Downvote>(token, d => d.ActivityURI); await Plain<Downvote>(token, d => d.ActivityURI);
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);
await DB.Default.Index<PollVote>().Key(v => v.PostId, KeyType.Ascending).Key(v => v.VoterAccountId, KeyType.Ascending).CreateAsync(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,
@@ -86,6 +86,9 @@ namespace PrivaPub.Middleware
.AddSingleton<IJobHandler, InboxProcessor>() .AddSingleton<IJobHandler, InboxProcessor>()
.AddSingleton<IRemotePosts, RemotePosts>() .AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IObjectRecords, ObjectRecords>() .AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IPollService, PollService>()
.AddSingleton<IJobHandler, PollRefreshJob>()
.AddSingleton<IJobHandler, PollCloseJob>()
.AddSingleton<IJobHandler, InstanceDescriber>() .AddSingleton<IJobHandler, InstanceDescriber>()
.AddSingleton<IJobHandler, AncestorsJobHandler>() .AddSingleton<IJobHandler, AncestorsJobHandler>()
.AddSingleton<IJobQueue, JobQueue>() .AddSingleton<IJobQueue, JobQueue>()
+3 -1
View File
@@ -23,7 +23,9 @@ namespace PrivaPub.Models.Jobs
Deliver, Deliver,
ProcessInbox, ProcessInbox,
FetchAncestors, FetchAncestors,
DescribeInstance DescribeInstance,
PollRefresh,
PollClose
} }
public enum JobState public enum JobState
+3
View File
@@ -25,6 +25,7 @@ namespace PrivaPub.Models.Post
public List<PostMention> Mentions { get; set; } = new(); public List<PostMention> Mentions { get; set; } = new();
public List<string> Tags { 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<CustomEmoji> Emojis { get; set; } = new();
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public PostSource Source { get; set; }//the remote author's own text (Markdown, MFM, BBCode) public PostSource Source { get; set; }//the remote author's own text (Markdown, MFM, BBCode)
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
@@ -37,6 +38,8 @@ namespace PrivaPub.Models.Post
public AudioDetails Audio { get; set; } public AudioDetails Audio { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
public EventDetails Event { get; set; } public EventDetails Event { get; set; }
[BsonIgnoreIfNull]
public PostPoll Poll { get; set; }
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;
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
+23
View File
@@ -1,5 +1,28 @@
namespace PrivaPub.Models.Post namespace PrivaPub.Models.Post
{ {
public class CustomEmoji
{
public string Shortcode { get; set; }//without the colons
public string URL { get; set; }
public DateTime? Updated { get; set; }
}
public class PostPoll
{
public List<PollOption> Options { get; set; } = new();
public bool Multiple { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? ClosedAt { get; set; }
public int? VotersCount { get; set; }//null when the origin does not say (Misskey)
public bool HideTotals { get; set; }
}
public class PollOption
{
public string Title { get; set; }
public int Votes { get; set; }
}
public class PostSource public class PostSource
{ {
public string Content { get; set; } public string Content { get; set; }
+2 -1
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Models.Social
FollowRequest, FollowRequest,
Favourite, Favourite,
Reblog, Reblog,
Update Update,
Poll
} }
} }
+15
View File
@@ -0,0 +1,15 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class PollVote : Entity
{
public string PostId { get; set; }
public string VoterAccountId { get; set; }//Avatar.ID for a local voter, ForeignAvatar.ID for a remote one
public string ActorURI { get; set; }
public bool IsLocalVoter { get; set; }
public int Choice { get; set; }
public string ActivityURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+7
View File
@@ -59,6 +59,13 @@ namespace PrivaPub.Models.User
public string PersonalNote { get; set; } public string PersonalNote { get; set; }
public string ModerationNote { get; set; } public string ModerationNote { get; set; }
public bool IsDiscoverable { get; set; } = true; public bool IsDiscoverable { get; set; } = true;
public bool IsIndexable { get; set; }
public bool IsLocked { get; set; }//manuallyApprovesFollowers
public bool IsMemorial { get; set; }
public List<PrivaPub.Models.Post.CustomEmoji> Emojis { get; set; } = new();
public string PictureDescription { get; set; }
public string HeaderDescription { get; set; }
public DateTime? Published { get; set; }//the actor's own published
public string ActorURI { get; set; }//id public string ActorURI { get; set; }//id
public string PublicKeyId { get; set; } public string PublicKeyId { get; set; }
+4 -2
View File
@@ -448,8 +448,10 @@ it, raw where it doesn't.
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference. with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
#### P6 What people see: emoji, polls, quotes, reactions, cards, players #### P6 What people see: emoji, polls, quotes, reactions, cards, players
- **Custom emoji** on posts, names, fields and poll options, proxied. - **Custom emoji** on posts, names, fields and poll options, proxied (done in v1.10.0), together with fuller remote
- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit. profiles: header, fields, locked, published, moved-to, indexable, memorial, image descriptions.
- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit (done in
v1.10.0; checked live both ways against GoToSocial).
- **Quotes (FEP-044f):** - **Quotes (FEP-044f):**
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation; - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation;
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps; - be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps;
+13
View File
@@ -101,6 +101,19 @@ gcurl -s -o /dev/null -X POST -H "$GH" $G/api/v1/statuses -d 'status=@alice@priv
until_true 20 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret back"' && ok "gtsuser's DM reaches alice" || ko "DM missing on PrivaPub" until_true 20 'curl -s -H "$PH" "$P/api/v1/conversations" | grep -q "a secret back"' && ok "gtsuser's DM reaches alice" || ko "DM missing on PrivaPub"
until_true 5 '! gcurl -s -H "$GH" "$G/api/v1/timelines/public?local=false" | grep -q "a secret"' && ok "the DM is not public on GoToSocial" || ko "DM leaked to a public timeline" until_true 5 '! gcurl -s -H "$GH" "$G/api/v1/timelines/public?local=false" | grep -q "a secret"' && ok "the DM is not public on GoToSocial" || ko "DM leaked to a public timeline"
echo "polls"
pp_poll=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d 'status=tea or coffee&visibility=public&poll[options][]=tea&poll[options][]=coffee&poll[expires_in]=3600' | j "print(d['uri'])")
until_true 20 'on_gts "$pp_poll" | j "print(len(d[\"statuses\"][0][\"poll\"][\"options\"]))" | grep -q 2' && ok "alice's poll reaches GoToSocial as a poll" || ko "poll missing on GoToSocial"
pp_poll_on_gts=$(on_gts "$pp_poll" | j "print(d['statuses'][0]['poll']['id'])")
gcurl -s -o /dev/null -X POST -H "$GH" "$G/api/v1/polls/$pp_poll_on_gts/votes" -d 'choices[]=1'
pp_poll_id=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if s['poll'] and s['uri']=='$pp_poll'))")
until_true 20 '[ "$(curl -s -H "$PH" "$P/api/v1/polls/$pp_poll_id" | j "print(d[\"options\"][1][\"votes_count\"])")" = "1" ]' && ok "gtsuser's vote counts on PrivaPub" || ko "vote not counted on PrivaPub"
gts_poll=$(gcurl -s -X POST -H "$GH" $G/api/v1/statuses -d 'status=red or blue&visibility=public&poll[options][]=red&poll[options][]=blue&poll[expires_in]=3600' | j "print(d['id'])")
until_true 20 'curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(any(s[\"poll\"] and s[\"content\"].find(\"red or blue\")>=0 for s in d))" | grep -q True' && ok "gtsuser's poll reaches alice as a poll" || ko "poll missing on PrivaPub"
gts_poll_on_pp=$(curl -s -H "$PH" "$P/api/v1/timelines/home" | j "print(next(s['poll']['id'] for s in d if s['poll'] and 'red or blue' in s['content']))")
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"
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"