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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 17:58:22 +02:00
1 parent fc111d8c46
commit b691c6766d
20 files changed
+467 -13

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`.
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
View File
@@ -87,12 +87,14 @@ Received:
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested |
| `Undo{Follow, Like, Announce}` | reverses it |
| `Create{Note, Article, Page, Question, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread |
| `Update{Note}` | replaces the content; the previous version is kept |
| `Update{Person}` | refetches the actor |
| `Like` | counted and notified, on posts the liker could see |
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
| `Join` | answered with `Ignore`: PrivaPub hosts no events yet (FEP-8a8e) |
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
| `Delete` | deletes the object, or the actor and its follows |
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -0,0 +1,124 @@
using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
[Trait("Category", "Integration")]
public sealed class InboundRoutingTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
async Task<Post> LocalPost(string text)
{
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task A_downvote_counts_and_its_undo_takes_it_back()
{
var token = TestContext.Current.CancellationToken;
var post = await LocalPost("vote on me");
var lemmy = new RemoteActor(_harness.Peer, "voter");
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
await _harness.Deliver(lemmy, "/human-centipede", dislike);
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
});
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
}
[Fact]
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var mallory = new RemoteActor(_harness.Peer, "mallory");
var noteId = NewId(mallory, "notes");
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
});
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
{
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
["object"] = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
}
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var lemmy = new RemoteActor(_harness.Peer, "pm");
var messageId = NewId(lemmy, "private_message");
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
{
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["object"] = new JsonObject
{
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
}
});
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
Assert.NotNull(message);
Assert.Equal(PostVisibility.Direct, message.Visibility);
Assert.NotNull(message.ConversationId);
}
[Fact]
public async Task A_join_of_a_local_post_is_answered_with_ignore()
{
var post = await LocalPost("not an event");
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
{
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
});
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
}
}
}
+2
View File
@@ -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),
+101
View File
@@ -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)
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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)
{
+6
View File
@@ -13,6 +13,8 @@ namespace PrivaPub.Infrastructure.Data
{
public static class Indexes
{
public static readonly TimeSpan TombstoneLifetime = TimeSpan.FromDays(90);
public static async Task Create(CancellationToken token = default)
{
await Unique<Post>(p => p.ObjectURI, Builders<Post>.Filter.Type(p => p.ObjectURI, BsonType.String), token);
@@ -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
}
}
+1
View File
@@ -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();
+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;
}
}
+9 -5
View File
@@ -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.