P5: downvotes, private messages from Lemmy, late Creates of deleted posts, and typed details for clients
- 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
This commit is contained in:
1 parent
fc111d8c46
commit
b691c6766d
20 files changed
+467
-13
No files matched your search
@@ -199,6 +199,10 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ
|
||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
|
||||
one is the reader's choice.
|
||||
|
||||
## Media invariants
|
||||
|
||||
|
||||
+4
-2
@@ -87,12 +87,14 @@ Received:
|
||||
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
||||
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
|
||||
| `Undo{Follow, Like, Announce}` | reverses it |
|
||||
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
|
||||
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
|
||||
| `Update{Note}` | replaces the content; the previous version is kept |
|
||||
| `Update{Person}` | refetches the actor |
|
||||
| `Like` | counted and notified, on posts the liker could see |
|
||||
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
|
||||
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
|
||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||
| `Delete` | deletes the object, or the actor and its follows |
|
||||
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
|
||||
| `Flag` | becomes a report for this server's moderators |
|
||||
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InboundRoutingTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
|
||||
|
||||
async Task<Post> LocalPost(string text)
|
||||
{
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
|
||||
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_downvote_counts_and_its_undo_takes_it_back()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var post = await LocalPost("vote on me");
|
||||
var lemmy = new RemoteActor(_harness.Peer, "voter");
|
||||
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
|
||||
|
||||
await _harness.Deliver(lemmy, "/human-centipede", dislike);
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
||||
|
||||
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
|
||||
});
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
var noteId = NewId(mallory, "notes");
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
|
||||
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
|
||||
});
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
|
||||
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
|
||||
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
|
||||
}
|
||||
});
|
||||
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var lemmy = new RemoteActor(_harness.Peer, "pm");
|
||||
var messageId = NewId(lemmy, "private_message");
|
||||
|
||||
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
||||
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
|
||||
}
|
||||
});
|
||||
|
||||
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
|
||||
Assert.NotNull(message);
|
||||
Assert.Equal(PostVisibility.Direct, message.Visibility);
|
||||
Assert.NotNull(message.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_join_of_a_local_post_is_answered_with_ignore()
|
||||
{
|
||||
var post = await LocalPost("not an event");
|
||||
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
|
||||
|
||||
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
|
||||
});
|
||||
|
||||
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
|
||||
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ namespace PrivaPub.Tests.Support
|
||||
new RejectHandler(Db, Local),
|
||||
new UndoHandler(Db, Local),
|
||||
new LikeHandler(Db),
|
||||
new DislikeHandler(Db),
|
||||
new JoinHandler(Db, Local, Delivery),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||
|
||||
@@ -49,6 +49,106 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string AttributionDomains { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubStatus
|
||||
{
|
||||
public string ObjectType { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Excerpt { get; set; }
|
||||
public string Cover { get; set; }
|
||||
public PrivaPubSource Source { get; set; }
|
||||
public PrivaPubLink Link { get; set; }
|
||||
public PrivaPubVideo Video { get; set; }
|
||||
public PrivaPubAudio Audio { get; set; }
|
||||
public PrivaPubEvent Event { get; set; }
|
||||
public PrivaPubVotes Votes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubSource
|
||||
{
|
||||
public string Content { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubLink
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Image { get; set; }
|
||||
public bool FromPublisher { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVariant
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public double? Fps { get; set; }
|
||||
public long? Size { get; set; }
|
||||
public List<string> Streams { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubVideo
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public bool IsLive { get; set; }
|
||||
public int? State { get; set; }
|
||||
public long? Views { get; set; }
|
||||
public int? CommentsPolicy { get; set; }
|
||||
public bool? DownloadEnabled { get; set; }
|
||||
public string Licence { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string Channel { get; set; }
|
||||
public List<PrivaPubCaption> Captions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubCaption
|
||||
{
|
||||
public string Language { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubAudio
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public string Album { get; set; }
|
||||
public int? Position { get; set; }
|
||||
public string Licence { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubEvent
|
||||
{
|
||||
public string Start { get; set; }
|
||||
public string End { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
public bool Floating { get; set; }
|
||||
public List<PrivaPubPlace> Places { get; set; } = new();
|
||||
public string OnlineUrl { get; set; }
|
||||
public string JoinMode { get; set; }
|
||||
public int? Participants { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string Category { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubPlace
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Address { get; set; }
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVotes
|
||||
{
|
||||
public int Up { get; set; }
|
||||
public int Down { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewCard
|
||||
{
|
||||
public string Url { get; set; }
|
||||
@@ -104,6 +204,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string Language { get; set; }
|
||||
public string Text { get; set; }
|
||||
public List<object> Filtered { get; set; } = new();
|
||||
public PrivaPubStatus Privapub { get; set; }
|
||||
}
|
||||
|
||||
public class MediaAttachment
|
||||
|
||||
@@ -194,6 +194,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Language = post.Language,
|
||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||
Card = Card(post),
|
||||
Privapub = Extension(post),
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
};
|
||||
@@ -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)
|
||||
{
|
||||
if (!post.IsFederatedCopy)
|
||||
|
||||
@@ -64,7 +64,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
var note = NoteParser.Parse(node);
|
||||
if (note == default || note.AttributedTo != author.ActorURI)
|
||||
return;
|
||||
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
||||
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|
||||
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
||||
return;
|
||||
|
||||
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
@@ -65,10 +65,16 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
return;
|
||||
}
|
||||
|
||||
await DB.Default.Update<DeletedObject>()
|
||||
.Match(d => d.ObjectURI == objectUri)
|
||||
.Modify(d => d.DeletedAt, DateTime.UtcNow)
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return;
|
||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
|
||||
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class DislikeHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public DislikeHandler(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public string Type => "Dislike";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(activity["object"]);
|
||||
var post = objectUri == default
|
||||
? default
|
||||
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
if (post == default || !LikeHandler.MaySee(post, actor))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Downvote { AccountId = actor.ID, ActorURI = actor.ActorURI, PostId = post.ID, ActivityURI = Id(activity) }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
public class JoinHandler : IActivityHandler
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public JoinHandler(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public string Type => "Join";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var objectUri = Id(activity["object"]);
|
||||
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
|
||||
if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
|
||||
return;
|
||||
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = owner.ActivityUri($"ignore-{Guid.NewGuid():N}"),
|
||||
["type"] = "Ignore",
|
||||
["actor"] = owner.Uri,
|
||||
["object"] = activity.DeepClone()
|
||||
}, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await UndoLike(inner, innerId, actor, token);
|
||||
if (innerType is null or "Announce")
|
||||
await UndoAnnounce(innerId, actor, token);
|
||||
if (innerType is null or "Dislike")
|
||||
await UndoDislike(inner, innerId, actor, token);
|
||||
}
|
||||
|
||||
async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var downvote = innerId == default
|
||||
? default
|
||||
: await DB.Default.Find<Downvote>().Match(d => d.ActivityURI == innerId && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
if (downvote == default && inner is JsonObject && Id(inner["object"]) is { } objectUri)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||
downvote = post == default
|
||||
? default
|
||||
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
}
|
||||
if (downvote == default)
|
||||
return;
|
||||
await DB.Default.DeleteAsync<Downvote>(downvote.ID);
|
||||
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
|
||||
}
|
||||
|
||||
async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
|
||||
|
||||
@@ -114,7 +114,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
public async Task<PostEntity> StoreContext(string objectUri, int depth, CancellationToken token)
|
||||
{
|
||||
var existing = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token);
|
||||
if (existing != default || depth > MaxDepth)
|
||||
if (existing != default || depth > MaxDepth || await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token))
|
||||
return existing;
|
||||
|
||||
using var fetched = await _remoteActors.FetchObject(objectUri, token);
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace PrivaPub.Federation.Objects
|
||||
|
||||
public static class NoteParser
|
||||
{
|
||||
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image" };
|
||||
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
|
||||
const int MaxAttachments = 16;
|
||||
const int MaxTags = 64;
|
||||
|
||||
|
||||
@@ -123,12 +123,13 @@ namespace PrivaPub.Federation.Outbox
|
||||
await _breaker.Succeeded(job.Host, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests
|
||||
|| response.StatusCode == HttpStatusCode.ServiceUnavailable && response.Headers.RetryAfter != default)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow);
|
||||
return retryAfter > TimeSpan.Zero
|
||||
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), "429")
|
||||
: JobOutcome.Retry("429");
|
||||
? JobOutcome.Defer(DateTime.UtcNow + Min(retryAfter.Value, TimeSpan.FromHours(6)), $"{status}")
|
||||
: JobOutcome.Retry($"{status}");
|
||||
}
|
||||
if (status is >= 400 and < 500 && status != 408)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
|
||||
{
|
||||
public static class Indexes
|
||||
{
|
||||
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
|
||||
|
||||
public static async Task Create(CancellationToken token = default)
|
||||
{
|
||||
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
|
||||
@@ -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<ObjectRecord>(r => r.ObjectURI, Builders<ObjectRecord>.Filter.Type(r => r.ObjectURI, BsonType.String), token);
|
||||
await Plain<ObjectRecord>(token, r => r.PostId);
|
||||
await Unique<DeletedObject>(d => d.ObjectURI, Builders<DeletedObject>.Filter.Type(d => d.ObjectURI, BsonType.String), token);
|
||||
await DB.Default.Index<DeletedObject>().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token);
|
||||
await DB.Default.Index<Downvote>().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<Downvote>(token, d => d.ActivityURI);
|
||||
}
|
||||
|
||||
static async Task Unique<T>(System.Linq.Expressions.Expression<Func<T, object>> key, FilterDefinition<T> partial,
|
||||
|
||||
@@ -76,6 +76,8 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, RejectHandler>()
|
||||
.AddSingleton<IActivityHandler, UndoHandler>()
|
||||
.AddSingleton<IActivityHandler, LikeHandler>()
|
||||
.AddSingleton<IActivityHandler, DislikeHandler>()
|
||||
.AddSingleton<IActivityHandler, JoinHandler>()
|
||||
.AddSingleton<IActivityHandler, AnnounceHandler>()
|
||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||
.AddSingleton<IActivityHandler, CreateHandler>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ namespace PrivaPub.Models.Post
|
||||
|
||||
public int RepliesCount { get; set; }
|
||||
public int FavouritesCount { get; set; }
|
||||
public int DownvotesCount { get; set; }//Lemmy, PieFed, Mbin and Friendica Dislikes
|
||||
public int ReblogsCount { get; set; }
|
||||
public List<PostRevision> Revisions { get; set; } = new();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -431,13 +431,17 @@ 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
|
||||
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).
|
||||
- **Routing by object type** (done in v1.9.0):
|
||||
- `Accept`/`Reject` act only on a Follow we sent and ignore any other object (W6); routing to RSVPs and interaction
|
||||
approvals comes with those features.
|
||||
- `ChatMessage` in as a direct message.
|
||||
- `Dislike` and votes recorded in a ledger.
|
||||
- `Dislike` and its `Undo` in a downvote ledger, shown with favourites as `privapub.votes`.
|
||||
- A `Join` answered with `Ignore` until RSVP exists.
|
||||
- Friendica's thread-`Follow` refused without an error.
|
||||
- **Delivery:** honour 503 with `Retry-After`; answer 503 ourselves when a key fetch fails temporarily.
|
||||
- Friendica's thread-`Follow` is ignored without an error.
|
||||
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
|
||||
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
|
||||
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
|
||||
Still open: answering 503 ourselves when a key fetch fails temporarily, and making every activity id dereference.
|
||||
|
||||
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
||||
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
||||
|
||||
Reference in new issue
Block a user