Compare commits

..
3 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 b691c6766d P5: downvotes, private messages from Lemmy, late Creates of deleted posts, and typed details for clients
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 55s
- Dislike and its Undo are kept as downvotes (Lemmy, PieFed, Mbin, Friendica) and shown with favourites as votes.
- ChatMessage (Lemmy 0.19, Mbin, PieFed to those two) arrives as a direct message.
- A deleted object's id is remembered for 90 days, so a Create that arrives after its Delete cannot bring the post
  back; the post's ObjectRecord goes with it.
- A Join of one of our objects is answered with Ignore, as FEP-8a8e asks of a server without RSVP.
- A 503 with Retry-After is waited out like a 429 instead of counting as a failure of the host (GoToSocial throttling,
  Mastodon's temporary key failures).
- Status.privapub carries what a Mastodon Status cannot: object type, title, excerpt, cover, the author's source,
  link, video, audio and event details, and up/down votes, with every media URL proxied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:58:22 +02:00
thepraandClaude Opus 5.5 fc111d8c46 P5: remote objects are read in every shape, and videos, events, audio and links keep their own details
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
  rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
  `name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
  `preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
  `@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
  publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
  channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
  time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
  thumbnails and durations, and no media file offered as a preview image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:53:59 +02:00
thepraandClaude Opus 5.5 dcd024100a Every remote object keeps its raw form and how it reached us, for the client's details view
Build / Build (push) Successful in 36s
- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
  refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
  signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
  @context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
  someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
  used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
  links, emoji, polls, language maps, url variants, Markdown content).

Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:49:22 +02:00
37 changed files with 1880 additions and 67 deletions

No files matched your search

+16
View File
@@ -199,6 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice.
## Media invariants
@@ -238,6 +242,18 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object`, so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
- **Every stored remote object has an `ObjectRecord`** (raw JSON up to 256 KB plus its hash; delivered or fetched; the
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
revision. Delivery details reach them through `Arrival.Current`, which `InboxProcessor` sets for the handler's
duration. The raw form lives outside `Post` so timelines never load it. Read through
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
- **Remote objects are parsed for every shape in `ObjectShapes`:** `url`/`icon`/`image` as a value, an object or an
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
(that fetch is the owner's decision 1, for P6).
- **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.
- **Post ids are the timeline order, so they follow arrival, not `published`.** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId`, which sorts after every post already stored,
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
+4 -2
View File
@@ -87,12 +87,14 @@ Received:
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -0,0 +1,124 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboundRoutingTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost(string text)
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_downvote_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost("vote on me");
var lemmy = new RemoteActor(_harness.Peer, "voter");
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(lemmy, "/human-centipede", dislike);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
}
[Fact]
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
});
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var messageId = NewId(lemmy, "private_message");
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
Assert.NotNull(message.ConversationId);
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
var post = await LocalPost("not an event");
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
{
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
});
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
}
}
}
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)),
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue)),
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
}, NullLogger<InboxProcessor>.Instance);
}
@@ -0,0 +1,179 @@
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
public class ObjectShapesTests
{
static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json));
[Fact]
public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S",
"mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1,
"isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true,
"licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" },
"attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }],
"icon": [
{ "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 },
{ "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 },
{ "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 }
],
"subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }],
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" },
{ "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" }
]
}
""");
Assert.Equal("https://tube.example/w/abc", note.Url);
Assert.Equal("A *talk*", note.Title);
Assert.Contains("<strong>Bold</strong>", note.ContentHtml);
Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL);
Assert.Equal(6299, note.Video.DurationSeconds);
var file = Assert.Single(note.Video.Variants);
Assert.Equal(new[] { "audio", "video" }, file.Streams);
Assert.Equal(942586378, file.Size);
Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL);
Assert.Equal(2, note.Video.CommentsPolicy);
Assert.Equal("Attribution - Share Alike", note.Video.Licence);
Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI);
Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url);
Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo);
}
[Fact]
public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link()
{
var note = Parse("""
{
"id": "https://events.example/events/9", "type": "Event", "name": "Repair café",
"summary": "<p>Saturday at the library</p>", "content": "<p>Bring broken things.</p>",
"startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome",
"joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED",
"location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68,
"address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } },
"attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }],
"attributedTo": "https://events.example/@club"
}
""");
Assert.Null(note.SpoilerText);
Assert.False(note.Sensitive);
Assert.Equal("Saturday at the library", note.Excerpt);
Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start);
Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address);
Assert.Equal("https://meet.example/repair", note.Event.OnlineURL);
Assert.Equal(40, note.Event.Capacity);
Assert.Equal("CONFIRMED", note.Event.Status);
Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event));
}
[Fact]
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
{
var note = Parse("""
{
"id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo",
"attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }],
"image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" },
"language": { "identifier": "en", "name": "English" }
}
""");
Assert.Equal("https://news.example/story", note.Link.Href);
Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL);
Assert.False(note.Link.FromPublisher);
Assert.Empty(note.Attachments);
}
[Fact]
public void Takes_a_publisher_supplied_preview_from_a_link_attachment()
{
var note = Parse("""
{
"id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "<p>look</p>",
"attachment": [{ "type": "Link", "href": "https://news.example/a",
"preview": { "type": "Article", "name": "Headline &amp; more", "summary": "<p>What happened</p>", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }]
}
""");
Assert.Equal("Headline & more", note.Link.Title);
Assert.Equal("What happened", note.Link.Description);
Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL);
Assert.True(note.Link.FromPublisher);
}
[Fact]
public void Reads_media_in_every_shape_and_infers_what_is_missing()
{
var note = Parse("""
{
"id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x",
"content": "<p>pics</p>",
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }],
"attachment": [
{ "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" },
{ "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }],
"icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true },
{ "type": "Image", "url": "https://cdn.example/nameless" }
]
}
""");
Assert.Equal("de", note.Language);
Assert.Equal("image/jpeg", note.Attachments[0].ContentType);
Assert.Equal("alt from summary", note.Attachments[0].Description);
Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL);
Assert.Equal("video/mp4", note.Attachments[1].ContentType);
Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL);
Assert.Equal(12.4, note.Attachments[1].DurationSeconds);
Assert.True(note.Attachments[1].Sensitive);
Assert.Equal("image/*", note.Attachments[2].ContentType);
}
[Fact]
public void Keeps_a_funkwhale_track_and_a_loops_poster()
{
var track = Parse("""
{
"id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band",
"summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/",
"url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" },
{ "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }]
}
""");
var loop = Parse("""
{
"id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "",
"preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } },
"attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }]
}
""");
Assert.Equal("#rock #live", track.Excerpt);
Assert.Null(track.SpoilerText);
Assert.Equal(241, track.Audio.DurationSeconds);
Assert.Equal(3, track.Audio.Position);
Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url);
Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
}
[Fact]
public void Normalises_hashtags_like_mastodon() =>
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
{ "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "",
"tag": [{ "type": "Hashtag", "name": "#FEDI" }] }
""").Tags));
}
}
@@ -0,0 +1,85 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class ProvenanceTests : 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();
}
[Fact]
public async Task A_delivered_post_keeps_its_raw_object_how_it_came_and_every_edit()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var origin = new Uri(mallory.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = mallory.Id,
["content"] = "<p>hi</p>",
["_misskey_quote"] = $"{origin}/notes/elsewhere",
["to"] = new JsonArray(Addressing.Public),
["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
};
var create = new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = note.DeepClone() };
await _harness.Deliver(mallory, "/human-centipede", create);
var edited = note.DeepClone().AsObject();
edited["content"] = "<p>hi again</p>";
edited["updated"] = DateTime.UtcNow.ToString("O");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = edited
});
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == note["id"]!.GetValue<string>()).ExecuteFirstAsync(token);
Assert.NotNull(record);
Assert.Equal(ObjectPath.Delivered, record.Path);
Assert.False(record.Refetched);
Assert.Equal("shared", record.Inbox);
Assert.Equal(mallory.KeyId, record.KeyId);
Assert.Equal(create["id"]!.GetValue<string>(), record.ActivityId);
Assert.Contains("digest", record.SignedHeaders);
Assert.Equal("<p>hi</p>", JsonNode.Parse(record.Raw)!["content"]!.GetValue<string>());
Assert.Contains("legacy-quote", ObjectFeatures.Detect(JsonNode.Parse(record.Raw)!.AsObject()));
Assert.Equal("<p>hi again</p>", JsonNode.Parse(Assert.Single(record.Revisions).Raw)!["content"]!.GetValue<string>());
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == record.Host).ExecuteAnyAsync(token));
}
[Fact]
public void An_object_larger_than_the_cap_keeps_only_its_hash()
{
var big = new JsonObject { ["content"] = new string('x', ObjectRecords.MaxRawBytes + 1) };
var (text, hash, bytes, truncated) = ObjectRecords.Capture(big);
Assert.Null(text);
Assert.True(truncated);
Assert.True(bytes > ObjectRecords.MaxRawBytes);
Assert.Equal(64, hash.Length);
}
}
}
+8 -3
View File
@@ -16,6 +16,7 @@ using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.User;
@@ -42,7 +43,8 @@ namespace PrivaPub.Tests.Support
Delivery = new DeliveryService(Db, Queue);
Fanout = new Fanout(Db);
Groups = new GroupDistributor(Delivery);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
Records = new ObjectRecords(Queue);
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
Processor = new InboxProcessor(Remote, new IActivityHandler[]
{
@@ -51,10 +53,12 @@ namespace PrivaPub.Tests.Support
new RejectHandler(Db, Local),
new UndoHandler(Db, Local),
new LikeHandler(Db),
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
new UpdateHandler(Db, Local, Remote, Groups),
new UpdateHandler(Db, Local, Remote, Groups, Records),
new FlagHandler(Db, Local)
}, NullLogger<InboxProcessor>.Instance);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
@@ -86,6 +90,7 @@ namespace PrivaPub.Tests.Support
public GroupDistributor Groups { get; }
public Fanout Fanout { get; }
public RemotePosts RemotePosts { get; }
public ObjectRecords Records { get; }
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public ReportService Reports { get; }
@@ -0,0 +1,181 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Api.Mastodon.Controllers
{
public class ProvenanceController : MastodonController
{
readonly DbEntities _dbEntities;
public ProvenanceController(DbEntities dbEntities) => _dbEntities = dbEntities;
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Status(string id, CancellationToken token)
{
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post?.ReblogOfPostId != default)
post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token))
return NotFoundError();
if (!post.IsFederatedCopy)
return Json(new Provenance { ObjectUri = post.ObjectURI, Url = post.Url, Path = "local" });
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.PostId == post.ID).ExecuteFirstAsync(token)
?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token);
var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default);
var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return Json(Describe(post, record, instance));
}
[HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous]
public async Task<IActionResult> Instance(string host, CancellationToken token)
{
host = host?.Trim().ToLowerInvariant();
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
return instance == default ? NotFoundError() : Json(Describe(instance));
}
static Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance)
{
var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
return new Provenance
{
ObjectUri = post.ObjectURI,
ObjectType = record?.ObjectType ?? post.ObjectType,
Url = post.Url,
Path = record == default ? "unrecorded" : record.Path == ObjectPath.Delivered ? "delivered" : "fetched",
Refetched = record?.Refetched == true,
Activity = record?.ActivityId == default ? default : new ProvenanceActivity
{
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
},
Inbox = record?.Inbox,
Signature = record?.KeyId == default ? default : new ProvenanceSignature
{
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
},
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
Extensions = ObjectFeatures.Detect(raw),
ContextNamespaces = ObjectFeatures.Namespaces(raw, record?.ActivityContext),
Raw = raw,
RawBytes = record?.RawBytes ?? 0,
RawHash = record?.RawHash,
RawTruncated = record?.RawTruncated == true,
Revisions = record?.Revisions.Select(r => new ProvenanceRevision
{
ActivityId = r.ActivityId,
Updated = r.Updated is { } at ? MastodonJson.Time(at) : default,
ReceivedAt = MastodonJson.Time(r.ReceivedAt),
RawHash = r.RawHash,
Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw)
}).ToList() ?? new(),
Instance = instance == default ? default : Describe(instance)
};
}
static InstanceDescription Describe(RemoteInstance instance) => new()
{
Host = instance.Host,
Software = instance.Software,
Version = instance.SoftwareVersion,
NodeName = instance.NodeName,
Protocols = instance.Protocols ?? new(),
OpenRegistrations = instance.OpenRegistrations,
DescribedAt = instance.DescribedAt is { } described ? MastodonJson.Time(described) : default,
DescriptionError = instance.DescriptionError,
NodeInfo = instance.NodeInfo == default ? default : JsonNode.Parse(instance.NodeInfo),
Delivery = new InstanceDelivery
{
ConsecutiveFailures = instance.ConsecutiveFailures,
UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default,
LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default,
LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default
}
};
public class Provenance
{
public string ObjectUri { get; set; }
public string ObjectType { get; set; }
public string Url { get; set; }
public string Path { get; set; }
public bool Refetched { get; set; }
public ProvenanceActivity Activity { get; set; }
public string Inbox { get; set; }
public ProvenanceSignature Signature { get; set; }
public string ReceivedAt { get; set; }
public string Published { get; set; }
public string Updated { get; set; }
public List<string> Extensions { get; set; } = new();
public List<string> ContextNamespaces { get; set; } = new();
public JsonNode Raw { get; set; }
public int RawBytes { get; set; }
public string RawHash { get; set; }
public bool RawTruncated { get; set; }
public List<ProvenanceRevision> Revisions { get; set; } = new();
public InstanceDescription Instance { get; set; }
}
public class ProvenanceActivity
{
public string Id { get; set; }
public string Type { get; set; }
public string Actor { get; set; }
}
public class ProvenanceSignature
{
public string Scheme { get; set; }
public string KeyId { get; set; }
public string Algorithm { get; set; }
public List<string> Headers { get; set; } = new();
}
public class ProvenanceRevision
{
public string ActivityId { get; set; }
public string Updated { get; set; }
public string ReceivedAt { get; set; }
public string RawHash { get; set; }
public JsonNode Raw { get; set; }
}
public class InstanceDescription
{
public string Host { get; set; }
public string Software { get; set; }
public string Version { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new();
public bool? OpenRegistrations { get; set; }
public string DescribedAt { get; set; }
public string DescriptionError { get; set; }
public JsonNode NodeInfo { get; set; }
public InstanceDelivery Delivery { get; set; }
}
public class InstanceDelivery
{
public int ConsecutiveFailures { get; set; }
public string UnavailableUntil { get; set; }
public string LastSuccessAt { get; set; }
public string LastFailureAt { get; set; }
}
}
}
+124 -1
View File
@@ -49,6 +49,128 @@ namespace PrivaPub.Api.Mastodon.Entities
public string AttributionDomains { get; set; }
}
public class PrivaPubStatus
{
public string ObjectType { get; set; }
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
public PrivaPubSource Source { get; set; }
public PrivaPubLink Link { get; set; }
public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubVotes Votes { get; set; } = new();
}
public class PrivaPubSource
{
public string Content { get; set; }
public string MediaType { get; set; }
}
public class PrivaPubLink
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Image { get; set; }
public bool FromPublisher { get; set; }
}
public class PrivaPubVariant
{
public string Url { get; set; }
public string MediaType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public double? Fps { get; set; }
public long? Size { get; set; }
public List<string> Streams { get; set; } = new();
}
public class PrivaPubVideo
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public bool IsLive { get; set; }
public int? State { get; set; }
public long? Views { get; set; }
public int? CommentsPolicy { get; set; }
public bool? DownloadEnabled { get; set; }
public string Licence { get; set; }
public string Category { get; set; }
public string Channel { get; set; }
public List<PrivaPubCaption> Captions { get; set; } = new();
}
public class PrivaPubCaption
{
public string Language { get; set; }
public string Url { get; set; }
}
public class PrivaPubAudio
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public string Album { get; set; }
public int? Position { get; set; }
public string Licence { get; set; }
}
public class PrivaPubEvent
{
public string Start { get; set; }
public string End { get; set; }
public string TimeZone { get; set; }
public bool Floating { get; set; }
public List<PrivaPubPlace> Places { get; set; } = new();
public string OnlineUrl { get; set; }
public string JoinMode { get; set; }
public int? Participants { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
public string Category { get; set; }
}
public class PrivaPubPlace
{
public string Name { get; set; }
public string Address { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Url { get; set; }
}
public class PrivaPubVotes
{
public int Up { get; set; }
public int Down { get; set; }
}
public class PreviewCard
{
public string Url { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = "link";
public string AuthorName { get; set; } = string.Empty;
public string AuthorUrl { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string ProviderUrl { get; set; } = string.Empty;
public string Html { get; set; } = string.Empty;
public int Width { get; set; }
public int Height { get; set; }
public string Image { get; set; }
public string ImageDescription { get; set; } = string.Empty;
public string EmbedUrl { get; set; } = string.Empty;
public string Blurhash { get; set; }
public string Language { get; set; }
public string PublishedAt { get; set; }
public List<object> Authors { get; set; } = new();
}
public class Status
{
public string Id { get; set; }
@@ -76,12 +198,13 @@ namespace PrivaPub.Api.Mastodon.Entities
public List<MediaAttachment> MediaAttachments { get; set; } = new();
public List<StatusMention> Mentions { get; set; } = new();
public List<StatusTag> Tags { get; set; } = new();
public object Card { get; set; }
public PreviewCard Card { get; set; }
public object Poll { get; set; }
public Application Application { get; set; }
public string Language { get; set; }
public string Text { get; set; }
public List<object> Filtered { get; set; } = new();
public PrivaPubStatus Privapub { get; set; }
}
public class MediaAttachment
+149 -8
View File
@@ -12,6 +12,7 @@ using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
@@ -192,6 +193,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
Visibility = Visibility(post.Visibility),
Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(),
Card = Card(post),
Privapub = Extension(post),
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()
};
@@ -227,12 +230,133 @@ namespace PrivaPub.Api.Mastodon.Mappers
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
if (post.Event is { } happening && EventLine(happening) is { } line)
content = $"<p>{WebUtility.HtmlEncode(line)}</p>{content}";
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
return titled
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
: content ?? string.Empty;
}
public static string EventLine(EventDetails happening)
{
if (happening.Start is not { } start)
return default;
var zone = Zone(happening.FloatingTime ? default : happening.TimeZone);
var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone);
var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
if (happening.End is { } end && end > start)
{
var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone);
when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})";
var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default)
?? (happening.OnlineURL != default ? "online" : default);
return where == default ? when : $"{when} · {where}";
}
static TimeZoneInfo Zone(string id)
{
if (string.IsNullOrWhiteSpace(id))
return default;
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
return default;
}
}
PrivaPubStatus Extension(PostEntity post) => new()
{
ObjectType = post.ObjectType,
Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL),
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
Link = post.Link == default ? default : new PrivaPubLink
{
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
},
Video = post.Video == default ? default : new PrivaPubVideo
{
Duration = post.Video.DurationSeconds,
Variants = post.Video.Variants.Select(Variant).ToList(),
IsLive = post.Video.IsLive,
State = post.Video.State,
Views = post.Video.Views,
CommentsPolicy = post.Video.CommentsPolicy,
DownloadEnabled = post.Video.DownloadEnabled,
Licence = post.Video.Licence,
Category = post.Video.Category,
Channel = post.Video.ChannelURI,
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
},
Audio = post.Audio == default ? default : new PrivaPubAudio
{
Duration = post.Audio.DurationSeconds,
Variants = post.Audio.Variants.Select(Variant).ToList(),
Album = post.Audio.Album,
Position = post.Audio.Position,
Licence = post.Audio.Licence
},
Event = post.Event == default ? default : new PrivaPubEvent
{
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
TimeZone = post.Event.TimeZone,
Floating = post.Event.FloatingTime,
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
OnlineUrl = post.Event.OnlineURL,
JoinMode = post.Event.JoinMode,
Participants = post.Event.ParticipantCount,
Capacity = post.Event.Capacity,
Status = post.Event.Status,
Category = post.Event.Category
},
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
};
PrivaPubVariant Variant(MediaVariant variant) => new()
{
Url = Proxied(variant.Url),
MediaType = variant.MediaType,
Width = variant.Width,
Height = variant.Height,
Fps = variant.Fps,
Size = variant.Size,
Streams = variant.Streams
};
PreviewCard Card(PostEntity post)
{
if (!post.IsFederatedCopy)
return default;
if (post.Link is { } link)
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
if (post.ObjectType is "Article" or "Event" or "Video" or "Audio" && post.Url != default && post.Media.Count == 0)
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
return default;
}
PreviewCard Card(string url, string title, string description, string image)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return default;
return new PreviewCard
{
Url = url,
Title = title ?? uri.Host,
Description = description ?? string.Empty,
ProviderName = uri.Host,
ProviderUrl = uri.GetLeftPart(UriPartial.Authority),
Image = Proxied(image)
};
}
public static string Visibility(PostVisibility visibility) => visibility switch
{
PostVisibility.Unlisted => "unlisted",
@@ -253,6 +377,28 @@ namespace PrivaPub.Api.Mastodon.Mappers
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
}
static Dictionary<string, object> Meta(PostMedia media)
{
var original = new Dictionary<string, object>();
if (media.Width > 0 && media.Height > 0)
{
original["width"] = media.Width;
original["height"] = media.Height;
original["size"] = $"{media.Width}x{media.Height}";
original["aspect"] = (double)media.Width.Value / media.Height.Value;
}
if (media.DurationSeconds is { } seconds)
original["duration"] = seconds;
var meta = new Dictionary<string, object>();
if (original.Count > 0)
meta["original"] = original;
if (media.DurationSeconds is { } length)
meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture);
if (media.Focus is { Length: 2 })
meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] };
return meta;
}
MediaAttachment Media(PostMedia media) => new()
{
Id = media.AttachmentId ?? media.Id.ToString("N"),
@@ -265,17 +411,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
_ => "unknown"
},
Url = media.URL ?? Proxied(media.RemoteURL),
PreviewUrl = media.PreviewURL ?? media.URL ?? Proxied(media.RemoteURL),
PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL)
?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default),
RemoteUrl = media.RemoteURL,
Description = media.Description,
Blurhash = media.Blurhash,
Meta = new
{
original = media.Width.HasValue && media.Height > 0
? new { width = media.Width, height = media.Height, size = $"{media.Width}x{media.Height}", aspect = (double)media.Width / media.Height.Value }
: default,
focus = media.Focus is { Length: 2 } ? new { x = media.Focus[0], y = media.Focus[1] } : default
}
Meta = Meta(media)
};
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace PrivaPub.Federation.Inbox
{
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm,
IReadOnlyList<string> SignedHeaders, DateTime ReceivedAt, string Context = default)
{
static readonly AsyncLocal<Arrival> current = new();
public static Arrival Current
{
get => current.Value;
set => current.Value = value;
}
}
}
@@ -34,10 +34,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records)
{
_records = records;
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
@@ -53,7 +55,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
{
var node = activity["object"];
if (node is not JsonObject || !Origin.Same(Id(node), author.ActorURI))
var refetched = node is not JsonObject || !Origin.Same(Id(node), author.ActorURI);
if (refetched)
{
using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
@@ -61,7 +64,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
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))
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))
return;
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
@@ -131,6 +135,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
return;
}
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
@@ -65,10 +65,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
return;
}
await DB.Default.Update<DeletedObject>()
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default)
return;
await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
@@ -0,0 +1,47 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class DislikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
public DislikeHandler(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public string Type => "Dislike";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor))
return;
try
{
await DB.Default.SaveAsync(new Downvote { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
}
}
}
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class JoinHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public JoinHandler(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public string Type => "Join";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
return;
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = owner.ActivityUri($"ignore-{Guid.NewGuid():N}"),
["type"] = "Ignore",
["actor"] = owner.Uri,
["object"] = activity.DeepClone()
}, token);
}
}
}
@@ -40,6 +40,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike")
await UndoDislike(inner, innerId, actor, token);
}
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var downvote = innerId == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.ActivityURI == innerId && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (downvote == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
downvote = post == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
}
if (downvote == default)
return;
await DB.Default.DeleteAsync<Downvote>(downvote.ID);
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
}
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
@@ -23,9 +23,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
readonly IObjectRecords _records;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups,
IObjectRecords records)
{
_records = records;
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
@@ -73,6 +76,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.Title = note.Title;
post.SpoilerText = note.SpoilerText;
post.Excerpt = note.Excerpt;
post.Source = note.Source;
post.CoverURL = note.CoverURL;
post.Link = note.Link;
post.Video = note.Video;
post.Audio = note.Audio;
post.Event = note.Event;
post.HasContentWarning = note.Sensitive;
post.Text = note.ContentHtml;
post.ContentHtml = note.ContentHtml;
@@ -83,6 +92,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
}
+10 -1
View File
@@ -46,7 +46,16 @@ namespace PrivaPub.Federation.Inbox
if (actor == default)
return JobOutcome.Retry("the actor could not be loaded");
await handler.Handle(activity, actor, token);
Arrival.Current = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
try
{
await handler.Handle(activity, actor, token);
}
finally
{
Arrival.Current = default;
}
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done;
}
+5 -2
View File
@@ -15,7 +15,8 @@ namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default);
public sealed record InboxPayload(string ActorURI, string Activity);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
public interface IInboxReceiver
{
@@ -99,7 +100,9 @@ namespace PrivaPub.Federation.Inbox
return shapeProblem;
var activityId = Id(activity);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(new InboxPayload(actorUri, activity.ToJsonString())),
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
parameters.Algorithm, parameters.Headers, DateTime.UtcNow);
await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload),
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted);
+12 -2
View File
@@ -6,6 +6,7 @@ using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Ids;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
@@ -37,10 +38,12 @@ namespace PrivaPub.Federation.Inbox
readonly IRemoteActorService _remoteActors;
readonly IDomainBlocks _domainBlocks;
readonly IJobQueue _queue;
readonly IObjectRecords _records;
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
IJobQueue queue)
IJobQueue queue, IObjectRecords records)
{
_records = records;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -74,6 +77,12 @@ namespace PrivaPub.Federation.Inbox
Title = note.Title,
SpoilerText = note.SpoilerText,
Excerpt = note.Excerpt,
Source = note.Source,
CoverURL = note.CoverURL,
Link = note.Link,
Video = note.Video,
Audio = note.Audio,
Event = note.Event,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
@@ -105,7 +114,7 @@ namespace PrivaPub.Federation.Inbox
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
{
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
if (existing != default || depth > MaxDepth)
if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
return existing;
using var fetched = await _remoteActors.FetchObject(objectUri, token);
@@ -131,6 +140,7 @@ namespace PrivaPub.Federation.Inbox
{
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
}
await _records.Record(note, post, ObjectPath.Fetched, refetched: false, token);
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
@@ -0,0 +1,84 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using System.Text.Json;
namespace PrivaPub.Federation.Objects
{
public class InstanceDescriber : IJobHandler
{
const int MaxNodeInfoChars = 64 * 1024;
static readonly string[] Schemas =
{
"http://nodeinfo.diaspora.software/ns/schema/2.1",
"http://nodeinfo.diaspora.software/ns/schema/2.0"
};
readonly IFederationHttp _http;
public InstanceDescriber(IFederationHttp http) => _http = http;
public JobKind Kind => JobKind.DescribeInstance;
public int Concurrency => 1;
public int MaxAttempts => 2;
public int PerHostLimit => 1;
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{
var host = job.Payload;
using var links = await _http.GetJson($"https://{host}/.well-known/nodeinfo", "application/json", sign: default, token);
var href = links == default ? default : Schemas.Select(schema => LinkFor(links.Root, schema)).FirstOrDefault(link => link != default);
if (href == default)
return await Described(host, default, "no NodeInfo", token);
using var nodeInfo = await _http.GetJson(href, "application/json", sign: default, token);
return nodeInfo == default ? await Described(host, default, "NodeInfo unreadable", token) : await Described(host, nodeInfo.Root, default, token);
}
static async Task<JobOutcome> Described(string host, JsonElement? nodeInfo, string error, CancellationToken token)
{
var update = DB.Default.Update<RemoteInstance>()
.Match(i => i.Host == host)
.Modify(i => i.DescribedAt, DateTime.UtcNow)
.Modify(i => i.DescriptionError, error)
.Option(o => o.IsUpsert = true);
if (nodeInfo is { } root)
{
var raw = root.GetRawText();
update = update
.Modify(i => i.Software, Text(root, "software", "name"))
.Modify(i => i.SoftwareVersion, Text(root, "software", "version"))
.Modify(i => i.NodeName, Text(root, "metadata", "nodeName"))
.Modify(i => i.Protocols, root.TryGetProperty("protocols", out var protocols) && protocols.ValueKind == JsonValueKind.Array
? protocols.EnumerateArray().Where(p => p.ValueKind == JsonValueKind.String).Select(p => p.GetString()).Take(16).ToList()
: new List<string>())
.Modify(i => i.OpenRegistrations, root.TryGetProperty("openRegistrations", out var open) && open.ValueKind is JsonValueKind.True or JsonValueKind.False
? open.GetBoolean()
: default(bool?))
.Modify(i => i.NodeInfo, raw.Length <= MaxNodeInfoChars ? raw : default);
}
await update.ExecuteAsync(token);
return JobOutcome.Done;
}
static string LinkFor(JsonElement root, string schema) =>
root.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array
? links.EnumerateArray()
.Where(l => l.ValueKind == JsonValueKind.Object && l.TryGetProperty("rel", out var rel) && rel.ValueKind == JsonValueKind.String && rel.GetString() == schema)
.Select(l => l.TryGetProperty("href", out var href) && href.ValueKind == JsonValueKind.String ? href.GetString() : default)
.FirstOrDefault(h => h != default && h.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
: default;
static string Text(JsonElement root, string parent, string name)
{
if (!root.TryGetProperty(parent, out var section) || section.ValueKind != JsonValueKind.Object
|| !section.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String)
return default;
var text = value.GetString();
return text.Length <= 200 ? text : text[..200];
}
}
}
+50 -27
View File
@@ -1,6 +1,7 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
@@ -11,6 +12,7 @@ namespace PrivaPub.Federation.Objects
{
public string Id { get; init; }
public string Type { get; init; }
public JsonObject Raw { get; init; }
public string AttributedTo { get; init; }
public string ContentHtml { get; init; }
public string Title { get; init; }
@@ -30,11 +32,17 @@ namespace PrivaPub.Federation.Objects
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 PostSource Source { get; init; }
public string CoverURL { get; init; }
public PostLink Link { get; init; }
public VideoDetails Video { get; init; }
public AudioDetails Audio { get; init; }
public EventDetails Event { get; init; }
}
public static class NoteParser
{
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
const int MaxAttachments = 16;
const int MaxTags = 64;
@@ -49,17 +57,20 @@ namespace PrivaPub.Federation.Objects
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
var excerpt = ObjectShapes.Text(summary, 1000)
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
var summaryWarns = type is "Note" or "Question" || sensitive;
return new NoteDocument
{
Id = id,
Type = type,
Raw = note,
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = Plain(Value(note, "name"), 500),
Title = ObjectShapes.Text(Value(note, "name"), 500),
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
Excerpt = summaryWarns ? default : Plain(summary, 1000),
Excerpt = summaryWarns ? default : excerpt,
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
@@ -76,12 +87,18 @@ namespace PrivaPub.Federation.Objects
.DistinctBy(m => m.ActorURI)
.Take(MaxTags)
.ToList(),
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').ToLowerInvariant())
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.Take(MaxTags)
.ToList(),
Attachments = Attachments(note)
Attachments = Attachments(note),
Source = ObjectShapes.Source(note),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note),
Audio = ObjectShapes.Audio(note),
Event = ObjectShapes.Event(note)
};
}
@@ -98,7 +115,9 @@ namespace PrivaPub.Federation.Objects
}
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
return (content, language);
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
content = Rendering.ActivityPubRenderer.Html(content);
return (content, language ?? ObjectShapes.Language(note));
}
static string Attribution(JsonNode node) => node switch
@@ -118,33 +137,37 @@ namespace PrivaPub.Federation.Objects
_ => 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
static List<PostMedia> Attachments(JsonObject note) =>
ObjectShapes.Objects(note["attachment"])
.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
})
.Select(Attachment)
.Where(m => Origin.Of(m.RemoteURL) != default)
.Take(MaxAttachments)
.ToList();
static PostMedia Attachment(JsonObject attachment)
{
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
return new PostMedia
{
RemoteURL = url,
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
Blurhash = Value(attachment, "blurhash"),
Width = Int(attachment["width"]),
Height = Int(attachment["height"]),
Focus = attachment["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
};
}
static string FirstOf(JsonNode map) =>
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
static string UrlOf(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
@@ -0,0 +1,68 @@
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static class ObjectFeatures
{
public static List<string> Detect(JsonObject raw)
{
var found = new List<string>();
if (raw == default)
return found;
void When(bool condition, string feature)
{
if (condition)
found.Add(feature);
}
When(raw.ContainsKey("quote"), "fep-044f-quote");
When(raw.ContainsKey("quoteAuthorization"), "fep-044f-authorization");
When(raw.ContainsKey("_misskey_quote") || raw.ContainsKey("quoteUrl") || raw.ContainsKey("quoteUri"), "legacy-quote");
When(raw.ContainsKey("interactionPolicy"), "interaction-policy");
When(raw.ContainsKey("context"), "context");
When(raw.ContainsKey("proof"), "fep-8b32-proof");
When(raw.Any(p => p.Key.StartsWith("_misskey_", StringComparison.Ordinal)), "misskey");
When(raw["source"] is JsonObject, "source:" + (Value(raw["source"], "mediaType") ?? "unknown"));
When(raw["source"] is JsonValue, "source:url");
When(raw.ContainsKey("htmlMfm"), "fep-c16b-mfm");
When(raw.ContainsKey("formerRepresentations"), "former-representations");
When(raw.ContainsKey("searchableBy"), "fep-268d-searchable-by");
When(raw.ContainsKey("audience"), "fep-1b12-audience");
When(Items(raw["attachment"]).Any(a => Value(a, "type") == "Link"), "fep-8967-link");
When(Items(raw["tag"]).Any(t => Value(t, "type") == "Emoji"), "custom-emoji");
When(Value(raw, "type") == "Question", "poll");
When(raw["contentMap"] is JsonObject, "content-map");
When(raw["url"] is JsonArray, "url-variants");
var contentType = Value(raw, "mediaType");
When(contentType is not (null or "text/html"), "content:" + contentType);
return found;
}
public static List<string> Namespaces(JsonObject raw, string activityContext = default) =>
Namespaces(raw?["@context"] ?? (activityContext == default ? default : JsonNode.Parse(activityContext)));
static List<string> Namespaces(JsonNode context) => context switch
{
JsonValue single when single.TryGetValue<string>(out var url) => new List<string> { url },
JsonArray array => array.SelectMany(entry => entry switch
{
JsonValue value when value.TryGetValue<string>(out var url) => new[] { url },
JsonObject terms => terms.Where(t => t.Value is JsonValue v && v.TryGetValue<string>(out var target) && target.EndsWith('#'))
.Select(t => t.Key + "=" + t.Value!.GetValue<string>()),
_ => Enumerable.Empty<string>()
})
.Take(32)
.ToList(),
_ => new List<string>()
};
static IEnumerable<JsonObject> Items(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
}
}
@@ -0,0 +1,112 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Objects
{
public interface IObjectRecords
{
Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token);
Task Revise(NoteDocument note, string activityId, CancellationToken token);
}
public class ObjectRecords : IObjectRecords
{
public const int MaxRawBytes = 256 * 1024;
const int MaxRevisions = 10;
static readonly TimeSpan DescriptionAge = TimeSpan.FromDays(7);
readonly IJobQueue _queue;
public ObjectRecords(IJobQueue queue) => _queue = queue;
public async Task Record(NoteDocument note, PostEntity post, ObjectPath path, bool refetched, CancellationToken token)
{
var arrival = Arrival.Current;
var raw = Capture(note.Raw);
var host = new Uri(note.Id).Host.ToLowerInvariant();
var record = new ObjectRecord
{
ObjectURI = note.Id,
PostId = post.ID,
ObjectType = note.Type,
Host = host,
Raw = raw.Text,
RawHash = raw.Hash,
RawBytes = raw.Bytes,
RawTruncated = raw.Truncated,
ActivityContext = note.Raw?.ContainsKey("@context") == true || arrival?.Context?.Length > 16 * 1024 ? default : arrival?.Context,
Path = path,
Refetched = refetched,
ActivityId = arrival?.ActivityId,
ActivityType = arrival?.ActivityType,
ActivityActorURI = arrival?.ActorURI,
Inbox = path == ObjectPath.Delivered ? arrival?.Inbox : default,
SignatureScheme = arrival?.KeyId == default ? default : "draft-cavage",
KeyId = arrival?.KeyId,
Algorithm = arrival?.Algorithm,
SignedHeaders = arrival?.SignedHeaders?.ToList() ?? new(),
ReceivedAt = arrival?.ReceivedAt ?? DateTime.UtcNow,
Published = note.Published,
Updated = note.Updated
};
try
{
await DB.Default.SaveAsync(record, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await Describe(host, token);
}
public async Task Revise(NoteDocument note, string activityId, CancellationToken token)
{
var raw = Capture(note.Raw);
var revision = new ObjectRevision
{
Raw = raw.Text,
RawHash = raw.Hash,
RawTruncated = raw.Truncated,
ActivityId = activityId,
Updated = note.Updated
};
await DB.Default.Update<ObjectRecord>()
.Match(r => r.ObjectURI == note.Id)
.Modify(b => b.PushEach(r => r.Revisions, new[] { revision }, -MaxRevisions))
.Modify(r => r.Updated, note.Updated)
.ExecuteAsync(token);
}
async Task Describe(string host, CancellationToken token)
{
var known = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
if (known?.DescribedAt > DateTime.UtcNow - DescriptionAge)
return;
var week = ISOWeek.GetYear(DateTime.UtcNow) + "-" + ISOWeek.GetWeekOfYear(DateTime.UtcNow);
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|{week}", token);
}
public static (string Text, string Hash, int Bytes, bool Truncated) Capture(JsonNode raw)
{
if (raw == default)
return default;
var text = raw.ToJsonString();
var bytes = Encoding.UTF8.GetBytes(text);
var hash = Convert.ToHexStringLower(SHA256.HashData(bytes));
return bytes.Length > MaxRawBytes ? (default, hash, bytes.Length, true) : (text, hash, bytes.Length, false);
}
}
}
+293
View File
@@ -0,0 +1,293 @@
using PrivaPub.Models.Post;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static partial class ObjectShapes
{
const int MaxVariants = 20;
const int MaxCaptions = 20;
const int MaxPlaces = 4;
const int CoverMaxWidth = 1280;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".heic"] = "image/heic", [".svg"] = "image/svg+xml",
[".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".mov"] = "video/quicktime", [".mkv"] = "video/x-matroska",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/opus", [".m4a"] = "audio/mp4",
[".flac"] = "audio/flac", [".wav"] = "audio/wav", [".m3u8"] = "application/x-mpegURL"
};
public static IEnumerable<JsonObject> Objects(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
public static (string Url, string MediaType) MediaLink(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<string>(out var text):
return (text, default);
case JsonObject link when Value(link, "href") is { } href:
return (href, Value(link, "mediaType"));
case JsonObject wrapper when wrapper["url"] is { } inner:
var (url, mediaType) = MediaLink(inner);
return (url, mediaType ?? Value(wrapper, "mediaType"));
case JsonArray array:
var links = array.Select(MediaLink).Where(l => l.Url != default).ToList();
return links.FirstOrDefault(l => l.MediaType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) != true);
default:
return default;
}
}
public static string InferMediaType(string declared, string url, string objectType)
{
if (!string.IsNullOrWhiteSpace(declared) && declared.Contains('/'))
return declared.Trim();
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && MediaTypesByExtension.TryGetValue(Path.GetExtension(uri.AbsolutePath), out var byExtension))
return byExtension;
return objectType switch
{
"Image" => "image/*",
"Video" => "video/*",
"Audio" => "audio/*",
_ => default
};
}
public static string Image(JsonNode node)
{
var candidates = node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => new[] { (Url: text, Width: (int?)default) },
JsonArray array => array.Select(item => (Url: MediaLink(item).Url, Width: Int(item as JsonObject, "width"))).ToArray(),
JsonObject single => new[] { (Url: MediaLink(single).Url, Width: Int(single, "width")) },
_ => Array.Empty<(string Url, int? Width)>()
};
var usable = candidates.Where(c => Origin.Of(c.Url) != default).ToList();
return usable.Where(c => c.Width <= CoverMaxWidth).OrderByDescending(c => c.Width).Select(c => c.Url).FirstOrDefault()
?? usable.Select(c => c.Url).FirstOrDefault();
}
public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
public static double? Seconds(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<double>(out var number):
return number >= 0 && number < 1e7 ? number : default;
case JsonValue value when value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text):
try
{
var span = XmlConvert.ToTimeSpan(text.Trim());
return span >= TimeSpan.Zero ? span.TotalSeconds : default;
}
catch (FormatException)
{
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 ? seconds : default;
}
default:
return default;
}
}
public static string Text(string html, int max)
{
if (string.IsNullOrWhiteSpace(html))
return default;
var text = WebUtility.HtmlDecode(Tags().Replace(html, " "));
text = Spaces().Replace(text, " ").Trim();
if (text.Length == 0)
return default;
return text.Length <= max ? text : text[..max];
}
public static string Language(JsonObject note) =>
note["@context"] is JsonArray context
? context.OfType<JsonObject>().Select(entry => Value(entry, "@language")).FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
: default;
public static PostSource Source(JsonObject note) => note["source"] is JsonObject source && Value(source, "content") is { } content
? new PostSource { Content = content.Length <= 64 * 1024 ? content : content[..(64 * 1024)], MediaType = Value(source, "mediaType") }
: default;
public static PostLink Link(JsonObject note)
{
var attached = Objects(note["attachment"]).FirstOrDefault(a => Value(a, "type") == "Link" && IsWeb(Value(a, "href")));
if (attached != default)
{
var preview = attached["preview"] as JsonObject;
return new PostLink
{
Href = Value(attached, "href"),
MediaType = Value(attached, "mediaType"),
Title = Text(Value(preview, "name") ?? Value(attached, "name"), 300),
Description = Text(Value(preview, "summary") ?? Value(preview, "content"), 1000),
ImageURL = preview == default ? Image(note["image"]) : Image(preview["image"]) ?? Image(preview["icon"]),
FromPublisher = preview != default
};
}
if (note["source"] is JsonValue source && source.TryGetValue<string>(out var sourceUrl) && IsWeb(sourceUrl))
return new PostLink { Href = sourceUrl, ImageURL = Image(note["image"]) };
return default;
}
public static VideoDetails Video(JsonObject note)
{
if (Value(note, "type") != "Video")
return default;
var links = Objects(note["url"]).ToList();
return new VideoDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links, "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true,
State = Int(note, "state"),
Views = Long(note, "views"),
CommentsPolicy = Int(note, "commentsPolicy"),
DownloadEnabled = Bool(note, "downloadEnabled"),
Licence = Named(note["licence"] ?? note["license"]),
Category = Named(note["category"]),
ChannelURI = Objects(note["attributedTo"]).Where(a => Value(a, "type") == "Group").Select(a => Id(a)).FirstOrDefault(),
EmbedURL = Value(note, "embedUrl") is { } embed && IsWeb(embed) ? embed : default,
ChaptersURL = Value(note, "hasParts") is { } chapters && IsWeb(chapters) ? chapters : default,
Captions = Objects(note["subtitleLanguage"])
.Select(c => new Caption
{
Language = Value(c, "identifier"),
Url = Objects(c["url"]).Where(u => Value(u, "mediaType") == "text/vtt").Select(u => Value(u, "href")).FirstOrDefault(IsWeb)
?? (Value(c, "url") is { } plain && IsWeb(plain) ? plain : default)
})
.Where(c => c.Url != default)
.Take(MaxCaptions)
.ToList()
};
}
public static AudioDetails Audio(JsonObject note)
{
if (Value(note, "type") != "Audio")
return default;
return new AudioDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(Objects(note["url"]).ToList(), "audio/"),
Album = Named(note["album"]),
Position = Int(note, "position"),
Licence = Named(note["license"] ?? note["licence"])
};
}
public static EventDetails Event(JsonObject note)
{
if (Value(note, "type") != "Event")
return default;
var start = Value(note, "startTime");
var places = Objects(note["location"]).Select(Place).Where(p => p != default).Take(MaxPlaces).ToList();
return new EventDetails
{
Start = Moment(start),
End = Moment(Value(note, "endTime")),
TimeZone = Value(note, "timezone"),
FloatingTime = start?.EndsWith("-00:00", StringComparison.Ordinal) == true,
Places = places,
OnlineURL = places.Select(p => p.Url).FirstOrDefault(u => u != default)
?? Objects(note["attachment"]).Where(a => Value(a, "type") == "Link" && Value(a, "name") == "Website").Select(a => Value(a, "href")).FirstOrDefault(IsWeb),
JoinMode = Value(note, "joinMode"),
ParticipantCount = Int(note, "participantCount"),
Capacity = Int(note, "maximumAttendeeCapacity"),
Status = Value(note, "ical:status") ?? Value(note, "eventStatus") ?? Value(note, "status"),
Category = Named(note["category"])
};
}
static EventPlace Place(JsonObject location)
{
var type = Value(location, "type");
if (type == "VirtualLocation")
return Value(location, "url") is { } url && IsWeb(url) ? new EventPlace { Name = Text(Value(location, "name"), 200), Url = url } : default;
var address = location["address"] switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject postal => string.Join(", ", new[] { "streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry" }
.Select(field => Value(postal, field)).Where(part => !string.IsNullOrWhiteSpace(part))),
_ => default
};
var place = new EventPlace
{
Name = Text(Value(location, "name") ?? Value(location, "content"), 200),
Address = Text(address, 300),
Latitude = Double(location, "latitude"),
Longitude = Double(location, "longitude")
};
return place.Name == default && place.Address == default && place.Latitude == default ? default : place;
}
static List<MediaVariant> Variants(List<JsonObject> links, params string[] prefixes) =>
links.Where(l => Value(l, "mediaType") is { } type && prefixes.Any(p => type.StartsWith(p, StringComparison.OrdinalIgnoreCase)) && IsWeb(Value(l, "href")))
.Select(l => new MediaVariant
{
Url = Value(l, "href"),
MediaType = Value(l, "mediaType"),
Width = Int(l, "width"),
Height = Int(l, "height"),
Fps = Double(l, "fps"),
Size = Long(l, "size"),
Streams = Objects(l["attachment"])
.Where(p => Value(p, "name") == "ffprobe_codec_type")
.Select(p => Value(p, "value"))
.Where(v => v is "audio" or "video")
.Distinct()
.ToList()
})
.Take(MaxVariants)
.ToList();
static string Named(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => Text(text, 200),
JsonObject named => Text(Value(named, "name") ?? Value(named, "identifier"), 200),
_ => default
};
static DateTime? Moment(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.UtcDateTime : (DateTime?)null;
static bool IsWeb(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
static int? Int(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < int.MaxValue ? (int)number : (int?)null;
static long? Long(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < long.MaxValue ? (long)number : (long?)null;
static double? Double(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && double.IsFinite(number) ? number : (double?)null;
static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex("<[^>]*>")]
private static partial Regex Tags();
[GeneratedRegex(@"\s+")]
private static partial Regex Spaces();
}
}
@@ -123,12 +123,13 @@ namespace PrivaPub.Federation.Outbox
await _breaker.Succeeded(job.Host, token);
return JobOutcome.Done;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
if (response.StatusCode == HttpStatusCode.TooManyRequests
|| response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.RetryAfter != default)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
return retryAfter > TimeSpan.Zero
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429")
: JobOutcome.Retry("429");
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), $"{status}")
: JobOutcome.Retry($"{status}");
}
if (status is >= 400 and < 500 && status != 408)
{
+8
View File
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
{
public static class Indexes
{
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static async Task Create(CancellationToken token = default)
{
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
@@ -102,6 +104,12 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
await Unique<DomainBlock>(b => b.Domain, Builders<DomainBlock>.Filter.Type(b => b.Domain, BsonType.String), token);
await Unique<RemoteInstance>(i => i.Host, Builders<RemoteInstance>.Filter.Type(i => i.Host, BsonType.String), token);
await Unique<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
await Plain<ObjectRecord>(token, r => r.PostId);
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Type(d => d.ObjectURI, BsonType.String), 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 Plain<Downvote>(token, d => d.ActivityURI);
}
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
@@ -17,6 +17,7 @@ using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Domain.Content;
@@ -75,6 +76,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, JoinHandler>()
.AddSingleton<IActivityHandler, AnnounceHandler>()
.AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
@@ -82,6 +85,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, UpdateHandler>()
.AddSingleton<IJobHandler, InboxProcessor>()
.AddSingleton<IRemotePosts, RemotePosts>()
.AddSingleton<IObjectRecords, ObjectRecords>()
.AddSingleton<IJobHandler, InstanceDescriber>()
.AddSingleton<IJobHandler, AncestorsJobHandler>()
.AddSingleton<IJobQueue, JobQueue>()
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
@@ -0,0 +1,10 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class DeletedObject : Entity
{
public string ObjectURI { get; set; }
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;//expires after Indexes.TombstoneLifetime
}
}
@@ -0,0 +1,50 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class ObjectRecord : Entity
{
public string ObjectURI { get; set; }
public string PostId { get; set; }
public string ObjectType { get; set; }
public string Host { get; set; }
public string Raw { get; set; }//the object exactly as it reached us, unless it was larger than MaxRawBytes
public string RawHash { get; set; }//sha-256 of the raw bytes, hex
public int RawBytes { get; set; }
public bool RawTruncated { get; set; }
public string ActivityContext { get; set; }//the delivering activity's @context, which an embedded object does not repeat
public ObjectPath Path { get; set; }
public bool Refetched { get; set; }//delivered, but read again from its origin because it came embedded from another one
public string ActivityId { get; set; }//the activity that brought it, or that caused the fetch
public string ActivityType { get; set; }
public string ActivityActorURI { get; set; }
public string Inbox { get; set; }//"shared" or "personal" when delivered
public string SignatureScheme { get; set; }
public string KeyId { get; set; }
public string Algorithm { get; set; }
public List<string> SignedHeaders { get; set; } = new();
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
public DateTime? Published { get; set; }
public DateTime? Updated { get; set; }
public List<ObjectRevision> Revisions { get; set; } = new();
}
public class ObjectRevision
{
public string Raw { get; set; }
public string RawHash { get; set; }
public bool RawTruncated { get; set; }
public string ActivityId { get; set; }
public DateTime? Updated { get; set; }
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
}
public enum ObjectPath
{
Delivered,
Fetched
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ namespace PrivaPub.Models.Jobs
{
Deliver,
ProcessInbox,
FetchAncestors
FetchAncestors,
DescribeInstance
}
public enum JobState
+9
View File
@@ -10,5 +10,14 @@ namespace PrivaPub.Models.Jobs
public DateTime? LastSuccessAt { get; set; }
public DateTime? LastFailureAt { get; set; }
public string LastError { get; set; }
public string Software { get; set; }//NodeInfo software.name: for display, never for deciding behaviour
public string SoftwareVersion { get; set; }
public string NodeName { get; set; }
public List<string> Protocols { get; set; } = new();
public bool? OpenRegistrations { get; set; }
public string NodeInfo { get; set; }//the raw document, when small enough
public DateTime? DescribedAt { get; set; }
public string DescriptionError { get; set; }
}
}
+13
View File
@@ -25,6 +25,18 @@ namespace PrivaPub.Models.Post
public List<PostMention> Mentions { get; set; } = new();
public List<string> Tags { get; set; } = new();
public List<PostMedia> Media { get; set; } = new();
[BsonIgnoreIfNull]
public PostSource Source { get; set; }//the remote author's own text (Markdown, MFM, BBCode)
[BsonIgnoreIfNull]
public string CoverURL { get; set; }//a remote object's own image: article cover, video poster, event banner
[BsonIgnoreIfNull]
public PostLink Link { get; set; }
[BsonIgnoreIfNull]
public VideoDetails Video { get; set; }
[BsonIgnoreIfNull]
public AudioDetails Audio { get; set; }
[BsonIgnoreIfNull]
public EventDetails Event { get; set; }
public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f;
[BsonIgnoreIfNull]
@@ -47,6 +59,7 @@ namespace PrivaPub.Models.Post
public int RepliesCount { get; set; }
public int FavouritesCount { get; set; }
public int DownvotesCount { get; set; }//Lemmy, PieFed, Mbin and Friendica Dislikes
public int ReblogsCount { get; set; }
public List<PostRevision> Revisions { get; set; } = new();
+86
View File
@@ -0,0 +1,86 @@
namespace PrivaPub.Models.Post
{
public class PostSource
{
public string Content { get; set; }
public string MediaType { get; set; }//text/markdown, text/x.misskeymarkdown (MFM), text/bbcode…
}
public class PostLink
{
public string Href { get; set; }
public string MediaType { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string ImageURL { get; set; }
public bool FromPublisher { get; set; }//title, description and image came with the post (FEP-8967 preview), nothing was fetched
}
public class MediaVariant
{
public string Url { get; set; }
public string MediaType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public double? Fps { get; set; }
public long? Size { get; set; }
public List<string> Streams { get; set; } = new();//"audio", "video", from PeerTube's ffprobe_codec_type
}
public class VideoDetails
{
public double? DurationSeconds { get; set; }
public List<MediaVariant> Variants { get; set; } = new();
public string PlaylistURL { get; set; }//HLS
public bool IsLive { get; set; }
public int? State { get; set; }//PeerTube: 1 published, 4 waiting for live, 5 live ended
public long? Views { get; set; }
public int? CommentsPolicy { get; set; }//PeerTube: 1 open, 2 closed, 3 needs approval
public bool? DownloadEnabled { get; set; }
public string Licence { get; set; }
public string Category { get; set; }
public string ChannelURI { get; set; }
public string EmbedURL { get; set; }
public string ChaptersURL { get; set; }
public List<Caption> Captions { get; set; } = new();
}
public class Caption
{
public string Language { get; set; }
public string Url { get; set; }
}
public class AudioDetails
{
public double? DurationSeconds { get; set; }
public List<MediaVariant> Variants { get; set; } = new();
public string Album { get; set; }
public int? Position { get; set; }
public string Licence { get; set; }
}
public class EventDetails
{
public DateTime? Start { get; set; }
public DateTime? End { get; set; }
public string TimeZone { get; set; }
public bool FloatingTime { get; set; }//"-00:00": a local time with no zone (Hubzilla)
public List<EventPlace> Places { get; set; } = new();
public string OnlineURL { get; set; }
public string JoinMode { get; set; }
public int? ParticipantCount { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
public string Category { get; set; }
}
public class EventPlace
{
public string Name { get; set; }
public string Address { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Url { get; set; }//a VirtualLocation
}
}
+3
View File
@@ -16,5 +16,8 @@ namespace PrivaPub.Models.Post
public int? Width { get; set; }
public int? Height { get; set; }
public float[] Focus { get; set; }
public string RemotePreviewURL { get; set; }//the remote thumbnail (attachment icon), served through the proxy
public double? DurationSeconds { get; set; }
public bool? Sensitive { get; set; }//Misskey marks each attachment
}
}
+13
View File
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Downvote : Entity
{
public string AccountId { get; set; }//ForeignAvatar.ID
public string ActorURI { get; set; }
public string PostId { get; set; }
public string ActivityURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+17 -11
View File
@@ -417,25 +417,31 @@ it, raw where it doesn't.
- Still open: every activity id dereferences.
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
group's `published` is a random day up to two weeks before its creation (migration `_007`).
- **Parsing every shape:**
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
- `url`, `icon`, `image`, `attachment` and `attributedTo` as a value, an object or an array (W1).
- A Markdown `content` and `source` (W3).
- Inferring a missing `mediaType` (W4).
- Every thumbnail location (W5).
- Language from `@context` (W11); alt text from `summary` (W12).
- A null `id`, a 200 `Tombstone`, a `Delete` before its `Create` (W9, W13).
- **`Post.Kind`** plus typed payloads for article, video, audio, event, link, review and thread (INTEROP §4.1). The
Mastodon API view of each kind: content, a card made from the object without fetching, attachments.
- **Raw capture for the details view:** the object as received, how it arrived, the signature scheme and key, received
versus `published`, the extensions detected, and the origin's software (§4.3). Exposed at
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`.
- **Routing by object type:**
- `Accept`/`Reject`/`TentativeAccept` routed by what their object is (W6).
- **Typed payloads** (done in v1.8.0 for link, video, audio, event and the cover image; review and thread are still
open): `Post.ObjectType` plus `Link`, `Video`, `Audio`, `Event`, `CoverURL` and `Source` (INTEROP §4.1). The Mastodon
API view: the title above the body, an event's "when · where" line, a card made from the object without fetching,
attachments with their thumbnail and duration. Video and audio playback through the proxy is P6.
- **Raw capture for the details view** (done in v1.8.0): the object as received, how it arrived, the signature scheme and
key, received versus `published`, the extensions detected, and the origin's software (§4.3). Exposed at
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. Checked live against GoToSocial.
- **Routing by object type** (done in v1.9.0):
- `Accept`/`Reject` act only on a Follow we sent and ignore any other object (W6); routing to RSVPs and interaction
approvals comes with those features.
- `ChatMessage` in as a direct message.
- `Dislike` and votes recorded in a ledger.
- `Dislike` and its `Undo` in a downvote ledger, shown with favourites as `privapub.votes`.
- A `Join` answered with `Ignore` until RSVP exists.
- Friendica's thread-`Follow` refused without an error.
- **Delivery:** honour 503 with `Retry-After`; answer 503 ourselves when a key fetch fails temporarily.
- Friendica's thread-`Follow` is ignored without an error.
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
Still open: answering 503 ourselves when a key fetch fails temporarily, and making every activity id dereference.
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
- **Custom emoji** on posts, names, fields and poll options, proxied.