diff --git a/CLAUDE.md b/CLAUDE.md index 8ed8268..35865e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/FEDERATION.md b/FEDERATION.md index 5ed3c86..439d408 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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}`, diff --git a/PrivaPub.Tests/Federation/InboundRoutingTests.cs b/PrivaPub.Tests/Federation/InboundRoutingTests.cs new file mode 100644 index 0000000..492036f --- /dev/null +++ b/PrivaPub.Tests/Federation/InboundRoutingTests.cs @@ -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 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().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().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().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"] = "

late

", + ["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().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"] = "

psst

", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O") + } + }); + + var message = await DB.Default.Find().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()); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index d8a5196..cbfc53d 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -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), diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index cd055a3..98e6332 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -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 Streams { get; set; } = new(); + } + + public class PrivaPubVideo + { + public double? Duration { get; set; } + public List 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 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 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 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 Filtered { get; set; } = new(); + public PrivaPubStatus Privapub { get; set; } } public class MediaAttachment diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index e8cc05d..4d554bd 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -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) diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 3dd9209..4667ba6 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -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().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) return; var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index ff723c0..46e8bcc 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -65,10 +65,16 @@ namespace PrivaPub.Federation.Inbox.Handlers return; } + await DB.Default.Update() + .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(post.ID); + await DB.Default.DeleteAsync(r => r.PostId == post.ID || r.ObjectURI == objectUri); await DB.Default.DeleteAsync(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); diff --git a/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs new file mode 100644 index 0000000..1d29290 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs @@ -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().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs b/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs new file mode 100644 index 0000000..0ae0fd7 --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs @@ -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); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 27da40a..2378c2b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -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().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().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); + } + if (downvote == default) + return; + await DB.Default.DeleteAsync(downvote.ID); + await DB.Default.Update().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); } async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index ceb6914..f52235d 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -114,7 +114,7 @@ namespace PrivaPub.Federation.Inbox public async Task 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().Match(d => d.ObjectURI == objectUri).ExecuteAnyAsync(token)) return existing; using var fetched = await _remoteActors.FetchObject(objectUri, token); diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index 6f3f97c..0e97a2e 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -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; diff --git a/PrivaPub/Federation/Outbox/DeliveryService.cs b/PrivaPub/Federation/Outbox/DeliveryService.cs index c8a3e1d..3f92f3d 100644 --- a/PrivaPub/Federation/Outbox/DeliveryService.cs +++ b/PrivaPub/Federation/Outbox/DeliveryService.cs @@ -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) { diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index af2a7cd..33d81c8 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -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(p => p.ObjectURI, Builders.Filter.Type(p => p.ObjectURI, BsonType.String), token); @@ -104,6 +106,10 @@ namespace PrivaPub.Infrastructure.Data await Unique(i => i.Host, Builders.Filter.Type(i => i.Host, BsonType.String), token); await Unique(r => r.ObjectURI, Builders.Filter.Type(r => r.ObjectURI, BsonType.String), token); await Plain(token, r => r.PostId); + await Unique(d => d.ObjectURI, Builders.Filter.Type(d => d.ObjectURI, BsonType.String), token); + await DB.Default.Index().Key(d => d.DeletedAt, KeyType.Ascending).Option(o => o.ExpireAfter = TombstoneLifetime).CreateAsync(token); + await DB.Default.Index().Key(d => d.PostId, KeyType.Ascending).Key(d => d.ActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); + await Plain(token, d => d.ActivityURI); } static async Task Unique(System.Linq.Expressions.Expression> key, FilterDefinition partial, diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 02ee58a..7600456 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -76,6 +76,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Federation/DeletedObject.cs b/PrivaPub/Models/Federation/DeletedObject.cs new file mode 100644 index 0000000..c246a09 --- /dev/null +++ b/PrivaPub/Models/Federation/DeletedObject.cs @@ -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 + } +} diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 8165472..6ea6d22 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -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 Revisions { get; set; } = new(); diff --git a/PrivaPub/Models/Social/Downvote.cs b/PrivaPub/Models/Social/Downvote.cs new file mode 100644 index 0000000..094fd66 --- /dev/null +++ b/PrivaPub/Models/Social/Downvote.cs @@ -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; + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf5488b..ef8b27a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.