Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc111d8c46 | ||
|
|
dcd024100a | ||
|
|
bb9bd71391 | ||
|
|
4c94254502 | ||
|
|
829eae3ccf |
No files matched your search
@@ -1,7 +1,9 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
|
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
|
||||||
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped.
|
ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the
|
||||||
|
per-platform evidence behind phases P5 to P8: what each peer sends, what it expects, and what PrivaPub still drops.
|
||||||
|
Check the platform's section there before writing a parser or a renderer, and add to it whatever you learn.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
@@ -215,8 +217,17 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
equal to the login.
|
equal to the login.
|
||||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||||
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
|
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
|
||||||
|
- **Usernames are `Constants.UserNameRegex`** (`^[a-z0-9_]+$`): the intersection of what Mastodon and Misskey accept. A
|
||||||
|
name outside it creates a persona nobody on those servers can reach.
|
||||||
|
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
|
||||||
|
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
|
||||||
|
see ids. Never emit `CreatedAt`.
|
||||||
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
|
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
|
||||||
- **Blocks never federate,** and reports leave as `Flag` from the instance actor.
|
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
|
||||||
|
`Undo{Block}`. Reports still leave as `Flag` from the instance actor, never from the reporting avatar.
|
||||||
|
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
|
||||||
|
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
|
||||||
|
something about an avatar gets the same treatment: ask, then record it there.
|
||||||
- **Location-ranged posts never federate.**
|
- **Location-ranged posts never federate.**
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
@@ -227,6 +238,18 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
|||||||
- **Collection name = class name, so never rename an entity class.**
|
- **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.
|
- `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.
|
- 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
|
- **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,
|
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
|
and only backfill keeps a `published`-derived id. With `published` ids a reply arriving in the same second could sort
|
||||||
|
|||||||
+12
-4
@@ -29,9 +29,12 @@ PrivaPub is an ActivityPub server written in C#. This document follows
|
|||||||
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
||||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
||||||
|
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
|
||||||
|
|
||||||
Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move),
|
Planned (see `docs/ROADMAP.md`, phases P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes),
|
||||||
FEP-044f (quotes).
|
FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move),
|
||||||
|
FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys
|
||||||
|
and integrity proofs), FEP-ae0c (relays).
|
||||||
|
|
||||||
## Actors
|
## Actors
|
||||||
|
|
||||||
@@ -53,7 +56,8 @@ actor document, a collection, NodeInfo or a delivery relates two avatars of the
|
|||||||
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
|
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
|
||||||
|
|
||||||
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
|
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
|
||||||
- `published` on an actor is truncated to the day. `indexable` is `false`.
|
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
|
||||||
|
personas made on the same day do not share a date. `indexable` is `false`.
|
||||||
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
|
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
|
||||||
is used to read another server's content.
|
is used to read another server's content.
|
||||||
|
|
||||||
@@ -97,13 +101,17 @@ Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`
|
|||||||
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
||||||
Uploaded files have all metadata removed.
|
Uploaded files have all metadata removed.
|
||||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
|
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
|
||||||
- **Blocks are never sent.** A blocked account is sent `Reject{Follow}` if it followed, and is unfollowed.
|
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
||||||
|
followed); an unblock sends `Undo{Block}`.
|
||||||
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
|
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
|
||||||
|
|
||||||
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
|
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
|
||||||
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
tags. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
||||||
`name`. A content warning without its own text uses the title, or "Content warning".
|
`name`. A content warning without its own text uses the title, or "Content warning".
|
||||||
|
|
||||||
|
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
|
||||||
|
`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
|
||||||
|
|
||||||
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
|
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
|
||||||
followers-only posts are recognised by the author's own `followers` collection.
|
followers-only posts are recognised by the author's own `followers` collection.
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,6 @@
|
|||||||
public const int GroupInvitationLength = 64;
|
public const int GroupInvitationLength = 64;
|
||||||
|
|
||||||
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
||||||
|
public const string UserNameRegex = "^[a-z0-9_]+$";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
|
|
||||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
|
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
NoWhiteSpaces]
|
NoWhiteSpaces]
|
||||||
public string UserName { get; set; }
|
public string UserName { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels
|
|||||||
public string InvitationCode { get; set; }
|
public string InvitationCode { get; set; }
|
||||||
|
|
||||||
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource))]
|
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
public string AvatarUserName { get; set; }
|
public string AvatarUserName { get; set; }
|
||||||
|
|
||||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ namespace PrivaPub.ClientModels.Resources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
|
||||||
|
/// </summary>
|
||||||
|
public static string InvalidUserName {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("InvalidUserName", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<data name="InvalidEmail" xml:space="preserve">
|
<data name="InvalidEmail" xml:space="preserve">
|
||||||
<value>{0} è invalida.</value>
|
<value>{0} è invalida.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="InvalidUserName" xml:space="preserve">
|
||||||
|
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
|
||||||
|
</data>
|
||||||
<data name="InvalidPassword" xml:space="preserve">
|
<data name="InvalidPassword" xml:space="preserve">
|
||||||
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<data name="InvalidEmail" xml:space="preserve">
|
<data name="InvalidEmail" xml:space="preserve">
|
||||||
<value>{0} is invalid.</value>
|
<value>{0} is invalid.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="InvalidUserName" xml:space="preserve">
|
||||||
|
<value>Use only lower-case letters, digits and underscores.</value>
|
||||||
|
</data>
|
||||||
<data name="InvalidPassword" xml:space="preserve">
|
<data name="InvalidPassword" xml:space="preserve">
|
||||||
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
|
|||||||
|
|
||||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
|
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
|
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
|
||||||
public string UserName { get; set; }//preferredUsername
|
public string UserName { get; set; }//preferredUsername
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using PrivaPub.Api.Mastodon.Mappers;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Api
|
||||||
|
{
|
||||||
|
public class StatusContentTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void A_remote_article_shows_its_title_above_its_body() =>
|
||||||
|
Assert.Equal("<p><strong>Rain & sun</strong></p><p>body</p>", MastodonMapper.Content(new Post
|
||||||
|
{
|
||||||
|
IsFederatedCopy = true, ObjectType = "Article", Title = "Rain & sun", ContentHtml = "<p>body</p>"
|
||||||
|
}));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_remote_note_keeps_its_content_alone() =>
|
||||||
|
Assert.Equal("<p>body</p>", MastodonMapper.Content(new Post
|
||||||
|
{
|
||||||
|
IsFederatedCopy = true, ObjectType = "Note", Title = "a poll choice", ContentHtml = "<p>body</p>"
|
||||||
|
}));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_remote_event_without_a_body_shows_its_excerpt() =>
|
||||||
|
Assert.Equal("<p><strong>Meetup</strong></p><p>Saturday <10:00></p>", MastodonMapper.Content(new Post
|
||||||
|
{
|
||||||
|
IsFederatedCopy = true, ObjectType = "Event", Title = "Meetup", Excerpt = "Saturday <10:00>", ContentHtml = string.Empty
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,31 @@ namespace PrivaPub.Tests.Domain
|
|||||||
});
|
});
|
||||||
|
|
||||||
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
||||||
Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue<string>());
|
var sent = (await _harness.Outgoing(mallory.Id + "/inbox")).Select(a => a["type"]!.GetValue<string>()).ToList();
|
||||||
|
Assert.Equal(new[] { "Block", "Reject" }, sent.Order());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_block_is_told_to_the_blocked_server_and_so_is_the_unblock()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||||
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri
|
||||||
|
});
|
||||||
|
|
||||||
|
await _harness.Relationships.Block(alice, mallory.Id, default, token);
|
||||||
|
await _harness.Relationships.Unblock(alice, mallory.Id, token);
|
||||||
|
|
||||||
|
var sent = await _harness.Outgoing(mallory.Id + "/inbox");
|
||||||
|
var block = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Block");
|
||||||
|
Assert.Equal(mallory.Id, block["object"]!.GetValue<string>());
|
||||||
|
Assert.Equal(alice.Uri, block["actor"]!.GetValue<string>());
|
||||||
|
var undo = Assert.Single(sent, a => a["type"]!.GetValue<string>() == "Undo");
|
||||||
|
Assert.Equal(block["id"]!.GetValue<string>(), undo["object"]!["id"]!.GetValue<string>());
|
||||||
|
Assert.False(await DB.Default.Find<Block>().Match(b => b.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
|
|||||||
{
|
{
|
||||||
new FollowHandler(db, _local, remote, delivery),
|
new FollowHandler(db, _local, remote, delivery),
|
||||||
new UndoHandler(db, _local),
|
new UndoHandler(db, _local),
|
||||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new 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 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);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,28 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
|
Assert.Equal("https://k.example/notes/8", note.QuoteUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Article", false, false)]
|
||||||
|
[InlineData("Event", false, false)]
|
||||||
|
[InlineData("Video", false, false)]
|
||||||
|
[InlineData("Page", false, false)]
|
||||||
|
[InlineData("Note", false, true)]
|
||||||
|
[InlineData("Question", false, true)]
|
||||||
|
[InlineData("Article", true, true)]
|
||||||
|
public void A_summary_warns_only_on_notes_or_when_marked_sensitive(string type, bool sensitive, bool warns)
|
||||||
|
{
|
||||||
|
var note = NoteParser.Parse(JsonNode.Parse($$"""
|
||||||
|
{
|
||||||
|
"id": "https://w.example/?p=12", "type": "{{type}}", "name": "Twelve", "summary": "Saturday, 10:00 at the library",
|
||||||
|
"sensitive": {{(sensitive ? "true" : "false")}}, "attributedTo": "https://w.example/author", "content": "<p>body</p>"
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.Equal(warns, note.Sensitive);
|
||||||
|
Assert.Equal(warns ? "Saturday, 10:00 at the library" : null, note.SpoilerText);
|
||||||
|
Assert.Equal(warns ? null : "Saturday, 10:00 at the library", note.Excerpt);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
|
[InlineData("""{ "id": "https://x.example/1", "type": "Person" }""")]
|
||||||
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
|
[InlineData("""{ "id": "not a uri", "type": "Note" }""")]
|
||||||
|
|||||||
@@ -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 & 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,18 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Persona_ids_carry_only_the_day_and_share_nothing_else()
|
public void Persona_ids_carry_only_the_day_and_share_nothing_else()
|
||||||
{
|
{
|
||||||
var first = (string)new Avatar().GenerateNewID();
|
var avatar = new Avatar();
|
||||||
var second = (string)new Avatar().GenerateNewID();
|
var first = (string)avatar.GenerateNewID();
|
||||||
|
var second = (string)new Avatar { PublishedOn = avatar.PublishedOn }.GenerateNewID();
|
||||||
|
var group = new GroupEntity();
|
||||||
|
|
||||||
Assert.True(ObjectId.TryParse(first, out var a));
|
Assert.True(ObjectId.TryParse(first, out var a));
|
||||||
Assert.True(ObjectId.TryParse(second, out var b));
|
Assert.True(ObjectId.TryParse(second, out var b));
|
||||||
Assert.Equal(DateTime.UtcNow.Date, a.CreationTime);
|
Assert.Equal(avatar.PublishedOn, a.CreationTime);
|
||||||
|
Assert.InRange(a.CreationTime, DateTime.UtcNow.Date.AddDays(-13), DateTime.UtcNow.Date);
|
||||||
Assert.Equal(a.CreationTime, b.CreationTime);
|
Assert.Equal(a.CreationTime, b.CreationTime);
|
||||||
Assert.NotEqual(first[8..14], second[8..14]);
|
Assert.NotEqual(first[8..14], second[8..14]);
|
||||||
Assert.Equal(DateTime.UtcNow.Date, ObjectId.Parse((string)new GroupEntity().GenerateNewID()).CreationTime);
|
Assert.Equal(group.PublishedOn, ObjectId.Parse((string)group.GenerateNewID()).CreationTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -49,6 +52,17 @@ namespace PrivaPub.Tests.Infrastructure
|
|||||||
public void A_backfilled_post_keeps_its_published_time() =>
|
public void A_backfilled_post_keeps_its_published_time() =>
|
||||||
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime);
|
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_persona_publishes_a_day_within_two_weeks_before_its_creation()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 10, 1, 15, 30, 0, DateTimeKind.Utc);
|
||||||
|
var days = Enumerable.Range(0, 200).Select(_ => PrivacyIds.PublishedDay(created)).ToList();
|
||||||
|
|
||||||
|
Assert.All(days, d => Assert.Equal(TimeSpan.Zero, d.TimeOfDay));
|
||||||
|
Assert.All(days, d => Assert.InRange(d, created.Date.AddDays(-13), created.Date));
|
||||||
|
Assert.True(days.Distinct().Count() > 1);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_published_time_in_the_future_is_clamped() =>
|
public void A_published_time_in_the_future_is_clamped() =>
|
||||||
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
|
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using PrivaPub.Federation.Inbox.Handlers;
|
|||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
using PrivaPub.Models;
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
@@ -42,7 +43,8 @@ namespace PrivaPub.Tests.Support
|
|||||||
Delivery = new DeliveryService(Db, Queue);
|
Delivery = new DeliveryService(Db, Queue);
|
||||||
Fanout = new Fanout(Db);
|
Fanout = new Fanout(Db);
|
||||||
Groups = new GroupDistributor(Delivery);
|
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);
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
||||||
{
|
{
|
||||||
@@ -52,9 +54,9 @@ namespace PrivaPub.Tests.Support
|
|||||||
new UndoHandler(Db, Local),
|
new UndoHandler(Db, Local),
|
||||||
new LikeHandler(Db),
|
new LikeHandler(Db),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
|
||||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||||
new UpdateHandler(Db, Local, Remote, Groups),
|
new UpdateHandler(Db, Local, Remote, Groups, Records),
|
||||||
new FlagHandler(Db, Local)
|
new FlagHandler(Db, Local)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||||
@@ -86,6 +88,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public GroupDistributor Groups { get; }
|
public GroupDistributor Groups { get; }
|
||||||
public Fanout Fanout { get; }
|
public Fanout Fanout { get; }
|
||||||
public RemotePosts RemotePosts { get; }
|
public RemotePosts RemotePosts { get; }
|
||||||
|
public ObjectRecords Records { get; }
|
||||||
public TimelineService Timelines { get; }
|
public TimelineService Timelines { get; }
|
||||||
public RelationshipService Relationships { get; }
|
public RelationshipService Relationships { get; }
|
||||||
public ReportService Reports { get; }
|
public ReportService Reports { get; }
|
||||||
|
|||||||
@@ -0,0 +1,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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,28 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public string AttributionDomains { get; set; }
|
public string AttributionDomains { 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 class Status
|
||||||
{
|
{
|
||||||
public string Id { get; set; }
|
public string Id { get; set; }
|
||||||
@@ -76,7 +98,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
|||||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||||
public List<StatusMention> Mentions { get; set; } = new();
|
public List<StatusMention> Mentions { get; set; } = new();
|
||||||
public List<StatusTag> Tags { get; set; } = new();
|
public List<StatusTag> Tags { get; set; } = new();
|
||||||
public object Card { get; set; }
|
public PreviewCard Card { get; set; }
|
||||||
public object Poll { get; set; }
|
public object Poll { get; set; }
|
||||||
public Application Application { get; set; }
|
public Application Application { get; set; }
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using PrivaPub.Models.Social;
|
|||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||||
@@ -192,6 +193,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
Visibility = Visibility(post.Visibility),
|
Visibility = Visibility(post.Visibility),
|
||||||
Language = post.Language,
|
Language = post.Language,
|
||||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||||
|
Card = Card(post),
|
||||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||||
};
|
};
|
||||||
@@ -225,9 +227,72 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
public static string Content(PostEntity post)
|
public static string Content(PostEntity post)
|
||||||
{
|
{
|
||||||
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||||||
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
|
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
|
||||||
? content ?? string.Empty
|
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
|
||||||
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
public static string Visibility(PostVisibility visibility) => visibility switch
|
||||||
@@ -250,6 +315,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 };
|
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()
|
MediaAttachment Media(PostMedia media) => new()
|
||||||
{
|
{
|
||||||
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
||||||
@@ -262,17 +349,12 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
|||||||
_ => "unknown"
|
_ => "unknown"
|
||||||
},
|
},
|
||||||
Url = media.URL ?? Proxied(media.RemoteURL),
|
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,
|
RemoteUrl = media.RemoteURL,
|
||||||
Description = media.Description,
|
Description = media.Description,
|
||||||
Blurhash = media.Blurhash,
|
Blurhash = media.Blurhash,
|
||||||
Meta = new
|
Meta = Meta(media)
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,12 +38,14 @@ namespace PrivaPub.Domain.Relationships
|
|||||||
|
|
||||||
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
|
public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var block = new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri };
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token);
|
await DB.Default.SaveAsync(block, token);
|
||||||
}
|
}
|
||||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
{
|
{
|
||||||
|
block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _follows.UnfollowAs(me, targetActorUri, token);
|
await _follows.UnfollowAs(me, targetActorUri, token);
|
||||||
@@ -67,10 +69,44 @@ namespace PrivaPub.Domain.Relationships
|
|||||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
|
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
|
||||||
}
|
}
|
||||||
await Forget(me, targetAccountId, token);
|
await Forget(me, targetAccountId, token);
|
||||||
|
if (block != default)
|
||||||
|
await Tell(me, targetActorUri, BlockActivity(me, block), token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) =>
|
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
|
||||||
await DB.Default.DeleteAsync<Block>(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri);
|
{
|
||||||
|
var block = await DB.Default.Find<Block>().Match(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||||
|
if (block == default)
|
||||||
|
return;
|
||||||
|
await DB.Default.DeleteAsync<Block>(block.ID);
|
||||||
|
await Tell(me, targetActorUri, new JsonObject
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
|
["id"] = me.ActivityUri($"undo-block-{block.ID}"),
|
||||||
|
["type"] = "Undo",
|
||||||
|
["actor"] = me.Uri,
|
||||||
|
["object"] = BlockActivity(me, block)
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonObject BlockActivity(LocalActor me, Block block) => new()
|
||||||
|
{
|
||||||
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
|
["id"] = me.ActivityUri($"block-{block.ID}"),
|
||||||
|
["type"] = "Block",
|
||||||
|
["actor"] = me.Uri,
|
||||||
|
["object"] = block.TargetActorURI,
|
||||||
|
["to"] = new JsonArray(block.TargetActorURI)
|
||||||
|
};
|
||||||
|
|
||||||
|
async Task Tell(LocalActor me, string targetActorUri, JsonObject activity, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
var target = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
|
||||||
|
if (!string.IsNullOrEmpty(target?.InboxURL))
|
||||||
|
await _delivery.Enqueue(me, new[] { target.InboxURL }, activity, token);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
|
public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
ThumbnailURL = avatar.ThumbnailURL,
|
ThumbnailURL = avatar.ThumbnailURL,
|
||||||
PrivateKeyPem = avatar.PrivateKey,
|
PrivateKeyPem = avatar.PrivateKey,
|
||||||
PublicKeyPem = avatar.PublicKey,
|
PublicKeyPem = avatar.PublicKey,
|
||||||
Published = avatar.CreatedAt,
|
Published = avatar.PublishedOn,
|
||||||
Fields = avatar.Fields ?? new Dictionary<string, string>(),
|
Fields = avatar.Fields ?? new Dictionary<string, string>(),
|
||||||
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
|
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
|
||||||
Discoverable = avatar.Settings?.IsDiscoverable != false,
|
Discoverable = avatar.Settings?.IsDiscoverable != false,
|
||||||
@@ -227,7 +227,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
||||||
IsCircle = group.Kind == GroupKind.Circle,
|
IsCircle = group.Kind == GroupKind.Circle,
|
||||||
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
||||||
Published = group.CreationDate,
|
Published = group.PublishedOn,
|
||||||
BaseAddress = BaseAddress
|
BaseAddress = BaseAddress
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
[NonAction]
|
[NonAction]
|
||||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
{
|
{
|
||||||
|
if (HttpMethods.IsGet(Request.Method))
|
||||||
|
Response.Headers.Vary = "Accept";
|
||||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
|
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
|
||||||
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
|
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
|
||||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||||
|
|||||||
@@ -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 IFanout _fanout;
|
||||||
readonly IRemotePosts _remotePosts;
|
readonly IRemotePosts _remotePosts;
|
||||||
readonly IGroupDistributor _groups;
|
readonly IGroupDistributor _groups;
|
||||||
|
readonly IObjectRecords _records;
|
||||||
|
|
||||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
||||||
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
|
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records)
|
||||||
{
|
{
|
||||||
|
_records = records;
|
||||||
_groups = groups;
|
_groups = groups;
|
||||||
_fanout = fanout;
|
_fanout = fanout;
|
||||||
_remotePosts = remotePosts;
|
_remotePosts = remotePosts;
|
||||||
@@ -53,7 +55,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
|
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
|
||||||
{
|
{
|
||||||
var node = activity["object"];
|
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);
|
using var fetched = await _remoteActors.FetchObject(Id(node), token);
|
||||||
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
||||||
@@ -131,6 +134,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
|
||||||
|
|
||||||
if (parent != default)
|
if (parent != default)
|
||||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IGroupDistributor _groups;
|
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;
|
_groups = groups;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
@@ -72,6 +75,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
|
|
||||||
post.Title = note.Title;
|
post.Title = note.Title;
|
||||||
post.SpoilerText = note.SpoilerText;
|
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.HasContentWarning = note.Sensitive;
|
||||||
post.Text = note.ContentHtml;
|
post.Text = note.ContentHtml;
|
||||||
post.ContentHtml = note.ContentHtml;
|
post.ContentHtml = note.ContentHtml;
|
||||||
@@ -82,6 +92,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||||
post.UpdateDate = DateTime.UtcNow;
|
post.UpdateDate = DateTime.UtcNow;
|
||||||
await DB.Default.SaveAsync(post, token);
|
await DB.Default.SaveAsync(post, token);
|
||||||
|
await _records.Revise(note, Id(activity), token);
|
||||||
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
|
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
|
||||||
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
|
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,16 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
if (actor == default)
|
if (actor == default)
|
||||||
return JobOutcome.Retry("the actor could not be loaded");
|
return JobOutcome.Retry("the actor could not be loaded");
|
||||||
|
|
||||||
|
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);
|
await handler.Handle(activity, actor, token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Arrival.Current = default;
|
||||||
|
}
|
||||||
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
|
_logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
|
||||||
return JobOutcome.Done;
|
return JobOutcome.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
{
|
{
|
||||||
public sealed record InboxResult(int StatusCode, string Error = default);
|
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
|
public interface IInboxReceiver
|
||||||
{
|
{
|
||||||
@@ -99,7 +100,9 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
return shapeProblem;
|
return shapeProblem;
|
||||||
|
|
||||||
var activityId = Id(activity);
|
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);
|
new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
|
||||||
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
|
||||||
return new(StatusCodes.Status202Accepted);
|
return new(StatusCodes.Status202Accepted);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using PrivaPub.Federation.Moderation;
|
|||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Infrastructure.Ids;
|
using PrivaPub.Infrastructure.Ids;
|
||||||
using PrivaPub.Infrastructure.Jobs;
|
using PrivaPub.Infrastructure.Jobs;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
using PrivaPub.Models.Jobs;
|
using PrivaPub.Models.Jobs;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
@@ -37,10 +38,12 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IDomainBlocks _domainBlocks;
|
readonly IDomainBlocks _domainBlocks;
|
||||||
readonly IJobQueue _queue;
|
readonly IJobQueue _queue;
|
||||||
|
readonly IObjectRecords _records;
|
||||||
|
|
||||||
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
|
public RemotePosts(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDomainBlocks domainBlocks,
|
||||||
IJobQueue queue)
|
IJobQueue queue, IObjectRecords records)
|
||||||
{
|
{
|
||||||
|
_records = records;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
@@ -70,8 +73,16 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
Url = note.Url,
|
Url = note.Url,
|
||||||
ContextURI = note.Context,
|
ContextURI = note.Context,
|
||||||
QuoteURI = note.QuoteUri,
|
QuoteURI = note.QuoteUri,
|
||||||
|
ObjectType = note.Type,
|
||||||
Title = note.Title,
|
Title = note.Title,
|
||||||
SpoilerText = note.SpoilerText,
|
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,
|
HasContentWarning = note.Sensitive,
|
||||||
Text = note.ContentHtml,
|
Text = note.ContentHtml,
|
||||||
ContentHtml = note.ContentHtml,
|
ContentHtml = note.ContentHtml,
|
||||||
@@ -129,6 +140,7 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
{
|
{
|
||||||
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
return await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||||
}
|
}
|
||||||
|
await _records.Record(note, post, ObjectPath.Fetched, refetched: false, token);
|
||||||
|
|
||||||
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
|
if (grandparent == default && !string.IsNullOrEmpty(note.InReplyTo) && depth < MaxDepth)
|
||||||
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
|
await _queue.Enqueue(JobKind.FetchAncestors, JsonSerializer.Serialize(new AncestorsPayload(post.ID, depth + 1)), new Uri(note.InReplyTo).Host,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||||
@@ -11,10 +12,12 @@ namespace PrivaPub.Federation.Objects
|
|||||||
{
|
{
|
||||||
public string Id { get; init; }
|
public string Id { get; init; }
|
||||||
public string Type { get; init; }
|
public string Type { get; init; }
|
||||||
|
public JsonObject Raw { get; init; }
|
||||||
public string AttributedTo { get; init; }
|
public string AttributedTo { get; init; }
|
||||||
public string ContentHtml { get; init; }
|
public string ContentHtml { get; init; }
|
||||||
public string Title { get; init; }
|
public string Title { get; init; }
|
||||||
public string SpoilerText { get; init; }
|
public string SpoilerText { get; init; }
|
||||||
|
public string Excerpt { get; init; }
|
||||||
public bool Sensitive { get; init; }
|
public bool Sensitive { get; init; }
|
||||||
public string Language { get; init; }
|
public string Language { get; init; }
|
||||||
public string Url { get; init; }
|
public string Url { get; init; }
|
||||||
@@ -29,6 +32,12 @@ namespace PrivaPub.Federation.Objects
|
|||||||
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
|
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
|
||||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||||
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
|
||||||
|
public PostSource Source { get; init; }
|
||||||
|
public 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
|
public static class NoteParser
|
||||||
@@ -48,16 +57,21 @@ namespace PrivaPub.Federation.Objects
|
|||||||
|
|
||||||
var (contentHtml, language) = Content(note);
|
var (contentHtml, language) = Content(note);
|
||||||
var summary = Value(note, "summary");
|
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
|
return new NoteDocument
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Type = type,
|
Type = type,
|
||||||
|
Raw = note,
|
||||||
AttributedTo = Attribution(note["attributedTo"]),
|
AttributedTo = Attribution(note["attributedTo"]),
|
||||||
ContentHtml = ContentSanitizer.Html(contentHtml),
|
ContentHtml = ContentSanitizer.Html(contentHtml),
|
||||||
Title = Plain(Value(note, "name"), 500),
|
Title = ObjectShapes.Text(Value(note, "name"), 500),
|
||||||
SpoilerText = Plain(summary, 500),
|
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
|
||||||
Sensitive = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s
|
Excerpt = summaryWarns ? default : excerpt,
|
||||||
|| !string.IsNullOrWhiteSpace(summary),
|
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
|
||||||
Language = language,
|
Language = language,
|
||||||
Url = UrlOf(note["url"]) ?? id,
|
Url = UrlOf(note["url"]) ?? id,
|
||||||
InReplyTo = Id(note["inReplyTo"]),
|
InReplyTo = Id(note["inReplyTo"]),
|
||||||
@@ -73,12 +87,18 @@ namespace PrivaPub.Federation.Objects
|
|||||||
.DistinctBy(m => m.ActorURI)
|
.DistinctBy(m => m.ActorURI)
|
||||||
.Take(MaxTags)
|
.Take(MaxTags)
|
||||||
.ToList(),
|
.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))
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Take(MaxTags)
|
.Take(MaxTags)
|
||||||
.ToList(),
|
.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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +115,9 @@ namespace PrivaPub.Federation.Objects
|
|||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
|
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
|
||||||
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
|
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
|
static string Attribution(JsonNode node) => node switch
|
||||||
@@ -115,33 +137,37 @@ namespace PrivaPub.Federation.Objects
|
|||||||
_ => Enumerable.Empty<JsonObject>()
|
_ => Enumerable.Empty<JsonObject>()
|
||||||
}).Where(t => Value(t, "type") == type);
|
}).Where(t => Value(t, "type") == type);
|
||||||
|
|
||||||
static List<PostMedia> Attachments(JsonObject note)
|
static List<PostMedia> Attachments(JsonObject note) =>
|
||||||
{
|
ObjectShapes.Objects(note["attachment"])
|
||||||
var items = note["attachment"] switch
|
|
||||||
{
|
|
||||||
JsonArray array => array.OfType<JsonObject>(),
|
|
||||||
JsonObject single => new[] { single },
|
|
||||||
_ => Enumerable.Empty<JsonObject>()
|
|
||||||
};
|
|
||||||
return items
|
|
||||||
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
|
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
|
||||||
.Select(a => new PostMedia
|
.Select(Attachment)
|
||||||
{
|
|
||||||
RemoteURL = UrlOf(a["url"]),
|
|
||||||
ContentType = Value(a, "mediaType"),
|
|
||||||
Description = Plain(Value(a, "name") ?? Value(a, "summary"), 1500),
|
|
||||||
Blurhash = Value(a, "blurhash"),
|
|
||||||
Width = Int(a["width"]),
|
|
||||||
Height = Int(a["height"]),
|
|
||||||
Focus = a["focalPoint"] is JsonArray focus && focus.Count == 2
|
|
||||||
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
|
|
||||||
: default
|
|
||||||
})
|
|
||||||
.Where(m => Origin.Of(m.RemoteURL) != default)
|
.Where(m => Origin.Of(m.RemoteURL) != default)
|
||||||
.Take(MaxAttachments)
|
.Take(MaxAttachments)
|
||||||
.ToList();
|
.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
|
static string UrlOf(JsonNode node) => node switch
|
||||||
{
|
{
|
||||||
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,9 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
["schema"] = "http://schema.org#",
|
["schema"] = "http://schema.org#",
|
||||||
["PropertyValue"] = "schema:PropertyValue",
|
["PropertyValue"] = "schema:PropertyValue",
|
||||||
["value"] = "schema:value",
|
["value"] = "schema:value",
|
||||||
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger"
|
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
||||||
|
["lemmy"] = "https://join-lemmy.org/ns#",
|
||||||
|
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
|
||||||
});
|
});
|
||||||
|
|
||||||
public static string Html(string markdown) =>
|
public static string Html(string markdown) =>
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ namespace PrivaPub.Infrastructure.Data
|
|||||||
await Plain<Report>(token, r => r.IsResolved, r => r.ID);
|
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<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<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);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
|
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Infrastructure.Ids;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||||
|
|
||||||
|
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
public class _007_personas_publish_a_spread_day : IMigration
|
||||||
|
{
|
||||||
|
public async Task UpgradeAsync()
|
||||||
|
{
|
||||||
|
var missing = new BsonDocument(nameof(Avatar.PublishedOn), new BsonDocument("$exists", false));
|
||||||
|
foreach (var avatar in await DB.Default.Collection<Avatar>().Find(missing).ToListAsync())
|
||||||
|
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.PublishedOn, PrivacyIds.PublishedDay(avatar.CreatedAt)).ExecuteAsync();
|
||||||
|
foreach (var group in await DB.Default.Collection<GroupEntity>().Find(missing).ToListAsync())
|
||||||
|
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.PublishedOn, PrivacyIds.PublishedDay(group.CreationDate)).ExecuteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ namespace PrivaPub.Infrastructure.Ids
|
|||||||
{
|
{
|
||||||
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
|
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
|
||||||
|
|
||||||
|
public static DateTime PublishedDay(DateTime created) =>
|
||||||
|
DateTime.SpecifyKind(created, DateTimeKind.Utc).Date.AddDays(-RandomNumberGenerator.GetInt32(0, PublishedSpreadDays));
|
||||||
|
|
||||||
|
const int PublishedSpreadDays = 14;
|
||||||
|
|
||||||
public static string Arrived(DateTime published) =>
|
public static string Arrived(DateTime published) =>
|
||||||
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
|
DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using PrivaPub.Federation.Actors;
|
|||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Federation.Signing;
|
using PrivaPub.Federation.Signing;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
using PrivaPub.Federation.Moderation;
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Federation.Inbox.Handlers;
|
using PrivaPub.Federation.Inbox.Handlers;
|
||||||
using PrivaPub.Domain.Content;
|
using PrivaPub.Domain.Content;
|
||||||
@@ -82,6 +83,8 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IActivityHandler, UpdateHandler>()
|
.AddSingleton<IActivityHandler, UpdateHandler>()
|
||||||
.AddSingleton<IJobHandler, InboxProcessor>()
|
.AddSingleton<IJobHandler, InboxProcessor>()
|
||||||
.AddSingleton<IRemotePosts, RemotePosts>()
|
.AddSingleton<IRemotePosts, RemotePosts>()
|
||||||
|
.AddSingleton<IObjectRecords, ObjectRecords>()
|
||||||
|
.AddSingleton<IJobHandler, InstanceDescriber>()
|
||||||
.AddSingleton<IJobHandler, AncestorsJobHandler>()
|
.AddSingleton<IJobHandler, AncestorsJobHandler>()
|
||||||
.AddSingleton<IJobQueue, JobQueue>()
|
.AddSingleton<IJobQueue, JobQueue>()
|
||||||
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
|
.AddSingleton<IHostCircuitBreaker, HostCircuitBreaker>()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,11 @@ namespace PrivaPub.Models.Group
|
|||||||
public string HashedInvitationPassword { get; set; }
|
public string HashedInvitationPassword { get; set; }
|
||||||
|
|
||||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime PublishedOn { get; set; } = PrivacyIds.PublishedDay(DateTime.UtcNow);
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? DeletionAt { get; set; }
|
public DateTime? DeletionAt { get; set; }
|
||||||
|
|
||||||
public override object GenerateNewID() => PrivacyIds.ForDay(DateTime.UtcNow);
|
public override object GenerateNewID() => PrivacyIds.ForDay(PublishedOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GroupMember
|
public class GroupMember
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ namespace PrivaPub.Models.Jobs
|
|||||||
{
|
{
|
||||||
Deliver,
|
Deliver,
|
||||||
ProcessInbox,
|
ProcessInbox,
|
||||||
FetchAncestors
|
FetchAncestors,
|
||||||
|
DescribeInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum JobState
|
public enum JobState
|
||||||
|
|||||||
@@ -10,5 +10,14 @@ namespace PrivaPub.Models.Jobs
|
|||||||
public DateTime? LastSuccessAt { get; set; }
|
public DateTime? LastSuccessAt { get; set; }
|
||||||
public DateTime? LastFailureAt { get; set; }
|
public DateTime? LastFailureAt { get; set; }
|
||||||
public string LastError { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ namespace PrivaPub.Models.Post
|
|||||||
public PostVisibility Visibility { get; set; }
|
public PostVisibility Visibility { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string SpoilerText { get; set; }
|
public string SpoilerText { get; set; }
|
||||||
|
public string Excerpt { get; set; }//a non-Note's summary: a teaser, a description, an event's date and place
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
public string ContentHtml { get; set; }
|
public string ContentHtml { get; set; }
|
||||||
public ContentFormat ContentFormat { get; set; }
|
public ContentFormat ContentFormat { get; set; }
|
||||||
@@ -24,6 +25,18 @@ namespace PrivaPub.Models.Post
|
|||||||
public List<PostMention> Mentions { get; set; } = new();
|
public List<PostMention> Mentions { get; set; } = new();
|
||||||
public List<string> Tags { get; set; } = new();
|
public List<string> Tags { get; set; } = new();
|
||||||
public List<PostMedia> Media { get; set; } = new();
|
public List<PostMedia> Media { get; set; } = new();
|
||||||
|
[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 List<float> Location { get; set; } = new();
|
||||||
public float RangeKm { get; set; } = 5.0f;
|
public float RangeKm { get; set; } = 5.0f;
|
||||||
[BsonIgnoreIfNull]
|
[BsonIgnoreIfNull]
|
||||||
@@ -35,6 +48,7 @@ namespace PrivaPub.Models.Post
|
|||||||
|
|
||||||
public bool IsFederatedCopy { get; set; }
|
public bool IsFederatedCopy { get; set; }
|
||||||
public string ObjectURI { get; set; }//the Note's id
|
public string ObjectURI { get; set; }//the Note's id
|
||||||
|
public string ObjectType { get; set; }//the remote object's type: Note, Article, Page, Video, Event…; null for local posts
|
||||||
public string ActivityURI { get; set; }//the Create's id
|
public string ActivityURI { get; set; }//the Create's id
|
||||||
public string ActorURI { get; set; }//attributedTo
|
public string ActorURI { get; set; }//attributedTo
|
||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,5 +16,8 @@ namespace PrivaPub.Models.Post
|
|||||||
public int? Width { get; set; }
|
public int? Width { get; set; }
|
||||||
public int? Height { get; set; }
|
public int? Height { get; set; }
|
||||||
public float[] Focus { 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,12 +32,13 @@ namespace PrivaPub.Models.User
|
|||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime PublishedOn { get; set; } = PrivacyIds.PublishedDay(DateTime.UtcNow);//the actor's published: a few days before CreatedAt, so same-day personas differ
|
||||||
public DateTime? SilencedAt { get; set; }
|
public DateTime? SilencedAt { get; set; }
|
||||||
public DateTime? SuspendedAt { get; set; }
|
public DateTime? SuspendedAt { get; set; }
|
||||||
public DateTime? BannedAt { get; set; }
|
public DateTime? BannedAt { get; set; }
|
||||||
public DateTime? DeletionAt { get; set; }
|
public DateTime? DeletionAt { get; set; }
|
||||||
|
|
||||||
public override object GenerateNewID() => PrivacyIds.ForDay(DateTime.UtcNow);
|
public override object GenerateNewID() => PrivacyIds.ForDay(PublishedOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ForeignAvatar : Entity
|
public class ForeignAvatar : Entity
|
||||||
|
|||||||
+771
@@ -0,0 +1,771 @@
|
|||||||
|
# Interop: what every peer sends, what it expects, and what PrivaPub still drops
|
||||||
|
|
||||||
|
Research as of **2026-10-01**: the platforms' source on their default branches, live ActivityPub fetches from large
|
||||||
|
instances, release notes and the FEP repository. Version numbers are what was current that day. Claims the
|
||||||
|
research could not confirm from a primary source are marked *(unconfirmed)*. The sources are at the end.
|
||||||
|
|
||||||
|
This file is for two readers:
|
||||||
|
- **Whoever changes federation code.** Every gotcha below has broken somebody.
|
||||||
|
- **Whoever builds the rich client.** That client shows every kind of fediverse content in one place, with a secondary
|
||||||
|
"details" view of the raw object and how it reached us. Section 4 says what the server must keep for it.
|
||||||
|
|
||||||
|
Priorities, used throughout:
|
||||||
|
|
||||||
|
| Priority | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| **P1** | Breaks interop, or loses content the user would see. |
|
||||||
|
| **P2** | Degrades the experience. |
|
||||||
|
| **P3** | Nice to have. |
|
||||||
|
|
||||||
|
## 1. Where PrivaPub stands (checked in the code, 2026-10-01)
|
||||||
|
|
||||||
|
**Already right**
|
||||||
|
- **Public addressing:** all three spellings are recognised (`…#Public`, `as:Public`, `Public`).
|
||||||
|
- **Undo:** embeds its whole object, which Misskey needs; it fetches the object only when it isn't embedded.
|
||||||
|
- **Content types:**
|
||||||
|
- Served documents are `application/activity+json; charset=utf-8`, which is on Lemmy's exact allowlist and is
|
||||||
|
accepted by GoToSocial and Misskey.
|
||||||
|
- WebFinger answers `application/jrd+json`, and answers for the actor URL as well as `acct:`. Akkoma and
|
||||||
|
Iceshrimp.NET require both.
|
||||||
|
- **SSRF guard:**
|
||||||
|
- Its IPv6 rule is an allowlist (`2000::/3` only), so IPv4-compatible `::a.b.c.d`, NAT64 and the other tricks behind
|
||||||
|
Mastodon's GHSA-vwhj (Jul 2026) are already refused.
|
||||||
|
- It caps fetches at 1 MB. Misskey caps at 256 KiB, so keep our own documents well under that.
|
||||||
|
- **Attachments:**
|
||||||
|
- Up to 16 are kept. Mastodon drops anything past 4; Pixelfed albums and Threads carousels go beyond it.
|
||||||
|
- `{type: Link}` attachments are not mistaken for media.
|
||||||
|
- **Deleted posts** answer 410 with a `Tombstone`. Actor `published` is truncated to the day. The `webfinger` property
|
||||||
|
(FEP-2c59) is on every actor.
|
||||||
|
- **Live check:** the whole follow, post, reply, like, boost, DM, edit and delete set round-trips with GoToSocial
|
||||||
|
0.22.1 (`tools/pasture/`).
|
||||||
|
|
||||||
|
**Wrong at the time of the research (P1, cheap; all three fixed in v1.7.0)**
|
||||||
|
- **`summary` is read as a content warning on every object type.** It is one only on a `Note`, or when
|
||||||
|
`sensitive: true` is set. Elsewhere it is something else:
|
||||||
|
|
||||||
|
| Sender | What `summary` holds |
|
||||||
|
|---|---|
|
||||||
|
| WordPress, WriteFreely, Ghost, NodeBB (`Article`) | a teaser or excerpt |
|
||||||
|
| Mobilizon, Gancio (`Event`) | the date and address, or the whole description |
|
||||||
|
| Funkwhale (`Audio`) | a line of hashtags |
|
||||||
|
| Mbin (`Page`) | a short title plus tags |
|
||||||
|
|
||||||
|
Today all of these arrive hidden behind a warning, and marked sensitive.
|
||||||
|
- **Persona and group usernames are only lowercased.** Mastodon accepts `[a-z0-9_]` with `.` and `-` only inside the
|
||||||
|
name; Misskey `^\w([\w-.]*\w)?$`, 128 characters at most. A persona named outside that is unreachable from either.
|
||||||
|
- **Our JSON-LD context does not define `postingRestrictedToMods`.** Iceshrimp.NET runs full JSON-LD expansion and
|
||||||
|
silently drops every undefined term. Any term we add later (`quote`, `Emoji`, `EmojiReact`, `interactionPolicy`,
|
||||||
|
`votersCount`) must be defined in `ActivityPubRenderer.Context()` in the same commit.
|
||||||
|
|
||||||
|
**Smaller**
|
||||||
|
- No `Vary: Accept` on actor and object URLs, although they answer HTML or JSON depending on `Accept`.
|
||||||
|
- Only `create-…` and `announce-…` activity ids dereference. `follow-`, `like-`, `accept-` and `undo-…` answer 404.
|
||||||
|
That is harmless while objects are embedded, but every id should resolve.
|
||||||
|
|
||||||
|
## 2. Rules that hold across platforms
|
||||||
|
|
||||||
|
| # | Rule | What PrivaPub must do | P |
|
||||||
|
|---|---|---|---|
|
||||||
|
| W1 | `url`, `icon`, `image`, `attributedTo`, `actor`, `tag`, `attachment` and `alsoKnownAs` can each be a single value, an object or an array. The `url` array matters most: PeerTube video files, Funkwhale streams, and Bridgy posts, whose `rel: canonical` link is `at://…`. | Parse every shape. For "open original", use the `text/html` Link. Keep every other Link as a media variant. | P1 |
|
||||||
|
| W2 | `summary` is a content warning only on a `Note`, or when `sensitive: true`. | See §1. On other types store it as an excerpt or description. NodeBB 4.16 honours a CW only on a sensitive Note. | P1 |
|
||||||
|
| W3 | `content` can be Markdown. PeerTube descriptions and comments carry `mediaType: text/markdown`. | Render it, then sanitise. Keep `source{content, mediaType}`: Markdown, BBCode, `text/x.misskeymarkdown` (MFM). | P1 |
|
||||||
|
| W4 | `mediaType` is missing or wrong: Bridgy media has none, Funkwhale hard-codes `audio/mpeg`, Gancio `image/jpeg`. Mastodon types every attachment `Document`. | Infer it from the object or attachment type, then sniff it in the media proxy. | P1 |
|
||||||
|
| W5 | Thumbnails live in five places: attachment `icon` (Mastodon), object `icon[]` (PeerTube), object `preview` (Loops), `image` (Bridgy video; Ghost as a bare string; WordPress), and `icon` (Plume). | Keep them all; choose one per kind. | P1 |
|
||||||
|
| W6 | `Accept`, `Reject` and `TentativeAccept` are not always follow answers. Friendica and Hubzilla use them as event RSVPs; Mobilizon answers a `Join`; GoToSocial answers interaction requests with `result`; Mastodon answers a `QuoteRequest`. | Route on what the object *is*. | P1 |
|
||||||
|
| W7 | `id` is not the page a person opens. WordPress uses `?p=123`, Ghost `/.ghost/…`, Bridgy `/convert/ap/at://…`. | Link to `url`, never to `id`. | P1 |
|
||||||
|
| W8 | Rich types are updated in place: PeerTube live state, WordPress (on every save), Mobilizon. A poll's counts are refreshed with an `Update{Question}` that changes nothing else. | Apply Updates to every kind. An Update with no newer `updated` is a refresh, never an edit revision. Mastodon applies the same rule. | P1 |
|
||||||
|
| W9 | A `Delete` can arrive before its `Create`, and relays and forwarders re-send old Creates. | Keep tombstones so deleted posts stay deleted. When the deleter is not the author, confirm with the origin: 404 or 410 means deleted. | P1 |
|
||||||
|
| W10 | Time comes in seconds (Mastodon), milliseconds (Misskey, us), or with offsets. `-00:00` means floating local time (Hubzilla events). `duration` is ISO 8601 (`PT6299S`). GoToSocial rejects a status whose `updated` is earlier than `published`. | Parse all of these, and clamp future times. Order by arrival (PrivacyIds.Arrived). Never emit `updated` < `published`. | P1 |
|
||||||
|
| W11 | Language may be in `contentMap`, in `@context[].@language` (Pleroma 2.9+), or a `language{identifier,name}` object (Lemmy, PeerTube). Akkoma replaces `content` with the *first* `contentMap` entry. | Read all three. When sending, put `content` and the primary `contentMap` entry first and keep them equal. | P2 |
|
||||||
|
| W12 | Alt text: `name` (Mastodon), `summary` (GoToSocial 0.20.0, Akkoma reads it first), or their `*Map` forms. Avatar and header alt is in `icon.name`/`image.name`, or `summary` on Mastodon. | Read both; send `name`. | P2 |
|
||||||
|
| W13 | `"id": null` objects (Akkoma); a `Tombstone` served with **200** as a soft delete (FEP-4f05: NodeBB, Discourse); 410 for deleted GoToSocial 0.22 statuses. | Accept a null id inside an activity; never emit one. Any `Tombstone`, whatever the status code, means deleted. | P2 |
|
||||||
|
| W14 | Size limits on the receiving side: Misskey reads at most 256 KiB, truncates text at 8192 characters, CW 512, poll choice 256, alt 512. GoToSocial takes emoji up to 100 KB. Peers cap fetches at about 1 MB. | Accept long posts from others. Keep our own documents small. | P2 |
|
||||||
|
| W15 | Hashtag `name` comes with or without `#`. Mastodon normalises with NFKC + lowercase (watch Turkish `İ`). Lemmy adds an automatic `#<community>` tag to every post. | Normalise the same way; ignore Lemmy's automatic tag. | P3 |
|
||||||
|
|
||||||
|
## 3. Per platform
|
||||||
|
|
||||||
|
### Mastodon: 4.7.2 (2026-09-15); mastodon.social runs 4.8 alpha; client `api_versions.mastodon` = 11
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
- **Objects and activities:**
|
||||||
|
- Objects: `Note` and `Question` only.
|
||||||
|
- Federated `Block`.
|
||||||
|
- `Add`/`Remove` for pins, featured hashtags and featured collections (4.6, FEP-7aa9).
|
||||||
|
- `Move`.
|
||||||
|
- `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`.
|
||||||
|
- `FeatureRequest`/`FeatureAuthorization` (4.6).
|
||||||
|
- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+).
|
||||||
|
- **Interaction counts:** `likes` and `shares` carry `totalItems`.
|
||||||
|
- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`.
|
||||||
|
- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967).
|
||||||
|
- **Attachments:** `duration`, and `icon` thumbnails.
|
||||||
|
- **Actors:**
|
||||||
|
- `webfinger`, `featuredCollections`, `interactionPolicy.canFeature`, `attributionDomains`;
|
||||||
|
- `memorial`, `suspended`, `indexable`, `discoverable`;
|
||||||
|
- avatar and header alt text in `summary`;
|
||||||
|
- several keys allowed (4.6).
|
||||||
|
- **Ids:** new accounts get numeric actor ids (4.5). Remote renames are accepted, keyed on the actor `id` (4.7).
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Fetched documents:**
|
||||||
|
- `id` equals the URL requested, exactly.
|
||||||
|
- The type is `activity+json`, or `ld+json` with the profile.
|
||||||
|
- `@context` includes the ActivityStreams URL.
|
||||||
|
- **WebFinger:** it loops back to the same `id`.
|
||||||
|
- **draft-cavage signatures:**
|
||||||
|
- `(request-target)` includes the query string (4.3).
|
||||||
|
- `digest` is signed on POST and `host` on GET.
|
||||||
|
- The window is 12 h, with 1 h of skew.
|
||||||
|
- Mastodon no longer signs `Accept` (4.6). Never require it.
|
||||||
|
- **RFC 9421:** verified by default since 4.5.0.
|
||||||
|
- A request carries one signature only.
|
||||||
|
- It must cover `@method`, `@target-uri` and `content-digest`, and include `created` and `keyid`.
|
||||||
|
- **4.7 double-knocks:** it signs draft-cavage first, and if we answer 400 or 401 it retries with RFC 9421. A 400 for
|
||||||
|
any other reason therefore triggers a retry we would fail.
|
||||||
|
- Mastodon answers **503** when it temporarily cannot fetch a key; that is a retry, not a failure.
|
||||||
|
- **Edits:** an Update counts as an edit only with a newer `updated`.
|
||||||
|
- **Quotes:** a post with no `canQuote` **cannot be quoted** by Mastodon users at all. A quote without a valid stamp
|
||||||
|
stays "pending", and only its fallback link shows.
|
||||||
|
- **Fetch all replies** is always on (4.5). Mastodon crawls `replies` as its instance actor, up to 500 per post, and
|
||||||
|
**deletes its copy** when a refetch answers 404.
|
||||||
|
- **Converted types:** `Article`, `Page`, `Event`, `Video`, `Audio` and `Image` become `<h2>name</h2>` + summary +
|
||||||
|
link. **The body is dropped.** Only `Mention` tags notify. Previews come from fetching the page; FEP-8967's
|
||||||
|
`preview` is ignored.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Quotes: read all keys; verify the `QuoteAuthorization` (type, host, `attributedTo`, `interactingObject`, `interactionTarget`; GHSA-vg36 compared domains only); revocation; strip the `.quote-inline` fallback | P1 | `Status.quote{state, quoted_status}`, `quotes_count`, `/statuses/:id/quotes`, `quote`/`quoted_update` notifications, `api_versions.mastodon ≥ 7` |
|
||||||
|
| Being quotable: emit `canQuote`; answer `QuoteRequest` with `Accept{object: request id, result: stamp}`; serve and revoke stamps | P2 | `Status.quote_approval`, `PUT /statuses/:id/interaction_policy` |
|
||||||
|
| Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification |
|
||||||
|
| Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` |
|
||||||
|
| Link attachments as the card source | P1 | `Status.card` |
|
||||||
|
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` |
|
||||||
|
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
|
||||||
|
| Inbound `Block`: stop delivering, hide | P2 | `relationship.blocked_by` |
|
||||||
|
| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` |
|
||||||
|
| Remote like and boost totals | P2 | counts |
|
||||||
|
| Publish `context` and a paged `replies` | P2 | — |
|
||||||
|
| `FeatureRequest`: send `Reject` (or implement FEP-7aa9) | P3 | — |
|
||||||
|
|
||||||
|
### GoToSocial: 0.22.1 (2026-07-20)
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
- **Objects:** `Note` and `Question` only.
|
||||||
|
- **Interaction policies** on every status: `canLike`, `canReply`, `canAnnounce` and `canQuote`, each split into
|
||||||
|
`automaticApproval` and `manualApproval`.
|
||||||
|
- Defaults: open on public and unlisted posts.
|
||||||
|
- On followers-only posts, liking and replying are limited to the author, followers and mentioned accounts, and
|
||||||
|
boosting to the author.
|
||||||
|
- `canQuote` is author-only on every post (0.21).
|
||||||
|
- **Since 0.21 it asks politely:** `LikeRequest`, `ReplyRequest` and `AnnounceRequest`, with the interaction in
|
||||||
|
`instrument`. The answer is `Accept`/`Reject` whose `result` is a `*Authorization`. The interaction then carries
|
||||||
|
`replyAuthorization` (with `approvedBy` as the legacy fallback).
|
||||||
|
- **Keys and actors:**
|
||||||
|
- The key id is `…/main-key` (no `#`), and its document is a stub actor.
|
||||||
|
- There is **no `sharedInbox`**.
|
||||||
|
- `hidesToPublicFromUnauthedWeb` / `hidesCcPublicFromUnauthedWeb`, `indexable`.
|
||||||
|
- `featured` holds URIs only, and changes to it are never announced, so read it instead.
|
||||||
|
- **What it leaves out:**
|
||||||
|
- `context`;
|
||||||
|
- `likes`/`shares`;
|
||||||
|
- attachment `width`/`height`.
|
||||||
|
- **Deleted statuses:** 0.22 keeps a stub and answers 410.
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Signed requests:** every GET and POST is signed, draft-cavage only, with RSA keys. **No RFC 9421 in either
|
||||||
|
direction.**
|
||||||
|
- **Key handshake:** the instance actor and key documents must be served **unsigned**, or both sides deadlock fetching
|
||||||
|
each other's keys. Ours are: SecureMode exempts the instance actor.
|
||||||
|
- **Content-Type:** an inbox POST must be `activity+json`, or `ld+json` with the profile. Anything else gets 406.
|
||||||
|
- **Activities:** one without an `id` is dropped. A 400 is never retried.
|
||||||
|
- **Keys:** a changed public key on refresh is refused. **Never rotate keys silently.**
|
||||||
|
- **Interaction policies:**
|
||||||
|
- Third-party GoToSocial servers drop replies that have no valid `replyAuthorization`.
|
||||||
|
- On followers-only GoToSocial posts, send `ReplyRequest` / `LikeRequest` instead of a bare Create or Like.
|
||||||
|
- **Rate limit:** 300 requests per 5 minutes per IP, answered with 503 and `Retry-After`.
|
||||||
|
- **Not accepted:** top-level `Audio`.
|
||||||
|
- **Timelines:** its cached home timeline can miss new posts. Check a delivery by URI, not through its timelines; see
|
||||||
|
CLAUDE.md, "Testing".
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions | P1 | GoToSocial-style `Status.interaction_policy` |
|
||||||
|
| Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization | P1 | own: pending/approved/rejected on our own reply |
|
||||||
|
| Honour 503 with `Retry-After` in delivery and in the proxy | P1 | — |
|
||||||
|
| Respect `hides*FromUnauthedWeb` on our public pages; emit it for personas (it suits the privacy design) | P2 | — |
|
||||||
|
| Measure media size when proxying | P2 | `MediaAttachment.meta` |
|
||||||
|
| Only advertise the policies we enforce | P2 | — |
|
||||||
|
|
||||||
|
### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17
|
||||||
|
|
||||||
|
Firefish is dead (its site has answered 410 since February 2025).
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
- **Text and quotes:**
|
||||||
|
- `_misskey_content` plus `source{content, mediaType: text/x.misskeymarkdown}` (MFM).
|
||||||
|
- The quote is `_misskey_quote`/`quoteUrl`, plus a `RE:` fallback inside the content.
|
||||||
|
- An empty CW is a zero-width space.
|
||||||
|
- **Attachments:** each carries its own `sensitive`.
|
||||||
|
- **Emoji tags** carry `_misskey_license`.
|
||||||
|
- **Polls:** `Question` with `oneOf`/`anyOf`. Counts are in `replies.totalItems` per option. `endTime` and `closed` are
|
||||||
|
set; there is **no `votersCount`**.
|
||||||
|
- A vote is `Create{Note{name, inReplyTo, to:[owner]}}`, one per choice.
|
||||||
|
- Every vote triggers an `Update{Question}`.
|
||||||
|
- **Reactions** are `Like{content: "👍"|":name:", _misskey_reaction, tag:[Emoji]}`.
|
||||||
|
- One reaction per user per note; a plain like is `❤`.
|
||||||
|
- They are sent to the author **and to the reactor's followers**.
|
||||||
|
- **Actors:**
|
||||||
|
- `isCat`, `_misskey_summary`, `_misskey_followedMessage`;
|
||||||
|
- `_misskey_requireSigninToViewContents`;
|
||||||
|
- `_misskey_makeNotesFollowersOnlyBefore`/`HiddenBefore` (seconds; a negative value is relative to now);
|
||||||
|
- `vcard:bday`, `vcard:Address`, `backgroundUrl`.
|
||||||
|
- **Vanilla Misskey has no edits.**
|
||||||
|
- **Sharkey adds:**
|
||||||
|
- edits;
|
||||||
|
- a FEP-e232 quote `Link` tag (it deliberately leaves out `quote`);
|
||||||
|
- a `replies` collection;
|
||||||
|
- `hideOnlineStatus`, `noindex`, `enableRss`, `speakAsCat`.
|
||||||
|
- It sends contentless Likes only to Mastodon-like peers, so **we always receive Like+content**.
|
||||||
|
- **Iceshrimp.NET adds:**
|
||||||
|
- `EmojiReact`, several per user, and `:name@host:` for remote emoji;
|
||||||
|
- a FEP-7888 `context`;
|
||||||
|
- `htmlMfm: true` (FEP-c16b);
|
||||||
|
- every `QuoteRequest` is auto-accepted;
|
||||||
|
- `Bite`, `pronouns`.
|
||||||
|
- **CherryPick adds:** events on a plain Note (`startTime`/`endTime`), `deleteAt`, and federated chat
|
||||||
|
(`_misskey_talk: true`).
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Inbound signatures:**
|
||||||
|
- draft-cavage over `(request-target) host date digest`;
|
||||||
|
- at most 300 s of skew;
|
||||||
|
- since 2026.10.0, the query string is included in `(request-target)`.
|
||||||
|
- **No RFC 9421 anywhere in the family.**
|
||||||
|
- **Activities:**
|
||||||
|
- An activity's `id` must be on the signer's host.
|
||||||
|
- Activities forwarded on behalf of someone else are refused. A group must `Announce`.
|
||||||
|
- Misskey answers 202 even when it drops something, so errors stay invisible.
|
||||||
|
- **Fetched documents:** request URL = final URL = `id`; ≤256 KiB; `activity+json` or `ld+json`.
|
||||||
|
- **Actor collections** must be on the actor's host.
|
||||||
|
- **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise:
|
||||||
|
- **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.**
|
||||||
|
Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404.
|
||||||
|
- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both.
|
||||||
|
- **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤.
|
||||||
|
- **Article/Page titles** are never shown in Misskey's web UI.
|
||||||
|
- **Iceshrimp.NET:**
|
||||||
|
- full JSON-LD expansion (see §1);
|
||||||
|
- `@graph`, `@reverse` and `@included` are refused;
|
||||||
|
- every actor must resolve through WebFinger;
|
||||||
|
- `preferredUsername` must be unique per domain. PrivaPub shares one name space across personas and groups, so this
|
||||||
|
holds.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Emoji reactions in all three inbound forms: Like with `content`/`_misskey_reaction`, `EmojiReact`, and `Dislike`-as-un-like (Sharkey). Normalise `:n:`, `:n@host:` and `n@host`. Store per actor, emoji and activity, including reactions to remote posts. Undo by id. | P1 | `emoji_reactions` and `pleroma.emoji_reactions` `[{name,count,me,url,static_url}]` (Phanpy reads the first); `PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` |
|
||||||
|
| Outbound reaction as `EmojiReact{content: ":name:", tag:[Emoji]}`. A plain Like stays a favourite. | P2 | same |
|
||||||
|
| Polls: per-option counts (fall back to `_misskey_votes`); `votersCount` null when absent; `Update{Question}` is a refresh; an inbound `Note{name, inReplyTo: question}` with no content is a vote, never a reply | P1 | `Status.poll` |
|
||||||
|
| Keep MFM source; let the sanitiser keep `<span class="mfm-*" data-mfm-*>` and `<ruby>` (FEP-c16b) | P2 | `text`, `content_type`; the rich client renders MFM from the source |
|
||||||
|
| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` |
|
||||||
|
| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — |
|
||||||
|
|
||||||
|
### Pleroma 2.10.2 and Akkoma 3.20.1
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
- **Notes:**
|
||||||
|
- `@context` with the instance's own `litepub-0.1.jsonld` URL (never fetch it) and `@language`;
|
||||||
|
- `source{content, mediaType: text/markdown}`;
|
||||||
|
- `context` and `conversation` holding the same value;
|
||||||
|
- `quoteUrl`/`quoteUri`.
|
||||||
|
- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions.
|
||||||
|
- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like.
|
||||||
|
- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`.
|
||||||
|
- **Pleroma only:**
|
||||||
|
- `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`;
|
||||||
|
- `Listen{Audio}`;
|
||||||
|
- outgoing `Block` is on by default.
|
||||||
|
- **Akkoma only:**
|
||||||
|
- local-only posts are addressed to `<base>/#Public`, which is **not** public;
|
||||||
|
- FEP-2c59.
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its
|
||||||
|
list (develop, 2026-09-30). Treat that 4xx as final.
|
||||||
|
- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future.
|
||||||
|
- **Activity ids** must be at least 8 bytes.
|
||||||
|
- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate.
|
||||||
|
- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f
|
||||||
|
`quote`**, so send `quoteUri` and `_misskey_quote` as well.
|
||||||
|
- **Edits:** a changed `name` is ignored on update.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Language from `@context[].@language` | P1 | `Status.language` |
|
||||||
|
| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` |
|
||||||
|
| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent |
|
||||||
|
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
|
||||||
|
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
|
||||||
|
|
||||||
|
### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May
|
||||||
|
|
||||||
|
join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled`
|
||||||
|
on a Page: pins live in `featured`, locks in `Lock`.
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
- **Group:**
|
||||||
|
- `summary` (sidebar HTML) and `source` (sidebar Markdown); a plain `description` in 1.0;
|
||||||
|
- `sensitive`; `attributedTo` → the moderators collection; `featured`; `postingRestrictedToMods`; `language[]`;
|
||||||
|
- 1.0 adds: `manuallyApprovesFollowers` for private communities, `discoverable: false` for unlisted ones, and the
|
||||||
|
community's post tags in `tag[]` as `CommunityPostTag` with colour slots `color01`–`color10`.
|
||||||
|
- **Page (post):**
|
||||||
|
- `name`, `content` and `source`;
|
||||||
|
- a link post is `attachment[0] = Link{href, mediaType}`, with `image` as the thumbnail;
|
||||||
|
- 1.0 image posts are `Image{url, name}`, where `name` is the alt text;
|
||||||
|
- `language{identifier, name}`, `audience`, `to: [community, Public]`;
|
||||||
|
- an automatic `#<community>` hashtag;
|
||||||
|
- 1.0 adds a Mention of the community and `context`.
|
||||||
|
- **Note (comment):** `distinguished`.
|
||||||
|
- **Votes:** `Like`/`Dislike` as `{actor, object, audience}` with no `to`/`cc`, and their `Undo`.
|
||||||
|
- **Moderation:**
|
||||||
|
- a `Delete` with a `summary` is a mod removal (1.0 adds `withReplies`); its `Undo` restores;
|
||||||
|
- `Lock`/`Undo{Lock}` (1.0 also on comments);
|
||||||
|
- a ban is `Block{target, removeData, summary, endTime}` (0.19 also sends `expires`);
|
||||||
|
- `Add`/`Remove` of featured posts and moderators;
|
||||||
|
- `Update{Group}` **by the moderator Person**;
|
||||||
|
- 1.0 adds `Warn` and `Resolve{Flag}`.
|
||||||
|
- **Delivery:** everything travels inside the community's `Announce`. For a new post Lemmy also sends a compatibility
|
||||||
|
`Announce(Page)` with a synthetic id; deduplicate it.
|
||||||
|
- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0.
|
||||||
|
- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post
|
||||||
|
thread**. Group threads by the root post, never by comparing `context` strings.
|
||||||
|
- **Feeds:** multi-communities are 1.0's `type: Feed` actors.
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with
|
||||||
|
the profile; and `id` equals the URL fetched.
|
||||||
|
- **Addressing:**
|
||||||
|
- Create, Update, Lock, Delete and Block **must carry both `to` and `cc` arrays**.
|
||||||
|
- A post's community is the first Group found in `to` ∪ `cc`.
|
||||||
|
- In a public community, the object, the activity **and the Announce** all include Public.
|
||||||
|
- **Accepted types:** Page, Article, Note, Video and Event become posts. **`Question` is dropped.**
|
||||||
|
- **Anti-spam:** activities for a community are accepted only if a local user follows it.
|
||||||
|
- **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or
|
||||||
|
Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an
|
||||||
|
Application instance actor, probably fail *(unconfirmed)*.
|
||||||
|
- **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`.
|
||||||
|
- **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its
|
||||||
|
actor is in the moderators list Lemmy fetched.
|
||||||
|
- **1.0 hides a local user's post or comment in a remote community until that community Announces it back.** A
|
||||||
|
community we host must therefore announce to the author's own instance too.
|
||||||
|
- **Refused:** Lemmy does not accept an incoming `Announce(Page)`.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| `Dislike` and its `Undo`: a vote ledger per object (actor, ±1, activity, relaying group, time) | P1 | `favourites_count` = upvotes; own `privapub.vote{score, up, down, mine}`, `POST …/vote` |
|
||||||
|
| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. | P1 | — |
|
||||||
|
| Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` |
|
||||||
|
| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` |
|
||||||
|
| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` |
|
||||||
|
| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — |
|
||||||
|
| Communities we host: announce to every follower instance including the author's; pick `Announce(object)` per peer by NodeInfo (as PieFed does) | P1 | — |
|
||||||
|
| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — |
|
||||||
|
| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning |
|
||||||
|
| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` |
|
||||||
|
| Serve our communities' collections the way Lemmy reads them: inline outbox of `Announce{Create{Page}}`, inline featured Pages, inline moderators. Lemmy does not page. | P2 | — |
|
||||||
|
| `Feed` actors | P2 | group-like account |
|
||||||
|
| Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — |
|
||||||
|
|
||||||
|
### PieFed 1.7.17 and Mbin 1.10.1
|
||||||
|
|
||||||
|
- **PieFed sends Lemmy's set plus:**
|
||||||
|
- posts: `commentsEnabled`, `stickied`, `nsfl`, `genAI`, `searchableBy`, `canQuote`;
|
||||||
|
- galleries of several Documents;
|
||||||
|
- **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s;
|
||||||
|
- flairs in two dialects;
|
||||||
|
- comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities;
|
||||||
|
- `Move{object: post}` between communities;
|
||||||
|
- `Feed` actors;
|
||||||
|
- emoji Likes and `EmojiReact` count as upvotes.
|
||||||
|
- **How PieFed formats what it sends us:**
|
||||||
|
- It **chooses the format by our NodeInfo software name**, so keep that accurate.
|
||||||
|
- Software it lists as microblogs gets a bare `Announce(URL)`.
|
||||||
|
- A poll vote is recognised only when it is a `Note{name, inReplyTo}` **without `published`**.
|
||||||
|
- Batched Announces (FEP-1a11) go only to PieFed and Pylova.
|
||||||
|
- **Mbin:**
|
||||||
|
- Thread `summary` is "short title + tags", so it is **not a CW**.
|
||||||
|
- On link threads `source` is a **plain URL string**.
|
||||||
|
- `commentsEnabled` and `stickied` are sent.
|
||||||
|
- A Person's `Announce` counts as an upvote; Mbin reads the `likes`/`dislikes`/`shares` counts we publish.
|
||||||
|
- Private messages are `ChatMessage`.
|
||||||
|
- The magazine outbox is empty.
|
||||||
|
- Mbin also auto-ingests Mastodon posts by hashtag and Announces them.
|
||||||
|
- **Gaps:**
|
||||||
|
- **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string.
|
||||||
|
- **P2:** galleries; community polls (vote without `published`); post `Move`; `repliesEnabled`; `nsfl`; flairs;
|
||||||
|
publish `likes`/`shares` totals.
|
||||||
|
- **P3:** `genAI`; accepted answers; batched Announces.
|
||||||
|
|
||||||
|
### NodeBB 4.16, Discourse, Friendica
|
||||||
|
|
||||||
|
- **NodeBB:**
|
||||||
|
- A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes.
|
||||||
|
- Categories are `Group`s **without `followers`**.
|
||||||
|
- `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`.
|
||||||
|
- Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors.
|
||||||
|
- It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd).
|
||||||
|
- **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an
|
||||||
|
OrderedCollection used as `context`.
|
||||||
|
- **Friendica** (2026.05-1):
|
||||||
|
- Group accounts relay with `Announce(object)`.
|
||||||
|
- Titled posts are `Page`/`Article`; it sends `Dislike`.
|
||||||
|
- `instrument{Service}` names the software.
|
||||||
|
- It sends **`Follow` with a post as the object**, meaning "include me in this thread". Answer that with `Reject` or
|
||||||
|
ignore it, without an error.
|
||||||
|
- **Gaps:**
|
||||||
|
- **P1:** W2 for NodeBB Articles (`privapub.excerpt`).
|
||||||
|
- **P2:** Groups without `followers`; Announces from an Application; context Move/Remove; paged `context` with ETag;
|
||||||
|
Friendica's thread-Follow.
|
||||||
|
|
||||||
|
### PeerTube 8.3.1 (2026-09-28)
|
||||||
|
|
||||||
|
**Emits**
|
||||||
|
|
||||||
|
The account sends `Create{Video}`; the channel (a Group) sends `Announce{Video}`, so deduplicate.
|
||||||
|
|
||||||
|
| Part of the Video | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| Attribution | `attributedTo: [Person, Group]` (both, possibly bare URLs); `audience` = the channel |
|
||||||
|
| Description | Markdown in `content` with `mediaType: text/markdown`; `summary` is the CW (since 7.2) |
|
||||||
|
| `url[]` | A `text/html` watch page; per-resolution mp4 Links (`height`, `width`, `fps`, `size`, ffprobe codec types); HLS `application/x-mpegURL` (since 6.3 audio and video can be separate, with "0" as the audio-only resolution); torrent and magnet; a metadata JSON |
|
||||||
|
| Images | `icon[]`: thumbnails up to 1920 px; `preview`: storyboards |
|
||||||
|
| Captions | `subtitleLanguage[]` with VTT and HLS URLs |
|
||||||
|
| Chapters | `hasParts` |
|
||||||
|
| Playback and metadata | `duration` (ISO 8601), `views`, `state`, `isLiveBroadcast`, `permanentLive`, `latencyMode`, `commentsPolicy`, `downloadEnabled`, `category`, `licence`, `language`, `support`, `uuid`, `embedUrl`, `originallyPublishedAt`, `schedules`, `aspectRatio` |
|
||||||
|
| Sensitivity | `SensitiveTag` |
|
||||||
|
|
||||||
|
- **Value meanings:**
|
||||||
|
- **Live now** means `isLiveBroadcast && state == 1`.
|
||||||
|
- `commentsPolicy`: 1 open, 2 closed, 3 needs approval.
|
||||||
|
- **Other activities:**
|
||||||
|
- Comments are Markdown Notes.
|
||||||
|
- `View` comes from the server's Application actor.
|
||||||
|
- `Dislike`; `ApproveReply` (FEP-5624, since 6.2); `CacheFile` (mirrors); playlists.
|
||||||
|
|
||||||
|
**Expects**
|
||||||
|
- **Replies** must:
|
||||||
|
- be Public;
|
||||||
|
- have non-empty `content`, a valid `url` and `published`;
|
||||||
|
- have an `id` on the actor's host;
|
||||||
|
- have an `inReplyTo` that resolves to the video or one of its comments.
|
||||||
|
- `commentsPolicy` 2 rejects replies; 3 holds them until approved.
|
||||||
|
- PeerTube signs its fetches.
|
||||||
|
- It drops followers that have been unreachable for about 7 days (8.2).
|
||||||
|
|
||||||
|
**What Mastodon does with it:** `<h2>name</h2>` + summary + link. The description is dropped and there is no
|
||||||
|
attachment. The player is a card whose iframe loads from the remote host, which our proxy rule forbids.
|
||||||
|
|
||||||
|
**Gaps**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Store the whole Video: variants, thumbnails, storyboards, captions, chapters, duration, live state, views, `commentsPolicy`, licence, category, language, support, channel | P1 | own `privapub.video` |
|
||||||
|
| Play through the proxy: a `MediaAttachment{type: video}` pointing at a proxied muxed mp4 (a `web-video` file, or a fragmented file whose codec types include both audio and video); `preview_url` = a ~560 px `icon`; `meta.original` with width, height, `frame_rate`, `duration` | P1 | `media_attachments` |
|
||||||
|
| The proxy answers **Range** requests and rewrites HLS playlists and caption URLs to proxied ones. A 720p file is about 0.9 GB, so stream it; never buffer. | P1 | — |
|
||||||
|
| A video card made from the object, **without** a remote iframe | P1 | `Status.card{type: video}` |
|
||||||
|
| Reply rules: closed when `commentsPolicy` is 2; replies sent Public with a `url`; `ApproveReply` shows our reply as pending | P1 / P2 | own |
|
||||||
|
| Dislike counts; live state through `Update`; chapters and captions | P2 | own |
|
||||||
|
| Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — |
|
||||||
|
|
||||||
|
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
|
||||||
|
|
||||||
|
- **Loops:**
|
||||||
|
- A video is a Note with one mp4 `Document` whose `url` is a string. **The poster is in the object's `preview`.**
|
||||||
|
Videos are vertical.
|
||||||
|
- Its interactionPolicy follows GoToSocial's model.
|
||||||
|
- It sends `QuoteRequest` and `FeatureRequest`.
|
||||||
|
- A top-level post it accepts must be a Note with an mp4 attachment, from an instance its admin allowlisted, **≤ 100 MB,
|
||||||
|
checked with a HEAD request**. Our media must answer HEAD.
|
||||||
|
- **Pixelfed:**
|
||||||
|
- Posts are a Note with attachments.
|
||||||
|
- It also sends `location: Place{name, latitude, longitude, country}`, `commentsEnabled`, `capabilities`, and
|
||||||
|
`canQuote` (0.14).
|
||||||
|
- Stories are `Add{Story}` with a bearcap only Pixelfed understands.
|
||||||
|
- Pixelfed 0.14 does FEP-044f and FEP-8fcf.
|
||||||
|
- **What Pixelfed accepts:**
|
||||||
|
- Only `Note`s, and **a top-level post must have media**.
|
||||||
|
- **Every** attachment must be a Document or Image with a string `url` and a `mediaType` in the instance's list. The
|
||||||
|
default list is **jpeg, png and gif only**, and a single webp or avif attachment rejects the whole post.
|
||||||
|
- **Gaps:**
|
||||||
|
- **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`.
|
||||||
|
- **P1 inbound:** Loops' `preview` poster.
|
||||||
|
- **P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated; `commentsEnabled: false`
|
||||||
|
disables replies.
|
||||||
|
- **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`.
|
||||||
|
|
||||||
|
### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2
|
||||||
|
|
||||||
|
FEP-b2b8 (draft) describes the shape: plain-text `name`, a `summary` teaser (≤500), full HTML `content`, `image`, and
|
||||||
|
a `preview` Note fallback.
|
||||||
|
|
||||||
|
- **WordPress:**
|
||||||
|
- Object type: an Article for a titled post, a Page for a page, otherwise a Note.
|
||||||
|
- Fields: `image` (the featured image), `preview`, `interactionPolicy.canQuote`.
|
||||||
|
- A CW is `sensitive` + `summary` + `dcterms:subject`.
|
||||||
|
- `id` is `?p=123`, different from `url`.
|
||||||
|
- The blog actor is a Group with `attributionDomains`.
|
||||||
|
- It sends an `Update` on every save, and **signs with RFC 9421 first** (9.3.0), falling back to draft-cavage after
|
||||||
|
any 4xx.
|
||||||
|
- It drops followers-only replies.
|
||||||
|
- **Ghost 6** (its ActivityPub service is separate, built on Fedify):
|
||||||
|
- Article with `image` as a bare string and `preview`; members-only parts removed.
|
||||||
|
- It refetches every object signed, **never applies remote Updates**, and accepts only Note and Article.
|
||||||
|
- Public is addressed as `as:Public`.
|
||||||
|
- **WriteFreely:** Article when the body has a paragraph break. **`preview` reuses the Article's id**, so never store
|
||||||
|
it as its own post. It has no comments.
|
||||||
|
- **Gaps:**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| An Article shown in the Mastodon API: `content` = name + teaser (`summary`, else `preview.content`) + a link to `url`; a card made from the object (title, description, `image` then `icon`, author, provider, date) | P1 | `Status.content`, `Status.card` |
|
||||||
|
| The full sanitised HTML kept for a reader view | P1 | own `privapub.article{title, html, cover, excerpt}` |
|
||||||
|
| Body images duplicated in `attachment` removed; `attributedTo` arrays resolved to the Person | P2 | — |
|
||||||
|
|
||||||
|
### Events: Mobilizon 5.2.4, Gancio 1.28, Friendica/Hubzilla/Forte
|
||||||
|
|
||||||
|
FEP-8a8e (draft) is the common reference.
|
||||||
|
|
||||||
|
- **Mobilizon:**
|
||||||
|
- Times and status: `startTime`, `endTime`, `timezone`, `status`/`ical:status`, `isOnline`, `draft`.
|
||||||
|
- Place: `location: Place{address: PostalAddress, latitude, longitude}`.
|
||||||
|
- Participation: `joinMode`, `participantCount`, `maximumAttendeeCapacity`, `remainingAttendeeCapacity`,
|
||||||
|
`anonymousParticipationEnabled`.
|
||||||
|
- Comments: `repliesModerationOption`, `commentsEnabled`.
|
||||||
|
- Other: `category`, `contacts`.
|
||||||
|
- Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`.
|
||||||
|
- The event is attributed to the Group.
|
||||||
|
- **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`;
|
||||||
|
`Leave`.
|
||||||
|
- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP.
|
||||||
|
- **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`,
|
||||||
|
with HTML in `location.content`, and `-00:00` for floating times.
|
||||||
|
- **FEP-8a8e:** a server that does not handle joins answers `Join` with `Ignore`.
|
||||||
|
- **Gaps:**
|
||||||
|
|
||||||
|
| Gap | P | Client surface |
|
||||||
|
|---|---|---|
|
||||||
|
| Store times, time zone and place in every one of those shapes; RSVP routing (W6); `Invite{Event}`; answer `Join` with `Ignore` until RSVP exists | P1 | `content` = title + "date (zone) · place" + link; card with the banner |
|
||||||
|
| Capacity, join mode, online link, status, category | P2 | own `privapub.event` |
|
||||||
|
| RSVP out (`Join`/`Leave`, stable ids), from the persona the user chose | P2 | own `…/rsvp` |
|
||||||
|
|
||||||
|
### Audio: Funkwhale 2.0.11, Castopod 1.15.5, Owncast 0.3.0
|
||||||
|
|
||||||
|
- **Funkwhale:**
|
||||||
|
- `Create{Audio}` whose `url` is an array: a page link plus the stream, with `bitrate` and `size`.
|
||||||
|
- Also `duration`, `position`, `disc`, `album`, `license` and `image`; `summary` is a hashtag line (W2).
|
||||||
|
- `Listen{Track}`.
|
||||||
|
- **Castopod:** an episode arrives as a link-only Note; the player comes from OpenGraph/oEmbed. Fetching the episode
|
||||||
|
with the podcast type gives a `PodcastEpisode` with transcript and chapters.
|
||||||
|
- **Owncast:** a `Service` actor; "go live" is a Note with a thumbnail.
|
||||||
|
- **Gaps:**
|
||||||
|
- **P1:** pick the audio Link out of `url`, sniff its type, apply W2. Surfaces as `MediaAttachment{type: audio}`
|
||||||
|
with `meta.original.duration` and the cover as `preview_url`.
|
||||||
|
- **P2:** duration, cover, album, position and licence (own `privapub.audio`); the Castopod transcript and chapters.
|
||||||
|
- **P3:** `Listen` history; Owncast live state.
|
||||||
|
|
||||||
|
### Books: BookWyrm 0.9.3, NeoDB 0.19.4
|
||||||
|
|
||||||
|
- **BookWyrm:**
|
||||||
|
- **To non-BookWyrm servers** it sends a review as an `Article` named `Review of "Title" (★★★★): …`, a comment or
|
||||||
|
quotation as a Note with the citation appended, and the cover as a `Document`.
|
||||||
|
- The fields `inReplyToBook`, `rating` and `quote` go only to BookWyrm servers.
|
||||||
|
- **NeoDB:** `relatedWith[]` carries the item, the rating (out of 10), the review and the shelf; `tag[]` holds catalogue
|
||||||
|
items (`{type: "Movie", href, name, image}`).
|
||||||
|
- **Gaps:**
|
||||||
|
- **P1:** keep the Article `name`, `tag` entries that are not Mention or Hashtag, and covers that have no blurhash.
|
||||||
|
- **P2:** keep `relatedWith`, `rating` and `inReplyToBook` raw; a card for the item; own
|
||||||
|
`privapub.review{item, rating, scale}`.
|
||||||
|
|
||||||
|
### Threads, Flipboard, Bluesky via Bridgy Fed
|
||||||
|
|
||||||
|
- **Threads:**
|
||||||
|
- **Unsigned GETs answer 404**, so fetch it signed, as our instance actor.
|
||||||
|
- Quotes arrive as `_misskey_quote` + a FEP-e232 tag + an `RE:` fallback.
|
||||||
|
- Polls are not federated.
|
||||||
|
- Threads users must be 18 or over, opt in, and live outside the EU.
|
||||||
|
- Threads blocks servers that ignore deletes or have no privacy policy.
|
||||||
|
- **P1:** process `Delete` promptly; publish a privacy policy and a minimum age.
|
||||||
|
- **Flipboard:**
|
||||||
|
- Each flip is a link-only Note: headline, a link with `utm_*` parameters, and a Mention of the magazine.
|
||||||
|
- Magazines are Groups that `Announce` bare URIs.
|
||||||
|
- **P1:** fetch objects that arrive as bare-URI Announces (we do).
|
||||||
|
- **P2:** a card, or the post reads as a bare headline; deduplicate by canonical URL with `utm_*` stripped.
|
||||||
|
- **Bridgy Fed:**
|
||||||
|
- Actors are `https://bsky.brid.gy/ap/did:plc:…`, with handles like `@x.bsky.social@bsky.brid.gy` (**dots in the
|
||||||
|
user part**). `alsoKnownAs` holds `did:` values; there is no `published` and the outbox is empty.
|
||||||
|
- Posts: a `url` array with an `at://` canonical link (W1); **media without `mediaType`** (W4); link embeds flattened
|
||||||
|
into `content`; video thumbnails in `image`; long-form as Article.
|
||||||
|
- **Bridging a persona:**
|
||||||
|
- opt-in, by following the bot;
|
||||||
|
- the persona needs an `icon` and must be at least 7 days old;
|
||||||
|
- only public posts are bridged;
|
||||||
|
- **opting out takes a `Block` sent to the bot**, which we never send today.
|
||||||
|
- **Privacy note:** two personas created on the same day share the same day-truncated `published`. That is weak,
|
||||||
|
but a link.
|
||||||
|
- **P2:** `Account.created_at` fallback; the Bridgy opt-out Block (see §6).
|
||||||
|
|
||||||
|
## 4. What the rich client needs from the server
|
||||||
|
|
||||||
|
The rule: **never drop what arrived; keep it typed where we understand it and raw where we don't.** The Mastodon API
|
||||||
|
keeps working for existing apps. Everything it cannot express goes under a `privapub` object on the same entities,
|
||||||
|
following the precedent of `pleroma.*` and `akkoma.*`, plus a few endpoints of our own.
|
||||||
|
|
||||||
|
### 4.1 One post, many kinds
|
||||||
|
|
||||||
|
`Post` gains a `Kind` and one typed payload per kind.
|
||||||
|
|
||||||
|
| Kind | From | Typed payload | Mastodon API view | `privapub.*` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| note | everyone | — | as today | `source` (Markdown/MFM/BBCode), per-attachment `sensitive` |
|
||||||
|
| article | WordPress, Ghost, WriteFreely, NodeBB, BookWyrm, Bridgy | title, excerpt, cover, full HTML, `preview` | content = title + excerpt + link; card from the object | `article` (reader view) |
|
||||||
|
| video | PeerTube, Loops, Bridgy, Owncast | variants, HLS, poster, storyboards, captions, chapters, duration, live state, licence, channel | proxied `video` attachment + card | `video` |
|
||||||
|
| audio | Funkwhale, Castopod | stream variants, cover, duration, album, position, licence, transcript, chapters | `audio` attachment | `audio` |
|
||||||
|
| event | Mobilizon, Gancio, PieFed, Friendica, Hubzilla, CherryPick | start, end, zone, place, online link, join mode, capacity, status | text + card | `event` (+ RSVP) |
|
||||||
|
| poll | Mastodon, Misskey, Pleroma, GoToSocial, PieFed | options, counts, multiple, end, closed, voters | `poll` | — |
|
||||||
|
| link | Lemmy, PieFed, Mbin, Flipboard, Mastodon 4.7 | href, thumbnail, alt, author | `card` | `link` |
|
||||||
|
| review | BookWyrm, NeoDB | item, rating, scale, shelf | text + card | `review` |
|
||||||
|
| page / thread | Lemmy, PieFed, Mbin, NodeBB | title, community, flair, lock, removal, score | content + `card` for links | `thread{community, flairs, locked, removed, score, distinguished}` |
|
||||||
|
|
||||||
|
These cut across every kind:
|
||||||
|
|
||||||
|
| Feature | Mastodon API | `privapub.*` |
|
||||||
|
|---|---|---|
|
||||||
|
| Quote | `quote{state, quoted_status}` | the URI when it could not be fetched |
|
||||||
|
| Emoji | `emojis` | — |
|
||||||
|
| Reactions | `emoji_reactions` (and `pleroma.emoji_reactions`) | — |
|
||||||
|
| Votes | — | `vote{score, up, down, mine}` |
|
||||||
|
| Interaction policy | GoToSocial-style `interaction_policy` | — |
|
||||||
|
| Edit history | `edited_at`, `/history` | — |
|
||||||
|
|
||||||
|
### 4.2 Linking things together
|
||||||
|
|
||||||
|
Every reference is kept as a URI even when the target could not be fetched: the parent, the quoted post, the
|
||||||
|
community, the channel, the book, the original `url`. The client can then link out where it cannot embed. This
|
||||||
|
follows the `akkoma.in_reply_to_apid` / `akkoma.quote_apid` precedent.
|
||||||
|
|
||||||
|
A link opens `url` (W7), never `id`. Media and thumbnails only ever go through the proxy, so the client never contacts
|
||||||
|
a remote host.
|
||||||
|
|
||||||
|
### 4.3 Details view ("nerd stats")
|
||||||
|
|
||||||
|
`GET /api/privapub/v1/statuses/:id/provenance` (and the same for accounts):
|
||||||
|
|
||||||
|
| Group | Fields |
|
||||||
|
|---|---|
|
||||||
|
| Raw | The object exactly as received, with its hash. Every later refetch and `Update`, with timestamps. The `@context` as sent (the namespaces show `toot`, `misskey`, `litepub`, `lemmy`, `pt`, `mz`, `gts`, `fedibird`, …). |
|
||||||
|
| Path in | How it arrived: direct Create, inbox forward, Announce (and by whom: community, channel, magazine, relay), backfill, or fetch on demand. Delivered to the personal or the shared inbox. |
|
||||||
|
| Trust | Signature scheme: draft-cavage (algorithm string, signed headers, query signed or not), RFC 9421, FEP-8b32 proof, or verified by refetch from origin. Key id and key type. LD signature present but ignored. |
|
||||||
|
| Time | `published`, `updated`, received, and their gaps (backdated posts, clock skew). |
|
||||||
|
| Extensions | Detected from properties: 044f `quote`, `interactionPolicy`, 7888 `context`, 8b32 `proof`, 521a `assertionMethod`, 8967 Link attachment, c16b `htmlMfm`, `_misskey_*`, `searchableBy`, `formerRepresentations`, FEP-1b12 `audience`. |
|
||||||
|
| Origin | Software and version from NodeInfo, with a family. This is for display only: FEP-0151 says the names are opaque, so never branch on them except where a peer does the same to us (PieFed). |
|
||||||
|
| Media | Variants with codec, fps, size and bitrate; infohashes, magnets and mirrors; licence. |
|
||||||
|
| Counts | Remote likes, boosts, views, downloads and dislikes as last seen, with the time. |
|
||||||
|
| Interactions | Policy as received, defaults applied, approval state, authorization URIs. |
|
||||||
|
| Moderation | Removals, locks and bans as received from a community, with reasons. |
|
||||||
|
|
||||||
|
`GET /api/privapub/v1/instances/:host` returns cached NodeInfo (`metadata` extras: Misskey `themeColor`/`maxNoteTextLength`,
|
||||||
|
Pleroma `features[]`/`federation.mrf_policies`), the instance API's icon and description, the delivery health our
|
||||||
|
circuit breaker sees, and which signature scheme worked.
|
||||||
|
|
||||||
|
`software.name` → family, for display:
|
||||||
|
|
||||||
|
| Family | `software.name` values |
|
||||||
|
|---|---|
|
||||||
|
| Mastodon | mastodon (glitch-soc shows `+glitch` in the version), hometown, fedibird, kmyblue |
|
||||||
|
| Misskey | misskey, sharkey, cherrypick, iceshrimp, firefish, foundkey, catodon |
|
||||||
|
| Pleroma | pleroma, akkoma |
|
||||||
|
| Microblog | gotosocial, takahe, hollo, mitra, snac, smithereen, ktistec, wafrn, bonfire, friendica, hubzilla, streams/forte |
|
||||||
|
| Threadiverse | lemmy, piefed, mbin, kbin, lotide, nodebb, discourse |
|
||||||
|
| Media | peertube, pixelfed, loops, funkwhale, owncast, vernissage, castopod |
|
||||||
|
| Publishing | wordpress, ghost, writefreely, plume |
|
||||||
|
| Events | mobilizon, gancio |
|
||||||
|
| Other | bookwyrm, neodb, forgejo |
|
||||||
|
| Bridges and relays | bridgy-fed, activityrelay |
|
||||||
|
|
||||||
|
## 5. Signatures, identity and transport
|
||||||
|
|
||||||
|
| Topic | State on 2026-10-01 | PrivaPub | P |
|
||||||
|
|---|---|---|---|
|
||||||
|
| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 |
|
||||||
|
| RFC 9421 outbound | Mastodon 4.7 double-knocks | draft-cavage first; RFC 9421 after a 401; remember per host | P2 |
|
||||||
|
| 400 vs 401 | A 400 or 401 makes Mastodon 4.7 and WordPress retry with the other scheme | 401 only for signature failures (we do this); 400 only for bodies that are really malformed | P1 (keep) |
|
||||||
|
| Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 |
|
||||||
|
| Keys | `publicKey` can be an array (Mastodon 4.6). FEP-521a `assertionMethod` Multikey is FINAL (Ed25519 `z6Mk…`). GoToSocial key ids have no `#` and point at a stub. | Read all of these | P2 |
|
||||||
|
| Integrity proofs | FEP-8b32 `eddsa-jcs-2022`: JCS, no JSON-LD. Sent by Mitra, Streams, Hubzilla, Fedify and others; Mastodon verifies them from 4.7 | Verify, so relayed or forwarded objects need no refetch | P2 |
|
||||||
|
| LD signatures | Mastodon still sends `RsaSignature2017` | Ignore, and refetch from origin (we do) | — |
|
||||||
|
| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 |
|
||||||
|
| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 |
|
||||||
|
| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 |
|
||||||
|
| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 |
|
||||||
|
| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 |
|
||||||
|
| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 |
|
||||||
|
| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 |
|
||||||
|
| Search consent | `indexable` (missing = false), `discoverable`, `searchableBy` (FEP-268d, which takes precedence) | Honour all three; emit explicit `false` per persona | P2 |
|
||||||
|
|
||||||
|
## 6. Choices the privacy design has to make
|
||||||
|
|
||||||
|
These change what PrivaPub reveals, so they are the owner's calls, not implementation details. **All six were decided on
|
||||||
|
2026-10-01; the decisions are in `docs/ROADMAP.md`, "Owner decisions on what PrivaPub reveals".** In short:
|
||||||
|
1. pages are fetched by the server for public posts;
|
||||||
|
2. blocks federate;
|
||||||
|
3. website authorship stays off;
|
||||||
|
4. PeerTube views are never sent;
|
||||||
|
5. Bluesky bridging is per persona, with persona dates randomised;
|
||||||
|
6. a one-time notice before the first reaction or vote.
|
||||||
|
|
||||||
|
The options as they were laid out:
|
||||||
|
|
||||||
|
1. **Link previews.**
|
||||||
|
- Building a card from the object, or from FEP-8967 `preview` data, fetches nothing; we do that.
|
||||||
|
- Fetching the linked page tells that site our server read the link. Cache per URL across personas, so no fetch
|
||||||
|
ties a page to one persona; add jitter.
|
||||||
|
- Proposed admin switch: *off / from the object only / fetch*.
|
||||||
|
2. **Outbound Block.**
|
||||||
|
- Blocks are never sent today.
|
||||||
|
- Bridgy Fed's opt-out *requires* one, and Mastodon, GoToSocial and Misskey federate blocks as a matter of course.
|
||||||
|
- Proposal: never by default; an explicit per-persona "tell their server" option.
|
||||||
|
3. **`attributionDomains` / `fediverse:creator`.** It ties a persona to a website. Opt-in per persona only.
|
||||||
|
4. **PeerTube `View`.** Counting a view tells the origin a video was watched. If ever, send it from the instance actor.
|
||||||
|
5. **Bridging to Bluesky.** It is per persona; each persona needs its own avatar and 7 days of age. Same-day personas
|
||||||
|
share a `published` day.
|
||||||
|
6. **Emoji reactions and votes** are public by nature on every platform that has them. The client should say so
|
||||||
|
before the first one.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
The full source lists, with versions and dates, were gathered by five research passes on 2026-10-01. The primary
|
||||||
|
ones:
|
||||||
|
|
||||||
|
| Area | Sources |
|
||||||
|
|---|---|
|
||||||
|
| Mastodon | `CHANGELOG.md` (4.3.0–4.7.2); docs.joinmastodon.org (`spec/activitypub`, `spec/security`, `spec/webfinger`, `client/quotes`, `client/collections`, entities); the `main` source (`note_serializer.rb`, `activity/{create,update,accept,delete,quote_request,move,like,flag}.rb`, `status_parser.rb`, `media_attachment_parser.rb`, `fetch_link_card_service.rb`, `verify_quote_service.rb`, `fetch_all_replies_service.rb`, `signed_request.rb`); developer posts for 4.5, 4.6 and 4.7; GHSA-vwhj, GHSA-rwcw, GHSA-vg36; issue #39997 (spam, July 2026) |
|
||||||
|
| GoToSocial | Codeberg releases 0.18.0–0.22.1; `docs/federation/*` (`interaction_controls`, `posts`, `actors`, `http_signatures`, `access_control`); `internal/typeutils`, `internal/ap`, `internal/federation`, `internal/transport`; issues #4939, #1894, #4994, #4849, #4677 |
|
||||||
|
| Misskey family | misskey-dev/misskey develop `ed9654b` (`ApRendererService`, `ApInboxService`, `ApNoteService`, `ApAudienceService`, `ReactionService`, `check-against-url`); PR #16250; Sharkey develop `effcecb`; Iceshrimp.NET dev `926fcde` (`FEDERATION.md`, `LdHelpers.cs`, `HttpSignature.cs`, `NoteRenderer.cs`); CherryPick develop |
|
||||||
|
| Pleroma, Akkoma | Pleroma develop `cfeca7f` (`transmogrifier.ex`, `inbox_guard_plug.ex`, `ap_extensions.md`); Akkoma develop (`FEDERATION.md`, `CHANGELOG.md`, `nodeinfo_extensions.md`) |
|
||||||
|
| Lemmy | LemmyNet/lemmy `main` `f1476db` and tag 0.19.20 (`crates/apub/apub/assets/*` fixtures, `objects/src/protocol/*`, `activities/src/protocol/*`, the pending-post migration); PRs #5856, #6152, #6401, #6409, #6466; issues #6281, #6343, #6346; activitypub-federation-rust `fetch/mod.rs` |
|
||||||
|
| Other threadiverse | PieFed `41cccb7` (`FEDERATION.md`, `docs/activitypub_examples`, `docs/fep-1a11.md`); Mbin `cf95b04` (`docs/05-fediverse_developers`); NodeBB `260a0cc` (`src/activitypub/*`); discourse-activity-pub; friendica `FEDERATION.md` |
|
||||||
|
| PeerTube | docs.joinpeertube.org/api/activitypub; Chocobozzz/PeerTube develop (`object-to-model-attributes.ts`, `custom-validators/activitypub/*`, `process-*.ts`); live fetches from peertube2.cpy.re, garr.tv (8.2.4), tube.tchncs.de |
|
||||||
|
| Pixelfed, Loops | pixelfed dev (`Transformer/ActivityPub/Verb/*`, `Inbox/HandlesCreates.php`, `Helpers::verifyAttachments`); loops-server main (`FEDERATION.md`, `NoteWithVideoAttachmentValidator.php`) |
|
||||||
|
| Long-form, events, audio, books | wordpress-activitypub trunk (`class-post.php`, `class-signature.php`); TryGhost/ActivityPub v1.2.13; writefreely `posts.go`; Mobilizon 5.2.4 converters; Gancio 1.28.2/2.0-beta; Funkwhale 2.0.x; Castopod 1.15.5; Owncast 0.3.0; BookWyrm 0.9.3; NeoDB `docs/internals/activitypub.md` |
|
||||||
|
| New entrants | fed.brid.gy/docs and bridgy-fed `activitypub.py`; live probes of threads.net, flipboard.com and bsky.brid.gy; engineering.fb.com (2024-03-21) |
|
||||||
|
| FEPs | codeberg.org/fediverse/fep at 2026-09-30: 044f, 1311, 1a11, 1b12, 2345, 268d, 2c59, 4f05, 521a, 5624, 5feb, 7458, 7628, 7888, 7aa9, 844e, 8967, 8a8e, 8b32, 8fcf, 9098, 9967, ae0c, b2b8, c0e0, c16b, d556, e232, ef61, f15d, f228, fb2a, fe34 |
|
||||||
|
| Signatures | SWICG "ActivityPub and HTTP Signatures" report and its RFC 9421 adoption tracker; SocialHub "RFC 9421 HTTP signatures in 2026" (Jan 2026) |
|
||||||
+116
-13
@@ -10,7 +10,10 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
||||||
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
||||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||||
- [ ] P5 FEPs and polish
|
- [ ] P5 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`)
|
||||||
|
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
||||||
|
- [ ] P7 Threads, communities, moderation, the social graph
|
||||||
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
|
|
||||||
## Intent
|
## Intent
|
||||||
|
|
||||||
@@ -115,6 +118,17 @@ and circles (see Owner decisions).
|
|||||||
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
|
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
|
||||||
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
|
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
|
||||||
|
|
||||||
|
### Owner decisions on what PrivaPub reveals (2026-10-01, from `docs/INTEROP.md` §6)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Link previews | **The server fetches the linked page itself.** It does this only for public posts, after a short random delay, once per link for the whole server (a shared cache, so a fetch never points at one persona), and when the post arrives, never when someone reads it. Cards built from the post's own data are used first. The client never contacts the site. |
|
||||||
|
| Blocks | **Federated.** A persona's block is sent to the blocked account's server as `Block`, and an unblock as `Undo{Block}`. Their server enforces it too, and they can learn they were blocked. This replaces the earlier "blocks never federate" rule. |
|
||||||
|
| Website authorship (`attributionDomains`, `fediverse:creator`) | **Off.** It is never emitted, not even as a per-persona option, because it would publicly tie a persona to a website. |
|
||||||
|
| PeerTube views | **Never sent.** The remote video file is still downloaded through our proxy when it is played; that cannot be avoided without pre-downloading video. |
|
||||||
|
| Bluesky bridging (Bridgy Fed) | **Allowed per persona**, with a clear warning that the posts become far more widely copied. Leaving the bridge must work, through a federated `Block` sent to the bridge. Persona creation dates are moved back by a random number of days, so personas created the same day no longer share a date. |
|
||||||
|
| Reactions and votes | **Public, with a one-time notice.** The client tells each persona once, before its first reaction or vote, that these are public and visible as that persona. |
|
||||||
|
|
||||||
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
|
||||||
|
|
||||||
| Area | Choice |
|
| Area | Choice |
|
||||||
@@ -386,28 +400,117 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol
|
|||||||
- **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no
|
- **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no
|
||||||
suggestions or directories that could relate sibling avatars.
|
suggestions or directories that could relate sibling avatars.
|
||||||
|
|
||||||
### P5 FEPs and polish (ongoing)
|
### P5 and beyond: rich content and the rest of the fediverse
|
||||||
- **FEP-8fcf:** followers synchronisation.
|
|
||||||
- **FEP-5feb:** honour remote `indexable` in search.
|
Rewritten on 2026-10-01 from the research in `docs/INTEROP.md`, which holds the per-platform evidence and the
|
||||||
- **FEP-7628:** Move in both directions, plus editing `alsoKnownAs`.
|
priorities. The goal is a future client that shows and links every kind of fediverse content in one place, with a
|
||||||
- **FEP-044f:** quotes and `interactionPolicy`.
|
details view of where each object came from. So the server keeps everything it receives: typed where it understands
|
||||||
- **RFC 9421:** inbound verification with NSign, plus Content-Digest.
|
it, raw where it doesn't.
|
||||||
- **Mastodon API extras:** polls, the streaming WebSocket (nginx Upgrade headers), Web Push (VAPID), and grouped
|
|
||||||
notifications v2, after which the advertised version moves to 4.3.
|
#### P5 Lose nothing (wire tolerance and full objects)
|
||||||
|
- **Cheap fixes that were wrong** (done in v1.7.0):
|
||||||
|
- `summary` is a content warning only on a `Note`/`Question`, or when `sensitive` is set; elsewhere it is an excerpt
|
||||||
|
(INTEROP W2). Remote titles now show in the Mastodon API for non-Note objects.
|
||||||
|
- Persona usernames match Mastodon's and Misskey's pattern (groups already did).
|
||||||
|
- Every term we emit is defined in our JSON-LD context.
|
||||||
|
- `Vary: Accept`.
|
||||||
|
- 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** (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).
|
||||||
|
- **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:**
|
||||||
|
- `Accept`/`Reject`/`TentativeAccept` routed by what their object is (W6).
|
||||||
|
- `ChatMessage` in as a direct message.
|
||||||
|
- `Dislike` and votes recorded in a ledger.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
||||||
|
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
||||||
|
- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit.
|
||||||
|
- **Quotes (FEP-044f):**
|
||||||
|
- read every key; verify `QuoteAuthorization` on all of its fields; handle revocation;
|
||||||
|
- be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps;
|
||||||
|
- then advertise `api_versions.mastodon ≥ 7` (4.5.0).
|
||||||
|
- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions`.
|
||||||
|
- **Link cards:**
|
||||||
|
- from the object, or from FEP-8967 `preview`, without fetching;
|
||||||
|
- fetching the page itself is the owner's decision (INTEROP §6.1).
|
||||||
|
- **Media:**
|
||||||
|
- video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card;
|
||||||
|
- audio attachments;
|
||||||
|
- an article reader view;
|
||||||
|
- JPEG/PNG renditions kept for Pixelfed.
|
||||||
|
|
||||||
|
#### P7 Threads, communities and the social graph
|
||||||
|
- **Thread backfill:**
|
||||||
|
- read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`;
|
||||||
|
- group by the root post;
|
||||||
|
- publish our own `context` and a paged `replies`.
|
||||||
|
- **Lemmy, PieFed and Mbin:**
|
||||||
|
- the moderation set: removals, locks, bans, featured, moderators, `Warn`, `Resolve`;
|
||||||
|
- votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`;
|
||||||
|
- the outbound shape Lemmy requires;
|
||||||
|
- communities we host announce to the author's own instance too;
|
||||||
|
- Flags from a `Service`-typed reporter actor.
|
||||||
|
- **GoToSocial interaction policies:** stored and shown; send `ReplyRequest`/`LikeRequest` where approval is
|
||||||
|
needed; handle `Accept{result}`.
|
||||||
|
- **Accounts and follows:**
|
||||||
|
- inbound `Move` with Mastodon's checks;
|
||||||
|
- re-run WebFinger on a rename;
|
||||||
|
- inbound `Block`, plus `Add`/`Remove` of pins;
|
||||||
|
- FEP-8fcf followers sync;
|
||||||
|
- `indexable`/`discoverable`/`searchableBy`;
|
||||||
|
- edit history from `formerRepresentations`;
|
||||||
|
- PeerTube reply rules and `ApproveReply`.
|
||||||
|
- **Events:** structured RSVP (`Join`/`Leave` with stable ids).
|
||||||
|
|
||||||
|
#### P8 Signatures, discovery and the long tail
|
||||||
|
- **Signatures:**
|
||||||
|
- RFC 9421 inbound (RSA and Ed25519, Content-Digest);
|
||||||
|
- outbound double-knock, remembered per host;
|
||||||
|
- `publicKey` arrays and FEP-521a Multikey;
|
||||||
|
- FEP-8b32 proof verification;
|
||||||
|
- `hs2019` with SHA-512.
|
||||||
|
- **Discovery:**
|
||||||
|
- a relay client for both relay styles;
|
||||||
|
- instance actor discovery (FEP-d556, FEP-2677);
|
||||||
|
- `implements` (FEP-844e).
|
||||||
|
- **Mastodon API:** streaming WebSocket, Web Push, grouped notifications. Then advertise an honest version.
|
||||||
- **Backfill:** an author's outbox after following them.
|
- **Backfill:** an author's outbox after following them.
|
||||||
- **Later:** FEP-521a, FEP-8b32, FEP-e232, relays.
|
- **Long tail:**
|
||||||
|
- MFM rendering data;
|
||||||
|
- Misskey actor extras;
|
||||||
|
- book reviews (`relatedWith`, `rating`);
|
||||||
|
- Funkwhale and Castopod metadata;
|
||||||
|
- Pixelfed `place` (display only, never re-federated);
|
||||||
|
- Bluesky bridging per persona.
|
||||||
|
|
||||||
### Cut or deferred (deliberately)
|
### Cut or deferred (deliberately)
|
||||||
- **Cut:**
|
- **Cut:**
|
||||||
- Link-preview cards: an SSRF and privacy risk.
|
- Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967
|
||||||
|
`preview` data, are in P6; fetching the page is an owner decision (INTEROP §6.1).
|
||||||
- Translation, trends, directory and lists (filters stay as stubs).
|
- Translation, trends, directory and lists (filters stay as stubs).
|
||||||
- Scheduled posts.
|
- Scheduled posts.
|
||||||
- The Mastodon admin API: moderation stays on `/clientapi`.
|
- The Mastodon admin API: moderation stays on `/clientapi`.
|
||||||
- `POST /api/v1/accounts` registration: avatars are created through `/clientapi`.
|
- `POST /api/v1/accounts` registration: avatars are created through `/clientapi`.
|
||||||
- Local custom emoji.
|
- Local custom emoji.
|
||||||
- S3 storage.
|
- S3 storage.
|
||||||
- JSON-LD and LD-signature processing.
|
- JSON-LD and LD-signature processing. FEP-8b32 proofs need neither (JCS), so they are in P8.
|
||||||
- Outbound RFC 9421.
|
|
||||||
- **Deferred:** video transcoding (remux only for now).
|
- **Deferred:** video transcoding (remux only for now).
|
||||||
- **Out of scope:** moving decePubClient onto the Mastodon API.
|
- **Out of scope:** moving decePubClient onto the Mastodon API.
|
||||||
|
|
||||||
|
|||||||
@@ -113,5 +113,11 @@ echo "unfollow"
|
|||||||
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
|
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unfollow
|
||||||
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
|
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"followed_by\"])")" = "False" ]' && ok "alice's unfollow reaches GoToSocial" || ko "unfollow not applied"
|
||||||
|
|
||||||
|
echo "blocks"
|
||||||
|
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/block
|
||||||
|
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "True" ]' && ok "alice's block reaches GoToSocial" || ko "block not applied"
|
||||||
|
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/accounts/$gts_on_pp/unblock
|
||||||
|
until_true 20 '[ "$(gcurl -s -H "$GH" "$G/api/v1/accounts/relationships?id[]=$alice_on_gts" | j "print(d[0][\"blocked_by\"])")" = "False" ]' && ok "alice's unblock reaches GoToSocial" || ko "unblock not applied"
|
||||||
|
|
||||||
echo; echo "$pass passed, $fail failed"
|
echo; echo "$pass passed, $fail failed"
|
||||||
[ "$fail" = 0 ]
|
[ "$fail" = 0 ]
|
||||||
Reference in new issue
Block a user