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

+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;
}
}