Compare commits

...
2 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 81de470f64 P5 done: a key we cannot fetch for now gets 503, and follow/like/block ids stay private on purpose
Build / Build (push) Successful in 59s
Deploy / privapub.thepra.dev (push) Successful in 1m11s
- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with
  Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not
  verify yet. The fetcher's failure cache now remembers whether a failure was temporary.
- Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish
  who follows, likes and blocks whom. They are always sent with their object embedded.

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

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

No files matched your search

+4
View File
@@ -199,6 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. 4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. 5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist. 6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice.
## Media invariants ## Media invariants
+9 -2
View File
@@ -87,12 +87,14 @@ Received:
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand | | `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested | | `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Undo{Follow, Like, Announce}` | reverses it | | `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread | | `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept | | `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor | | `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see | | `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin | | `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows | | `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators | | `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`, Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -130,6 +132,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three - **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
redirects and read at most 1 MB. redirects and read at most 1 MB.
- **HTML.** Received HTML is sanitised to Mastodon's allowlist. - **HTML.** Received HTML is sanitised to Mastodon's allowlist.
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
sent with their object embedded.
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, - **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week. starting at an hour and growing to a week.
@@ -0,0 +1,139 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboundRoutingTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost(string text)
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_downvote_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost("vote on me");
var lemmy = new RemoteActor(_harness.Peer, "voter");
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(lemmy, "/human-centipede", dislike);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
}
[Fact]
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
});
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var messageId = NewId(lemmy, "private_message");
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
Assert.NotNull(message.ConversationId);
}
[Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{
var (_, alice) = await _harness.Persona("alice");
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
{
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
});
Assert.Equal(503, result.StatusCode);
Assert.Equal(300, result.RetryAfterSeconds);
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
var post = await LocalPost("not an event");
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
{
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
});
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
}
}
}
+2
View File
@@ -53,6 +53,8 @@ namespace PrivaPub.Tests.Support
new RejectHandler(Db, Local), new RejectHandler(Db, Local),
new UndoHandler(Db, Local), new UndoHandler(Db, Local),
new LikeHandler(Db), new LikeHandler(Db),
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
new DeleteHandler(Db, Local, Remote, Delivery, Groups), new DeleteHandler(Db, Local, Remote, Delivery, Groups),
+101
View File
@@ -49,6 +49,106 @@ namespace PrivaPub.Api.Mastodon.Entities
public string AttributionDomains { get; set; } public string AttributionDomains { get; set; }
} }
public class PrivaPubStatus
{
public string ObjectType { get; set; }
public string Title { get; set; }
public string Excerpt { get; set; }
public string Cover { get; set; }
public PrivaPubSource Source { get; set; }
public PrivaPubLink Link { get; set; }
public PrivaPubVideo Video { get; set; }
public PrivaPubAudio Audio { get; set; }
public PrivaPubEvent Event { get; set; }
public PrivaPubVotes Votes { get; set; } = new();
}
public class PrivaPubSource
{
public string Content { get; set; }
public string MediaType { get; set; }
}
public class PrivaPubLink
{
public string Url { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Image { get; set; }
public bool FromPublisher { get; set; }
}
public class PrivaPubVariant
{
public string Url { get; set; }
public string MediaType { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public double? Fps { get; set; }
public long? Size { get; set; }
public List<string> Streams { get; set; } = new();
}
public class PrivaPubVideo
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public bool IsLive { get; set; }
public int? State { get; set; }
public long? Views { get; set; }
public int? CommentsPolicy { get; set; }
public bool? DownloadEnabled { get; set; }
public string Licence { get; set; }
public string Category { get; set; }
public string Channel { get; set; }
public List<PrivaPubCaption> Captions { get; set; } = new();
}
public class PrivaPubCaption
{
public string Language { get; set; }
public string Url { get; set; }
}
public class PrivaPubAudio
{
public double? Duration { get; set; }
public List<PrivaPubVariant> Variants { get; set; } = new();
public string Album { get; set; }
public int? Position { get; set; }
public string Licence { get; set; }
}
public class PrivaPubEvent
{
public string Start { get; set; }
public string End { get; set; }
public string TimeZone { get; set; }
public bool Floating { get; set; }
public List<PrivaPubPlace> Places { get; set; } = new();
public string OnlineUrl { get; set; }
public string JoinMode { get; set; }
public int? Participants { get; set; }
public int? Capacity { get; set; }
public string Status { get; set; }
public string Category { get; set; }
}
public class PrivaPubPlace
{
public string Name { get; set; }
public string Address { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string Url { get; set; }
}
public class PrivaPubVotes
{
public int Up { get; set; }
public int Down { get; set; }
}
public class PreviewCard public class PreviewCard
{ {
public string Url { get; set; } public string Url { get; set; }
@@ -104,6 +204,7 @@ namespace PrivaPub.Api.Mastodon.Entities
public string Language { get; set; } public string Language { get; set; }
public string Text { get; set; } public string Text { get; set; }
public List<object> Filtered { get; set; } = new(); public List<object> Filtered { get; set; } = new();
public PrivaPubStatus Privapub { get; set; }
} }
public class MediaAttachment public class MediaAttachment
@@ -194,6 +194,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Language = post.Language, Language = post.Language,
MediaAttachments = post.Media.Select(Media).ToList(), MediaAttachments = post.Media.Select(Media).ToList(),
Card = Card(post), Card = Card(post),
Privapub = Extension(post),
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(), Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList() Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
}; };
@@ -269,6 +270,67 @@ namespace PrivaPub.Api.Mastodon.Mappers
} }
} }
PrivaPubStatus Extension(PostEntity post) => new()
{
ObjectType = post.ObjectType,
Title = post.IsFederatedCopy ? post.Title : default,
Excerpt = post.Excerpt,
Cover = Proxied(post.CoverURL),
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
Link = post.Link == default ? default : new PrivaPubLink
{
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
},
Video = post.Video == default ? default : new PrivaPubVideo
{
Duration = post.Video.DurationSeconds,
Variants = post.Video.Variants.Select(Variant).ToList(),
IsLive = post.Video.IsLive,
State = post.Video.State,
Views = post.Video.Views,
CommentsPolicy = post.Video.CommentsPolicy,
DownloadEnabled = post.Video.DownloadEnabled,
Licence = post.Video.Licence,
Category = post.Video.Category,
Channel = post.Video.ChannelURI,
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
},
Audio = post.Audio == default ? default : new PrivaPubAudio
{
Duration = post.Audio.DurationSeconds,
Variants = post.Audio.Variants.Select(Variant).ToList(),
Album = post.Audio.Album,
Position = post.Audio.Position,
Licence = post.Audio.Licence
},
Event = post.Event == default ? default : new PrivaPubEvent
{
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
TimeZone = post.Event.TimeZone,
Floating = post.Event.FloatingTime,
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
OnlineUrl = post.Event.OnlineURL,
JoinMode = post.Event.JoinMode,
Participants = post.Event.ParticipantCount,
Capacity = post.Event.Capacity,
Status = post.Event.Status,
Category = post.Event.Category
},
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount }
};
PrivaPubVariant Variant(MediaVariant variant) => new()
{
Url = Proxied(variant.Url),
MediaType = variant.MediaType,
Width = variant.Width,
Height = variant.Height,
Fps = variant.Fps,
Size = variant.Size,
Streams = variant.Streams
};
PreviewCard Card(PostEntity post) PreviewCard Card(PostEntity post)
{ {
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
@@ -19,6 +19,7 @@ namespace PrivaPub.Federation.Actors
Task<FetchedJson> FetchObject(string uri, CancellationToken token); Task<FetchedJson> FetchObject(string uri, CancellationToken token);
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token); Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false;
Task<string> ResolveHandle(string handle, CancellationToken token); Task<string> ResolveHandle(string handle, CancellationToken token);
} }
@@ -120,6 +121,8 @@ namespace PrivaPub.Federation.Actors
return await Upsert(actor, key, token); return await Upsert(actor, key, token);
} }
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
public async Task<string> ResolveHandle(string handle, CancellationToken token) public async Task<string> ResolveHandle(string handle, CancellationToken token)
{ {
var parts = handle?.TrimStart('@').Split('@'); var parts = handle?.TrimStart('@').Split('@');
@@ -333,6 +333,8 @@ namespace PrivaPub.Federation.Controllers
{ {
if (result.Error != default) if (result.Error != default)
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); _logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
} }
@@ -64,7 +64,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
var note = NoteParser.Parse(node); var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI) if (note == default || note.AttributedTo != author.ActorURI)
return; return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
return; return;
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
@@ -65,10 +65,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
return; return;
} }
await DB.Default.Update<DeletedObject>()
.Match(d => d.ObjectURI == objectUri)
.Modify(d => d.DeletedAt, DateTime.UtcNow)
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
return; return;
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
@@ -0,0 +1,47 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class DislikeHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
public DislikeHandler(DbEntities dbEntities)
{
_dbEntities = dbEntities;
}
public string Type => "Dislike";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var post = objectUri == default
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor))
return;
try
{
await DB.Default.SaveAsync(new Downvote { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
}
}
}
@@ -0,0 +1,48 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox.Handlers
{
public class JoinHandler : IActivityHandler
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public JoinHandler(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public string Type => "Join";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{
var objectUri = Id(activity["object"]);
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
return;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
return;
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = owner.ActivityUri($"ignore-{Guid.NewGuid():N}"),
["type"] = "Ignore",
["actor"] = owner.Uri,
["object"] = activity.DeepClone()
}, token);
}
}
}
@@ -40,6 +40,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
await UndoLike(inner, innerId, actor, token); await UndoLike(inner, innerId, actor, token);
if (innerType is null or "Announce") if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token); await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike")
await UndoDislike(inner, innerId, actor, token);
}
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{
var downvote = innerId == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.ActivityURI == innerId && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (downvote == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
{
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
downvote = post == default
? default
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
}
if (downvote == default)
return;
await DB.Default.DeleteAsync<Downvote>(downvote.ID);
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
} }
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
+5 -1
View File
@@ -13,7 +13,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record InboxResult(int StatusCode, string Error = default); public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default);
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default, public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default); string[] SignedHeaders = default, DateTime? ReceivedAt = default);
@@ -25,6 +25,8 @@ namespace PrivaPub.Federation.Inbox
public class InboxReceiver : IInboxReceiver public class InboxReceiver : IInboxReceiver
{ {
const int KeyRetrySeconds = 300;
const int MaxBodyBytes = 1024 * 1024; const int MaxBodyBytes = 1024 * 1024;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
@@ -88,6 +90,8 @@ namespace PrivaPub.Federation.Inbox
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{ {
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
} }
+1 -1
View File
@@ -114,7 +114,7 @@ namespace PrivaPub.Federation.Inbox
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token) public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
{ {
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token); var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
if (existing != default || depth > MaxDepth) if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
return existing; return existing;
using var fetched = await _remoteActors.FetchObject(objectUri, token); using var fetched = await _remoteActors.FetchObject(objectUri, token);
+1 -1
View File
@@ -42,7 +42,7 @@ namespace PrivaPub.Federation.Objects
public static class NoteParser public static class NoteParser
{ {
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" }; static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
const int MaxAttachments = 16; const int MaxAttachments = 16;
const int MaxTags = 64; const int MaxTags = 64;
@@ -123,12 +123,13 @@ namespace PrivaPub.Federation.Outbox
await _breaker.Succeeded(job.Host, token); await _breaker.Succeeded(job.Host, token);
return JobOutcome.Done; return JobOutcome.Done;
} }
if (response.StatusCode == HttpStatusCode.TooManyRequests) if (response.StatusCode == HttpStatusCode.TooManyRequests
|| response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.RetryAfter != default)
{ {
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow); var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
return retryAfter > TimeSpan.Zero return retryAfter > TimeSpan.Zero
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429") ? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), $"{status}")
: JobOutcome.Retry("429"); : JobOutcome.Retry($"{status}");
} }
if (status is >= 400 and < 500 && status != 408) if (status is >= 400 and < 500 && status != 408)
{ {
+6
View File
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
{ {
public static class Indexes public static class Indexes
{ {
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static async Task Create(CancellationToken token = default) public static async Task Create(CancellationToken token = default)
{ {
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token); await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
@@ -104,6 +106,10 @@ namespace PrivaPub.Infrastructure.Data
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 Unique<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
await Plain<ObjectRecord>(token, r => r.PostId); await Plain<ObjectRecord>(token, r => r.PostId);
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Type(d => d.ObjectURI, BsonType.String), token);
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
await Plain<Downvote>(token, d => d.ActivityURI);
} }
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial, static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
+14 -5
View File
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Http
{ {
bool IsAllowed(Uri target); bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token); Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url);
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token); Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token); Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
} }
@@ -107,7 +108,8 @@ namespace PrivaPub.Infrastructure.Http
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}"); return Refuse(negativeKey, url, $"status {(int)response.StatusCode}",
transient: (int)response.StatusCode is >= 500 or 429 or 408);
var mediaType = response.Content.Headers.ContentType?.MediaType; var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase)) if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
@@ -125,14 +127,21 @@ namespace PrivaPub.Infrastructure.Http
} }
catch (OperationCanceledException) when (!token.IsCancellationRequested) catch (OperationCanceledException) when (!token.IsCancellationRequested)
{ {
return Refuse(negativeKey, url, "a timeout"); return Refuse(negativeKey, url, "a timeout", transient: true);
} }
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException) catch (HttpRequestException ex)
{
return Refuse(negativeKey, url, ex.Message, transient: true);
}
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
{ {
return Refuse(negativeKey, url, ex.Message); return Refuse(negativeKey, url, ex.Message);
} }
} }
public bool FailedTemporarily(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token) public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target)) if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
@@ -200,9 +209,9 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri; static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
FetchedJson Refuse(string negativeKey, string url, string reason) FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false)
{ {
_cache.Set(negativeKey, true, NegativeCacheLifetime); _cache.Set(negativeKey, transient, NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason); _logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default; return default;
} }
@@ -76,6 +76,8 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, RejectHandler>() .AddSingleton<IActivityHandler, RejectHandler>()
.AddSingleton<IActivityHandler, UndoHandler>() .AddSingleton<IActivityHandler, UndoHandler>()
.AddSingleton<IActivityHandler, LikeHandler>() .AddSingleton<IActivityHandler, LikeHandler>()
.AddSingleton<IActivityHandler, DislikeHandler>()
.AddSingleton<IActivityHandler, JoinHandler>()
.AddSingleton<IActivityHandler, AnnounceHandler>() .AddSingleton<IActivityHandler, AnnounceHandler>()
.AddSingleton<IActivityHandler, FlagHandler>() .AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, CreateHandler>() .AddSingleton<IActivityHandler, CreateHandler>()
@@ -0,0 +1,10 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Federation
{
public class DeletedObject : Entity
{
public string ObjectURI { get; set; }
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;//expires after Indexes.TombstoneLifetime
}
}
+1
View File
@@ -59,6 +59,7 @@ namespace PrivaPub.Models.Post
public int RepliesCount { get; set; } public int RepliesCount { get; set; }
public int FavouritesCount { get; set; } public int FavouritesCount { get; set; }
public int DownvotesCount { get; set; }//Lemmy, PieFed, Mbin and Friendica Dislikes
public int ReblogsCount { get; set; } public int ReblogsCount { get; set; }
public List<PostRevision> Revisions { get; set; } = new(); public List<PostRevision> Revisions { get; set; } = new();
+13
View File
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Models.Social
{
public class Downvote : Entity
{
public string AccountId { get; set; }//ForeignAvatar.ID
public string ActorURI { get; set; }
public string PostId { get; set; }
public string ActivityURI { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
+15 -7
View File
@@ -10,7 +10,7 @@ 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 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`) - [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
- [ ] P6 Emoji, polls, quotes, reactions, cards, players - [ ] P6 Emoji, polls, quotes, reactions, cards, players
- [ ] P7 Threads, communities, moderation, the social graph - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] P8 Signatures, discovery, the long tail
@@ -414,7 +414,7 @@ it, raw where it doesn't.
- Persona usernames match Mastodon's and Misskey's pattern (groups already did). - Persona usernames match Mastodon's and Misskey's pattern (groups already did).
- Every term we emit is defined in our JSON-LD context. - Every term we emit is defined in our JSON-LD context.
- `Vary: Accept`. - `Vary: Accept`.
- Still open: every activity id dereferences. - Not done on purpose: see "Deliberately not done" below.
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a - **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`). 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`): - **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
@@ -431,13 +431,21 @@ it, raw where it doesn't.
- **Raw capture for the details view** (done in v1.8.0): the object as received, how it arrived, the signature scheme and - **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 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. `/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. Checked live against GoToSocial.
- **Routing by object type:** - **Routing by object type** (done in v1.9.0):
- `Accept`/`Reject`/`TentativeAccept` routed by what their object is (W6). - `Accept`/`Reject` act only on a Follow we sent and ignore any other object (W6); routing to RSVPs and interaction
approvals comes with those features.
- `ChatMessage` in as a direct message. - `ChatMessage` in as a direct message.
- `Dislike` and votes recorded in a ledger. - `Dislike` and its `Undo` in a downvote ledger, shown with favourites as `privapub.votes`.
- A `Join` answered with `Ignore` until RSVP exists. - A `Join` answered with `Ignore` until RSVP exists.
- Friendica's thread-`Follow` refused without an error. - Friendica's thread-`Follow` is ignored without an error.
- **Delivery:** honour 503 with `Retry-After`; answer 503 ourselves when a key fetch fails temporarily. - A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
We answer 503 with `Retry-After` ourselves when a sender's key cannot be fetched for a temporary reason (v1.9.1),
so Mastodon 4.7 retries instead of switching to RFC 9421.
- **Deliberately not done: dereferenceable `Follow`, `Like`, `Block`, `Accept`, `Reject` and `Undo` ids.** Serving them
would publish who follows, likes and blocks whom, which our collections deliberately hide. Every one of them is sent
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
#### P6 What people see: emoji, polls, quotes, reactions, cards, players #### P6 What people see: emoji, polls, quotes, reactions, cards, players
- **Custom emoji** on posts, names, fields and poll options, proxied. - **Custom emoji** on posts, names, fields and poll options, proxied.